diff --git a/package.json b/package.json index 61c6b16c40..18e9b8a401 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,9 @@ "workspaces": [ "packages/*" ], + "resolutions": { + "next/@swc/helpers": "0.4.36" + }, "scripts": { "react": "yarn workspace @headlessui/react", "react-playground": "yarn workspace playground-react dev", @@ -46,6 +49,7 @@ }, "devDependencies": { "@arethetypeswrong/cli": "^0.13.3", + "@swc-node/register": "^1.6.8", "@swc/core": "^1.2.131", "@swc/jest": "^0.2.17", "@testing-library/jest-dom": "^5.16.4", @@ -56,11 +60,11 @@ "jest": "26", "lint-staged": "^12.2.1", "npm-run-all": "^4.1.5", - "prettier": "^2.6.2", - "prettier-plugin-organize-imports": "^3.2.3", - "prettier-plugin-tailwindcss": "0.4", + "prettier": "^3.1.0", + "prettier-plugin-organize-imports": "^3.2.4", + "prettier-plugin-tailwindcss": "^0.5.7", "rimraf": "^3.0.2", "tslib": "^2.3.1", - "typescript": "^4.9.5" + "typescript": "^5.3.2" } } diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md index 6df6ed8363..955b95fe00 100644 --- a/packages/@headlessui-react/CHANGELOG.md +++ b/packages/@headlessui-react/CHANGELOG.md @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `immediate` prop to `` for immediately opening the Combobox when the `input` receives focus ([#2686](https://github.com/tailwindlabs/headlessui/pull/2686)) +- Add `virtual` prop to `Combobox` component ([#2779](https://github.com/tailwindlabs/headlessui/pull/2779)) +- Add new `Checkbox` component +- Add new `Radio` component as an alternative to the existing `RadioGroup.Option` component +- Add new `Button` component +- Add new `Input` component +- Add new `Textarea` component +- Add new `Select` component +- Add new `Field`, `Label`, `Description`, `Fieldset` and `Legend` components +- Add new `DataInteractive` component +- Add new `anchor` and `modal` prop to `ComboboxOptions`, `ListboxOptions`, `MenuItems` and `PopoverPanel` components +- Add new `ListboxSelectedOption` component +- Add new `MenuSection`, `MenuHeading`, and `MenuSeparator` components +- Add new simplified `data-*` attributes as an alternative to the existing `data-headlessui-state="..."` attribute +- Add `autoFocus` prop on focusable components (which maps to `data-autofocus`) + +### Changed + +- Bumped to React and React DOM 18 +- Dialog is focused by default instead of the first focusable element (unless an element exists with a `data-autofocus` in the dialog) + ### Fixed - Don't call ``'s `onClose` twice on mobile devices ([#2690](https://github.com/tailwindlabs/headlessui/pull/2690)) @@ -21,11 +44,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fix outside click detection when component is mounted in the Shadow DOM ([#2866](https://github.com/tailwindlabs/headlessui/pull/2866)) - Fix CJS types ([#2880](https://github.com/tailwindlabs/headlessui/pull/2880)) - Fix error when transition classes contain new lines ([#2871](https://github.com/tailwindlabs/headlessui/pull/2871)) - -### Added - -- Add `immediate` prop to `` for immediately opening the Combobox when the `input` receives focus ([#2686](https://github.com/tailwindlabs/headlessui/pull/2686)) -- Add `virtual` prop to `Combobox` component ([#2779](https://github.com/tailwindlabs/headlessui/pull/2779)) +- Fix iOS scroll lock glitches ## [1.7.17] - 2023-08-17 diff --git a/packages/@headlessui-react/package.json b/packages/@headlessui-react/package.json index 11a37d664d..e1f5e988f8 100644 --- a/packages/@headlessui-react/package.json +++ b/packages/@headlessui-react/package.json @@ -47,15 +47,17 @@ }, "devDependencies": { "@testing-library/react": "^13.0.0", - "@types/react": "^17.0.43", - "@types/react-dom": "^17.0.14", + "@types/react": "^18.2.14", + "@types/react-dom": "^18.2.6", "esbuild": "^0.11.18", - "react": "^18.0.0", - "react-dom": "^18.0.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", "snapshot-diff": "^0.8.1" }, "dependencies": { - "@tanstack/react-virtual": "^3.0.0-beta.60", - "client-only": "^0.0.1" + "@floating-ui/react": "^0.26.2", + "@tanstack/react-virtual": "3.0.0-beta.60", + "@react-aria/focus": "^3.14.3", + "@react-aria/interactions": "3.0.0-nightly.2584" } } diff --git a/packages/@headlessui-react/src/components/button/button.test.tsx b/packages/@headlessui-react/src/components/button/button.test.tsx new file mode 100644 index 0000000000..c7d6c9676c --- /dev/null +++ b/packages/@headlessui-react/src/components/button/button.test.tsx @@ -0,0 +1,39 @@ +import { render, screen } from '@testing-library/react' +import React from 'react' +import { Button } from './button' + +describe('Rendering', () => { + describe('Button', () => { + it('should render a button', async () => { + render() + + expect(screen.getByRole('button')).toBeInTheDocument() + }) + + it('should default to `type="button"`', async () => { + render() + + expect(screen.getByRole('button')).toHaveAttribute('type', 'button') + }) + + it('should render a button using a render prop', () => { + render() + + expect(screen.getByRole('button').textContent).toEqual( + JSON.stringify({ + disabled: false, + hover: false, + focus: false, + active: false, + autofocus: false, + }) + ) + }) + + it('should map the `autoFocus` prop to a `data-autofocus` attribute', () => { + render() + + expect(screen.getByRole('button')).toHaveAttribute('data-autofocus') + }) + }) +}) diff --git a/packages/@headlessui-react/src/components/button/button.tsx b/packages/@headlessui-react/src/components/button/button.tsx new file mode 100644 index 0000000000..031fef5220 --- /dev/null +++ b/packages/@headlessui-react/src/components/button/button.tsx @@ -0,0 +1,88 @@ +'use client' + +import { useFocusRing } from '@react-aria/focus' +import { useHover } from '@react-aria/interactions' +import { useMemo, type ElementType, type Ref } from 'react' +import { useActivePress } from '../../hooks/use-active-press' +import { useDisabled } from '../../internal/disabled' +import type { Props } from '../../types' +import { + forwardRefWithAs, + mergeProps, + render, + type HasDisplayName, + type RefProp, +} from '../../utils/render' + +let DEFAULT_BUTTON_TAG = 'button' as const + +type ButtonRenderPropArg = { + disabled: boolean + hover: boolean + focus: boolean + active: boolean + autofocus: boolean +} +type ButtonPropsWeControl = never + +export type ButtonProps = Props< + TTag, + ButtonRenderPropArg, + ButtonPropsWeControl, + { + disabled?: boolean + autoFocus?: boolean + type?: 'button' | 'submit' | 'reset' + } +> + +function ButtonFn( + props: ButtonProps, + ref: Ref +) { + let providedDisabled = useDisabled() + let { disabled = providedDisabled || false, ...theirProps } = props + + let { isFocusVisible: focus, focusProps } = useFocusRing({ autoFocus: props.autoFocus ?? false }) + let { isHovered: hover, hoverProps } = useHover({ isDisabled: disabled }) + let { pressed: active, pressProps } = useActivePress({ disabled }) + + let ourProps = mergeProps( + { + ref, + disabled: disabled || undefined, + type: theirProps.type ?? 'button', + }, + focusProps, + hoverProps, + pressProps + ) + + let slot = useMemo( + () => + ({ + disabled, + hover, + focus, + active, + autofocus: props.autoFocus ?? false, + }) satisfies ButtonRenderPropArg, + [disabled, hover, focus, active, props.autoFocus] + ) + + return render({ + ourProps, + theirProps, + slot, + defaultTag: DEFAULT_BUTTON_TAG, + name: 'Button', + }) +} + +export interface _internal_ComponentButton extends HasDisplayName { + ( + props: ButtonProps & RefProp + ): JSX.Element +} + +export let Button = forwardRefWithAs(ButtonFn) as unknown as _internal_ComponentButton diff --git a/packages/@headlessui-react/src/components/checkbox/checkbox.test.tsx b/packages/@headlessui-react/src/components/checkbox/checkbox.test.tsx new file mode 100644 index 0000000000..b8b2c46496 --- /dev/null +++ b/packages/@headlessui-react/src/components/checkbox/checkbox.test.tsx @@ -0,0 +1,121 @@ +import { render } from '@testing-library/react' +import React, { useState } from 'react' +import { + CheckboxState, + assertCheckbox, + getCheckbox, +} from '../../test-utils/accessibility-assertions' +import { Keys, click, focus, press } from '../../test-utils/interactions' +import { + commonControlScenarios, + commonFormScenarios, + commonRenderingScenarios, +} from '../../test-utils/scenarios' +import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs' +import { Checkbox, type CheckboxProps } from './checkbox' + +commonRenderingScenarios(Checkbox, { getElement: getCheckbox }) +commonControlScenarios(Checkbox) +commonFormScenarios((props) => , { + async performUserInteraction(control) { + await click(control) + }, +}) + +describe('Rendering', () => { + it( + 'should be possible to put the checkbox in an indeterminate state', + suppressConsoleLogs(async () => { + render() + + assertCheckbox({ state: CheckboxState.Indeterminate }) + }) + ) + + it( + 'should be possible to put the checkbox in an default checked state', + suppressConsoleLogs(async () => { + render() + + assertCheckbox({ state: CheckboxState.Checked }) + }) + ) + + it( + 'should render a checkbox in an unchecked state', + suppressConsoleLogs(async () => { + render() + + assertCheckbox({ state: CheckboxState.Unchecked }) + }) + ) +}) + +describe.each([ + [ + 'Uncontrolled', + function Example(props: CheckboxProps) { + return + }, + ], + [ + 'Controlled', + function Example(props: CheckboxProps) { + let [checked, setChecked] = useState(false) + return + }, + ], +])('Keyboard interactions (%s)', (_, Example) => { + describe('`Space` key', () => { + it( + 'should be possible to toggle a checkbox', + suppressConsoleLogs(async () => { + render() + + assertCheckbox({ state: CheckboxState.Unchecked }) + + await focus(getCheckbox()) + await press(Keys.Space) + + assertCheckbox({ state: CheckboxState.Checked }) + + await press(Keys.Space) + + assertCheckbox({ state: CheckboxState.Unchecked }) + }) + ) + }) +}) + +describe.each([ + [ + 'Uncontrolled', + function Example(props: CheckboxProps) { + return + }, + ], + [ + 'Controlled', + function Example(props: CheckboxProps) { + let [checked, setChecked] = useState(false) + return + }, + ], +])('Mouse interactions (%s)', (_, Example) => { + it( + 'should be possible to toggle a checkbox by clicking it', + suppressConsoleLogs(async () => { + render() + + assertCheckbox({ state: CheckboxState.Unchecked }) + + await click(getCheckbox()) + + assertCheckbox({ state: CheckboxState.Checked }) + + await click(getCheckbox()) + + assertCheckbox({ state: CheckboxState.Unchecked }) + }) + ) +}) diff --git a/packages/@headlessui-react/src/components/checkbox/checkbox.tsx b/packages/@headlessui-react/src/components/checkbox/checkbox.tsx new file mode 100644 index 0000000000..ec1cbf04b1 --- /dev/null +++ b/packages/@headlessui-react/src/components/checkbox/checkbox.tsx @@ -0,0 +1,193 @@ +'use client' + +import { useFocusRing } from '@react-aria/focus' +import { useHover } from '@react-aria/interactions' +import React, { + useCallback, + useMemo, + useState, + type ElementType, + type KeyboardEvent as ReactKeyboardEvent, + type MouseEvent as ReactMouseEvent, + type Ref, +} from 'react' +import { useActivePress } from '../../hooks/use-active-press' +import { useControllable } from '../../hooks/use-controllable' +import { useDisposables } from '../../hooks/use-disposables' +import { useEvent } from '../../hooks/use-event' +import { useId } from '../../hooks/use-id' +import { useDisabled } from '../../internal/disabled' +import { FormFields } from '../../internal/form-fields' +import { useProvidedId } from '../../internal/id' +import type { Props } from '../../types' +import { isDisabledReactIssue7711 } from '../../utils/bugs' +import { + forwardRefWithAs, + mergeProps, + render, + type HasDisplayName, + type RefProp, +} from '../../utils/render' +import { useDescribedBy } from '../description/description' +import { Keys } from '../keyboard' +import { useLabelledBy } from '../label/label' + +let DEFAULT_CHECKBOX_TAG = 'span' as const +type CheckboxRenderPropArg = { + checked: boolean + changing: boolean + focus: boolean + active: boolean + hover: boolean + autofocus: boolean + disabled: boolean + indeterminate: boolean +} +type CheckboxPropsWeControl = + | 'aria-checked' + | 'aria-describedby' + | 'aria-disabled' + | 'aria-labelledby' + | 'role' + | 'tabIndex' + +export type CheckboxProps< + TTag extends ElementType = typeof DEFAULT_CHECKBOX_TAG, + TType = string, +> = Props< + TTag, + CheckboxRenderPropArg, + CheckboxPropsWeControl, + { + value?: TType + disabled?: boolean + indeterminate?: boolean + + checked?: boolean + defaultChecked?: boolean + autoFocus?: boolean + form?: string + name?: string + onChange?: (checked: boolean) => void + } +> + +function CheckboxFn( + props: CheckboxProps, + ref: Ref +) { + let internalId = useId() + let providedId = useProvidedId() + let providedDisabled = useDisabled() + let { + id = providedId || `headlessui-checkbox-${internalId}`, + disabled = providedDisabled || false, + checked: controlledChecked, + defaultChecked = false, + onChange: controlledOnChange, + name, + value, + form, + indeterminate = false, + ...theirProps + } = props + + let [checked, onChange] = useControllable(controlledChecked, controlledOnChange, defaultChecked) + + let labelledBy = useLabelledBy() + let describedBy = useDescribedBy() + + let d = useDisposables() + let [changing, setChanging] = useState(false) + let toggle = useEvent(() => { + setChanging(true) + onChange?.(!checked) + + d.nextFrame(() => { + setChanging(false) + }) + }) + + let handleClick = useEvent((event: ReactMouseEvent) => { + if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault() + toggle() + }) + + let handleKeyDown = useEvent((event: ReactKeyboardEvent) => { + if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault() + + switch (event.key) { + case Keys.Space: + event.preventDefault() + toggle() + break + } + }) + + let { isFocusVisible: focus, focusProps } = useFocusRing({ autoFocus: props.autoFocus ?? false }) + let { isHovered: hover, hoverProps } = useHover({ isDisabled: disabled ?? false }) + let { pressed: active, pressProps } = useActivePress({ disabled: disabled ?? false }) + + let ourProps = mergeProps( + { + ref, + id, + role: 'checkbox', + 'aria-checked': indeterminate ? 'mixed' : checked ? 'true' : 'false', + 'aria-labelledby': labelledBy, + 'aria-describedby': describedBy, + 'aria-disabled': disabled ? true : undefined, + indeterminate: indeterminate ? 'true' : undefined, + tabIndex: 0, + onKeyDown: disabled ? undefined : handleKeyDown, + onClick: disabled ? undefined : handleClick, + }, + focusProps, + hoverProps, + pressProps + ) + + let slot = useMemo( + () => + ({ + checked, + disabled, + hover, + focus, + active, + indeterminate, + changing, + autofocus: props.autoFocus ?? false, + }) satisfies CheckboxRenderPropArg, + [checked, indeterminate, disabled, hover, focus, active, changing, props.autoFocus] + ) + + let reset = useCallback(() => { + return onChange?.(defaultChecked) + }, [onChange /* Explicitly ignoring `defaultChecked` */]) + + return ( + <> + {name != null && ( + + )} + {render({ + ourProps, + theirProps, + slot, + defaultTag: DEFAULT_CHECKBOX_TAG, + name: 'Checkbox', + })} + + ) +} + +// --- + +export interface _internal_ComponentCheckbox extends HasDisplayName { + ( + props: CheckboxProps & RefProp + ): JSX.Element +} + +export let Checkbox = forwardRefWithAs(CheckboxFn) as unknown as _internal_ComponentCheckbox diff --git a/packages/@headlessui-react/src/components/combobox-button/combobox-button.tsx b/packages/@headlessui-react/src/components/combobox-button/combobox-button.tsx new file mode 100644 index 0000000000..5c9d1209e6 --- /dev/null +++ b/packages/@headlessui-react/src/components/combobox-button/combobox-button.tsx @@ -0,0 +1,3 @@ +// Next.js barrel file improvements (GENERATED FILE) +export type * from '../combobox/combobox' +export { ComboboxButton } from '../combobox/combobox' diff --git a/packages/@headlessui-react/src/components/combobox-input/combobox-input.tsx b/packages/@headlessui-react/src/components/combobox-input/combobox-input.tsx new file mode 100644 index 0000000000..d2ca6f9cbd --- /dev/null +++ b/packages/@headlessui-react/src/components/combobox-input/combobox-input.tsx @@ -0,0 +1,3 @@ +// Next.js barrel file improvements (GENERATED FILE) +export type * from '../combobox/combobox' +export { ComboboxInput } from '../combobox/combobox' diff --git a/packages/@headlessui-react/src/components/combobox-label/combobox-label.tsx b/packages/@headlessui-react/src/components/combobox-label/combobox-label.tsx new file mode 100644 index 0000000000..452189ef51 --- /dev/null +++ b/packages/@headlessui-react/src/components/combobox-label/combobox-label.tsx @@ -0,0 +1,3 @@ +// Next.js barrel file improvements (GENERATED FILE) +export type * from '../combobox/combobox' +export { ComboboxLabel } from '../combobox/combobox' diff --git a/packages/@headlessui-react/src/components/combobox-option/combobox-option.tsx b/packages/@headlessui-react/src/components/combobox-option/combobox-option.tsx new file mode 100644 index 0000000000..33e774daaf --- /dev/null +++ b/packages/@headlessui-react/src/components/combobox-option/combobox-option.tsx @@ -0,0 +1,3 @@ +// Next.js barrel file improvements (GENERATED FILE) +export type * from '../combobox/combobox' +export { ComboboxOption } from '../combobox/combobox' diff --git a/packages/@headlessui-react/src/components/combobox-options/combobox-options.tsx b/packages/@headlessui-react/src/components/combobox-options/combobox-options.tsx new file mode 100644 index 0000000000..7044ed59ab --- /dev/null +++ b/packages/@headlessui-react/src/components/combobox-options/combobox-options.tsx @@ -0,0 +1,3 @@ +// Next.js barrel file improvements (GENERATED FILE) +export type * from '../combobox/combobox' +export { ComboboxOptions } from '../combobox/combobox' diff --git a/packages/@headlessui-react/src/components/combobox/combobox.test.tsx b/packages/@headlessui-react/src/components/combobox/combobox.test.tsx index 7a9bb90e50..525f345c0f 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.test.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.test.tsx @@ -1,6 +1,8 @@ import { render } from '@testing-library/react' import React, { createElement, useEffect, useState } from 'react' import { + ComboboxMode, + ComboboxState, assertActiveComboboxOption, assertActiveElement, assertCombobox, @@ -15,23 +17,21 @@ import { assertNoActiveComboboxOption, assertNoSelectedComboboxOption, assertNotActiveComboboxOption, - ComboboxMode, - ComboboxState, getByText, getComboboxButton, getComboboxButtons, - getComboboxes, getComboboxInput, getComboboxInputs, getComboboxLabel, getComboboxOptions, + getComboboxes, } from '../../test-utils/accessibility-assertions' import { + Keys, + MouseButton, blur, click, focus, - Keys, - MouseButton, mouseLeave, mouseMove, press, @@ -41,7 +41,7 @@ import { word, } from '../../test-utils/interactions' import { mockingConsoleLogs, suppressConsoleLogs } from '../../test-utils/suppress-console-logs' -import { Transition } from '../transitions/transition' +import { Transition } from '../transition/transition' import { Combobox } from './combobox' let NOOP = () => {} @@ -70,10 +70,17 @@ describe('safeguards', () => { ])( 'should error when we are using a <%s /> without a parent ', suppressConsoleLogs((name, Component) => { - // @ts-expect-error This is fine - expect(() => render(createElement(Component))).toThrowError( - `<${name} /> is missing a parent component.` - ) + if (name === 'Combobox.Label') { + // @ts-expect-error This is fine + expect(() => render(createElement(Component))).toThrow( + 'You used a