From ea268704802cf1de1149ddc227e1a718ee71480e Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 27 Jan 2022 16:42:47 +0100 Subject: [PATCH 1/9] Add `Combobox` component (#1047) * start of combobox * start with a copy of the Listbox * WIP * Add Vue Combobox * Update Vue version of combobox * Update tests * Fix typescript errors in combobox test * Fix input label The spec says that the combobox itself is labelled directly by the associated label. The button can however be labelled by the label or itself. * Add active descendant to combobox/input * Add listbox role to comobox options Right now the option list *is* just a listbox. If we were to allow other types in the future this will need to be changable * Update tests * move React playground to dedicated package * add react playground script to root * ensure we only open/close the combobox when necessary * ensure export order is correct * remove leftover pages directory from React package * Only add aria controls when combobox is open * add missing next commands * make typescript happy * build @headlessui/react before building playground-react * add empty public folder This makes vercel happy * wip * Add todo * Update tests Still more updates to do but some are blocked on implementation * change default combobox example slightly * ensure that we sync the input with new state When the changes, then the input should change as well. * only sync the value with the input in a single spot * WIP: object value to string * WIP * WIP * WIP groups * Add static search filtering to combobox * Move mouse leave event to combobox * Fix use in fragments * Update * WIP * make all tests pass for the combobox in React * remove unnecessary playground item * remove listbox wip * only fire change event on inputs Potentially we also have to do this for all kinds of form inputs. But this will do for now. * disable combobox vue tests * Fix vue typescript errors * Vue tests WIP * improve combobox playgrounds a tiny bit * ensure to lookup the correct value * make sure that we are using a div instead of a Fragment * expose `activeItem` This will be similar to `yourData[activeIndex]`, but in this case the active option's data. Can probably rename this if necessary! * Update comments * Port react tests to Vue * Vue tests WIP * WIP * Rename activeItem to activeOption * Move display value to input * Update playgrounds * Remove static filtering * Add tests for display value * WIP Vue Tests * WIP * unfocus suite * Cleanup react accessibility assertions code * Vue WIP * Cleanup errors in react interactions test utils * Update vue implementation closer :D * Fix searching * Update * Add display value stubs * Update tests * move `` to `` * use `useLatestValue` hook * make `onChange` explicitly required * remove unused variables * move `` to `` * use correct event * use `let` for consistency * remove unnecessary hidden check * implement displayValue for Vue * update playground to reflect changes * make sure that the activeOptionIndex stays correct * update changelog Co-authored-by: Jordan Pittman --- CHANGELOG.md | 9 +- package.json | 2 + packages/@headlessui-react/package.json | 1 + .../src/components/combobox/combobox.test.tsx | 4917 +++++++++++++++ .../src/components/combobox/combobox.tsx | 924 +++ .../src/hooks/use-computed.ts | 8 +- .../src/hooks/use-latest-value.ts | 11 + packages/@headlessui-react/src/index.test.ts | 1 + packages/@headlessui-react/src/index.ts | 1 + .../test-utils/accessibility-assertions.ts | 453 +- .../src/test-utils/interactions.ts | 34 +- .../src/utils/disposables.ts | 11 + .../src/components/combobox/combobox.test.tsx | 5334 +++++++++++++++++ .../src/components/combobox/combobox.ts | 686 +++ packages/@headlessui-vue/src/index.test.ts | 8 + packages/@headlessui-vue/src/index.ts | 1 + .../test-utils/accessibility-assertions.ts | 453 +- .../src/test-utils/interactions.ts | 39 +- .../@headlessui-vue/src/utils/disposables.ts | 11 + packages/playground-react/next-env.d.ts | 5 + packages/playground-react/next.config.js | 5 + packages/playground-react/package.json | 23 + packages/playground-react/pages/_app.tsx | 239 + packages/playground-react/pages/_error.tsx | 63 + .../combobox/combobox-with-pure-tailwind.tsx | 136 + .../combobox/command-palette-with-groups.tsx | 150 + .../pages/combobox/command-palette.tsx | 135 + .../playground-react/pages/dialog/dialog.tsx | 238 + .../pages/disclosure/disclosure.tsx | 25 + .../listbox/listbox-with-pure-tailwind.tsx | 115 + .../pages/listbox/multiple-elements.tsx | 134 + .../pages/menu/menu-with-framer-motion.tsx | 111 + .../pages/menu/menu-with-popper.tsx | 95 + .../menu/menu-with-transition-and-popper.tsx | 89 + .../pages/menu/menu-with-transition.tsx | 73 + packages/playground-react/pages/menu/menu.tsx | 72 + .../pages/menu/multiple-elements.tsx | 83 + .../pages/popover/popover.tsx | 116 + .../pages/radio-group/radio-group.tsx | 101 + .../switch/switch-with-pure-tailwind.tsx | 39 + .../pages/tabs/tabs-with-pure-tailwind.tsx | 86 + .../component-examples/dropdown.tsx | 98 + .../transitions/component-examples/modal.tsx | 168 + .../component-examples/nested/hidden.tsx | 60 + .../component-examples/nested/unmount.tsx | 60 + .../component-examples/peek-a-boo.tsx | 38 + .../full-page-transition.tsx | 181 + .../layout-with-sidebar.tsx | 170 + packages/playground-react/public/favicon.ico | 0 packages/playground-react/tsconfig.json | 30 + .../playground-react/utils/class-names.ts | 3 + .../utils/hooks/use-popper.ts | 37 + packages/playground-react/utils/match.ts | 20 + .../utils/resolve-all-examples.ts | 50 + yarn.lock | 338 +- 55 files changed, 16186 insertions(+), 104 deletions(-) create mode 100644 packages/@headlessui-react/src/components/combobox/combobox.test.tsx create mode 100644 packages/@headlessui-react/src/components/combobox/combobox.tsx create mode 100644 packages/@headlessui-react/src/hooks/use-latest-value.ts create mode 100644 packages/@headlessui-vue/src/components/combobox/combobox.test.tsx create mode 100644 packages/@headlessui-vue/src/components/combobox/combobox.ts create mode 100644 packages/playground-react/next-env.d.ts create mode 100644 packages/playground-react/next.config.js create mode 100644 packages/playground-react/package.json create mode 100644 packages/playground-react/pages/_app.tsx create mode 100644 packages/playground-react/pages/_error.tsx create mode 100644 packages/playground-react/pages/combobox/combobox-with-pure-tailwind.tsx create mode 100644 packages/playground-react/pages/combobox/command-palette-with-groups.tsx create mode 100644 packages/playground-react/pages/combobox/command-palette.tsx create mode 100644 packages/playground-react/pages/dialog/dialog.tsx create mode 100644 packages/playground-react/pages/disclosure/disclosure.tsx create mode 100644 packages/playground-react/pages/listbox/listbox-with-pure-tailwind.tsx create mode 100644 packages/playground-react/pages/listbox/multiple-elements.tsx create mode 100644 packages/playground-react/pages/menu/menu-with-framer-motion.tsx create mode 100644 packages/playground-react/pages/menu/menu-with-popper.tsx create mode 100644 packages/playground-react/pages/menu/menu-with-transition-and-popper.tsx create mode 100644 packages/playground-react/pages/menu/menu-with-transition.tsx create mode 100644 packages/playground-react/pages/menu/menu.tsx create mode 100644 packages/playground-react/pages/menu/multiple-elements.tsx create mode 100644 packages/playground-react/pages/popover/popover.tsx create mode 100644 packages/playground-react/pages/radio-group/radio-group.tsx create mode 100644 packages/playground-react/pages/switch/switch-with-pure-tailwind.tsx create mode 100644 packages/playground-react/pages/tabs/tabs-with-pure-tailwind.tsx create mode 100644 packages/playground-react/pages/transitions/component-examples/dropdown.tsx create mode 100644 packages/playground-react/pages/transitions/component-examples/modal.tsx create mode 100644 packages/playground-react/pages/transitions/component-examples/nested/hidden.tsx create mode 100644 packages/playground-react/pages/transitions/component-examples/nested/unmount.tsx create mode 100644 packages/playground-react/pages/transitions/component-examples/peek-a-boo.tsx create mode 100644 packages/playground-react/pages/transitions/full-page-examples/full-page-transition.tsx create mode 100644 packages/playground-react/pages/transitions/full-page-examples/layout-with-sidebar.tsx create mode 100644 packages/playground-react/public/favicon.ico create mode 100644 packages/playground-react/tsconfig.json create mode 100644 packages/playground-react/utils/class-names.ts create mode 100644 packages/playground-react/utils/hooks/use-popper.ts create mode 100644 packages/playground-react/utils/match.ts create mode 100644 packages/playground-react/utils/resolve-all-examples.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 44cccf4e3d..46dedd4452 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Improve controlled Tabs behaviour ([#1050](https://github.com/tailwindlabs/headlessui/pull/1050)) - Improve typeahead search logic ([#1051](https://github.com/tailwindlabs/headlessui/pull/1051)) +### Added + +- Add `Combobox` component ([#1047](https://github.com/tailwindlabs/headlessui/pull/1047)) + ## [Unreleased - @headlessui/vue] ### Fixed @@ -20,6 +24,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Ensure correct order when conditionally rendering `MenuItem`, `ListboxOption` and `RadioGroupOption` ([#1045](https://github.com/tailwindlabs/headlessui/pull/1045)) - Improve typeahead search logic ([#1051](https://github.com/tailwindlabs/headlessui/pull/1051)) +### Added + +- Add `Combobox` component ([#1047](https://github.com/tailwindlabs/headlessui/pull/1047)) + ## [@headlessui/react@v1.4.3] - 2022-01-14 ### Fixes @@ -88,7 +96,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `aria-orientation` to `Listbox`, which swaps Up/Down with Left/Right keys ([#683](https://github.com/tailwindlabs/headlessui/pull/683)) - Expose `close` function from the render prop for `Disclosure`, `Disclosure.Panel`, `Popover` and `Popover.Panel` ([#697](https://github.com/tailwindlabs/headlessui/pull/697)) - ## [@headlessui/vue@v1.4.0] - 2021-07-29 ### Added diff --git a/package.json b/package.json index b4996f4678..7f6b020967 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,8 @@ ], "scripts": { "react": "yarn workspace @headlessui/react", + "react-playground": "yarn workspace playground-react dev", + "playground-react": "yarn workspace playground-react dev", "vue": "yarn workspace @headlessui/vue", "shared": "yarn workspace @headlessui/shared", "build": "yarn workspaces run build", diff --git a/packages/@headlessui-react/package.json b/packages/@headlessui-react/package.json index f9b2721cdc..a638b2d81a 100644 --- a/packages/@headlessui-react/package.json +++ b/packages/@headlessui-react/package.json @@ -26,6 +26,7 @@ "prepublishOnly": "npm run build", "test": "../../scripts/test.sh", "build": "../../scripts/build.sh", + "watch": "../../scripts/watch.sh", "lint": "../../scripts/lint.sh" }, "peerDependencies": { diff --git a/packages/@headlessui-react/src/components/combobox/combobox.test.tsx b/packages/@headlessui-react/src/components/combobox/combobox.test.tsx new file mode 100644 index 0000000000..60c6375b12 --- /dev/null +++ b/packages/@headlessui-react/src/components/combobox/combobox.test.tsx @@ -0,0 +1,4917 @@ +import React, { createElement, useState, useEffect } from 'react' +import { render } from '@testing-library/react' + +import { Combobox } from './combobox' +import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs' +import { + click, + focus, + mouseMove, + mouseLeave, + press, + shift, + type, + word, + Keys, + MouseButton, +} from '../../test-utils/interactions' +import { + assertActiveElement, + assertActiveComboboxOption, + assertComboboxList, + assertComboboxButton, + assertComboboxButtonLinkedWithCombobox, + assertComboboxButtonLinkedWithComboboxLabel, + assertComboboxOption, + assertComboboxLabel, + assertComboboxLabelLinkedWithCombobox, + assertNoActiveComboboxOption, + assertNoSelectedComboboxOption, + getComboboxInput, + getComboboxButton, + getComboboxButtons, + getComboboxInputs, + getComboboxOptions, + getComboboxLabel, + ComboboxState, + getByText, + getComboboxes, +} from '../../test-utils/accessibility-assertions' +import { Transition } from '../transitions/transition' + +let NOOP = () => {} + +jest.mock('../../hooks/use-id') + +beforeAll(() => { + jest.spyOn(window, 'requestAnimationFrame').mockImplementation(setImmediate as any) + jest.spyOn(window, 'cancelAnimationFrame').mockImplementation(clearImmediate as any) +}) + +afterAll(() => jest.restoreAllMocks()) + +describe('safeguards', () => { + it.each([ + ['Combobox.Button', Combobox.Button], + ['Combobox.Label', Combobox.Label], + ['Combobox.Options', Combobox.Options], + ['Combobox.Option', Combobox.Option], + ])( + '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.` + ) + }) + ) + + it( + 'should be possible to render a Combobox without crashing', + suppressConsoleLogs(async () => { + render( + + + Trigger + + Option A + Option B + Option C + + + ) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + }) + ) +}) + +describe('Rendering', () => { + describe('Combobox', () => { + it( + 'should be possible to render a Combobox using a render prop', + suppressConsoleLogs(async () => { + render( + + {({ open }) => ( + <> + + Trigger + {open && ( + + Option A + Option B + Option C + + )} + + )} + + ) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + await click(getComboboxButton()) + + assertComboboxButton({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.Visible }) + }) + ) + + it( + 'should be possible to disable a Combobox', + suppressConsoleLogs(async () => { + render( + + + Trigger + + Option A + Option B + Option C + + + ) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + await click(getComboboxButton()) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + await press(Keys.Enter, getComboboxButton()) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + }) + ) + }) + + describe('Combobox.Input', () => { + it( + 'selecting an option puts the value into Combobox.Input when displayValue is not provided', + suppressConsoleLogs(async () => { + function Example() { + let [value, setValue] = useState(undefined) + + return ( + + + Trigger + + Option A + Option B + Option C + + + ) + } + + render() + + await click(getComboboxButton()) + + assertComboboxList({ state: ComboboxState.Visible }) + + await click(getComboboxOptions()[1]) + + expect(getComboboxInput()).toHaveValue('b') + }) + ) + + it( + 'selecting an option puts the display value into Combobox.Input when displayValue is provided', + suppressConsoleLogs(async () => { + function Example() { + let [value, setValue] = useState(undefined) + + return ( + + str?.toUpperCase() ?? ''} + /> + Trigger + + Option A + Option B + Option C + + + ) + } + + render() + + await click(getComboboxButton()) + + assertComboboxList({ state: ComboboxState.Visible }) + + await click(getComboboxOptions()[1]) + + expect(getComboboxInput()).toHaveValue('B') + }) + ) + }) + + describe('Combobox.Label', () => { + it( + 'should be possible to render a Combobox.Label using a render prop', + suppressConsoleLogs(async () => { + render( + + {JSON.stringify} + + Trigger + + Option A + Option B + Option C + + + ) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-3' }, + }) + assertComboboxLabel({ + attributes: { id: 'headlessui-combobox-label-1' }, + textContent: JSON.stringify({ open: false, disabled: false }), + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + await click(getComboboxButton()) + + assertComboboxLabel({ + attributes: { id: 'headlessui-combobox-label-1' }, + textContent: JSON.stringify({ open: true, disabled: false }), + }) + assertComboboxList({ state: ComboboxState.Visible }) + assertComboboxLabelLinkedWithCombobox() + assertComboboxButtonLinkedWithComboboxLabel() + }) + ) + + it( + 'should be possible to render a Combobox.Label using a render prop and an `as` prop', + suppressConsoleLogs(async () => { + render( + + {JSON.stringify} + + Trigger + + Option A + Option B + Option C + + + ) + + assertComboboxLabel({ + attributes: { id: 'headlessui-combobox-label-1' }, + textContent: JSON.stringify({ open: false, disabled: false }), + tag: 'p', + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + await click(getComboboxButton()) + assertComboboxLabel({ + attributes: { id: 'headlessui-combobox-label-1' }, + textContent: JSON.stringify({ open: true, disabled: false }), + tag: 'p', + }) + assertComboboxList({ state: ComboboxState.Visible }) + }) + ) + }) + + describe('Combobox.Button', () => { + it( + 'should be possible to render a Combobox.Button using a render prop', + suppressConsoleLogs(async () => { + render( + + + {JSON.stringify} + + Option A + Option B + Option C + + + ) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + textContent: JSON.stringify({ open: false, disabled: false }), + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + await click(getComboboxButton()) + + assertComboboxButton({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-button-2' }, + textContent: JSON.stringify({ open: true, disabled: false }), + }) + assertComboboxList({ state: ComboboxState.Visible }) + }) + ) + + it( + 'should be possible to render a Combobox.Button using a render prop and an `as` prop', + suppressConsoleLogs(async () => { + render( + + + + {JSON.stringify} + + + Option A + Option B + Option C + + + ) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + textContent: JSON.stringify({ open: false, disabled: false }), + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + await click(getComboboxButton()) + + assertComboboxButton({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-button-2' }, + textContent: JSON.stringify({ open: true, disabled: false }), + }) + assertComboboxList({ state: ComboboxState.Visible }) + }) + ) + + it( + 'should be possible to render a Combobox.Button and a Combobox.Label and see them linked together', + suppressConsoleLogs(async () => { + render( + + Label + + Trigger + + Option A + Option B + Option C + + + ) + + // TODO: Needed to make it similar to vue test implementation? + // await new Promise(requestAnimationFrame) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-3' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxButtonLinkedWithComboboxLabel() + }) + ) + + describe('`type` attribute', () => { + it('should set the `type` to "button" by default', async () => { + render( + + + Trigger + + ) + + expect(getComboboxButton()).toHaveAttribute('type', 'button') + }) + + it('should not set the `type` to "button" if it already contains a `type`', async () => { + render( + + + Trigger + + ) + + expect(getComboboxButton()).toHaveAttribute('type', 'submit') + }) + + it('should set the `type` to "button" when using the `as` prop which resolves to a "button"', async () => { + let CustomButton = React.forwardRef((props, ref) => ( + + + ) + + // Click the combobox button + await click(getComboboxButton()) + + // Ensure the combobox is open + assertComboboxList({ state: ComboboxState.Visible }) + + // Click the span inside the button + await click(getByText('Next')) + + // Ensure the combobox is closed + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Ensure the outside button is focused + assertActiveElement(document.getElementById('btn')) + + // Ensure that the focus button only got focus once (first click) + expect(focusFn).toHaveBeenCalledTimes(1) + }) + ) + + it( + 'should be possible to hover an option and make it active', + suppressConsoleLogs(async () => { + render( + + + Trigger + + alice + bob + charlie + + + ) + + // Open combobox + await click(getComboboxButton()) + + let options = getComboboxOptions() + // We should be able to go to the second option + await mouseMove(options[1]) + assertActiveComboboxOption(options[1]) + + // We should be able to go to the first option + await mouseMove(options[0]) + assertActiveComboboxOption(options[0]) + + // We should be able to go to the last option + await mouseMove(options[2]) + assertActiveComboboxOption(options[2]) + }) + ) + + it( + 'should make a combobox option active when you move the mouse over it', + suppressConsoleLogs(async () => { + render( + + + Trigger + + alice + bob + charlie + + + ) + + // Open combobox + await click(getComboboxButton()) + + let options = getComboboxOptions() + // We should be able to go to the second option + await mouseMove(options[1]) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + 'should be a no-op when we move the mouse and the combobox option is already active', + suppressConsoleLogs(async () => { + render( + + + Trigger + + alice + bob + charlie + + + ) + + // Open combobox + await click(getComboboxButton()) + + let options = getComboboxOptions() + + // We should be able to go to the second option + await mouseMove(options[1]) + assertActiveComboboxOption(options[1]) + + await mouseMove(options[1]) + + // Nothing should be changed + assertActiveComboboxOption(options[1]) + }) + ) + + it( + 'should be a no-op when we move the mouse and the combobox option is disabled', + suppressConsoleLogs(async () => { + render( + + + Trigger + + alice + + bob + + charlie + + + ) + + // Open combobox + await click(getComboboxButton()) + + let options = getComboboxOptions() + + await mouseMove(options[1]) + assertNoActiveComboboxOption() + }) + ) + + it( + 'should not be possible to hover an option that is disabled', + suppressConsoleLogs(async () => { + render( + + + Trigger + + alice + + bob + + charlie + + + ) + + // Open combobox + await click(getComboboxButton()) + + let options = getComboboxOptions() + + // Try to hover over option 1, which is disabled + await mouseMove(options[1]) + + // We should not have an active option now + assertNoActiveComboboxOption() + }) + ) + + it( + 'should be possible to mouse leave an option and make it inactive', + suppressConsoleLogs(async () => { + render( + + + Trigger + + alice + bob + charlie + + + ) + + // Open combobox + await click(getComboboxButton()) + + let options = getComboboxOptions() + + // We should be able to go to the second option + await mouseMove(options[1]) + assertActiveComboboxOption(options[1]) + + await mouseLeave(options[1]) + assertNoActiveComboboxOption() + + // We should be able to go to the first option + await mouseMove(options[0]) + assertActiveComboboxOption(options[0]) + + await mouseLeave(options[0]) + assertNoActiveComboboxOption() + + // We should be able to go to the last option + await mouseMove(options[2]) + assertActiveComboboxOption(options[2]) + + await mouseLeave(options[2]) + assertNoActiveComboboxOption() + }) + ) + + it( + 'should be possible to mouse leave a disabled option and be a no-op', + suppressConsoleLogs(async () => { + render( + + + Trigger + + alice + + bob + + charlie + + + ) + + // Open combobox + await click(getComboboxButton()) + + let options = getComboboxOptions() + + // Try to hover over option 1, which is disabled + await mouseMove(options[1]) + assertNoActiveComboboxOption() + + await mouseLeave(options[1]) + assertNoActiveComboboxOption() + }) + ) + + it( + 'should be possible to click a combobox option, which closes the combobox', + suppressConsoleLogs(async () => { + let handleChange = jest.fn() + function Example() { + let [value, setValue] = useState(undefined) + + return ( + { + setValue(value) + handleChange(value) + }} + > + + Trigger + + alice + bob + charlie + + + ) + } + + render() + + // Open combobox + await click(getComboboxButton()) + assertComboboxList({ state: ComboboxState.Visible }) + assertActiveElement(getComboboxInput()) + + let options = getComboboxOptions() + + // We should be able to click the first option + await click(options[1]) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + expect(handleChange).toHaveBeenCalledTimes(1) + expect(handleChange).toHaveBeenCalledWith('bob') + + // Verify the input is focused again + assertActiveElement(getComboboxInput()) + + // Open combobox again + await click(getComboboxButton()) + + // Verify the active option is the previously selected one + assertActiveComboboxOption(getComboboxOptions()[1]) + }) + ) + + it( + 'should be possible to click a disabled combobox option, which is a no-op', + suppressConsoleLogs(async () => { + let handleChange = jest.fn() + function Example() { + let [value, setValue] = useState(undefined) + + return ( + { + setValue(value) + handleChange(value) + }} + > + + Trigger + + alice + + bob + + charlie + + + ) + } + + render() + + // Open combobox + await click(getComboboxButton()) + assertComboboxList({ state: ComboboxState.Visible }) + assertActiveElement(getComboboxInput()) + + let options = getComboboxOptions() + + // We should be able to click the first option + await click(options[1]) + assertComboboxList({ state: ComboboxState.Visible }) + assertActiveElement(getComboboxInput()) + expect(handleChange).toHaveBeenCalledTimes(0) + + // Close the combobox + await click(getComboboxButton()) + + // Open combobox again + await click(getComboboxButton()) + + // Verify the active option is non existing + assertNoActiveComboboxOption() + }) + ) + + it( + 'should be possible focus a combobox option, so that it becomes active', + suppressConsoleLogs(async () => { + function Example() { + let [value, setValue] = useState(undefined) + + return ( + + + Trigger + + alice + bob + charlie + + + ) + } + + render() + + // Open combobox + await click(getComboboxButton()) + assertComboboxList({ state: ComboboxState.Visible }) + assertActiveElement(getComboboxInput()) + + let options = getComboboxOptions() + + // Verify that nothing is active yet + assertNoActiveComboboxOption() + + // We should be able to focus the first option + await focus(options[1]) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + 'should not be possible to focus a combobox option which is disabled', + suppressConsoleLogs(async () => { + render( + + + Trigger + + alice + + bob + + charlie + + + ) + + // Open combobox + await click(getComboboxButton()) + assertComboboxList({ state: ComboboxState.Visible }) + assertActiveElement(getComboboxInput()) + + let options = getComboboxOptions() + + // We should not be able to focus the first option + await focus(options[1]) + assertNoActiveComboboxOption() + }) + ) +}) diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx new file mode 100644 index 0000000000..d0f327b940 --- /dev/null +++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx @@ -0,0 +1,924 @@ +import React, { + Fragment, + createContext, + createRef, + useCallback, + useContext, + useMemo, + useReducer, + useRef, + + // Types + Dispatch, + ElementType, + KeyboardEvent as ReactKeyboardEvent, + MouseEvent as ReactMouseEvent, + MutableRefObject, + Ref, + ContextType, +} from 'react' + +import { useDisposables } from '../../hooks/use-disposables' +import { useId } from '../../hooks/use-id' +import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect' +import { useComputed } from '../../hooks/use-computed' +import { useSyncRefs } from '../../hooks/use-sync-refs' +import { Props } from '../../types' +import { Features, forwardRefWithAs, PropsForFeatures, render } from '../../utils/render' +import { match } from '../../utils/match' +import { disposables } from '../../utils/disposables' +import { Keys } from '../keyboard' +import { Focus, calculateActiveIndex } from '../../utils/calculate-active-index' +import { isDisabledReactIssue7711 } from '../../utils/bugs' +import { isFocusableElement, FocusableMode } from '../../utils/focus-management' +import { useWindowEvent } from '../../hooks/use-window-event' +import { useOpenClosed, State, OpenClosedProvider } from '../../internal/open-closed' +import { useResolveButtonType } from '../../hooks/use-resolve-button-type' +import { useLatestValue } from '../../hooks/use-latest-value' + +enum ComboboxStates { + Open, + Closed, +} + +type ComboboxOptionDataRef = MutableRefObject<{ + textValue?: string + disabled: boolean + value: unknown +}> + +interface StateDefinition { + comboboxState: ComboboxStates + + orientation: 'horizontal' | 'vertical' + + propsRef: MutableRefObject<{ + value: unknown + onChange(value: unknown): void + }> + inputPropsRef: MutableRefObject<{ + displayValue?(item: unknown): string + }> + labelRef: MutableRefObject + inputRef: MutableRefObject + buttonRef: MutableRefObject + optionsRef: MutableRefObject + + disabled: boolean + options: { id: string; dataRef: ComboboxOptionDataRef }[] + activeOptionIndex: number | null +} + +enum ActionTypes { + OpenCombobox, + CloseCombobox, + + SetDisabled, + SetOrientation, + + GoToOption, + + RegisterOption, + UnregisterOption, +} + +type Actions = + | { type: ActionTypes.CloseCombobox } + | { type: ActionTypes.OpenCombobox } + | { type: ActionTypes.SetDisabled; disabled: boolean } + | { type: ActionTypes.SetOrientation; orientation: StateDefinition['orientation'] } + | { type: ActionTypes.GoToOption; focus: Focus.Specific; id: string } + | { type: ActionTypes.GoToOption; focus: Exclude } + | { type: ActionTypes.RegisterOption; id: string; dataRef: ComboboxOptionDataRef } + | { type: ActionTypes.UnregisterOption; id: string } + +let reducers: { + [P in ActionTypes]: ( + state: StateDefinition, + action: Extract + ) => StateDefinition +} = { + [ActionTypes.CloseCombobox](state) { + if (state.disabled) return state + if (state.comboboxState === ComboboxStates.Closed) return state + return { ...state, activeOptionIndex: null, comboboxState: ComboboxStates.Closed } + }, + [ActionTypes.OpenCombobox](state) { + if (state.disabled) return state + if (state.comboboxState === ComboboxStates.Open) return state + return { ...state, comboboxState: ComboboxStates.Open } + }, + [ActionTypes.SetDisabled](state, action) { + if (state.disabled === action.disabled) return state + return { ...state, disabled: action.disabled } + }, + [ActionTypes.SetOrientation](state, action) { + if (state.orientation === action.orientation) return state + return { ...state, orientation: action.orientation } + }, + [ActionTypes.GoToOption](state, action) { + if (state.disabled) return state + if (state.comboboxState === ComboboxStates.Closed) return state + + let activeOptionIndex = calculateActiveIndex(action, { + resolveItems: () => state.options, + resolveActiveIndex: () => state.activeOptionIndex, + resolveId: item => item.id, + resolveDisabled: item => item.dataRef.current.disabled, + }) + + if (state.activeOptionIndex === activeOptionIndex) return state + return { ...state, activeOptionIndex } + }, + [ActionTypes.RegisterOption]: (state, action) => { + let currentActiveOption = + state.activeOptionIndex !== null ? state.options[state.activeOptionIndex] : null + + let orderMap = Array.from( + state.optionsRef.current?.querySelectorAll('[id^="headlessui-combobox-option-"]')! + ).reduce( + (lookup, element, index) => Object.assign(lookup, { [element.id]: index }), + {} + ) as Record + + let options = [...state.options, { id: action.id, dataRef: action.dataRef }].sort( + (a, z) => orderMap[a.id] - orderMap[z.id] + ) + + return { + ...state, + options, + activeOptionIndex: (() => { + if (currentActiveOption === null) return null + + // If we inserted an option before the current active option then the + // active option index would be wrong. To fix this, we will re-lookup + // the correct index. + return options.indexOf(currentActiveOption) + })(), + } + }, + [ActionTypes.UnregisterOption]: (state, action) => { + let nextOptions = state.options.slice() + let currentActiveOption = + state.activeOptionIndex !== null ? nextOptions[state.activeOptionIndex] : null + + let idx = nextOptions.findIndex(a => a.id === action.id) + + if (idx !== -1) nextOptions.splice(idx, 1) + + return { + ...state, + options: nextOptions, + activeOptionIndex: (() => { + if (idx === state.activeOptionIndex) return null + if (currentActiveOption === null) return null + + // If we removed the option before the actual active index, then it would be out of sync. To + // fix this, we will find the correct (new) index position. + return nextOptions.indexOf(currentActiveOption) + })(), + } + }, +} + +let ComboboxContext = createContext<[StateDefinition, Dispatch] | null>(null) +ComboboxContext.displayName = 'ComboboxContext' + +function useComboboxContext(component: string) { + let context = useContext(ComboboxContext) + if (context === null) { + let err = new Error(`<${component} /> is missing a parent <${Combobox.name} /> component.`) + if (Error.captureStackTrace) Error.captureStackTrace(err, useComboboxContext) + throw err + } + return context +} + +let ComboboxActions = createContext<{ + selectOption(id: string): void + selectActiveOption(): void +} | null>(null) +ComboboxActions.displayName = 'ComboboxActions' + +function useComboboxActions() { + let context = useContext(ComboboxActions) + if (context === null) { + let err = new Error(`ComboboxActions is missing a parent <${Combobox.name} /> component.`) + if (Error.captureStackTrace) Error.captureStackTrace(err, useComboboxActions) + throw err + } + return context +} + +function stateReducer(state: StateDefinition, action: Actions) { + return match(action.type, reducers, state, action) +} + +// --- + +let DEFAULT_COMBOBOX_TAG = Fragment +interface ComboboxRenderPropArg { + open: boolean + disabled: boolean + activeIndex: number | null + activeOption: T | null +} + +export function Combobox( + props: Props< + TTag, + ComboboxRenderPropArg, + 'value' | 'onChange' | 'disabled' | 'horizontal' + > & { + value: TType + onChange(value: TType): void + disabled?: boolean + horizontal?: boolean + } +) { + let { value, onChange, disabled = false, horizontal = false, ...passThroughProps } = props + const orientation = horizontal ? 'horizontal' : 'vertical' + + let reducerBag = useReducer(stateReducer, { + comboboxState: ComboboxStates.Closed, + propsRef: { + current: { + value, + onChange, + }, + }, + inputPropsRef: { + current: { + displayValue: undefined, + }, + }, + labelRef: createRef(), + inputRef: createRef(), + buttonRef: createRef(), + optionsRef: createRef(), + disabled, + orientation, + options: [], + activeOptionIndex: null, + } as StateDefinition) + let [ + { + comboboxState, + options, + activeOptionIndex, + propsRef, + inputPropsRef, + optionsRef, + inputRef, + buttonRef, + }, + dispatch, + ] = reducerBag + + useIsoMorphicEffect(() => { + propsRef.current.value = value + }, [value, propsRef]) + useIsoMorphicEffect(() => { + propsRef.current.onChange = onChange + }, [onChange, propsRef]) + + useIsoMorphicEffect(() => dispatch({ type: ActionTypes.SetDisabled, disabled }), [disabled]) + useIsoMorphicEffect(() => dispatch({ type: ActionTypes.SetOrientation, orientation }), [ + orientation, + ]) + + // Handle outside click + useWindowEvent('mousedown', event => { + let target = event.target as HTMLElement + + if (comboboxState !== ComboboxStates.Open) return + + if (buttonRef.current?.contains(target)) return + if (inputRef.current?.contains(target)) return + if (optionsRef.current?.contains(target)) return + + dispatch({ type: ActionTypes.CloseCombobox }) + + if (!isFocusableElement(target, FocusableMode.Loose)) { + event.preventDefault() + inputRef.current?.focus() + } + }) + + let slot = useMemo>( + () => ({ + open: comboboxState === ComboboxStates.Open, + disabled, + activeIndex: activeOptionIndex, + activeOption: + activeOptionIndex === null + ? null + : (options[activeOptionIndex].dataRef.current.value as TType), + }), + [comboboxState, disabled, options, activeOptionIndex] + ) + + let syncInputValue = useCallback(() => { + if (!inputRef.current) return + if (value === undefined) return + let displayValue = inputPropsRef.current.displayValue + + if (typeof displayValue === 'function') { + inputRef.current.value = displayValue(value) + } else if (typeof value === 'string') { + inputRef.current.value = value + } + }, [value, inputRef, inputPropsRef]) + + let selectOption = useCallback( + (id: string) => { + let option = options.find(item => item.id === id) + if (!option) return + + let { dataRef } = option + propsRef.current.onChange(dataRef.current.value) + syncInputValue() + }, + [options, propsRef, inputRef] + ) + + let selectActiveOption = useCallback(() => { + if (activeOptionIndex !== null) { + let { dataRef } = options[activeOptionIndex] + propsRef.current.onChange(dataRef.current.value) + syncInputValue() + } + }, [activeOptionIndex, options, propsRef, inputRef]) + + let actionsBag = useMemo>( + () => ({ selectOption, selectActiveOption }), + [selectOption, selectActiveOption] + ) + + useIsoMorphicEffect(() => { + if (comboboxState !== ComboboxStates.Closed) { + return + } + syncInputValue() + }, [syncInputValue, comboboxState]) + + // Ensure that we update the inputRef if the value changes + useIsoMorphicEffect(syncInputValue, [syncInputValue]) + + return ( + + + + {render({ + props: passThroughProps, + slot, + defaultTag: DEFAULT_COMBOBOX_TAG, + name: 'Combobox', + })} + + + + ) +} + +// --- + +let DEFAULT_INPUT_TAG = 'input' as const +interface InputRenderPropArg { + open: boolean + disabled: boolean +} +type InputPropsWeControl = + | 'id' + | 'role' + | 'type' + | 'aria-labelledby' + | 'aria-expanded' + | 'aria-activedescendant' + | 'onKeyDown' + | 'onChange' + | 'displayValue' + +let Input = forwardRefWithAs(function Input< + TTag extends ElementType = typeof DEFAULT_INPUT_TAG, + // TODO: One day we will be able to infer this type from the generic in Combobox itself. + // But today is not that day.. + TType = Parameters[0]['value'] +>( + props: Props & { + displayValue?(item: TType): string + onChange(event: React.ChangeEvent): void + }, + ref: Ref +) { + let { value, onChange, displayValue, ...passThroughProps } = props + let [state, dispatch] = useComboboxContext([Combobox.name, Input.name].join('.')) + let actions = useComboboxActions() + + let inputRef = useSyncRefs(state.inputRef, ref) + let inputPropsRef = state.inputPropsRef + + let id = `headlessui-combobox-input-${useId()}` + let d = useDisposables() + + let onChangeRef = useLatestValue(onChange) + + useIsoMorphicEffect(() => { + inputPropsRef.current.displayValue = displayValue + }, [displayValue, inputPropsRef]) + + let handleKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + switch (event.key) { + // Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-12 + + case Keys.Enter: + event.preventDefault() + event.stopPropagation() + + actions.selectActiveOption() + dispatch({ type: ActionTypes.CloseCombobox }) + break + + case match(state.orientation, { vertical: Keys.ArrowDown, horizontal: Keys.ArrowRight }): + event.preventDefault() + event.stopPropagation() + return match(state.comboboxState, { + [ComboboxStates.Open]: () => { + return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Next }) + }, + [ComboboxStates.Closed]: () => { + dispatch({ type: ActionTypes.OpenCombobox }) + // TODO: We can't do this outside next frame because the options aren't rendered yet + // But doing this in next frame results in a flicker because the dom mutations are async here + // Basically: + // Sync -> no option list yet + // Next frame -> option list already rendered with selection -> dispatch -> next frame -> now we have the focus on the right element + + // TODO: The spec here is underspecified. There's mention of skipping to the next item when autocomplete has suggested something but nothing regarding a non-autocomplete selection/value + d.nextFrame(() => { + if (!state.propsRef.current.value) { + dispatch({ type: ActionTypes.GoToOption, focus: Focus.First }) + } + }) + }, + }) + + case match(state.orientation, { vertical: Keys.ArrowUp, horizontal: Keys.ArrowLeft }): + event.preventDefault() + event.stopPropagation() + return match(state.comboboxState, { + [ComboboxStates.Open]: () => { + return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Previous }) + }, + [ComboboxStates.Closed]: () => { + dispatch({ type: ActionTypes.OpenCombobox }) + d.nextFrame(() => { + if (!state.propsRef.current.value) { + dispatch({ type: ActionTypes.GoToOption, focus: Focus.Last }) + } + }) + }, + }) + + case Keys.Home: + case Keys.PageUp: + event.preventDefault() + event.stopPropagation() + return dispatch({ type: ActionTypes.GoToOption, focus: Focus.First }) + + case Keys.End: + case Keys.PageDown: + event.preventDefault() + event.stopPropagation() + return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Last }) + + case Keys.Escape: + event.preventDefault() + event.stopPropagation() + return dispatch({ type: ActionTypes.CloseCombobox }) + + case Keys.Tab: + actions.selectActiveOption() + dispatch({ type: ActionTypes.CloseCombobox }) + break + } + }, + [d, dispatch, state, actions] + ) + + let handleChange = useCallback( + (event: React.ChangeEvent) => { + dispatch({ type: ActionTypes.OpenCombobox }) + onChangeRef.current(event) + }, + [dispatch, onChangeRef] + ) + + // TODO: Verify this. The spec says that, for the input/combobox, the lebel is the labelling element when present + // Otherwise it's the ID of the non-label element + let labelledby = useComputed(() => { + if (!state.labelRef.current) return undefined + return [state.labelRef.current.id].join(' ') + }, [state.labelRef.current]) + + let slot = useMemo( + () => ({ open: state.comboboxState === ComboboxStates.Open, disabled: state.disabled }), + [state] + ) + + let propsWeControl = { + ref: inputRef, + id, + role: 'combobox', + type: 'text', + 'aria-controls': state.optionsRef.current?.id, + 'aria-expanded': state.disabled ? undefined : state.comboboxState === ComboboxStates.Open, + 'aria-activedescendant': + state.activeOptionIndex === null ? undefined : state.options[state.activeOptionIndex]?.id, + 'aria-labelledby': labelledby, + disabled: state.disabled, + onKeyDown: handleKeyDown, + onChange: handleChange, + } + + return render({ + props: { ...passThroughProps, ...propsWeControl }, + slot, + defaultTag: DEFAULT_INPUT_TAG, + name: 'Combobox.Input', + }) +}) + +// --- + +let DEFAULT_BUTTON_TAG = 'button' as const +interface ButtonRenderPropArg { + open: boolean + disabled: boolean +} +type ButtonPropsWeControl = + | 'id' + | 'type' + | 'tabIndex' + | 'aria-haspopup' + | 'aria-controls' + | 'aria-expanded' + | 'aria-labelledby' + | 'disabled' + | 'onClick' + | 'onKeyDown' + +let Button = forwardRefWithAs(function Button( + props: Props, + ref: Ref +) { + let [state, dispatch] = useComboboxContext([Combobox.name, Button.name].join('.')) + let actions = useComboboxActions() + let buttonRef = useSyncRefs(state.buttonRef, ref) + + let id = `headlessui-combobox-button-${useId()}` + let d = useDisposables() + + let handleKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + switch (event.key) { + // Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-12 + + case match(state.orientation, { vertical: Keys.ArrowDown, horizontal: Keys.ArrowRight }): + event.preventDefault() + event.stopPropagation() + if (state.comboboxState === ComboboxStates.Closed) { + dispatch({ type: ActionTypes.OpenCombobox }) + // TODO: We can't do this outside next frame because the options aren't rendered yet + // But doing this in next frame results in a flicker because the dom mutations are async here + // Basically: + // Sync -> no option list yet + // Next frame -> option list already rendered with selection -> dispatch -> next frame -> now we have the focus on the right element + + // TODO: The spec here is underspecified. There's mention of skipping to the next item when autocomplete has suggested something but nothing regarding a non-autocomplete selection/value + d.nextFrame(() => { + if (!state.propsRef.current.value) { + dispatch({ type: ActionTypes.GoToOption, focus: Focus.First }) + } + }) + } + return d.nextFrame(() => state.inputRef.current?.focus({ preventScroll: true })) + + case match(state.orientation, { vertical: Keys.ArrowUp, horizontal: Keys.ArrowLeft }): + event.preventDefault() + event.stopPropagation() + if (state.comboboxState === ComboboxStates.Closed) { + dispatch({ type: ActionTypes.OpenCombobox }) + d.nextFrame(() => { + if (!state.propsRef.current.value) { + dispatch({ type: ActionTypes.GoToOption, focus: Focus.Last }) + } + }) + } + return d.nextFrame(() => state.inputRef.current?.focus({ preventScroll: true })) + + case Keys.Escape: + event.preventDefault() + event.stopPropagation() + dispatch({ type: ActionTypes.CloseCombobox }) + return d.nextFrame(() => state.inputRef.current?.focus({ preventScroll: true })) + } + }, + [d, dispatch, state, actions] + ) + + let handleClick = useCallback( + (event: ReactMouseEvent) => { + if (isDisabledReactIssue7711(event.currentTarget)) return event.preventDefault() + if (state.comboboxState === ComboboxStates.Open) { + dispatch({ type: ActionTypes.CloseCombobox }) + } else { + event.preventDefault() + dispatch({ type: ActionTypes.OpenCombobox }) + } + + d.nextFrame(() => state.inputRef.current?.focus({ preventScroll: true })) + }, + [dispatch, d, state] + ) + + let labelledby = useComputed(() => { + if (!state.labelRef.current) return undefined + return [state.labelRef.current.id, id].join(' ') + }, [state.labelRef.current, id]) + + let slot = useMemo( + () => ({ open: state.comboboxState === ComboboxStates.Open, disabled: state.disabled }), + [state] + ) + let passthroughProps = props + let propsWeControl = { + ref: buttonRef, + id, + type: useResolveButtonType(props, state.buttonRef), + tabIndex: -1, + 'aria-haspopup': true, + 'aria-controls': state.optionsRef.current?.id, + 'aria-expanded': state.disabled ? undefined : state.comboboxState === ComboboxStates.Open, + 'aria-labelledby': labelledby, + disabled: state.disabled, + onClick: handleClick, + onKeyDown: handleKeyDown, + } + + return render({ + props: { ...passthroughProps, ...propsWeControl }, + slot, + defaultTag: DEFAULT_BUTTON_TAG, + name: 'Combobox.Button', + }) +}) + +// --- + +let DEFAULT_LABEL_TAG = 'label' as const +interface LabelRenderPropArg { + open: boolean + disabled: boolean +} +type LabelPropsWeControl = 'id' | 'ref' | 'onClick' + +function Label( + props: Props +) { + let [state] = useComboboxContext([Combobox.name, Label.name].join('.')) + let id = `headlessui-combobox-label-${useId()}` + + let handleClick = useCallback(() => state.inputRef.current?.focus({ preventScroll: true }), [ + state.inputRef, + ]) + + let slot = useMemo( + () => ({ open: state.comboboxState === ComboboxStates.Open, disabled: state.disabled }), + [state] + ) + let propsWeControl = { ref: state.labelRef, id, onClick: handleClick } + return render({ + props: { ...props, ...propsWeControl }, + slot, + defaultTag: DEFAULT_LABEL_TAG, + name: 'Combobox.Label', + }) +} + +// --- + +let DEFAULT_OPTIONS_TAG = 'ul' as const +interface OptionsRenderPropArg { + open: boolean +} +type OptionsPropsWeControl = + | 'aria-activedescendant' + | 'aria-labelledby' + | 'aria-orientation' + | 'id' + | 'onKeyDown' + | 'role' + | 'tabIndex' + +let OptionsRenderFeatures = Features.RenderStrategy | Features.Static + +let Options = forwardRefWithAs(function Options< + TTag extends ElementType = typeof DEFAULT_OPTIONS_TAG +>( + props: Props & + PropsForFeatures, + ref: Ref +) { + let [state, dispatch] = useComboboxContext([Combobox.name, Options.name].join('.')) + let optionsRef = useSyncRefs(state.optionsRef, ref) + + let id = `headlessui-combobox-options-${useId()}` + + let usesOpenClosedState = useOpenClosed() + let visible = (() => { + if (usesOpenClosedState !== null) { + return usesOpenClosedState === State.Open + } + + return state.comboboxState === ComboboxStates.Open + })() + + let labelledby = useComputed(() => state.labelRef.current?.id ?? state.buttonRef.current?.id, [ + state.labelRef.current, + state.buttonRef.current, + ]) + + let handleLeave = useCallback(() => { + if (state.comboboxState !== ComboboxStates.Open) return + if (state.activeOptionIndex === null) return + dispatch({ type: ActionTypes.GoToOption, focus: Focus.Nothing }) + }, [state, dispatch]) + + let slot = useMemo( + () => ({ open: state.comboboxState === ComboboxStates.Open }), + [state] + ) + let propsWeControl = { + 'aria-activedescendant': + state.activeOptionIndex === null ? undefined : state.options[state.activeOptionIndex]?.id, + 'aria-labelledby': labelledby, + 'aria-orientation': state.orientation, + role: 'listbox', + id, + ref: optionsRef, + onPointerLeave: handleLeave, + onMouseLeave: handleLeave, + } + let passthroughProps = props + + return render({ + props: { ...passthroughProps, ...propsWeControl }, + slot, + defaultTag: DEFAULT_OPTIONS_TAG, + features: OptionsRenderFeatures, + visible, + name: 'Combobox.Options', + }) +}) + +// --- + +let DEFAULT_OPTION_TAG = 'li' as const +interface OptionRenderPropArg { + active: boolean + selected: boolean + disabled: boolean +} +type ComboboxOptionPropsWeControl = + | 'id' + | 'role' + | 'tabIndex' + | 'aria-disabled' + | 'aria-selected' + | 'onPointerLeave' + | 'onMouseLeave' + | 'onPointerMove' + | 'onMouseMove' + +function Option< + TTag extends ElementType = typeof DEFAULT_OPTION_TAG, + // TODO: One day we will be able to infer this type from the generic in Combobox itself. + // But today is not that day.. + TType = Parameters[0]['value'] +>( + props: Props & { + disabled?: boolean + value: TType + } +) { + let { disabled = false, value, ...passthroughProps } = props + let [state, dispatch] = useComboboxContext([Combobox.name, Option.name].join('.')) + let actions = useComboboxActions() + let id = `headlessui-combobox-option-${useId()}` + let active = + state.activeOptionIndex !== null ? state.options[state.activeOptionIndex].id === id : false + let selected = state.propsRef.current.value === value + let bag = useRef({ disabled, value }) + + useIsoMorphicEffect(() => { + bag.current.disabled = disabled + }, [bag, disabled]) + useIsoMorphicEffect(() => { + bag.current.value = value + }, [bag, value]) + useIsoMorphicEffect(() => { + bag.current.textValue = document.getElementById(id)?.textContent?.toLowerCase() + }, [bag, id]) + + let select = useCallback(() => actions.selectOption(id), [actions, id]) + + useIsoMorphicEffect(() => { + dispatch({ type: ActionTypes.RegisterOption, id, dataRef: bag }) + return () => dispatch({ type: ActionTypes.UnregisterOption, id }) + }, [bag, id]) + + useIsoMorphicEffect(() => { + if (state.comboboxState !== ComboboxStates.Open) return + if (!selected) return + dispatch({ type: ActionTypes.GoToOption, focus: Focus.Specific, id }) + }, [state.comboboxState]) + + useIsoMorphicEffect(() => { + if (state.comboboxState !== ComboboxStates.Open) return + if (!active) return + let d = disposables() + d.nextFrame(() => document.getElementById(id)?.scrollIntoView?.({ block: 'nearest' })) + return d.dispose + }, [id, active, state.comboboxState]) + + let handleClick = useCallback( + (event: { preventDefault: Function }) => { + if (disabled) return event.preventDefault() + select() + dispatch({ type: ActionTypes.CloseCombobox }) + disposables().nextFrame(() => state.inputRef.current?.focus({ preventScroll: true })) + }, + [dispatch, state.inputRef, disabled, select] + ) + + let handleFocus = useCallback(() => { + if (disabled) return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Nothing }) + dispatch({ type: ActionTypes.GoToOption, focus: Focus.Specific, id }) + }, [disabled, id, dispatch]) + + let handleMove = useCallback(() => { + if (disabled) return + if (active) return + dispatch({ type: ActionTypes.GoToOption, focus: Focus.Specific, id }) + }, [disabled, active, id, dispatch]) + + let handleLeave = useCallback(() => { + if (disabled) return + if (!active) return + dispatch({ type: ActionTypes.GoToOption, focus: Focus.Nothing }) + }, [disabled, active, dispatch]) + + let slot = useMemo(() => ({ active, selected, disabled }), [ + active, + selected, + disabled, + ]) + + let propsWeControl = { + id, + role: 'option', + tabIndex: disabled === true ? undefined : -1, + 'aria-disabled': disabled === true ? true : undefined, + 'aria-selected': selected === true ? true : undefined, + disabled: undefined, // Never forward the `disabled` prop + onClick: handleClick, + onFocus: handleFocus, + onPointerMove: handleMove, + onMouseMove: handleMove, + onPointerLeave: handleLeave, + onMouseLeave: handleLeave, + } + + return render({ + props: { ...passthroughProps, ...propsWeControl }, + slot, + defaultTag: DEFAULT_OPTION_TAG, + name: 'Combobox.Option', + }) +} + +// --- + +Combobox.Input = Input +Combobox.Button = Button +Combobox.Label = Label +Combobox.Options = Options +Combobox.Option = Option diff --git a/packages/@headlessui-react/src/hooks/use-computed.ts b/packages/@headlessui-react/src/hooks/use-computed.ts index 980ef9b420..3d416442d3 100644 --- a/packages/@headlessui-react/src/hooks/use-computed.ts +++ b/packages/@headlessui-react/src/hooks/use-computed.ts @@ -1,12 +1,10 @@ -import { useState, useRef } from 'react' +import { useState } from 'react' import { useIsoMorphicEffect } from './use-iso-morphic-effect' +import { useLatestValue } from './use-latest-value' export function useComputed(cb: () => T, dependencies: React.DependencyList) { let [value, setValue] = useState(cb) - let cbRef = useRef(cb) - useIsoMorphicEffect(() => { - cbRef.current = cb - }, [cb]) + let cbRef = useLatestValue(cb) useIsoMorphicEffect(() => setValue(cbRef.current), [cbRef, setValue, ...dependencies]) return value } diff --git a/packages/@headlessui-react/src/hooks/use-latest-value.ts b/packages/@headlessui-react/src/hooks/use-latest-value.ts new file mode 100644 index 0000000000..6795e7e74b --- /dev/null +++ b/packages/@headlessui-react/src/hooks/use-latest-value.ts @@ -0,0 +1,11 @@ +import { useRef, useEffect } from 'react' + +export function useLatestValue(value: T) { + let cache = useRef(value) + + useEffect(() => { + cache.current = value + }, [value]) + + return cache +} diff --git a/packages/@headlessui-react/src/index.test.ts b/packages/@headlessui-react/src/index.test.ts index 145b355530..1058a5ed5b 100644 --- a/packages/@headlessui-react/src/index.test.ts +++ b/packages/@headlessui-react/src/index.test.ts @@ -6,6 +6,7 @@ import * as HeadlessUI from './index' */ it('should expose the correct components', () => { expect(Object.keys(HeadlessUI)).toEqual([ + 'Combobox', 'Dialog', 'Disclosure', 'FocusTrap', diff --git a/packages/@headlessui-react/src/index.ts b/packages/@headlessui-react/src/index.ts index 2ef29db23c..2ba6c32e78 100644 --- a/packages/@headlessui-react/src/index.ts +++ b/packages/@headlessui-react/src/index.ts @@ -1,3 +1,4 @@ +export * from './components/combobox/combobox' export * from './components/dialog/dialog' export * from './components/disclosure/disclosure' export * from './components/focus-trap/focus-trap' diff --git a/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts b/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts index 1fb40a1f39..f478642b9a 100644 --- a/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts +++ b/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts @@ -91,7 +91,7 @@ export function assertMenuButton( expect(button).toHaveAttribute(attributeName, options.attributes[attributeName]) } } catch (err) { - Error.captureStackTrace(err, assertMenuButton) + if (err instanceof Error) Error.captureStackTrace(err, assertMenuButton) throw err } } @@ -105,7 +105,7 @@ export function assertMenuButtonLinkedWithMenu(button = getMenuButton(), menu = expect(button).toHaveAttribute('aria-controls', menu.getAttribute('id')) expect(menu).toHaveAttribute('aria-labelledby', button.getAttribute('id')) } catch (err) { - Error.captureStackTrace(err, assertMenuButtonLinkedWithMenu) + if (err instanceof Error) Error.captureStackTrace(err, assertMenuButtonLinkedWithMenu) throw err } } @@ -118,7 +118,7 @@ export function assertMenuLinkedWithMenuItem(item: HTMLElement | null, menu = ge // Ensure link between menu & menu item is correct expect(menu).toHaveAttribute('aria-activedescendant', item.getAttribute('id')) } catch (err) { - Error.captureStackTrace(err, assertMenuLinkedWithMenuItem) + if (err instanceof Error) Error.captureStackTrace(err, assertMenuLinkedWithMenuItem) throw err } } @@ -130,7 +130,7 @@ export function assertNoActiveMenuItem(menu = getMenu()) { // Ensure we don't have an active menu expect(menu).not.toHaveAttribute('aria-activedescendant') } catch (err) { - Error.captureStackTrace(err, assertNoActiveMenuItem) + if (err instanceof Error) Error.captureStackTrace(err, assertNoActiveMenuItem) throw err } } @@ -183,7 +183,7 @@ export function assertMenu( assertNever(options.state) } } catch (err) { - Error.captureStackTrace(err, assertMenu) + if (err instanceof Error) Error.captureStackTrace(err, assertMenu) throw err } } @@ -214,7 +214,393 @@ export function assertMenuItem( } } } catch (err) { - Error.captureStackTrace(err, assertMenuItem) + if (err instanceof Error) Error.captureStackTrace(err, assertMenuItem) + throw err + } +} + +// --- + +export function getComboboxLabel(): HTMLElement | null { + return document.querySelector('label,[id^="headlessui-combobox-label"]') +} + +export function getComboboxButton(): HTMLElement | null { + return document.querySelector('button,[role="button"],[id^="headlessui-combobox-button-"]') +} + +export function getComboboxButtons(): HTMLElement[] { + return Array.from(document.querySelectorAll('button,[role="button"]')) +} + +export function getComboboxInput(): HTMLInputElement | null { + return document.querySelector('[role="combobox"]') +} + +export function getCombobox(): HTMLElement | null { + return document.querySelector('[role="listbox"]') +} + +export function getComboboxInputs(): HTMLElement[] { + return Array.from(document.querySelectorAll('[role="combobox"]')) +} + +export function getComboboxes(): HTMLElement[] { + return Array.from(document.querySelectorAll('[role="listbox"]')) +} + +export function getComboboxOptions(): HTMLElement[] { + return Array.from(document.querySelectorAll('[role="option"]')) +} + +// --- + +export enum ComboboxState { + /** The combobox is visible to the user. */ + Visible, + + /** The combobox is **not** visible to the user. It's still in the DOM, but it is hidden. */ + InvisibleHidden, + + /** The combobox is **not** visible to the user. It's not in the DOM, it is unmounted. */ + InvisibleUnmounted, +} + +export function assertCombobox( + options: { + attributes?: Record + textContent?: string + state: ComboboxState + orientation?: 'horizontal' | 'vertical' + }, + combobox = getComboboxInput() +) { + let { orientation = 'vertical' } = options + + try { + switch (options.state) { + case ComboboxState.InvisibleHidden: + if (combobox === null) return expect(combobox).not.toBe(null) + + assertHidden(combobox) + + expect(combobox).toHaveAttribute('aria-labelledby') + expect(combobox).toHaveAttribute('aria-orientation', orientation) + expect(combobox).toHaveAttribute('role', 'combobox') + + if (options.textContent) expect(combobox).toHaveTextContent(options.textContent) + + for (let attributeName in options.attributes) { + expect(combobox).toHaveAttribute(attributeName, options.attributes[attributeName]) + } + break + + case ComboboxState.Visible: + if (combobox === null) return expect(combobox).not.toBe(null) + + assertVisible(combobox) + + expect(combobox).toHaveAttribute('aria-labelledby') + expect(combobox).toHaveAttribute('aria-orientation', orientation) + expect(combobox).toHaveAttribute('role', 'combobox') + + if (options.textContent) expect(combobox).toHaveTextContent(options.textContent) + + for (let attributeName in options.attributes) { + expect(combobox).toHaveAttribute(attributeName, options.attributes[attributeName]) + } + break + + case ComboboxState.InvisibleUnmounted: + expect(combobox).toBe(null) + break + + default: + assertNever(options.state) + } + } catch (err) { + if (err instanceof Error) Error.captureStackTrace(err, assertCombobox) + throw err + } +} + +export function assertComboboxList( + options: { + attributes?: Record + textContent?: string + state: ComboboxState + orientation?: 'horizontal' | 'vertical' + }, + listbox = getCombobox() +) { + let { orientation = 'vertical' } = options + + try { + switch (options.state) { + case ComboboxState.InvisibleHidden: + if (listbox === null) return expect(listbox).not.toBe(null) + + assertHidden(listbox) + + expect(listbox).toHaveAttribute('aria-labelledby') + expect(listbox).toHaveAttribute('aria-orientation', orientation) + expect(listbox).toHaveAttribute('role', 'listbox') + + if (options.textContent) expect(listbox).toHaveTextContent(options.textContent) + + for (let attributeName in options.attributes) { + expect(listbox).toHaveAttribute(attributeName, options.attributes[attributeName]) + } + break + + case ComboboxState.Visible: + if (listbox === null) return expect(listbox).not.toBe(null) + + assertVisible(listbox) + + expect(listbox).toHaveAttribute('aria-labelledby') + expect(listbox).toHaveAttribute('aria-orientation', orientation) + expect(listbox).toHaveAttribute('role', 'listbox') + + if (options.textContent) expect(listbox).toHaveTextContent(options.textContent) + + for (let attributeName in options.attributes) { + expect(listbox).toHaveAttribute(attributeName, options.attributes[attributeName]) + } + break + + case ComboboxState.InvisibleUnmounted: + expect(listbox).toBe(null) + break + + default: + assertNever(options.state) + } + } catch (err) { + if (err instanceof Error) Error.captureStackTrace(err, assertCombobox) + throw err + } +} + +export function assertComboboxButton( + options: { + attributes?: Record + textContent?: string + state: ComboboxState + }, + button = getComboboxButton() +) { + try { + if (button === null) return expect(button).not.toBe(null) + + // Ensure menu button have these properties + expect(button).toHaveAttribute('id') + expect(button).toHaveAttribute('aria-haspopup') + + switch (options.state) { + case ComboboxState.Visible: + expect(button).toHaveAttribute('aria-controls') + expect(button).toHaveAttribute('aria-expanded', 'true') + break + + case ComboboxState.InvisibleHidden: + expect(button).toHaveAttribute('aria-controls') + if (button.hasAttribute('disabled')) { + expect(button).not.toHaveAttribute('aria-expanded') + } else { + expect(button).toHaveAttribute('aria-expanded', 'false') + } + break + + case ComboboxState.InvisibleUnmounted: + expect(button).not.toHaveAttribute('aria-controls') + if (button.hasAttribute('disabled')) { + expect(button).not.toHaveAttribute('aria-expanded') + } else { + expect(button).toHaveAttribute('aria-expanded', 'false') + } + break + + default: + assertNever(options.state) + } + + if (options.textContent) { + expect(button).toHaveTextContent(options.textContent) + } + + // Ensure menu button has the following attributes + for (let attributeName in options.attributes) { + expect(button).toHaveAttribute(attributeName, options.attributes[attributeName]) + } + } catch (err) { + if (err instanceof Error) Error.captureStackTrace(err, assertComboboxButton) + throw err + } +} + +export function assertComboboxLabel( + options: { + attributes?: Record + tag?: string + textContent?: string + }, + label = getComboboxLabel() +) { + try { + if (label === null) return expect(label).not.toBe(null) + + // Ensure menu button have these properties + expect(label).toHaveAttribute('id') + + if (options.textContent) { + expect(label).toHaveTextContent(options.textContent) + } + + if (options.tag) { + expect(label.tagName.toLowerCase()).toBe(options.tag) + } + + // Ensure menu button has the following attributes + for (let attributeName in options.attributes) { + expect(label).toHaveAttribute(attributeName, options.attributes[attributeName]) + } + } catch (err) { + if (err instanceof Error) Error.captureStackTrace(err, assertComboboxLabel) + throw err + } +} + +export function assertComboboxButtonLinkedWithCombobox( + button = getComboboxButton(), + combobox = getCombobox() +) { + try { + if (button === null) return expect(button).not.toBe(null) + if (combobox === null) return expect(combobox).not.toBe(null) + + // Ensure link between button & combobox is correct + expect(button).toHaveAttribute('aria-controls', combobox.getAttribute('id')) + expect(combobox).toHaveAttribute('aria-labelledby', button.getAttribute('id')) + } catch (err) { + if (err instanceof Error) Error.captureStackTrace(err, assertComboboxButtonLinkedWithCombobox) + throw err + } +} + +export function assertComboboxLabelLinkedWithCombobox( + label = getComboboxLabel(), + combobox = getComboboxInput() +) { + try { + if (label === null) return expect(label).not.toBe(null) + if (combobox === null) return expect(combobox).not.toBe(null) + + expect(combobox).toHaveAttribute('aria-labelledby', label.getAttribute('id')) + } catch (err) { + if (err instanceof Error) Error.captureStackTrace(err, assertComboboxLabelLinkedWithCombobox) + throw err + } +} + +export function assertComboboxButtonLinkedWithComboboxLabel( + button = getComboboxButton(), + label = getComboboxLabel() +) { + try { + if (button === null) return expect(button).not.toBe(null) + if (label === null) return expect(label).not.toBe(null) + + // Ensure link between button & label is correct + expect(button).toHaveAttribute('aria-labelledby', `${label.id} ${button.id}`) + } catch (err) { + if (err instanceof Error) + Error.captureStackTrace(err, assertComboboxButtonLinkedWithComboboxLabel) + throw err + } +} + +export function assertActiveComboboxOption( + item: HTMLElement | null, + combobox = getComboboxInput() +) { + try { + if (combobox === null) return expect(combobox).not.toBe(null) + if (item === null) return expect(item).not.toBe(null) + + // Ensure link between combobox & combobox item is correct + expect(combobox).toHaveAttribute('aria-activedescendant', item.getAttribute('id')) + } catch (err) { + if (err instanceof Error) Error.captureStackTrace(err, assertActiveComboboxOption) + throw err + } +} + +export function assertNoActiveComboboxOption(combobox = getComboboxInput()) { + try { + if (combobox === null) return expect(combobox).not.toBe(null) + + // Ensure we don't have an active combobox + expect(combobox).not.toHaveAttribute('aria-activedescendant') + } catch (err) { + if (err instanceof Error) Error.captureStackTrace(err, assertNoActiveComboboxOption) + throw err + } +} + +export function assertNoSelectedComboboxOption(items = getComboboxOptions()) { + try { + for (let item of items) expect(item).not.toHaveAttribute('aria-selected') + } catch (err) { + if (err instanceof Error) Error.captureStackTrace(err, assertNoSelectedComboboxOption) + throw err + } +} + +export function assertComboboxOption( + item: HTMLElement | null, + options?: { + tag?: string + attributes?: Record + selected?: boolean + } +) { + try { + if (item === null) return expect(item).not.toBe(null) + + // Check that some attributes exists, doesn't really matter what the values are at this point in + // time, we just require them. + expect(item).toHaveAttribute('id') + + // Check that we have the correct values for certain attributes + expect(item).toHaveAttribute('role', 'option') + if (!item.getAttribute('aria-disabled')) expect(item).toHaveAttribute('tabindex', '-1') + + // Ensure combobox button has the following attributes + if (!options) return + + for (let attributeName in options.attributes) { + expect(item).toHaveAttribute(attributeName, options.attributes[attributeName]) + } + + if (options.tag) { + expect(item.tagName.toLowerCase()).toBe(options.tag) + } + + if (options.selected != null) { + switch (options.selected) { + case true: + return expect(item).toHaveAttribute('aria-selected', 'true') + + case false: + return expect(item).not.toHaveAttribute('aria-selected') + + default: + assertNever(options.selected) + } + } + } catch (err) { + if (err instanceof Error) Error.captureStackTrace(err, assertComboboxOption) throw err } } @@ -311,7 +697,7 @@ export function assertListbox( assertNever(options.state) } } catch (err) { - Error.captureStackTrace(err, assertListbox) + if (err instanceof Error) Error.captureStackTrace(err, assertListbox) throw err } } @@ -368,7 +754,7 @@ export function assertListboxButton( expect(button).toHaveAttribute(attributeName, options.attributes[attributeName]) } } catch (err) { - Error.captureStackTrace(err, assertListboxButton) + if (err instanceof Error) Error.captureStackTrace(err, assertListboxButton) throw err } } @@ -400,7 +786,7 @@ export function assertListboxLabel( expect(label).toHaveAttribute(attributeName, options.attributes[attributeName]) } } catch (err) { - Error.captureStackTrace(err, assertListboxLabel) + if (err instanceof Error) Error.captureStackTrace(err, assertListboxLabel) throw err } } @@ -417,7 +803,7 @@ export function assertListboxButtonLinkedWithListbox( expect(button).toHaveAttribute('aria-controls', listbox.getAttribute('id')) expect(listbox).toHaveAttribute('aria-labelledby', button.getAttribute('id')) } catch (err) { - Error.captureStackTrace(err, assertListboxButtonLinkedWithListbox) + if (err instanceof Error) Error.captureStackTrace(err, assertListboxButtonLinkedWithListbox) throw err } } @@ -432,7 +818,7 @@ export function assertListboxLabelLinkedWithListbox( expect(listbox).toHaveAttribute('aria-labelledby', label.getAttribute('id')) } catch (err) { - Error.captureStackTrace(err, assertListboxLabelLinkedWithListbox) + if (err instanceof Error) Error.captureStackTrace(err, assertListboxLabelLinkedWithListbox) throw err } } @@ -448,7 +834,8 @@ export function assertListboxButtonLinkedWithListboxLabel( // Ensure link between button & label is correct expect(button).toHaveAttribute('aria-labelledby', `${label.id} ${button.id}`) } catch (err) { - Error.captureStackTrace(err, assertListboxButtonLinkedWithListboxLabel) + if (err instanceof Error) + Error.captureStackTrace(err, assertListboxButtonLinkedWithListboxLabel) throw err } } @@ -461,7 +848,7 @@ export function assertActiveListboxOption(item: HTMLElement | null, listbox = ge // Ensure link between listbox & listbox item is correct expect(listbox).toHaveAttribute('aria-activedescendant', item.getAttribute('id')) } catch (err) { - Error.captureStackTrace(err, assertActiveListboxOption) + if (err instanceof Error) Error.captureStackTrace(err, assertActiveListboxOption) throw err } } @@ -473,7 +860,7 @@ export function assertNoActiveListboxOption(listbox = getListbox()) { // Ensure we don't have an active listbox expect(listbox).not.toHaveAttribute('aria-activedescendant') } catch (err) { - Error.captureStackTrace(err, assertNoActiveListboxOption) + if (err instanceof Error) Error.captureStackTrace(err, assertNoActiveListboxOption) throw err } } @@ -482,7 +869,7 @@ export function assertNoSelectedListboxOption(items = getListboxOptions()) { try { for (let item of items) expect(item).not.toHaveAttribute('aria-selected') } catch (err) { - Error.captureStackTrace(err, assertNoSelectedListboxOption) + if (err instanceof Error) Error.captureStackTrace(err, assertNoSelectedListboxOption) throw err } } @@ -530,7 +917,7 @@ export function assertListboxOption( } } } catch (err) { - Error.captureStackTrace(err, assertListboxOption) + if (err instanceof Error) Error.captureStackTrace(err, assertListboxOption) throw err } } @@ -597,7 +984,7 @@ export function assertSwitch( assertNever(options.state) } } catch (err) { - Error.captureStackTrace(err, assertSwitch) + if (err instanceof Error) Error.captureStackTrace(err, assertSwitch) throw err } } @@ -678,7 +1065,7 @@ export function assertDisclosureButton( expect(button).toHaveAttribute(attributeName, options.attributes[attributeName]) } } catch (err) { - Error.captureStackTrace(err, assertDisclosureButton) + if (err instanceof Error) Error.captureStackTrace(err, assertDisclosureButton) throw err } } @@ -725,7 +1112,7 @@ export function assertDisclosurePanel( assertNever(options.state) } } catch (err) { - Error.captureStackTrace(err, assertDisclosurePanel) + if (err instanceof Error) Error.captureStackTrace(err, assertDisclosurePanel) throw err } } @@ -810,7 +1197,7 @@ export function assertPopoverButton( expect(button).toHaveAttribute(attributeName, options.attributes[attributeName]) } } catch (err) { - Error.captureStackTrace(err, assertPopoverButton) + if (err instanceof Error) Error.captureStackTrace(err, assertPopoverButton) throw err } } @@ -857,7 +1244,7 @@ export function assertPopoverPanel( assertNever(options.state) } } catch (err) { - Error.captureStackTrace(err, assertPopoverPanel) + if (err instanceof Error) Error.captureStackTrace(err, assertPopoverPanel) throw err } } @@ -984,7 +1371,7 @@ export function assertDialog( assertNever(options.state) } } catch (err) { - Error.captureStackTrace(err, assertDialog) + if (err instanceof Error) Error.captureStackTrace(err, assertDialog) throw err } } @@ -1040,7 +1427,7 @@ export function assertDialogTitle( assertNever(options.state) } } catch (err) { - Error.captureStackTrace(err, assertDialogTitle) + if (err instanceof Error) Error.captureStackTrace(err, assertDialogTitle) throw err } } @@ -1096,7 +1483,7 @@ export function assertDialogDescription( assertNever(options.state) } } catch (err) { - Error.captureStackTrace(err, assertDialogDescription) + if (err instanceof Error) Error.captureStackTrace(err, assertDialogDescription) throw err } } @@ -1143,7 +1530,7 @@ export function assertDialogOverlay( assertNever(options.state) } } catch (err) { - Error.captureStackTrace(err, assertDialogOverlay) + if (err instanceof Error) Error.captureStackTrace(err, assertDialogOverlay) throw err } } @@ -1185,7 +1572,7 @@ export function assertRadioGroupLabel( expect(label).toHaveAttribute(attributeName, options.attributes[attributeName]) } } catch (err) { - Error.captureStackTrace(err, assertRadioGroupLabel) + if (err instanceof Error) Error.captureStackTrace(err, assertRadioGroupLabel) throw err } } @@ -1267,7 +1654,7 @@ export function assertTabs( } } } catch (err) { - Error.captureStackTrace(err, assertTabs) + if (err instanceof Error) Error.captureStackTrace(err, assertTabs) throw err } } @@ -1287,7 +1674,7 @@ export function assertActiveElement(element: HTMLElement | null) { expect(document.activeElement?.outerHTML).toBe(element.outerHTML) } } catch (err) { - Error.captureStackTrace(err, assertActiveElement) + if (err instanceof Error) Error.captureStackTrace(err, assertActiveElement) throw err } } @@ -1297,7 +1684,7 @@ export function assertContainsActiveElement(element: HTMLElement | null) { if (element === null) return expect(element).not.toBe(null) expect(element.contains(document.activeElement)).toBe(true) } catch (err) { - Error.captureStackTrace(err, assertContainsActiveElement) + if (err instanceof Error) Error.captureStackTrace(err, assertContainsActiveElement) throw err } } @@ -1311,7 +1698,7 @@ export function assertHidden(element: HTMLElement | null) { expect(element).toHaveAttribute('hidden') expect(element).toHaveStyle({ display: 'none' }) } catch (err) { - Error.captureStackTrace(err, assertHidden) + if (err instanceof Error) Error.captureStackTrace(err, assertHidden) throw err } } @@ -1323,7 +1710,7 @@ export function assertVisible(element: HTMLElement | null) { expect(element).not.toHaveAttribute('hidden') expect(element).not.toHaveStyle({ display: 'none' }) } catch (err) { - Error.captureStackTrace(err, assertVisible) + if (err instanceof Error) Error.captureStackTrace(err, assertVisible) throw err } } @@ -1336,7 +1723,7 @@ export function assertFocusable(element: HTMLElement | null) { expect(isFocusableElement(element, FocusableMode.Strict)).toBe(true) } catch (err) { - Error.captureStackTrace(err, assertFocusable) + if (err instanceof Error) Error.captureStackTrace(err, assertFocusable) throw err } } @@ -1347,7 +1734,7 @@ export function assertNotFocusable(element: HTMLElement | null) { expect(isFocusableElement(element, FocusableMode.Strict)).toBe(false) } catch (err) { - Error.captureStackTrace(err, assertNotFocusable) + if (err instanceof Error) Error.captureStackTrace(err, assertNotFocusable) throw err } } diff --git a/packages/@headlessui-react/src/test-utils/interactions.ts b/packages/@headlessui-react/src/test-utils/interactions.ts index d78f9b6bd3..b65ece9968 100644 --- a/packages/@headlessui-react/src/test-utils/interactions.ts +++ b/packages/@headlessui-react/src/test-utils/interactions.ts @@ -1,4 +1,7 @@ import { fireEvent } from '@testing-library/react' +import { disposables } from '../utils/disposables' + +let d = disposables() function nextFrame(cb: Function): void { setImmediate(() => @@ -33,7 +36,19 @@ export function shift(event: Partial) { } export function word(input: string): Partial[] { - return input.split('').map(key => ({ key })) + let result = input.split('').map(key => ({ key })) + + d.enqueue(() => { + let element = document.activeElement + + if (element instanceof HTMLInputElement) { + fireEvent.change(element, { + target: Object.assign({}, element, { value: input }), + }) + } + }) + + return result } let Default = Symbol() @@ -76,6 +91,9 @@ let order: Record< function keypress(element, event) { return fireEvent.keyPress(element, event) }, + function input(element, event) { + return fireEvent.input(element, event) + }, function keyup(element, event) { return fireEvent.keyUp(element, event) }, @@ -159,9 +177,11 @@ export async function type(events: Partial[], element = document. // We don't want to actually wait in our tests, so let's advance jest.runAllTimers() + await d.workQueue() + await new Promise(nextFrame) } catch (err) { - Error.captureStackTrace(err, type) + if (err instanceof Error) Error.captureStackTrace(err, type) throw err } finally { jest.useRealTimers() @@ -224,7 +244,7 @@ export async function click( await new Promise(nextFrame) } catch (err) { - Error.captureStackTrace(err, click) + if (err instanceof Error) Error.captureStackTrace(err, click) throw err } } @@ -237,7 +257,7 @@ export async function focus(element: Document | Element | Window | Node | null) await new Promise(nextFrame) } catch (err) { - Error.captureStackTrace(err, focus) + if (err instanceof Error) Error.captureStackTrace(err, focus) throw err } } @@ -251,7 +271,7 @@ export async function mouseEnter(element: Document | Element | Window | null) { await new Promise(nextFrame) } catch (err) { - Error.captureStackTrace(err, mouseEnter) + if (err instanceof Error) Error.captureStackTrace(err, mouseEnter) throw err } } @@ -265,7 +285,7 @@ export async function mouseMove(element: Document | Element | Window | null) { await new Promise(nextFrame) } catch (err) { - Error.captureStackTrace(err, mouseMove) + if (err instanceof Error) Error.captureStackTrace(err, mouseMove) throw err } } @@ -281,7 +301,7 @@ export async function mouseLeave(element: Document | Element | Window | null) { await new Promise(nextFrame) } catch (err) { - Error.captureStackTrace(err, mouseLeave) + if (err instanceof Error) Error.captureStackTrace(err, mouseLeave) throw err } } diff --git a/packages/@headlessui-react/src/utils/disposables.ts b/packages/@headlessui-react/src/utils/disposables.ts index 7c9a388338..4c0f89c0f8 100644 --- a/packages/@headlessui-react/src/utils/disposables.ts +++ b/packages/@headlessui-react/src/utils/disposables.ts @@ -1,7 +1,12 @@ export function disposables() { let disposables: Function[] = [] + let queue: Function[] = [] let api = { + enqueue(fn: Function) { + queue.push(fn) + }, + requestAnimationFrame(...args: Parameters) { let raf = requestAnimationFrame(...args) api.add(() => cancelAnimationFrame(raf)) @@ -27,6 +32,12 @@ export function disposables() { dispose() } }, + + async workQueue() { + for (let handle of queue.splice(0)) { + await handle() + } + }, } return api diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.test.tsx b/packages/@headlessui-vue/src/components/combobox/combobox.test.tsx new file mode 100644 index 0000000000..fbe2047a64 --- /dev/null +++ b/packages/@headlessui-vue/src/components/combobox/combobox.test.tsx @@ -0,0 +1,5334 @@ +import { + DefineComponent, + defineComponent, + nextTick, + ref, + watch, + h, + reactive, + computed, + PropType, +} from 'vue' +import { render } from '../../test-utils/vue-testing-library' +import { + Combobox, + ComboboxInput, + ComboboxLabel, + ComboboxButton, + ComboboxOptions, + ComboboxOption, +} from './combobox' +import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs' +import { + click, + focus, + mouseMove, + mouseLeave, + press, + shift, + type, + word, + Keys, + MouseButton, +} from '../../test-utils/interactions' +import { + assertActiveElement, + assertActiveComboboxOption, + assertComboboxList, + assertComboboxButton, + assertComboboxButtonLinkedWithCombobox, + assertComboboxButtonLinkedWithComboboxLabel, + assertComboboxOption, + assertComboboxLabel, + assertComboboxLabelLinkedWithCombobox, + assertNoActiveComboboxOption, + assertNoSelectedComboboxOption, + getComboboxInput, + getComboboxButton, + getComboboxButtons, + getComboboxInputs, + getComboboxOptions, + getComboboxLabel, + ComboboxState, + getByText, + getComboboxes, +} from '../../test-utils/accessibility-assertions' +import { html } from '../../test-utils/html' +import { useOpenClosedProvider, State, useOpenClosed } from '../../internal/open-closed' + +jest.mock('../../hooks/use-id') + +beforeAll(() => { + jest.spyOn(window, 'requestAnimationFrame').mockImplementation(setImmediate as any) + jest.spyOn(window, 'cancelAnimationFrame').mockImplementation(clearImmediate as any) +}) + +afterAll(() => jest.restoreAllMocks()) + +function nextFrame() { + return new Promise(resolve => { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + resolve() + }) + }) + }) +} + +function getDefaultComponents() { + return { + Combobox, + ComboboxInput, + ComboboxLabel, + ComboboxButton, + ComboboxOptions, + ComboboxOption, + } +} + +function renderTemplate(input: string | Partial) { + let defaultComponents = getDefaultComponents() + + if (typeof input === 'string') { + return render(defineComponent({ template: input, components: defaultComponents })) + } + + return render( + defineComponent( + (Object.assign({}, input, { + components: { ...defaultComponents, ...input.components }, + }) as unknown) as DefineComponent + ) + ) +} + +describe('safeguards', () => { + it.each([ + ['ComboboxButton', ComboboxButton], + ['ComboboxLabel', ComboboxLabel], + ['ComboboxOptions', ComboboxOptions], + ['ComboboxOption', ComboboxOption], + ])( + 'should error when we are using a <%s /> without a parent ', + suppressConsoleLogs((name, Component) => { + expect(() => render(Component)).toThrowError( + `<${name} /> is missing a parent component.` + ) + }) + ) + + it( + 'should be possible to render a Combobox without crashing', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + }) + ) +}) + +describe('Rendering', () => { + describe('Combobox', () => { + it( + 'should be possible to render a Combobox using a render prop', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + await click(getComboboxButton()) + + assertComboboxButton({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + + assertComboboxList({ state: ComboboxState.Visible }) + }) + ) + + it( + 'should be possible to disable a Combobox', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + await click(getComboboxButton()) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + await press(Keys.Enter, getComboboxButton()) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + }) + ) + }) + + describe('Combobox.Input', () => { + it( + 'selecting an option puts the value into Combobox.Input when displayValue is not provided', + suppressConsoleLogs(async () => { + let Example = defineComponent({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + // TODO: Rendering Example directly reveals a vue bug — I think it's been fixed for a while but I can't find the commit + renderTemplate(Example) + + await click(getComboboxButton()) + + assertComboboxList({ state: ComboboxState.Visible }) + + await click(getComboboxOptions()[1]) + + expect(getComboboxInput()).toHaveValue('b') + }) + ) + + it( + 'selecting an option puts the display value into Combobox.Input when displayValue is provided', + suppressConsoleLogs(async () => { + let Example = defineComponent({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + renderTemplate(Example) + + await click(getComboboxButton()) + + assertComboboxList({ state: ComboboxState.Visible }) + + await click(getComboboxOptions()[1]) + + expect(getComboboxInput()).toHaveValue('B') + }) + ) + }) + + describe('ComboboxLabel', () => { + it( + 'should be possible to render a ComboboxLabel using a render prop', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + {{JSON.stringify(data)}} + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-3' }, + }) + assertComboboxLabel({ + attributes: { id: 'headlessui-combobox-label-1' }, + textContent: JSON.stringify({ open: false, disabled: false }), + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + await click(getComboboxButton()) + + assertComboboxLabel({ + attributes: { id: 'headlessui-combobox-label-1' }, + textContent: JSON.stringify({ open: true, disabled: false }), + }) + assertComboboxList({ state: ComboboxState.Visible }) + assertComboboxLabelLinkedWithCombobox() + assertComboboxButtonLinkedWithComboboxLabel() + }) + ) + + it( + 'should be possible to render a ComboboxLabel using a render prop and an `as` prop', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + {{JSON.stringify(data)}} + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxLabel({ + attributes: { id: 'headlessui-combobox-label-1' }, + textContent: JSON.stringify({ open: false, disabled: false }), + tag: 'p', + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + await click(getComboboxButton()) + assertComboboxLabel({ + attributes: { id: 'headlessui-combobox-label-1' }, + textContent: JSON.stringify({ open: true, disabled: false }), + tag: 'p', + }) + assertComboboxList({ state: ComboboxState.Visible }) + }) + ) + }) + + describe('ComboboxButton', () => { + it( + 'should be possible to render a ComboboxButton using a render prop', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + {{JSON.stringify(data)}} + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + textContent: JSON.stringify({ open: false, disabled: false }), + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + await click(getComboboxButton()) + + assertComboboxButton({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-button-2' }, + textContent: JSON.stringify({ open: true, disabled: false }), + }) + assertComboboxList({ state: ComboboxState.Visible }) + }) + ) + + it( + 'should be possible to render a ComboboxButton using a render prop and an `as` prop', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + {{JSON.stringify(data)}} + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + textContent: JSON.stringify({ open: false, disabled: false }), + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + await click(getComboboxButton()) + + assertComboboxButton({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-button-2' }, + textContent: JSON.stringify({ open: true, disabled: false }), + }) + assertComboboxList({ state: ComboboxState.Visible }) + }) + ) + + it( + 'should be possible to render a ComboboxButton and a ComboboxLabel and see them linked together', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + Label + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + await new Promise(requestAnimationFrame) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-3' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxButtonLinkedWithComboboxLabel() + }) + ) + + describe('`type` attribute', () => { + it('should set the `type` to "button" by default', async () => { + renderTemplate({ + template: html` + + + Trigger + + `, + setup: () => ({ value: ref(null) }), + }) + + expect(getComboboxButton()).toHaveAttribute('type', 'button') + }) + + it('should not set the `type` to "button" if it already contains a `type`', async () => { + renderTemplate({ + template: html` + + + + Trigger + + + `, + setup: () => ({ value: ref(null) }), + }) + + expect(getComboboxButton()).toHaveAttribute('type', 'submit') + }) + + it( + 'should set the `type` to "button" when using the `as` prop which resolves to a "button"', + suppressConsoleLogs(async () => { + let CustomButton = defineComponent({ + setup: props => () => h('button', { ...props }), + }) + + renderTemplate({ + template: html` + + + + Trigger + + + `, + setup: () => ({ + value: ref(null), + CustomButton, + }), + }) + + await new Promise(requestAnimationFrame) + + expect(getComboboxButton()).toHaveAttribute('type', 'button') + }) + ) + + it('should not set the type if the "as" prop is not a "button"', async () => { + renderTemplate({ + template: html` + + + + Trigger + + + `, + setup: () => ({ value: ref(null) }), + }) + + expect(getComboboxButton()).not.toHaveAttribute('type') + }) + + it( + 'should not set the `type` to "button" when using the `as` prop which resolves to a "div"', + suppressConsoleLogs(async () => { + let CustomButton = defineComponent({ + setup: props => () => h('div', props), + }) + + renderTemplate({ + template: html` + + + + Trigger + + + `, + setup: () => ({ + value: ref(null), + CustomButton, + }), + }) + + await new Promise(requestAnimationFrame) + + expect(getComboboxButton()).not.toHaveAttribute('type') + }) + ) + }) + }) + + describe('ComboboxOptions', () => { + it( + 'should be possible to render ComboboxOptions using a render prop', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + {{JSON.stringify(data)}} + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + await click(getComboboxButton()) + + assertComboboxButton({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + + assertComboboxList({ + state: ComboboxState.Visible, + textContent: JSON.stringify({ open: true }), + }) + + assertActiveElement(getComboboxInput()) + }) + ) + + it('should be possible to always render the ComboboxOptions if we provide it a `static` prop', () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Let's verify that the combobox is already there + expect(getComboboxInput()).not.toBe(null) + }) + + it('should be possible to use a different render strategy for the ComboboxOptions', async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + await new Promise(nextTick) + + assertComboboxList({ state: ComboboxState.InvisibleHidden }) + + // Let's open the combobox, to see if it is not hidden anymore + await click(getComboboxButton()) + + assertComboboxList({ state: ComboboxState.Visible }) + }) + }) + + describe('ComboboxOption', () => { + it( + 'should be possible to render a ComboboxOption using a render prop', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + {{JSON.stringify(data)}} + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + await click(getComboboxButton()) + + assertComboboxButton({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ + state: ComboboxState.Visible, + textContent: JSON.stringify({ active: false, selected: false, disabled: false }), + }) + }) + ) + }) + + it('should guarantee the order of DOM nodes when performing actions', async () => { + let props = reactive({ hide: false }) + + renderTemplate({ + template: html` + + + Trigger + + Option 1 + Option 2 + Option 3 + + + `, + setup() { + return { + value: ref(null), + get hide() { + return props.hide + }, + } + }, + }) + + // Open the combobox + await click(getByText('Trigger')) + + props.hide = true + await nextFrame() + + props.hide = false + await nextFrame() + + assertComboboxList({ state: ComboboxState.Visible }) + + let options = getComboboxOptions() + + // Focus the first item + await press(Keys.ArrowDown) + + // Verify that the first combobox option is active + assertActiveComboboxOption(options[0]) + + await press(Keys.ArrowDown) + + // Verify that the second combobox option is active + assertActiveComboboxOption(options[1]) + + await press(Keys.ArrowDown) + + // Verify that the third combobox option is active + assertActiveComboboxOption(options[2]) + }) +}) + +describe('Rendering composition', () => { + it( + 'should be possible to swap the Combobox option with a button for example', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + Option A + + + Option B + + + Option C + + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Open combobox + await click(getComboboxButton()) + + // Verify options are buttons now + getComboboxOptions().forEach(option => assertComboboxOption(option, { tag: 'button' })) + }) + ) +}) + +describe('Composition', () => { + let OpenClosedWrite = defineComponent({ + props: { open: { type: Boolean } }, + setup(props, { slots }) { + useOpenClosedProvider(ref(props.open ? State.Open : State.Closed)) + return () => slots.default?.() + }, + }) + + let OpenClosedRead = defineComponent({ + emits: ['read'], + setup(_, { slots, emit }) { + let state = useOpenClosed() + watch([state], ([value]) => emit('read', value)) + return () => slots.default?.() + }, + }) + + it( + 'should always open the ComboboxOptions because of a wrapping OpenClosed component', + suppressConsoleLogs(async () => { + renderTemplate({ + components: { OpenClosedWrite }, + template: html` + + + Trigger + + + {{JSON.stringify(data)}} + + + + `, + }) + + await new Promise(nextTick) + + // Verify the combobox is visible + assertComboboxList({ state: ComboboxState.Visible }) + + // Let's try and open the combobox + await click(getComboboxButton()) + + // Verify the combobox is still visible + assertComboboxList({ state: ComboboxState.Visible }) + }) + ) + + it( + 'should always close the ComboboxOptions because of a wrapping OpenClosed component', + suppressConsoleLogs(async () => { + renderTemplate({ + components: { OpenClosedWrite }, + template: html` + + + Trigger + + + {{JSON.stringify(data)}} + + + + `, + }) + + await new Promise(nextTick) + + // Verify the combobox is hidden + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Let's try and open the combobox + await click(getComboboxButton()) + + // Verify the combobox is still hidden + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + }) + ) + + it( + 'should be possible to read the OpenClosed state', + suppressConsoleLogs(async () => { + let readFn = jest.fn() + renderTemplate({ + components: { OpenClosedRead }, + template: html` + + + Trigger + + + Option A + + + + `, + setup() { + return { value: ref(null), readFn } + }, + }) + + await new Promise(nextTick) + + // Verify the combobox is hidden + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Let's toggle the combobox 3 times + await click(getComboboxButton()) + await click(getComboboxButton()) + await click(getComboboxButton()) + + // Verify the combobox is visible + assertComboboxList({ state: ComboboxState.Visible }) + + expect(readFn).toHaveBeenCalledTimes(3) + expect(readFn).toHaveBeenNthCalledWith(1, State.Open) + expect(readFn).toHaveBeenNthCalledWith(2, State.Closed) + expect(readFn).toHaveBeenNthCalledWith(3, State.Open) + }) + ) +}) + +describe('Keyboard interactions', () => { + describe('Button', () => { + describe('`Enter` key', () => { + it( + 'should be possible to open the Combobox with Enter', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.Enter) + + // Verify we moved focus to the input field + assertActiveElement(getComboboxInput()) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertComboboxOption(option, { selected: false })) + + assertNoActiveComboboxOption() + assertNoSelectedComboboxOption() + }) + ) + + it( + 'should not be possible to open the combobox with Enter when the button is disabled', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Try to focus the button + getComboboxButton()?.focus() + + // Try to open the combobox + await press(Keys.Enter) + + // Verify it is still closed + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + }) + ) + + it( + 'should be possible to open the combobox with Enter, and focus the selected option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref('b') }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.Enter) + + // Verify we moved focus to the input field + assertActiveElement(getComboboxInput()) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) + + // Verify that the second combobox option is active (because it is already selected) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + 'should be possible to open the combobox with Enter, and focus the selected option (when using the `hidden` render strategy)', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref('b') }), + }) + + await new Promise(nextTick) + + assertComboboxButton({ + state: ComboboxState.InvisibleHidden, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleHidden }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.Enter) + + // Verify we moved focus to the input field + assertActiveElement(getComboboxInput()) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + let options = getComboboxOptions() + + // Hover over Option A + await mouseMove(options[0]) + + // Verify that Option A is active + assertActiveComboboxOption(options[0]) + + // Verify that Option B is still selected + assertComboboxOption(options[1], { selected: true }) + + // Close/Hide the combobox + await press(Keys.Escape) + + // Re-open the combobox + await click(getComboboxButton()) + + // Verify we have combobox options + expect(options).toHaveLength(3) + options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) + + // Verify that the second combobox option is active (because it is already selected) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + 'should be possible to open the combobox with Enter, and focus the selected option (with a list of objects)', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + {{ option.name }} + + + `, + setup: () => { + let options = [ + { id: 'a', name: 'Option A' }, + { id: 'b', name: 'Option B' }, + { id: 'c', name: 'Option C' }, + ] + let value = ref(options[1]) + + return { value, options } + }, + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.Enter) + + // Verify we moved focus to the input field + assertActiveElement(getComboboxInput()) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) + + // Verify that the second combobox option is active (because it is already selected) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + 'should have no active combobox option when there are no combobox options at all', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.Enter) + + // Verify we moved focus to the input field + assertActiveElement(getComboboxInput()) + + assertComboboxList({ state: ComboboxState.Visible }) + assertActiveElement(getComboboxInput()) + + assertNoActiveComboboxOption() + }) + ) + }) + + describe('`Space` key', () => { + it( + 'should be possible to open the combobox with Space', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.Space) + + // Verify we moved focus to the input field + assertActiveElement(getComboboxInput()) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertComboboxOption(option)) + assertNoActiveComboboxOption() + }) + ) + + it( + 'should not be possible to open the combobox with Space when the button is disabled', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + getComboboxButton()?.focus() + + // Try to open the combobox + await press(Keys.Space) + + // Verify it is still closed + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + }) + ) + + it( + 'should be possible to open the combobox with Space, and focus the selected option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref('b') }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ + state: ComboboxState.InvisibleUnmounted, + }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.Space) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) + + // Verify that the second combobox option is active (because it is already selected) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + 'should have no active combobox option when there are no combobox options at all', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxList({ + state: ComboboxState.InvisibleUnmounted, + }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.Space) + assertComboboxList({ state: ComboboxState.Visible }) + assertActiveElement(getComboboxInput()) + + assertNoActiveComboboxOption() + }) + ) + + it( + 'should have no active combobox option upon Space key press, when there are no non-disabled combobox options', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + Option A + + + Option B + + + Option C + + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ + state: ComboboxState.InvisibleUnmounted, + }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.Space) + + assertNoActiveComboboxOption() + }) + ) + }) + + describe('`Escape` key', () => { + it( + 'should be possible to close an open combobox with Escape', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Re-focus the button + getComboboxButton()?.focus() + assertActiveElement(getComboboxButton()) + + // Close combobox + await press(Keys.Escape) + + // Verify it is closed + assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Verify the input is focused again + assertActiveElement(getComboboxInput()) + }) + ) + }) + + describe('`ArrowDown` key', () => { + it( + 'should be possible to open the combobox with ArrowDown', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref('test') }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.ArrowDown) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertComboboxOption(option)) + + // Verify that the first combobox option is active + assertNoActiveComboboxOption() + }) + ) + + it( + 'should not be possible to open the combobox with ArrowDown when the button is disabled', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + getComboboxButton()?.focus() + + // Try to open the combobox + await press(Keys.ArrowDown) + + // Verify it is still closed + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + }) + ) + + it( + 'should be possible to open the combobox with ArrowDown, and focus the selected option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref('b') }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.ArrowDown) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) + + // Verify that the second combobox option is active (because it is already selected) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + 'should have no active combobox option when there are no combobox options at all', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.ArrowDown) + assertComboboxList({ state: ComboboxState.Visible }) + assertActiveElement(getComboboxInput()) + + assertNoActiveComboboxOption() + }) + ) + }) + + describe('`ArrowRight` key', () => { + it( + 'should be possible to open the combobox with ArrowRight', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref('test') }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.ArrowRight) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + orientation: 'horizontal', + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertComboboxOption(option)) + + // Verify that the first combobox option is active + assertNoActiveComboboxOption() + }) + ) + + it( + 'should not be possible to open the combobox with ArrowRight when the button is disabled', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' }) + + // Focus the button + getComboboxButton()?.focus() + + // Try to open the combobox + await press(Keys.ArrowRight) + + // Verify it is still closed + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' }) + }) + ) + + it( + 'should be possible to open the combobox with ArrowRight, and focus the selected option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref('b') }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.ArrowRight) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + orientation: 'horizontal', + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) + + // Verify that the second combobox option is active (because it is already selected) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + 'should have no active combobox option when there are no combobox options at all', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + `, + setup: () => ({ value: ref('test') }), + }) + + assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.ArrowRight) + assertComboboxList({ state: ComboboxState.Visible, orientation: 'horizontal' }) + assertActiveElement(getComboboxInput()) + + assertNoActiveComboboxOption() + }) + ) + }) + + describe('`ArrowUp` key', () => { + it( + 'should be possible to open the combobox with ArrowUp and the last option should be active', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.ArrowUp) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertComboboxOption(option)) + + // ! ALERT: The LAST option should now be active + assertActiveComboboxOption(options[2]) + }) + ) + + it( + 'should not be possible to open the combobox with ArrowUp and the last option should be active when the button is disabled', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + getComboboxButton()?.focus() + + // Try to open the combobox + await press(Keys.ArrowUp) + + // Verify it is still closed + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + }) + ) + + it( + 'should be possible to open the combobox with ArrowUp, and focus the selected option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref('b') }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.ArrowUp) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) + + // Verify that the second combobox option is active (because it is already selected) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + 'should have no active combobox option when there are no combobox options at all', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.ArrowUp) + assertComboboxList({ state: ComboboxState.Visible }) + assertActiveElement(getComboboxInput()) + + assertNoActiveComboboxOption() + }) + ) + + it( + 'should be possible to use ArrowUp to navigate the combobox options and jump to the first non-disabled one', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + + Option B + + + Option C + + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.ArrowUp) + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertComboboxOption(option)) + assertActiveComboboxOption(options[0]) + }) + ) + }) + + describe('`ArrowLeft` key', () => { + it( + 'should be possible to open the combobox with ArrowLeft and the last option should be active', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.ArrowLeft) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + orientation: 'horizontal', + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertComboboxOption(option)) + + // ! ALERT: The LAST option should now be active + assertActiveComboboxOption(options[2]) + }) + ) + + it( + 'should not be possible to open the combobox with ArrowLeft and the last option should be active when the button is disabled', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' }) + + // Focus the button + getComboboxButton()?.focus() + + // Try to open the combobox + await press(Keys.ArrowLeft) + + // Verify it is still closed + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' }) + }) + ) + + it( + 'should be possible to open the combobox with ArrowLeft, and focus the selected option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref('b') }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.ArrowLeft) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + orientation: 'horizontal', + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) + + // Verify that the second combobox option is active (because it is already selected) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + 'should have no active combobox option when there are no combobox options at all', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + `, + setup: () => ({ value: ref('test') }), + }) + + assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.ArrowLeft) + assertComboboxList({ state: ComboboxState.Visible, orientation: 'horizontal' }) + assertActiveElement(getComboboxInput()) + + assertNoActiveComboboxOption() + }) + ) + + it( + 'should be possible to use ArrowLeft to navigate the combobox options and jump to the first non-disabled one', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + + Option B + + + Option C + + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' }) + + // Focus the button + getComboboxButton()?.focus() + + // Open combobox + await press(Keys.ArrowLeft) + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertComboboxOption(option)) + assertActiveComboboxOption(options[0]) + }) + ) + }) + }) + + describe('Input', () => { + describe('`Enter` key', () => { + it( + 'should be possible to close the combobox with Enter and choose the active combobox option', + suppressConsoleLogs(async () => { + let handleChange = jest.fn() + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup() { + let value = ref(null) + watch([value], () => handleChange(value.value)) + return { value } + }, + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Open combobox + await click(getComboboxButton()) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + + // Activate the first combobox option + let options = getComboboxOptions() + await mouseMove(options[0]) + + // Choose option, and close combobox + await press(Keys.Enter) + + // Verify it is closed + assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Verify we got the change event + expect(handleChange).toHaveBeenCalledTimes(1) + expect(handleChange).toHaveBeenCalledWith('a') + + // Verify the button is focused again + assertActiveElement(getComboboxInput()) + + // Open combobox again + await click(getComboboxButton()) + + // Verify the active option is the previously selected one + assertActiveComboboxOption(getComboboxOptions()[0]) + }) + ) + }) + + describe('`Tab` key', () => { + it( + 'pressing Tab should select the active item and move to the next DOM node', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + + Trigger + + Option A + Option B + Option C + + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Open combobox + await click(getComboboxButton()) + + // Select the 2nd option + await press(Keys.ArrowDown) + await press(Keys.ArrowDown) + + // Tab to the next DOM node + await press(Keys.Tab) + + // Verify it is closed + assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // That the selected value was the highlighted one + expect(getComboboxInput()?.value).toBe('b') + + // And focus has moved to the next element + assertActiveElement(document.querySelector('#after-combobox')) + }) + ) + + it( + 'pressing Shift+Tab should select the active item and move to the previous DOM node', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + + Trigger + + Option A + Option B + Option C + + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Open combobox + await click(getComboboxButton()) + + // Select the 2nd option + await press(Keys.ArrowDown) + await press(Keys.ArrowDown) + + // Tab to the next DOM node + await press(shift(Keys.Tab)) + + // Verify it is closed + assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // That the selected value was the highlighted one + expect(getComboboxInput()?.value).toBe('b') + + // And focus has moved to the next element + assertActiveElement(document.querySelector('#before-combobox')) + }) + ) + }) + + describe('`Escape` key', () => { + it( + 'should be possible to close an open combobox with Escape', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Close combobox + await press(Keys.Escape) + + // Verify it is closed + assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Verify the button is focused again + assertActiveElement(getComboboxInput()) + }) + ) + }) + + describe('`ArrowDown` key', () => { + it( + 'should be possible to open the combobox with ArrowDown', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref('test') }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the input + getComboboxInput()?.focus() + + // Open combobox + await press(Keys.ArrowDown) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertComboboxOption(option)) + + // Verify that the first combobox option is active + assertNoActiveComboboxOption() + }) + ) + + it( + 'should not be possible to open the combobox with ArrowDown when the button is disabled', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the input + getComboboxInput()?.focus() + + // Try to open the combobox + await press(Keys.ArrowDown) + + // Verify it is still closed + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + }) + ) + + it( + 'should be possible to open the combobox with ArrowDown, and focus the selected option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref('b') }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the input + getComboboxInput()?.focus() + + // Open combobox + await press(Keys.ArrowDown) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) + + // Verify that the second combobox option is active (because it is already selected) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + 'should have no active combobox option when there are no combobox options at all', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the input + getComboboxInput()?.focus() + + // Open combobox + await press(Keys.ArrowDown) + assertComboboxList({ state: ComboboxState.Visible }) + assertActiveElement(getComboboxInput()) + + assertNoActiveComboboxOption() + }) + ) + + it( + 'should be possible to use ArrowDown to navigate the combobox options', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Open combobox + await click(getComboboxButton()) + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertComboboxOption(option)) + assertNoActiveComboboxOption() + + // We should be able to go down once + await press(Keys.ArrowDown) + assertActiveComboboxOption(options[0]) + + // We should be able to go down again + await press(Keys.ArrowDown) + assertActiveComboboxOption(options[1]) + + // We should be able to go down again + await press(Keys.ArrowDown) + assertActiveComboboxOption(options[2]) + + // We should NOT be able to go down again (because last option). Current implementation won't go around. + await press(Keys.ArrowDown) + assertActiveComboboxOption(options[2]) + }) + ) + + it( + 'should be possible to use ArrowDown to navigate the combobox options and skip the first disabled one', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + Option A + + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Open combobox + await click(getComboboxButton()) + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertComboboxOption(option)) + assertNoActiveComboboxOption() + + // We should be able to go down once + await press(Keys.ArrowDown) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + 'should be possible to use ArrowDown to navigate the combobox options and jump to the first non-disabled one', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + Option A + + + Option B + + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Open combobox + await click(getComboboxButton()) + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertComboboxOption(option)) + assertNoActiveComboboxOption() + + // Open combobox + await press(Keys.ArrowDown) + assertActiveComboboxOption(options[2]) + }) + ) + }) + + describe('`ArrowRight` key', () => { + it( + 'should be possible to open the combobox with ArrowRight', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref('test') }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' }) + + // Focus the input + getComboboxInput()?.focus() + + // Open combobox + await press(Keys.ArrowRight) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + orientation: 'horizontal', + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertComboboxOption(option)) + + // Verify that the first combobox option is active + assertNoActiveComboboxOption() + }) + ) + + it( + 'should not be possible to open the combobox with ArrowRight when the button is disabled', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' }) + + // Focus the input + getComboboxInput()?.focus() + + // Try to open the combobox + await press(Keys.ArrowRight) + + // Verify it is still closed + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' }) + }) + ) + + it( + 'should be possible to open the combobox with ArrowRight, and focus the selected option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref('b') }), + }) + + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref('test') }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' }) + + // Focus the input + getComboboxInput()?.focus() + + // Open combobox + await press(Keys.ArrowRight) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + orientation: 'horizontal', + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) + + // Verify that the second combobox option is active (because it is already selected) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + 'should have no active combobox option when there are no combobox options at all', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + `, + setup: () => ({ value: ref('test') }), + }) + + assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' }) + + // Focus the input + getComboboxInput()?.focus() + + // Open combobox + await press(Keys.ArrowRight) + assertComboboxList({ state: ComboboxState.Visible, orientation: 'horizontal' }) + assertActiveElement(getComboboxInput()) + + assertNoActiveComboboxOption() + }) + ) + + it( + 'should be possible to use ArrowRight to navigate the combobox options', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref('test') }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' }) + + // Open combobox + await click(getComboboxButton()) + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertComboboxOption(option)) + assertNoActiveComboboxOption() + + // We should be able to go down once + await press(Keys.ArrowRight) + assertActiveComboboxOption(options[0]) + + // We should be able to go down again + await press(Keys.ArrowRight) + assertActiveComboboxOption(options[1]) + + // We should be able to go down again + await press(Keys.ArrowRight) + assertActiveComboboxOption(options[2]) + + // We should NOT be able to go down again (because last option). Current implementation won't go around. + await press(Keys.ArrowRight) + assertActiveComboboxOption(options[2]) + }) + ) + + it( + 'should be possible to use ArrowRight to navigate the combobox options and skip the first disabled one', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + Option A + + Option B + Option C + + + `, + setup: () => ({ value: ref('test') }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' }) + + // Open combobox + await click(getComboboxButton()) + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertComboboxOption(option)) + assertNoActiveComboboxOption() + + // We should be able to go down once + await press(Keys.ArrowRight) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + 'should be possible to use ArrowRight to navigate the combobox options and jump to the first non-disabled one', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + Option A + + + Option B + + Option C + + + `, + setup: () => ({ value: ref('test') }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' }) + + // Open combobox + await click(getComboboxButton()) + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertComboboxOption(option)) + assertNoActiveComboboxOption() + + // Open combobox + await press(Keys.ArrowRight) + assertActiveComboboxOption(options[2]) + }) + ) + }) + + describe('`ArrowUp` key', () => { + it( + 'should be possible to open the combobox with ArrowUp and the last option should be active', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the input + getComboboxInput()?.focus() + + // Open combobox + await press(Keys.ArrowUp) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertComboboxOption(option)) + + // ! ALERT: The LAST option should now be active + assertActiveComboboxOption(options[2]) + }) + ) + + it( + 'should not be possible to open the combobox with ArrowUp and the last option should be active when the button is disabled', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the input + getComboboxInput()?.focus() + + // Try to open the combobox + await press(Keys.ArrowUp) + + // Verify it is still closed + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + }) + ) + + it( + 'should be possible to open the combobox with ArrowUp, and focus the selected option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref('b') }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the input + getComboboxInput()?.focus() + + // Open combobox + await press(Keys.ArrowUp) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) + + // Verify that the second combobox option is active (because it is already selected) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + 'should have no active combobox option when there are no combobox options at all', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the input + getComboboxInput()?.focus() + + // Open combobox + await press(Keys.ArrowUp) + assertComboboxList({ state: ComboboxState.Visible }) + assertActiveElement(getComboboxInput()) + + assertNoActiveComboboxOption() + }) + ) + + it( + 'should be possible to use ArrowUp to navigate the combobox options and jump to the first non-disabled one', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + + Option B + + + Option C + + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the input + getComboboxInput()?.focus() + + // Open combobox + await press(Keys.ArrowUp) + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertComboboxOption(option)) + assertActiveComboboxOption(options[0]) + }) + ) + + it( + 'should not be possible to navigate up or down if there is only a single non-disabled option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + Option A + + + Option B + + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Open combobox + await click(getComboboxButton()) + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertComboboxOption(option)) + assertNoActiveComboboxOption() + + // Going up or down should select the single available option + await press(Keys.ArrowUp) + + // We should not be able to go up (because those are disabled) + await press(Keys.ArrowUp) + assertActiveComboboxOption(options[2]) + + // We should not be able to go down (because this is the last option) + await press(Keys.ArrowDown) + assertActiveComboboxOption(options[2]) + }) + ) + + it( + 'should be possible to use ArrowUp to navigate the combobox options', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Focus the input + getComboboxInput()?.focus() + + // Open combobox + await press(Keys.ArrowUp) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertComboboxOption(option)) + assertActiveComboboxOption(options[2]) + + // We should be able to go down once + await press(Keys.ArrowUp) + assertActiveComboboxOption(options[1]) + + // We should be able to go down again + await press(Keys.ArrowUp) + assertActiveComboboxOption(options[0]) + + // We should NOT be able to go up again (because first option). Current implementation won't go around. + await press(Keys.ArrowUp) + assertActiveComboboxOption(options[0]) + }) + ) + }) + + describe('`ArrowLeft` key', () => { + it( + 'should be possible to open the combobox with ArrowLeft and the last option should be active', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' }) + + // Focus the input + getComboboxInput()?.focus() + + // Open combobox + await press(Keys.ArrowLeft) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + orientation: 'horizontal', + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertComboboxOption(option)) + + // ! ALERT: The LAST option should now be active + assertActiveComboboxOption(options[2]) + }) + ) + + it( + 'should not be possible to open the combobox with ArrowLeft and the last option should be active when the button is disabled', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' }) + + // Focus the input + getComboboxInput()?.focus() + + // Try to open the combobox + await press(Keys.ArrowLeft) + + // Verify it is still closed + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' }) + }) + ) + + it( + 'should be possible to open the combobox with ArrowLeft, and focus the selected option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref('b') }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' }) + + // Focus the input + getComboboxInput()?.focus() + + // Open combobox + await press(Keys.ArrowLeft) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + orientation: 'horizontal', + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) + + // Verify that the second combobox option is active (because it is already selected) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + 'should have no active combobox option when there are no combobox options at all', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + `, + setup: () => ({ value: ref('test') }), + }) + + assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' }) + + // Focus the input + getComboboxInput()?.focus() + + // Open combobox + await press(Keys.ArrowLeft) + assertComboboxList({ state: ComboboxState.Visible, orientation: 'horizontal' }) + assertActiveElement(getComboboxInput()) + + assertNoActiveComboboxOption() + }) + ) + + it( + 'should be possible to use ArrowLeft to navigate the combobox options and jump to the first non-disabled one', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + + Option B + + + Option C + + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted, orientation: 'horizontal' }) + + // Focus the input + getComboboxInput()?.focus() + + // Open combobox + await press(Keys.ArrowLeft) + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertComboboxOption(option)) + assertActiveComboboxOption(options[0]) + }) + ) + }) + + describe('`End` key', () => { + it( + 'should be possible to use the End key to go to the last combobox option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + + let options = getComboboxOptions() + + // We should have no option selected + assertNoActiveComboboxOption() + + // We should be able to go to the last option + await press(Keys.End) + assertActiveComboboxOption(options[2]) + }) + ) + + it( + 'should be possible to use the End key to go to the last non disabled combobox option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + + Option C + + + Option D + + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + + let options = getComboboxOptions() + + // We should have no option selected + assertNoActiveComboboxOption() + + // We should be able to go to the last non-disabled option + await press(Keys.End) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + 'should be possible to use the End key to go to the first combobox option if that is the only non-disabled combobox option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + + Option B + + + Option C + + + Option D + + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + + // We opened via click, we don't have an active option + assertNoActiveComboboxOption() + + // We should not be able to go to the end + await press(Keys.End) + + let options = getComboboxOptions() + assertActiveComboboxOption(options[0]) + }) + ) + + it( + 'should have no active combobox option upon End key press, when there are no non-disabled combobox options', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + Option A + + + Option B + + + Option C + + + Option D + + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + + // We opened via click, we don't have an active option + assertNoActiveComboboxOption() + + // We should not be able to go to the end + await press(Keys.End) + + assertNoActiveComboboxOption() + }) + ) + }) + + describe('`PageDown` key', () => { + it( + 'should be possible to use the PageDown key to go to the last combobox option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + + let options = getComboboxOptions() + + // We should be on the first option + assertNoActiveComboboxOption() + + // We should be able to go to the last option + await press(Keys.PageDown) + assertActiveComboboxOption(options[2]) + }) + ) + + it( + 'should be possible to use the PageDown key to go to the last non disabled Combobox option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + + Option C + + + Option D + + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + + // Open combobox + await press(Keys.Space) + + let options = getComboboxOptions() + + // We should have nothing active + assertNoActiveComboboxOption() + + // We should be able to go to the last non-disabled option + await press(Keys.PageDown) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + 'should be possible to use the PageDown key to go to the first combobox option if that is the only non-disabled combobox option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + + Option B + + + Option C + + + Option D + + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + + // We opened via click, we don't have an active option + assertNoActiveComboboxOption() + + // We should not be able to go to the end + await press(Keys.PageDown) + + let options = getComboboxOptions() + assertActiveComboboxOption(options[0]) + }) + ) + + it( + 'should have no active combobox option upon PageDown key press, when there are no non-disabled combobox options', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + Option A + + + Option B + + + Option C + + + Option D + + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + + // We opened via click, we don't have an active option + assertNoActiveComboboxOption() + + // We should not be able to go to the end + await press(Keys.PageDown) + + assertNoActiveComboboxOption() + }) + ) + }) + + describe('`Home` key', () => { + it( + 'should be possible to use the Home key to go to the first combobox option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Focus the input + getComboboxInput()?.focus() + + // Open combobox + await press(Keys.ArrowUp) + + let options = getComboboxOptions() + + // We should be on the last option + assertActiveComboboxOption(options[2]) + + // We should be able to go to the first option + await press(Keys.Home) + assertActiveComboboxOption(options[0]) + }) + ) + + it( + 'should be possible to use the Home key to go to the first non disabled combobox option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + Option A + + + Option B + + Option C + Option D + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + + // We opened via click, we don't have an active option + assertNoActiveComboboxOption() + + // We should not be able to go to the end + await press(Keys.Home) + + let options = getComboboxOptions() + + // We should be on the first non-disabled option + assertActiveComboboxOption(options[2]) + }) + ) + + it( + 'should be possible to use the Home key to go to the last combobox option if that is the only non-disabled combobox option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + Option A + + + Option B + + + Option C + + Option D + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + + // We opened via click, we don't have an active option + assertNoActiveComboboxOption() + + // We should not be able to go to the end + await press(Keys.Home) + + let options = getComboboxOptions() + assertActiveComboboxOption(options[3]) + }) + ) + + it( + 'should have no active combobox option upon Home key press, when there are no non-disabled combobox options', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + Option A + + + Option B + + + Option C + + + Option D + + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + + // We opened via click, we don't have an active option + assertNoActiveComboboxOption() + + // We should not be able to go to the end + await press(Keys.Home) + + assertNoActiveComboboxOption() + }) + ) + }) + + describe('`PageUp` key', () => { + it( + 'should be possible to use the PageUp key to go to the first combobox option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Focus the input + getComboboxInput()?.focus() + + // Open combobox + await press(Keys.ArrowUp) + + let options = getComboboxOptions() + + // We should be on the last option + assertActiveComboboxOption(options[2]) + + // We should be able to go to the first option + await press(Keys.PageUp) + assertActiveComboboxOption(options[0]) + }) + ) + + it( + 'should be possible to use the PageUp key to go to the first non disabled combobox option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + Option A + + + Option B + + Option C + Option D + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + + // We opened via click, we don't have an active option + assertNoActiveComboboxOption() + + // We should not be able to go to the end + await press(Keys.PageUp) + + let options = getComboboxOptions() + + // We should be on the first non-disabled option + assertActiveComboboxOption(options[2]) + }) + ) + + it( + 'should be possible to use the PageUp key to go to the last combobox option if that is the only non-disabled combobox option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + Option A + + + Option B + + + Option C + + Option D + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + + // We opened via click, we don't have an active option + assertNoActiveComboboxOption() + + // We should not be able to go to the end + await press(Keys.PageUp) + + let options = getComboboxOptions() + assertActiveComboboxOption(options[3]) + }) + ) + + it( + 'should have no active combobox option upon PageUp key press, when there are no non-disabled combobox options', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + + Option A + + + Option B + + + Option C + + + Option D + + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + + // We opened via click, we don't have an active option + assertNoActiveComboboxOption() + + // We should not be able to go to the end + await press(Keys.PageUp) + + assertNoActiveComboboxOption() + }) + ) + }) + + describe('`Any` key aka search', () => { + let Example = defineComponent({ + components: getDefaultComponents(), + + template: html` + + + Trigger + + + {{ person.name }} + + + + `, + + props: { + people: { + type: Array as PropType<{ value: string; name: string; disabled: boolean }[]>, + required: true, + }, + }, + + setup(props) { + let value = ref(null) + let query = ref('') + let filteredPeople = computed(() => { + return query.value === '' + ? props.people + : props.people.filter(person => + person.name.toLowerCase().includes(query.value.toLowerCase()) + ) + }) + + return { + value, + query, + filteredPeople, + setQuery: (event: Event & { target: HTMLInputElement }) => { + query.value = event.target.value + }, + } + }, + }) + + it( + 'should be possible to type a full word that has a perfect match', + suppressConsoleLogs(async () => { + renderTemplate({ + components: { Example }, + template: html` + + `, + }) + + // Open combobox + await click(getComboboxButton()) + + // Verify we moved focus to the input field + assertActiveElement(getComboboxInput()) + let options: ReturnType + + // We should be able to go to the second option + await type(word('bob')) + await press(Keys.Home) + + options = getComboboxOptions() + expect(options).toHaveLength(1) + expect(options[0]).toHaveTextContent('bob') + assertActiveComboboxOption(options[0]) + + // We should be able to go to the first option + await type(word('alice')) + await press(Keys.Home) + + options = getComboboxOptions() + expect(options).toHaveLength(1) + expect(options[0]).toHaveTextContent('alice') + assertActiveComboboxOption(options[0]) + + // We should be able to go to the last option + await type(word('charlie')) + await press(Keys.Home) + + options = getComboboxOptions() + expect(options).toHaveLength(1) + expect(options[0]).toHaveTextContent('charlie') + assertActiveComboboxOption(options[0]) + }) + ) + + it( + 'should be possible to type a partial of a word', + suppressConsoleLogs(async () => { + renderTemplate({ + components: { Example }, + template: html` + + `, + }) + + // Open combobox + await click(getComboboxButton()) + + let options: ReturnType + + // We should be able to go to the second option + await type(word('bo')) + await press(Keys.Home) + options = getComboboxOptions() + expect(options).toHaveLength(1) + expect(options[0]).toHaveTextContent('bob') + assertActiveComboboxOption(options[0]) + + // We should be able to go to the first option + await type(word('ali')) + await press(Keys.Home) + options = getComboboxOptions() + expect(options).toHaveLength(1) + expect(options[0]).toHaveTextContent('alice') + assertActiveComboboxOption(options[0]) + + // We should be able to go to the last option + await type(word('char')) + await press(Keys.Home) + options = getComboboxOptions() + expect(options).toHaveLength(1) + expect(options[0]).toHaveTextContent('charlie') + assertActiveComboboxOption(options[0]) + }) + ) + + it( + 'should be possible to type words with spaces', + suppressConsoleLogs(async () => { + renderTemplate({ + components: { Example }, + template: html` + + `, + }) + + // Open combobox + await click(getComboboxButton()) + + let options: ReturnType + + // We should be able to go to the second option + await type(word('bob t')) + await press(Keys.Home) + options = getComboboxOptions() + expect(options).toHaveLength(1) + expect(options[0]).toHaveTextContent('bob the builder') + assertActiveComboboxOption(options[0]) + + // We should be able to go to the first option + await type(word('alice j')) + await press(Keys.Home) + options = getComboboxOptions() + expect(options).toHaveLength(1) + expect(options[0]).toHaveTextContent('alice jones') + assertActiveComboboxOption(options[0]) + + // We should be able to go to the last option + await type(word('charlie b')) + await press(Keys.Home) + options = getComboboxOptions() + expect(options).toHaveLength(1) + expect(options[0]).toHaveTextContent('charlie bit me') + assertActiveComboboxOption(options[0]) + }) + ) + + it( + 'should not be possible to search and activate a disabled option', + suppressConsoleLogs(async () => { + renderTemplate({ + components: { Example }, + template: html` + + `, + }) + + // Open combobox + await click(getComboboxButton()) + + // We should not be able to go to the disabled option + await type(word('bo')) + await press(Keys.Home) + + assertNoActiveComboboxOption() + assertNoSelectedComboboxOption() + }) + ) + + it( + 'should maintain activeIndex and activeOption when filtering', + suppressConsoleLogs(async () => { + renderTemplate({ + components: { Example }, + template: html` + + `, + }) + + // Open combobox + await click(getComboboxButton()) + + let options: ReturnType + + await press(Keys.ArrowDown) + await press(Keys.ArrowDown) + + // Person B should be active + options = getComboboxOptions() + expect(options[1]).toHaveTextContent('person b') + assertActiveComboboxOption(options[1]) + + // Filter more, remove `person a` + await type(word('person b')) + options = getComboboxOptions() + expect(options[0]).toHaveTextContent('person b') + assertActiveComboboxOption(options[0]) + + // Filter less, insert `person a` before `person b` + await type(word('person')) + options = getComboboxOptions() + expect(options[1]).toHaveTextContent('person b') + assertActiveComboboxOption(options[1]) + }) + ) + }) + }) +}) + +describe('Mouse interactions', () => { + it( + 'should focus the ComboboxButton when we click the ComboboxLabel', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + Label + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Ensure the button is not focused yet + assertActiveElement(document.body) + + // Focus the label + await click(getComboboxLabel()) + + // Ensure that the actual button is focused instead + assertActiveElement(getComboboxInput()) + }) + ) + + it( + 'should not focus the ComboboxInput when we right click the ComboboxLabel', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + Label + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Ensure the button is not focused yet + assertActiveElement(document.body) + + // Focus the label + await click(getComboboxLabel(), MouseButton.Right) + + // Ensure that the body is still active + assertActiveElement(document.body) + }) + ) + + it( + 'should be possible to open the combobox on click', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Open combobox + await click(getComboboxButton()) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertComboboxOption(option)) + }) + ) + + it( + 'should not be possible to open the combobox on right click', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Try to open the combobox + await click(getComboboxButton(), MouseButton.Right) + + // Verify it is still closed + assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) + }) + ) + + it( + 'should not be possible to open the combobox on click when the button is disabled', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Try to open the combobox + await click(getComboboxButton()) + + // Verify it is still closed + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + }) + ) + + it( + 'should be possible to open the combobox on click, and focus the selected option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref('b') }), + }) + + assertComboboxButton({ + state: ComboboxState.InvisibleUnmounted, + attributes: { id: 'headlessui-combobox-button-2' }, + }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Open combobox + await click(getComboboxButton()) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + assertComboboxList({ + state: ComboboxState.Visible, + attributes: { id: 'headlessui-combobox-options-3' }, + }) + assertActiveElement(getComboboxInput()) + assertComboboxButtonLinkedWithCombobox() + + // Verify we have combobox options + let options = getComboboxOptions() + expect(options).toHaveLength(3) + options.forEach((option, i) => assertComboboxOption(option, { selected: i === 1 })) + + // Verify that the second combobox option is active (because it is already selected) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + 'should be possible to close a combobox on click', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + + // Verify it is visible + assertComboboxButton({ state: ComboboxState.Visible }) + + // Click to close + await click(getComboboxButton()) + + // Verify it is closed + assertComboboxButton({ state: ComboboxState.InvisibleUnmounted }) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + }) + ) + + it( + 'should be a no-op when we click outside of a closed combobox', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + alice + bob + charlie + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Verify that the window is closed + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Click something that is not related to the combobox + await click(document.body) + + // Should still be closed + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + }) + ) + + it( + 'should be possible to click outside of the combobox which should close the combobox', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + alice + bob + charlie + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + assertComboboxList({ state: ComboboxState.Visible }) + assertActiveElement(getComboboxInput()) + + // Click something that is not related to the combobox + await click(document.body) + + // Should be closed now + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Verify the input is focused again + assertActiveElement(getComboboxInput()) + }) + ) + + it( + 'should be possible to click outside of the combobox on another combobox button which should close the current combobox and open the new combobox', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` +
+ + + Trigger + + alice + bob + charlie + + + + + + Trigger + + alice + bob + charlie + + +
+ `, + setup: () => ({ value: ref(null) }), + }) + + let [button1, button2] = getComboboxButtons() + + // Click the first combobox button + await click(button1) + expect(getComboboxes()).toHaveLength(1) // Only 1 combobox should be visible + + // Verify that the first input is focused + assertActiveElement(getComboboxInputs()[0]) + + // Click the second combobox button + await click(button2) + + expect(getComboboxes()).toHaveLength(1) // Only 1 combobox should be visible + + // Verify that the first input is focused + assertActiveElement(getComboboxInputs()[1]) + }) + ) + + it( + 'should be possible to click outside of the combobox which should close the combobox (even if we press the combobox button)', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + alice + bob + charlie + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + assertComboboxList({ state: ComboboxState.Visible }) + assertActiveElement(getComboboxInput()) + + // Click the combobox button again + await click(getComboboxButton()) + + // Should be closed now + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Verify the input is focused again + assertActiveElement(getComboboxInput()) + }) + ) + + it( + 'should be possible to click outside of the combobox, on an element which is within a focusable element, which closes the combobox', + suppressConsoleLogs(async () => { + let focusFn = jest.fn() + renderTemplate({ + template: html` +
+ + + Trigger + + alice + bob + charlie + + + + +
+ `, + setup: () => ({ value: ref('test'), focusFn }), + }) + + // Click the combobox button + await click(getComboboxButton()) + + // Ensure the combobox is open + assertComboboxList({ state: ComboboxState.Visible }) + + // Click the span inside the button + await click(getByText('Next')) + + // Ensure the combobox is closed + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + + // Ensure the outside button is focused + assertActiveElement(document.getElementById('btn')) + + // Ensure that the focus button only got focus once (first click) + expect(focusFn).toHaveBeenCalledTimes(1) + }) + ) + + it( + 'should be possible to hover an option and make it active', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + alice + bob + charlie + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + + let options = getComboboxOptions() + // We should be able to go to the second option + await mouseMove(options[1]) + assertActiveComboboxOption(options[1]) + + // We should be able to go to the first option + await mouseMove(options[0]) + assertActiveComboboxOption(options[0]) + + // We should be able to go to the last option + await mouseMove(options[2]) + assertActiveComboboxOption(options[2]) + }) + ) + + it( + 'should make a combobox option active when you move the mouse over it', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + alice + bob + charlie + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + + let options = getComboboxOptions() + // We should be able to go to the second option + await mouseMove(options[1]) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + 'should be a no-op when we move the mouse and the combobox option is already active', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + alice + bob + charlie + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + + let options = getComboboxOptions() + + // We should be able to go to the second option + await mouseMove(options[1]) + assertActiveComboboxOption(options[1]) + + await mouseMove(options[1]) + + // Nothing should be changed + assertActiveComboboxOption(options[1]) + }) + ) + + it( + 'should be a no-op when we move the mouse and the combobox option is disabled', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + alice + + bob + + charlie + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + + let options = getComboboxOptions() + + await mouseMove(options[1]) + assertNoActiveComboboxOption() + }) + ) + + it( + 'should not be possible to hover an option that is disabled', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + alice + + bob + + charlie + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + + let options = getComboboxOptions() + + // Try to hover over option 1, which is disabled + await mouseMove(options[1]) + + // We should not have an active option now + assertNoActiveComboboxOption() + }) + ) + + it( + 'should be possible to mouse leave an option and make it inactive', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + alice + bob + charlie + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + + let options = getComboboxOptions() + + // We should be able to go to the second option + await mouseMove(options[1]) + assertActiveComboboxOption(options[1]) + + await mouseLeave(options[1]) + assertNoActiveComboboxOption() + + // We should be able to go to the first option + await mouseMove(options[0]) + assertActiveComboboxOption(options[0]) + + await mouseLeave(options[0]) + assertNoActiveComboboxOption() + + // We should be able to go to the last option + await mouseMove(options[2]) + assertActiveComboboxOption(options[2]) + + await mouseLeave(options[2]) + assertNoActiveComboboxOption() + }) + ) + + it( + 'should be possible to mouse leave a disabled option and be a no-op', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + alice + + bob + + charlie + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + + let options = getComboboxOptions() + + // Try to hover over option 1, which is disabled + await mouseMove(options[1]) + assertNoActiveComboboxOption() + + await mouseLeave(options[1]) + assertNoActiveComboboxOption() + }) + ) + + it( + 'should be possible to click a combobox option, which closes the combobox', + suppressConsoleLogs(async () => { + let handleChange = jest.fn() + renderTemplate({ + template: html` + + + Trigger + + alice + bob + charlie + + + `, + setup() { + let value = ref(null) + watch([value], () => handleChange(value.value)) + return { value } + }, + }) + + // Open combobox + await click(getComboboxButton()) + assertComboboxList({ state: ComboboxState.Visible }) + assertActiveElement(getComboboxInput()) + + let options = getComboboxOptions() + + // We should be able to click the first option + await click(options[1]) + assertComboboxList({ state: ComboboxState.InvisibleUnmounted }) + expect(handleChange).toHaveBeenCalledTimes(1) + expect(handleChange).toHaveBeenCalledWith('bob') + + // Verify the input is focused again + assertActiveElement(getComboboxInput()) + + // Open combobox again + await click(getComboboxButton()) + + // Verify the active option is the previously selected one + assertActiveComboboxOption(getComboboxOptions()[1]) + }) + ) + + it( + 'should be possible to click a disabled combobox option, which is a no-op', + suppressConsoleLogs(async () => { + let handleChange = jest.fn() + renderTemplate({ + template: html` + + + Trigger + + alice + + bob + + charlie + + + `, + setup() { + let value = ref(null) + watch([value], () => handleChange(value.value)) + return { value } + }, + }) + + // Open combobox + await click(getComboboxButton()) + assertComboboxList({ state: ComboboxState.Visible }) + assertActiveElement(getComboboxInput()) + + let options = getComboboxOptions() + + // We should be able to click the first option + await click(options[1]) + assertComboboxList({ state: ComboboxState.Visible }) + assertActiveElement(getComboboxInput()) + expect(handleChange).toHaveBeenCalledTimes(0) + + // Close the combobox + await click(getComboboxButton()) + + // Open combobox again + await click(getComboboxButton()) + + // Verify the active option is non existing + assertNoActiveComboboxOption() + }) + ) + + it( + 'should be possible focus a combobox option, so that it becomes active', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + alice + bob + charlie + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + assertComboboxList({ state: ComboboxState.Visible }) + assertActiveElement(getComboboxInput()) + + let options = getComboboxOptions() + + // Verify that nothing is active yet + assertNoActiveComboboxOption() + + // We should be able to focus the first option + await focus(options[1]) + assertActiveComboboxOption(options[1]) + }) + ) + + it( + 'should not be possible to focus a combobox option which is disabled', + suppressConsoleLogs(async () => { + renderTemplate({ + template: html` + + + Trigger + + alice + + bob + + charlie + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open combobox + await click(getComboboxButton()) + assertComboboxList({ state: ComboboxState.Visible }) + assertActiveElement(getComboboxInput()) + + let options = getComboboxOptions() + + // We should not be able to focus the first option + await focus(options[1]) + assertNoActiveComboboxOption() + }) + ) +}) diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.ts b/packages/@headlessui-vue/src/components/combobox/combobox.ts new file mode 100644 index 0000000000..784ee1cb43 --- /dev/null +++ b/packages/@headlessui-vue/src/components/combobox/combobox.ts @@ -0,0 +1,686 @@ +import { + defineComponent, + ref, + provide, + inject, + onMounted, + onUnmounted, + computed, + nextTick, + InjectionKey, + Ref, + ComputedRef, + watchEffect, + toRaw, + watch, + PropType, +} from 'vue' + +import { Features, render, omit } from '../../utils/render' +import { useId } from '../../hooks/use-id' +import { Keys } from '../../keyboard' +import { calculateActiveIndex, Focus } from '../../utils/calculate-active-index' +import { dom } from '../../utils/dom' +import { useWindowEvent } from '../../hooks/use-window-event' +import { useOpenClosed, State, useOpenClosedProvider } from '../../internal/open-closed' +import { match } from '../../utils/match' +import { useResolveButtonType } from '../../hooks/use-resolve-button-type' + +enum ComboboxStates { + Open, + Closed, +} + +type ComboboxOptionDataRef = Ref<{ disabled: boolean; value: unknown }> +type StateDefinition = { + // State + ComboboxState: Ref + value: ComputedRef + orientation: Ref<'vertical' | 'horizontal'> + + labelRef: Ref + inputRef: Ref + buttonRef: Ref + optionsRef: Ref + inputPropsRef: Ref<{ displayValue?: (item: unknown) => string }> + + disabled: Ref + options: Ref<{ id: string; dataRef: ComboboxOptionDataRef }[]> + activeOptionIndex: Ref + + // State mutators + closeCombobox(): void + openCombobox(): void + goToOption(focus: Focus, id?: string): void + selectOption(id: string): void + selectActiveOption(): void + registerOption(id: string, dataRef: ComboboxOptionDataRef): void + unregisterOption(id: string): void + select(value: unknown): void +} + +let ComboboxContext = Symbol('ComboboxContext') as InjectionKey + +function useComboboxContext(component: string) { + let context = inject(ComboboxContext, null) + + if (context === null) { + let err = new Error(`<${component} /> is missing a parent component.`) + if (Error.captureStackTrace) Error.captureStackTrace(err, useComboboxContext) + throw err + } + + return context +} + +// --- + +export let Combobox = defineComponent({ + name: 'Combobox', + emits: { 'update:modelValue': (_value: any) => true }, + props: { + as: { type: [Object, String], default: 'template' }, + disabled: { type: [Boolean], default: false }, + horizontal: { type: [Boolean], default: false }, + modelValue: { type: [Object, String, Number, Boolean] }, + }, + setup(props, { slots, attrs, emit }) { + let ComboboxState = ref(ComboboxStates.Closed) + let labelRef = ref(null) + let inputRef = ref(null) as StateDefinition['inputRef'] + let buttonRef = ref(null) as StateDefinition['buttonRef'] + let optionsRef = ref( + null + ) as StateDefinition['optionsRef'] + let options = ref([]) + let activeOptionIndex = ref(null) + + let value = computed(() => props.modelValue) + + let api = { + ComboboxState, + value, + orientation: computed(() => (props.horizontal ? 'horizontal' : 'vertical')), + inputRef, + labelRef, + buttonRef, + optionsRef, + disabled: computed(() => props.disabled), + options, + activeOptionIndex, + inputPropsRef: ref<{ displayValue?: (item: unknown) => string }>({ displayValue: undefined }), + closeCombobox() { + if (props.disabled) return + if (ComboboxState.value === ComboboxStates.Closed) return + ComboboxState.value = ComboboxStates.Closed + activeOptionIndex.value = null + }, + openCombobox() { + if (props.disabled) return + if (ComboboxState.value === ComboboxStates.Open) return + ComboboxState.value = ComboboxStates.Open + }, + goToOption(focus: Focus, id?: string) { + if (props.disabled) return + if (ComboboxState.value === ComboboxStates.Closed) return + + let nextActiveOptionIndex = calculateActiveIndex( + focus === Focus.Specific + ? { focus: Focus.Specific, id: id! } + : { focus: focus as Exclude }, + { + resolveItems: () => options.value, + resolveActiveIndex: () => activeOptionIndex.value, + resolveId: option => option.id, + resolveDisabled: option => option.dataRef.disabled, + } + ) + + if (activeOptionIndex.value === nextActiveOptionIndex) return + activeOptionIndex.value = nextActiveOptionIndex + }, + syncInputValue() { + let value = api.value.value + if (!dom(api.inputRef)) return + if (value === undefined) return + let displayValue = api.inputPropsRef.value.displayValue + + if (typeof displayValue === 'function') { + api.inputRef!.value!.value = displayValue(value) + } else if (typeof value === 'string') { + api.inputRef!.value!.value = value + } + }, + selectOption(id: string) { + let option = options.value.find(item => item.id === id) + if (!option) return + + let { dataRef } = option + emit('update:modelValue', dataRef.value) + api.syncInputValue() + }, + selectActiveOption() { + if (activeOptionIndex.value === null) return + + let { dataRef } = options.value[activeOptionIndex.value] + emit('update:modelValue', dataRef.value) + api.syncInputValue() + }, + registerOption(id: string, dataRef: ComboboxOptionDataRef) { + let currentActiveOption = + activeOptionIndex.value !== null ? options.value[activeOptionIndex.value] : null + let orderMap = Array.from( + optionsRef.value?.querySelectorAll('[id^="headlessui-combobox-option-"]') ?? [] + ).reduce( + (lookup, element, index) => Object.assign(lookup, { [element.id]: index }), + {} + ) as Record + + // @ts-expect-error The expected type comes from property 'dataRef' which is declared here on type '{ id: string; dataRef: { textValue: string; disabled: boolean; }; }' + options.value = [...options.value, { id, dataRef }].sort( + (a, z) => orderMap[a.id] - orderMap[z.id] + ) + + // If we inserted an option before the current active option then the + // active option index would be wrong. To fix this, we will re-lookup + // the correct index. + activeOptionIndex.value = (() => { + if (currentActiveOption === null) return null + return options.value.indexOf(currentActiveOption) + })() + }, + unregisterOption(id: string) { + let nextOptions = options.value.slice() + let currentActiveOption = + activeOptionIndex.value !== null ? nextOptions[activeOptionIndex.value] : null + let idx = nextOptions.findIndex(a => a.id === id) + if (idx !== -1) nextOptions.splice(idx, 1) + options.value = nextOptions + activeOptionIndex.value = (() => { + if (idx === activeOptionIndex.value) return null + if (currentActiveOption === null) return null + + // If we removed the option before the actual active index, then it would be out of sync. To + // fix this, we will find the correct (new) index position. + return nextOptions.indexOf(currentActiveOption) + })() + }, + } + + useWindowEvent('mousedown', event => { + let target = event.target as HTMLElement + let active = document.activeElement + + if (ComboboxState.value !== ComboboxStates.Open) return + + if (dom(inputRef)?.contains(target)) return + if (dom(buttonRef)?.contains(target)) return + if (dom(optionsRef)?.contains(target)) return + + api.closeCombobox() + + if (active !== document.body && active?.contains(target)) return // Keep focus on newly clicked/focused element + if (!event.defaultPrevented) dom(inputRef)?.focus({ preventScroll: true }) + }) + + watchEffect(() => { + api.syncInputValue() + }) + + // @ts-expect-error Types of property 'dataRef' are incompatible. + provide(ComboboxContext, api) + useOpenClosedProvider( + computed(() => + match(ComboboxState.value, { + [ComboboxStates.Open]: State.Open, + [ComboboxStates.Closed]: State.Closed, + }) + ) + ) + + return () => { + let slot = { open: ComboboxState.value === ComboboxStates.Open, disabled: props.disabled } + return render({ + props: omit(props, ['modelValue', 'onUpdate:modelValue', 'disabled', 'horizontal']), + slot, + slots, + attrs, + name: 'Combobox', + }) + } + }, +}) + +// --- + +export let ComboboxLabel = defineComponent({ + name: 'ComboboxLabel', + props: { as: { type: [Object, String], default: 'label' } }, + render() { + let api = useComboboxContext('ComboboxLabel') + + let slot = { + open: api.ComboboxState.value === ComboboxStates.Open, + disabled: api.disabled.value, + } + let propsWeControl = { id: this.id, ref: 'el', onClick: this.handleClick } + + return render({ + props: { ...this.$props, ...propsWeControl }, + slot, + attrs: this.$attrs, + slots: this.$slots, + name: 'ComboboxLabel', + }) + }, + setup() { + let api = useComboboxContext('ComboboxLabel') + let id = `headlessui-combobox-label-${useId()}` + + return { + id, + el: api.labelRef, + handleClick() { + dom(api.inputRef)?.focus({ preventScroll: true }) + }, + } + }, +}) + +// --- + +export let ComboboxButton = defineComponent({ + name: 'ComboboxButton', + props: { + as: { type: [Object, String], default: 'button' }, + }, + render() { + let api = useComboboxContext('ComboboxButton') + + let slot = { + open: api.ComboboxState.value === ComboboxStates.Open, + disabled: api.disabled.value, + } + let propsWeControl = { + ref: 'el', + id: this.id, + type: this.type, + tabindex: '-1', + 'aria-haspopup': true, + 'aria-controls': dom(api.optionsRef)?.id, + 'aria-expanded': api.disabled.value + ? undefined + : api.ComboboxState.value === ComboboxStates.Open, + 'aria-labelledby': api.labelRef.value + ? [dom(api.labelRef)?.id, this.id].join(' ') + : undefined, + disabled: api.disabled.value === true ? true : undefined, + onKeydown: this.handleKeydown, + onClick: this.handleClick, + } + + return render({ + props: { ...this.$props, ...propsWeControl }, + slot, + attrs: this.$attrs, + slots: this.$slots, + name: 'ComboboxButton', + }) + }, + setup(props, { attrs }) { + let api = useComboboxContext('ComboboxButton') + let id = `headlessui-combobox-button-${useId()}` + + function handleClick(event: MouseEvent) { + if (api.disabled.value) return + if (api.ComboboxState.value === ComboboxStates.Open) { + api.closeCombobox() + } else { + event.preventDefault() + api.openCombobox() + } + + nextTick(() => dom(api.inputRef)?.focus({ preventScroll: true })) + } + + function handleKeydown(event: KeyboardEvent) { + switch (event.key) { + // Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-12 + + case match(api.orientation.value, { + vertical: Keys.ArrowDown, + horizontal: Keys.ArrowRight, + }): + event.preventDefault() + event.stopPropagation() + if (api.ComboboxState.value === ComboboxStates.Closed) { + api.openCombobox() + // TODO: We can't do this outside next frame because the options aren't rendered yet + // But doing this in next frame results in a flicker because the dom mutations are async here + // Basically: + // Sync -> no option list yet + // Next frame -> option list already rendered with selection -> dispatch -> next frame -> now we have the focus on the right element + + // TODO: The spec here is underspecified. There's mention of skipping to the next item when autocomplete has suggested something but nothing regarding a non-autocomplete selection/value + nextTick(() => { + if (!api.value.value) { + api.goToOption(Focus.First) + } + }) + } + nextTick(() => api.inputRef.value?.focus({ preventScroll: true })) + return + + case match(api.orientation.value, { vertical: Keys.ArrowUp, horizontal: Keys.ArrowLeft }): + event.preventDefault() + event.stopPropagation() + if (api.ComboboxState.value === ComboboxStates.Closed) { + api.openCombobox() + nextTick(() => { + if (!api.value.value) { + api.goToOption(Focus.Last) + } + }) + } + nextTick(() => api.inputRef.value?.focus({ preventScroll: true })) + return + + case Keys.Escape: + event.preventDefault() + event.stopPropagation() + api.closeCombobox() + nextTick(() => api.inputRef.value?.focus({ preventScroll: true })) + return + } + } + + return { + api, + id, + el: api.buttonRef, + type: useResolveButtonType( + computed(() => ({ as: props.as, type: attrs.type })), + api.buttonRef + ), + handleClick, + handleKeydown, + } + }, +}) + +// --- + +export let ComboboxInput = defineComponent({ + name: 'ComboboxInput', + props: { + as: { type: [Object, String], default: 'input' }, + static: { type: Boolean, default: false }, + unmount: { type: Boolean, default: true }, + displayValue: { type: Function as PropType<(item: unknown) => string> }, + }, + emits: { + change: (_value: Event & { target: HTMLInputElement }) => true, + }, + render() { + let api = useComboboxContext('ComboboxInput') + + let slot = { open: api.ComboboxState.value === ComboboxStates.Open } + let propsWeControl = { + 'aria-activedescendant': + api.activeOptionIndex.value === null + ? undefined + : api.options.value[api.activeOptionIndex.value]?.id, + 'aria-labelledby': dom(api.labelRef)?.id ?? dom(api.buttonRef)?.id, + 'aria-orientation': api.orientation.value, + id: this.id, + onKeydown: this.handleKeyDown, + onChange: this.handleChange, + role: 'combobox', + tabIndex: 0, + ref: 'el', + } + let passThroughProps = this.$props + + return render({ + props: { ...passThroughProps, ...propsWeControl }, + slot, + attrs: this.$attrs, + slots: this.$slots, + features: Features.RenderStrategy | Features.Static, + name: 'ComboboxInput', + }) + }, + setup(props, { emit }) { + let api = useComboboxContext('ComboboxInput') + let id = `headlessui-combobox-input-${useId()}` + api.inputPropsRef = computed(() => props) + + function handleKeyDown(event: KeyboardEvent) { + switch (event.key) { + // Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-12 + + case Keys.Enter: + event.preventDefault() + event.stopPropagation() + + api.selectActiveOption() + api.closeCombobox() + break + + case match(api.orientation.value, { + vertical: Keys.ArrowDown, + horizontal: Keys.ArrowRight, + }): + event.preventDefault() + event.stopPropagation() + return match(api.ComboboxState.value, { + [ComboboxStates.Open]: () => api.goToOption(Focus.Next), + [ComboboxStates.Closed]: () => { + api.openCombobox() + nextTick(() => { + if (!api.value.value) { + api.goToOption(Focus.First) + } + }) + }, + }) + + case match(api.orientation.value, { vertical: Keys.ArrowUp, horizontal: Keys.ArrowLeft }): + event.preventDefault() + event.stopPropagation() + return match(api.ComboboxState.value, { + [ComboboxStates.Open]: () => api.goToOption(Focus.Previous), + [ComboboxStates.Closed]: () => { + api.openCombobox() + nextTick(() => { + if (!api.value.value) { + api.goToOption(Focus.Last) + } + }) + }, + }) + + case Keys.Home: + case Keys.PageUp: + event.preventDefault() + event.stopPropagation() + return api.goToOption(Focus.First) + + case Keys.End: + case Keys.PageDown: + event.preventDefault() + event.stopPropagation() + return api.goToOption(Focus.Last) + + case Keys.Escape: + event.preventDefault() + event.stopPropagation() + api.closeCombobox() + break + + case Keys.Tab: + api.selectActiveOption() + api.closeCombobox() + break + } + } + + function handleChange(event: Event & { target: HTMLInputElement }) { + api.openCombobox() + emit('change', event) + } + + return { id, el: api.inputRef, handleKeyDown, handleChange } + }, +}) + +// --- + +export let ComboboxOptions = defineComponent({ + name: 'ComboboxOptions', + props: { + as: { type: [Object, String], default: 'ul' }, + static: { type: Boolean, default: false }, + unmount: { type: Boolean, default: true }, + }, + render() { + let api = useComboboxContext('ComboboxOptions') + + let slot = { open: api.ComboboxState.value === ComboboxStates.Open } + let propsWeControl = { + 'aria-activedescendant': + api.activeOptionIndex.value === null + ? undefined + : api.options.value[api.activeOptionIndex.value]?.id, + 'aria-labelledby': dom(api.labelRef)?.id ?? dom(api.buttonRef)?.id, + 'aria-orientation': api.orientation.value, + id: this.id, + ref: 'el', + role: 'listbox', + } + let passThroughProps = this.$props + + return render({ + props: { ...passThroughProps, ...propsWeControl }, + slot, + attrs: this.$attrs, + slots: this.$slots, + features: Features.RenderStrategy | Features.Static, + visible: this.visible, + name: 'ComboboxOptions', + }) + }, + setup() { + let api = useComboboxContext('ComboboxOptions') + let id = `headlessui-combobox-options-${useId()}` + + let usesOpenClosedState = useOpenClosed() + let visible = computed(() => { + if (usesOpenClosedState !== null) { + return usesOpenClosedState.value === State.Open + } + + return api.ComboboxState.value === ComboboxStates.Open + }) + + return { id, el: api.optionsRef, visible } + }, +}) + +export let ComboboxOption = defineComponent({ + name: 'ComboboxOption', + props: { + as: { type: [Object, String], default: 'li' }, + value: { type: [Object, String, Number, Boolean] }, + disabled: { type: Boolean, default: false }, + }, + setup(props, { slots, attrs }) { + let api = useComboboxContext('ComboboxOption') + let id = `headlessui-combobox-option-${useId()}` + + let active = computed(() => { + return api.activeOptionIndex.value !== null + ? api.options.value[api.activeOptionIndex.value].id === id + : false + }) + + let selected = computed(() => toRaw(api.value.value) === toRaw(props.value)) + + let dataRef = computed(() => ({ + disabled: props.disabled, + value: props.value, + })) + + onMounted(() => api.registerOption(id, dataRef)) + onUnmounted(() => api.unregisterOption(id)) + + onMounted(() => { + watch( + [api.ComboboxState, selected], + () => { + if (api.ComboboxState.value !== ComboboxStates.Open) return + if (!selected.value) return + api.goToOption(Focus.Specific, id) + }, + { immediate: true } + ) + }) + + watchEffect(() => { + if (api.ComboboxState.value !== ComboboxStates.Open) return + if (!active.value) return + nextTick(() => document.getElementById(id)?.scrollIntoView?.({ block: 'nearest' })) + }) + + function handleClick(event: MouseEvent) { + if (props.disabled) return event.preventDefault() + api.selectOption(id) + api.closeCombobox() + nextTick(() => dom(api.inputRef)?.focus({ preventScroll: true })) + } + + function handleFocus() { + if (props.disabled) return api.goToOption(Focus.Nothing) + api.goToOption(Focus.Specific, id) + } + + function handleMove() { + if (props.disabled) return + if (active.value) return + api.goToOption(Focus.Specific, id) + } + + function handleLeave() { + if (props.disabled) return + if (!active.value) return + api.goToOption(Focus.Nothing) + } + + return () => { + let { disabled } = props + let slot = { active: active.value, selected: selected.value, disabled } + let propsWeControl = { + id, + role: 'option', + tabIndex: disabled === true ? undefined : -1, + 'aria-disabled': disabled === true ? true : undefined, + 'aria-selected': selected.value === true ? selected.value : undefined, + disabled: undefined, // Never forward the `disabled` prop + onClick: handleClick, + onFocus: handleFocus, + onPointermove: handleMove, + onMousemove: handleMove, + onPointerleave: handleLeave, + onMouseleave: handleLeave, + } + + return render({ + props: { ...props, ...propsWeControl }, + slot, + attrs, + slots, + name: 'ComboboxOption', + }) + } + }, +}) diff --git a/packages/@headlessui-vue/src/index.test.ts b/packages/@headlessui-vue/src/index.test.ts index 97ddbee3e8..d8d8fac3e0 100644 --- a/packages/@headlessui-vue/src/index.test.ts +++ b/packages/@headlessui-vue/src/index.test.ts @@ -6,6 +6,14 @@ import * as HeadlessUI from './index' */ it('should expose the correct components', () => { expect(Object.keys(HeadlessUI)).toEqual([ + // Combobox + 'Combobox', + 'ComboboxLabel', + 'ComboboxButton', + 'ComboboxInput', + 'ComboboxOptions', + 'ComboboxOption', + // Dialog 'Dialog', 'DialogOverlay', diff --git a/packages/@headlessui-vue/src/index.ts b/packages/@headlessui-vue/src/index.ts index 2ef29db23c..2ba6c32e78 100644 --- a/packages/@headlessui-vue/src/index.ts +++ b/packages/@headlessui-vue/src/index.ts @@ -1,3 +1,4 @@ +export * from './components/combobox/combobox' export * from './components/dialog/dialog' export * from './components/disclosure/disclosure' export * from './components/focus-trap/focus-trap' diff --git a/packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts b/packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts index 1fb40a1f39..f478642b9a 100644 --- a/packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts +++ b/packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts @@ -91,7 +91,7 @@ export function assertMenuButton( expect(button).toHaveAttribute(attributeName, options.attributes[attributeName]) } } catch (err) { - Error.captureStackTrace(err, assertMenuButton) + if (err instanceof Error) Error.captureStackTrace(err, assertMenuButton) throw err } } @@ -105,7 +105,7 @@ export function assertMenuButtonLinkedWithMenu(button = getMenuButton(), menu = expect(button).toHaveAttribute('aria-controls', menu.getAttribute('id')) expect(menu).toHaveAttribute('aria-labelledby', button.getAttribute('id')) } catch (err) { - Error.captureStackTrace(err, assertMenuButtonLinkedWithMenu) + if (err instanceof Error) Error.captureStackTrace(err, assertMenuButtonLinkedWithMenu) throw err } } @@ -118,7 +118,7 @@ export function assertMenuLinkedWithMenuItem(item: HTMLElement | null, menu = ge // Ensure link between menu & menu item is correct expect(menu).toHaveAttribute('aria-activedescendant', item.getAttribute('id')) } catch (err) { - Error.captureStackTrace(err, assertMenuLinkedWithMenuItem) + if (err instanceof Error) Error.captureStackTrace(err, assertMenuLinkedWithMenuItem) throw err } } @@ -130,7 +130,7 @@ export function assertNoActiveMenuItem(menu = getMenu()) { // Ensure we don't have an active menu expect(menu).not.toHaveAttribute('aria-activedescendant') } catch (err) { - Error.captureStackTrace(err, assertNoActiveMenuItem) + if (err instanceof Error) Error.captureStackTrace(err, assertNoActiveMenuItem) throw err } } @@ -183,7 +183,7 @@ export function assertMenu( assertNever(options.state) } } catch (err) { - Error.captureStackTrace(err, assertMenu) + if (err instanceof Error) Error.captureStackTrace(err, assertMenu) throw err } } @@ -214,7 +214,393 @@ export function assertMenuItem( } } } catch (err) { - Error.captureStackTrace(err, assertMenuItem) + if (err instanceof Error) Error.captureStackTrace(err, assertMenuItem) + throw err + } +} + +// --- + +export function getComboboxLabel(): HTMLElement | null { + return document.querySelector('label,[id^="headlessui-combobox-label"]') +} + +export function getComboboxButton(): HTMLElement | null { + return document.querySelector('button,[role="button"],[id^="headlessui-combobox-button-"]') +} + +export function getComboboxButtons(): HTMLElement[] { + return Array.from(document.querySelectorAll('button,[role="button"]')) +} + +export function getComboboxInput(): HTMLInputElement | null { + return document.querySelector('[role="combobox"]') +} + +export function getCombobox(): HTMLElement | null { + return document.querySelector('[role="listbox"]') +} + +export function getComboboxInputs(): HTMLElement[] { + return Array.from(document.querySelectorAll('[role="combobox"]')) +} + +export function getComboboxes(): HTMLElement[] { + return Array.from(document.querySelectorAll('[role="listbox"]')) +} + +export function getComboboxOptions(): HTMLElement[] { + return Array.from(document.querySelectorAll('[role="option"]')) +} + +// --- + +export enum ComboboxState { + /** The combobox is visible to the user. */ + Visible, + + /** The combobox is **not** visible to the user. It's still in the DOM, but it is hidden. */ + InvisibleHidden, + + /** The combobox is **not** visible to the user. It's not in the DOM, it is unmounted. */ + InvisibleUnmounted, +} + +export function assertCombobox( + options: { + attributes?: Record + textContent?: string + state: ComboboxState + orientation?: 'horizontal' | 'vertical' + }, + combobox = getComboboxInput() +) { + let { orientation = 'vertical' } = options + + try { + switch (options.state) { + case ComboboxState.InvisibleHidden: + if (combobox === null) return expect(combobox).not.toBe(null) + + assertHidden(combobox) + + expect(combobox).toHaveAttribute('aria-labelledby') + expect(combobox).toHaveAttribute('aria-orientation', orientation) + expect(combobox).toHaveAttribute('role', 'combobox') + + if (options.textContent) expect(combobox).toHaveTextContent(options.textContent) + + for (let attributeName in options.attributes) { + expect(combobox).toHaveAttribute(attributeName, options.attributes[attributeName]) + } + break + + case ComboboxState.Visible: + if (combobox === null) return expect(combobox).not.toBe(null) + + assertVisible(combobox) + + expect(combobox).toHaveAttribute('aria-labelledby') + expect(combobox).toHaveAttribute('aria-orientation', orientation) + expect(combobox).toHaveAttribute('role', 'combobox') + + if (options.textContent) expect(combobox).toHaveTextContent(options.textContent) + + for (let attributeName in options.attributes) { + expect(combobox).toHaveAttribute(attributeName, options.attributes[attributeName]) + } + break + + case ComboboxState.InvisibleUnmounted: + expect(combobox).toBe(null) + break + + default: + assertNever(options.state) + } + } catch (err) { + if (err instanceof Error) Error.captureStackTrace(err, assertCombobox) + throw err + } +} + +export function assertComboboxList( + options: { + attributes?: Record + textContent?: string + state: ComboboxState + orientation?: 'horizontal' | 'vertical' + }, + listbox = getCombobox() +) { + let { orientation = 'vertical' } = options + + try { + switch (options.state) { + case ComboboxState.InvisibleHidden: + if (listbox === null) return expect(listbox).not.toBe(null) + + assertHidden(listbox) + + expect(listbox).toHaveAttribute('aria-labelledby') + expect(listbox).toHaveAttribute('aria-orientation', orientation) + expect(listbox).toHaveAttribute('role', 'listbox') + + if (options.textContent) expect(listbox).toHaveTextContent(options.textContent) + + for (let attributeName in options.attributes) { + expect(listbox).toHaveAttribute(attributeName, options.attributes[attributeName]) + } + break + + case ComboboxState.Visible: + if (listbox === null) return expect(listbox).not.toBe(null) + + assertVisible(listbox) + + expect(listbox).toHaveAttribute('aria-labelledby') + expect(listbox).toHaveAttribute('aria-orientation', orientation) + expect(listbox).toHaveAttribute('role', 'listbox') + + if (options.textContent) expect(listbox).toHaveTextContent(options.textContent) + + for (let attributeName in options.attributes) { + expect(listbox).toHaveAttribute(attributeName, options.attributes[attributeName]) + } + break + + case ComboboxState.InvisibleUnmounted: + expect(listbox).toBe(null) + break + + default: + assertNever(options.state) + } + } catch (err) { + if (err instanceof Error) Error.captureStackTrace(err, assertCombobox) + throw err + } +} + +export function assertComboboxButton( + options: { + attributes?: Record + textContent?: string + state: ComboboxState + }, + button = getComboboxButton() +) { + try { + if (button === null) return expect(button).not.toBe(null) + + // Ensure menu button have these properties + expect(button).toHaveAttribute('id') + expect(button).toHaveAttribute('aria-haspopup') + + switch (options.state) { + case ComboboxState.Visible: + expect(button).toHaveAttribute('aria-controls') + expect(button).toHaveAttribute('aria-expanded', 'true') + break + + case ComboboxState.InvisibleHidden: + expect(button).toHaveAttribute('aria-controls') + if (button.hasAttribute('disabled')) { + expect(button).not.toHaveAttribute('aria-expanded') + } else { + expect(button).toHaveAttribute('aria-expanded', 'false') + } + break + + case ComboboxState.InvisibleUnmounted: + expect(button).not.toHaveAttribute('aria-controls') + if (button.hasAttribute('disabled')) { + expect(button).not.toHaveAttribute('aria-expanded') + } else { + expect(button).toHaveAttribute('aria-expanded', 'false') + } + break + + default: + assertNever(options.state) + } + + if (options.textContent) { + expect(button).toHaveTextContent(options.textContent) + } + + // Ensure menu button has the following attributes + for (let attributeName in options.attributes) { + expect(button).toHaveAttribute(attributeName, options.attributes[attributeName]) + } + } catch (err) { + if (err instanceof Error) Error.captureStackTrace(err, assertComboboxButton) + throw err + } +} + +export function assertComboboxLabel( + options: { + attributes?: Record + tag?: string + textContent?: string + }, + label = getComboboxLabel() +) { + try { + if (label === null) return expect(label).not.toBe(null) + + // Ensure menu button have these properties + expect(label).toHaveAttribute('id') + + if (options.textContent) { + expect(label).toHaveTextContent(options.textContent) + } + + if (options.tag) { + expect(label.tagName.toLowerCase()).toBe(options.tag) + } + + // Ensure menu button has the following attributes + for (let attributeName in options.attributes) { + expect(label).toHaveAttribute(attributeName, options.attributes[attributeName]) + } + } catch (err) { + if (err instanceof Error) Error.captureStackTrace(err, assertComboboxLabel) + throw err + } +} + +export function assertComboboxButtonLinkedWithCombobox( + button = getComboboxButton(), + combobox = getCombobox() +) { + try { + if (button === null) return expect(button).not.toBe(null) + if (combobox === null) return expect(combobox).not.toBe(null) + + // Ensure link between button & combobox is correct + expect(button).toHaveAttribute('aria-controls', combobox.getAttribute('id')) + expect(combobox).toHaveAttribute('aria-labelledby', button.getAttribute('id')) + } catch (err) { + if (err instanceof Error) Error.captureStackTrace(err, assertComboboxButtonLinkedWithCombobox) + throw err + } +} + +export function assertComboboxLabelLinkedWithCombobox( + label = getComboboxLabel(), + combobox = getComboboxInput() +) { + try { + if (label === null) return expect(label).not.toBe(null) + if (combobox === null) return expect(combobox).not.toBe(null) + + expect(combobox).toHaveAttribute('aria-labelledby', label.getAttribute('id')) + } catch (err) { + if (err instanceof Error) Error.captureStackTrace(err, assertComboboxLabelLinkedWithCombobox) + throw err + } +} + +export function assertComboboxButtonLinkedWithComboboxLabel( + button = getComboboxButton(), + label = getComboboxLabel() +) { + try { + if (button === null) return expect(button).not.toBe(null) + if (label === null) return expect(label).not.toBe(null) + + // Ensure link between button & label is correct + expect(button).toHaveAttribute('aria-labelledby', `${label.id} ${button.id}`) + } catch (err) { + if (err instanceof Error) + Error.captureStackTrace(err, assertComboboxButtonLinkedWithComboboxLabel) + throw err + } +} + +export function assertActiveComboboxOption( + item: HTMLElement | null, + combobox = getComboboxInput() +) { + try { + if (combobox === null) return expect(combobox).not.toBe(null) + if (item === null) return expect(item).not.toBe(null) + + // Ensure link between combobox & combobox item is correct + expect(combobox).toHaveAttribute('aria-activedescendant', item.getAttribute('id')) + } catch (err) { + if (err instanceof Error) Error.captureStackTrace(err, assertActiveComboboxOption) + throw err + } +} + +export function assertNoActiveComboboxOption(combobox = getComboboxInput()) { + try { + if (combobox === null) return expect(combobox).not.toBe(null) + + // Ensure we don't have an active combobox + expect(combobox).not.toHaveAttribute('aria-activedescendant') + } catch (err) { + if (err instanceof Error) Error.captureStackTrace(err, assertNoActiveComboboxOption) + throw err + } +} + +export function assertNoSelectedComboboxOption(items = getComboboxOptions()) { + try { + for (let item of items) expect(item).not.toHaveAttribute('aria-selected') + } catch (err) { + if (err instanceof Error) Error.captureStackTrace(err, assertNoSelectedComboboxOption) + throw err + } +} + +export function assertComboboxOption( + item: HTMLElement | null, + options?: { + tag?: string + attributes?: Record + selected?: boolean + } +) { + try { + if (item === null) return expect(item).not.toBe(null) + + // Check that some attributes exists, doesn't really matter what the values are at this point in + // time, we just require them. + expect(item).toHaveAttribute('id') + + // Check that we have the correct values for certain attributes + expect(item).toHaveAttribute('role', 'option') + if (!item.getAttribute('aria-disabled')) expect(item).toHaveAttribute('tabindex', '-1') + + // Ensure combobox button has the following attributes + if (!options) return + + for (let attributeName in options.attributes) { + expect(item).toHaveAttribute(attributeName, options.attributes[attributeName]) + } + + if (options.tag) { + expect(item.tagName.toLowerCase()).toBe(options.tag) + } + + if (options.selected != null) { + switch (options.selected) { + case true: + return expect(item).toHaveAttribute('aria-selected', 'true') + + case false: + return expect(item).not.toHaveAttribute('aria-selected') + + default: + assertNever(options.selected) + } + } + } catch (err) { + if (err instanceof Error) Error.captureStackTrace(err, assertComboboxOption) throw err } } @@ -311,7 +697,7 @@ export function assertListbox( assertNever(options.state) } } catch (err) { - Error.captureStackTrace(err, assertListbox) + if (err instanceof Error) Error.captureStackTrace(err, assertListbox) throw err } } @@ -368,7 +754,7 @@ export function assertListboxButton( expect(button).toHaveAttribute(attributeName, options.attributes[attributeName]) } } catch (err) { - Error.captureStackTrace(err, assertListboxButton) + if (err instanceof Error) Error.captureStackTrace(err, assertListboxButton) throw err } } @@ -400,7 +786,7 @@ export function assertListboxLabel( expect(label).toHaveAttribute(attributeName, options.attributes[attributeName]) } } catch (err) { - Error.captureStackTrace(err, assertListboxLabel) + if (err instanceof Error) Error.captureStackTrace(err, assertListboxLabel) throw err } } @@ -417,7 +803,7 @@ export function assertListboxButtonLinkedWithListbox( expect(button).toHaveAttribute('aria-controls', listbox.getAttribute('id')) expect(listbox).toHaveAttribute('aria-labelledby', button.getAttribute('id')) } catch (err) { - Error.captureStackTrace(err, assertListboxButtonLinkedWithListbox) + if (err instanceof Error) Error.captureStackTrace(err, assertListboxButtonLinkedWithListbox) throw err } } @@ -432,7 +818,7 @@ export function assertListboxLabelLinkedWithListbox( expect(listbox).toHaveAttribute('aria-labelledby', label.getAttribute('id')) } catch (err) { - Error.captureStackTrace(err, assertListboxLabelLinkedWithListbox) + if (err instanceof Error) Error.captureStackTrace(err, assertListboxLabelLinkedWithListbox) throw err } } @@ -448,7 +834,8 @@ export function assertListboxButtonLinkedWithListboxLabel( // Ensure link between button & label is correct expect(button).toHaveAttribute('aria-labelledby', `${label.id} ${button.id}`) } catch (err) { - Error.captureStackTrace(err, assertListboxButtonLinkedWithListboxLabel) + if (err instanceof Error) + Error.captureStackTrace(err, assertListboxButtonLinkedWithListboxLabel) throw err } } @@ -461,7 +848,7 @@ export function assertActiveListboxOption(item: HTMLElement | null, listbox = ge // Ensure link between listbox & listbox item is correct expect(listbox).toHaveAttribute('aria-activedescendant', item.getAttribute('id')) } catch (err) { - Error.captureStackTrace(err, assertActiveListboxOption) + if (err instanceof Error) Error.captureStackTrace(err, assertActiveListboxOption) throw err } } @@ -473,7 +860,7 @@ export function assertNoActiveListboxOption(listbox = getListbox()) { // Ensure we don't have an active listbox expect(listbox).not.toHaveAttribute('aria-activedescendant') } catch (err) { - Error.captureStackTrace(err, assertNoActiveListboxOption) + if (err instanceof Error) Error.captureStackTrace(err, assertNoActiveListboxOption) throw err } } @@ -482,7 +869,7 @@ export function assertNoSelectedListboxOption(items = getListboxOptions()) { try { for (let item of items) expect(item).not.toHaveAttribute('aria-selected') } catch (err) { - Error.captureStackTrace(err, assertNoSelectedListboxOption) + if (err instanceof Error) Error.captureStackTrace(err, assertNoSelectedListboxOption) throw err } } @@ -530,7 +917,7 @@ export function assertListboxOption( } } } catch (err) { - Error.captureStackTrace(err, assertListboxOption) + if (err instanceof Error) Error.captureStackTrace(err, assertListboxOption) throw err } } @@ -597,7 +984,7 @@ export function assertSwitch( assertNever(options.state) } } catch (err) { - Error.captureStackTrace(err, assertSwitch) + if (err instanceof Error) Error.captureStackTrace(err, assertSwitch) throw err } } @@ -678,7 +1065,7 @@ export function assertDisclosureButton( expect(button).toHaveAttribute(attributeName, options.attributes[attributeName]) } } catch (err) { - Error.captureStackTrace(err, assertDisclosureButton) + if (err instanceof Error) Error.captureStackTrace(err, assertDisclosureButton) throw err } } @@ -725,7 +1112,7 @@ export function assertDisclosurePanel( assertNever(options.state) } } catch (err) { - Error.captureStackTrace(err, assertDisclosurePanel) + if (err instanceof Error) Error.captureStackTrace(err, assertDisclosurePanel) throw err } } @@ -810,7 +1197,7 @@ export function assertPopoverButton( expect(button).toHaveAttribute(attributeName, options.attributes[attributeName]) } } catch (err) { - Error.captureStackTrace(err, assertPopoverButton) + if (err instanceof Error) Error.captureStackTrace(err, assertPopoverButton) throw err } } @@ -857,7 +1244,7 @@ export function assertPopoverPanel( assertNever(options.state) } } catch (err) { - Error.captureStackTrace(err, assertPopoverPanel) + if (err instanceof Error) Error.captureStackTrace(err, assertPopoverPanel) throw err } } @@ -984,7 +1371,7 @@ export function assertDialog( assertNever(options.state) } } catch (err) { - Error.captureStackTrace(err, assertDialog) + if (err instanceof Error) Error.captureStackTrace(err, assertDialog) throw err } } @@ -1040,7 +1427,7 @@ export function assertDialogTitle( assertNever(options.state) } } catch (err) { - Error.captureStackTrace(err, assertDialogTitle) + if (err instanceof Error) Error.captureStackTrace(err, assertDialogTitle) throw err } } @@ -1096,7 +1483,7 @@ export function assertDialogDescription( assertNever(options.state) } } catch (err) { - Error.captureStackTrace(err, assertDialogDescription) + if (err instanceof Error) Error.captureStackTrace(err, assertDialogDescription) throw err } } @@ -1143,7 +1530,7 @@ export function assertDialogOverlay( assertNever(options.state) } } catch (err) { - Error.captureStackTrace(err, assertDialogOverlay) + if (err instanceof Error) Error.captureStackTrace(err, assertDialogOverlay) throw err } } @@ -1185,7 +1572,7 @@ export function assertRadioGroupLabel( expect(label).toHaveAttribute(attributeName, options.attributes[attributeName]) } } catch (err) { - Error.captureStackTrace(err, assertRadioGroupLabel) + if (err instanceof Error) Error.captureStackTrace(err, assertRadioGroupLabel) throw err } } @@ -1267,7 +1654,7 @@ export function assertTabs( } } } catch (err) { - Error.captureStackTrace(err, assertTabs) + if (err instanceof Error) Error.captureStackTrace(err, assertTabs) throw err } } @@ -1287,7 +1674,7 @@ export function assertActiveElement(element: HTMLElement | null) { expect(document.activeElement?.outerHTML).toBe(element.outerHTML) } } catch (err) { - Error.captureStackTrace(err, assertActiveElement) + if (err instanceof Error) Error.captureStackTrace(err, assertActiveElement) throw err } } @@ -1297,7 +1684,7 @@ export function assertContainsActiveElement(element: HTMLElement | null) { if (element === null) return expect(element).not.toBe(null) expect(element.contains(document.activeElement)).toBe(true) } catch (err) { - Error.captureStackTrace(err, assertContainsActiveElement) + if (err instanceof Error) Error.captureStackTrace(err, assertContainsActiveElement) throw err } } @@ -1311,7 +1698,7 @@ export function assertHidden(element: HTMLElement | null) { expect(element).toHaveAttribute('hidden') expect(element).toHaveStyle({ display: 'none' }) } catch (err) { - Error.captureStackTrace(err, assertHidden) + if (err instanceof Error) Error.captureStackTrace(err, assertHidden) throw err } } @@ -1323,7 +1710,7 @@ export function assertVisible(element: HTMLElement | null) { expect(element).not.toHaveAttribute('hidden') expect(element).not.toHaveStyle({ display: 'none' }) } catch (err) { - Error.captureStackTrace(err, assertVisible) + if (err instanceof Error) Error.captureStackTrace(err, assertVisible) throw err } } @@ -1336,7 +1723,7 @@ export function assertFocusable(element: HTMLElement | null) { expect(isFocusableElement(element, FocusableMode.Strict)).toBe(true) } catch (err) { - Error.captureStackTrace(err, assertFocusable) + if (err instanceof Error) Error.captureStackTrace(err, assertFocusable) throw err } } @@ -1347,7 +1734,7 @@ export function assertNotFocusable(element: HTMLElement | null) { expect(isFocusableElement(element, FocusableMode.Strict)).toBe(false) } catch (err) { - Error.captureStackTrace(err, assertNotFocusable) + if (err instanceof Error) Error.captureStackTrace(err, assertNotFocusable) throw err } } diff --git a/packages/@headlessui-vue/src/test-utils/interactions.ts b/packages/@headlessui-vue/src/test-utils/interactions.ts index dd483d0966..8e93518bb1 100644 --- a/packages/@headlessui-vue/src/test-utils/interactions.ts +++ b/packages/@headlessui-vue/src/test-utils/interactions.ts @@ -1,4 +1,7 @@ import { fireEvent } from '@testing-library/dom' +import { disposables } from '../utils/disposables' + +let d = disposables() function nextFrame(cb: Function): void { setImmediate(() => @@ -33,7 +36,19 @@ export function shift(event: Partial) { } export function word(input: string): Partial[] { - return input.split('').map(key => ({ key })) + let result = input.split('').map(key => ({ key })) + + d.enqueue(() => { + let element = document.activeElement + + if (element instanceof HTMLInputElement) { + fireEvent.change(element, { + target: Object.assign({}, element, { value: input }), + }) + } + }) + + return result } let Default = Symbol() @@ -76,6 +91,9 @@ let order: Record< function keypress(element, event) { return fireEvent.keyPress(element, event) }, + function input(element, event) { + return fireEvent.input(element, event) + }, function keyup(element, event) { return fireEvent.keyUp(element, event) }, @@ -159,9 +177,11 @@ export async function type(events: Partial[], element = document. // We don't want to actually wait in our tests, so let's advance jest.runAllTimers() + await d.workQueue() + await new Promise(nextFrame) } catch (err) { - Error.captureStackTrace(err, type) + if (err instanceof Error) Error.captureStackTrace(err, type) throw err } finally { jest.useRealTimers() @@ -178,7 +198,7 @@ export enum MouseButton { } export async function click( - element: Document | Element | Window | null, + element: Document | Element | Window | Node | null, button = MouseButton.Left ) { try { @@ -224,12 +244,12 @@ export async function click( await new Promise(nextFrame) } catch (err) { - Error.captureStackTrace(err, click) + if (err instanceof Error) Error.captureStackTrace(err, click) throw err } } -export async function focus(element: Document | Element | Window | null) { +export async function focus(element: Document | Element | Window | Node | null) { try { if (element === null) return expect(element).not.toBe(null) @@ -237,11 +257,10 @@ export async function focus(element: Document | Element | Window | null) { await new Promise(nextFrame) } catch (err) { - Error.captureStackTrace(err, focus) + if (err instanceof Error) Error.captureStackTrace(err, focus) throw err } } - export async function mouseEnter(element: Document | Element | Window | null) { try { if (element === null) return expect(element).not.toBe(null) @@ -252,7 +271,7 @@ export async function mouseEnter(element: Document | Element | Window | null) { await new Promise(nextFrame) } catch (err) { - Error.captureStackTrace(err, mouseEnter) + if (err instanceof Error) Error.captureStackTrace(err, mouseEnter) throw err } } @@ -266,7 +285,7 @@ export async function mouseMove(element: Document | Element | Window | null) { await new Promise(nextFrame) } catch (err) { - Error.captureStackTrace(err, mouseMove) + if (err instanceof Error) Error.captureStackTrace(err, mouseMove) throw err } } @@ -282,7 +301,7 @@ export async function mouseLeave(element: Document | Element | Window | null) { await new Promise(nextFrame) } catch (err) { - Error.captureStackTrace(err, mouseLeave) + if (err instanceof Error) Error.captureStackTrace(err, mouseLeave) throw err } } diff --git a/packages/@headlessui-vue/src/utils/disposables.ts b/packages/@headlessui-vue/src/utils/disposables.ts index 7c9a388338..4c0f89c0f8 100644 --- a/packages/@headlessui-vue/src/utils/disposables.ts +++ b/packages/@headlessui-vue/src/utils/disposables.ts @@ -1,7 +1,12 @@ export function disposables() { let disposables: Function[] = [] + let queue: Function[] = [] let api = { + enqueue(fn: Function) { + queue.push(fn) + }, + requestAnimationFrame(...args: Parameters) { let raf = requestAnimationFrame(...args) api.add(() => cancelAnimationFrame(raf)) @@ -27,6 +32,12 @@ export function disposables() { dispose() } }, + + async workQueue() { + for (let handle of queue.splice(0)) { + await handle() + } + }, } return api diff --git a/packages/playground-react/next-env.d.ts b/packages/playground-react/next-env.d.ts new file mode 100644 index 0000000000..4f11a03dc6 --- /dev/null +++ b/packages/playground-react/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/packages/playground-react/next.config.js b/packages/playground-react/next.config.js new file mode 100644 index 0000000000..5b8efdfbd8 --- /dev/null +++ b/packages/playground-react/next.config.js @@ -0,0 +1,5 @@ +module.exports = { + devIndicators: { + autoPrerender: false, + }, +} diff --git a/packages/playground-react/package.json b/packages/playground-react/package.json new file mode 100644 index 0000000000..40c43256a8 --- /dev/null +++ b/packages/playground-react/package.json @@ -0,0 +1,23 @@ +{ + "name": "playground-react", + "version": "1.0.0", + "main": "next.config.js", + "scripts": { + "prebuild": "yarn workspace @headlessui/react build", + "dev": "next dev", + "build": "next build", + "start": "next start" + }, + "keywords": [], + "author": "Robin Malfait", + "license": "ISC", + "description": "", + "dependencies": { + "@headlessui/react": "*", + "@popperjs/core": "^2.6.0", + "framer-motion": "^6.0.0", + "next": "^12.0.8", + "react": "16.14.0", + "react-dom": "16.14.0" + } +} diff --git a/packages/playground-react/pages/_app.tsx b/packages/playground-react/pages/_app.tsx new file mode 100644 index 0000000000..671e2ee472 --- /dev/null +++ b/packages/playground-react/pages/_app.tsx @@ -0,0 +1,239 @@ +import React, { useState, useEffect } from 'react' +import Link from 'next/link' +import Head from 'next/head' + +function disposables() { + let disposables: Function[] = [] + + let api = { + requestAnimationFrame(...args: Parameters) { + let raf = requestAnimationFrame(...args) + api.add(() => cancelAnimationFrame(raf)) + }, + + nextFrame(...args: Parameters) { + api.requestAnimationFrame(() => { + api.requestAnimationFrame(...args) + }) + }, + + setTimeout(...args: Parameters) { + let timer = setTimeout(...args) + api.add(() => clearTimeout(timer)) + }, + + add(cb: () => void) { + disposables.push(cb) + }, + + dispose() { + for (let dispose of disposables.splice(0)) { + dispose() + } + }, + } + + return api +} + +export function useDisposables() { + // Using useState instead of useRef so that we can use the initializer function. + let [d] = useState(disposables) + useEffect(() => () => d.dispose(), [d]) + return d +} + +function NextLink(props: React.ComponentProps<'a'>) { + let { href, children, ...rest } = props + return ( + + {children} + + ) +} + +enum KeyDisplayMac { + ArrowUp = '↑', + ArrowDown = '↓', + ArrowLeft = '←', + ArrowRight = '→', + Home = '↖', + End = '↘', + Alt = '⌥', + CapsLock = '⇪', + Meta = '⌘', + Shift = '⇧', + Control = '⌃', + Backspace = '⌫', + Delete = '⌦', + Enter = '↵', + Escape = '⎋', + Tab = '↹', + PageUp = '⇞', + PageDown = '⇟', + ' ' = '␣', +} + +enum KeyDisplayWindows { + ArrowUp = '↑', + ArrowDown = '↓', + ArrowLeft = '←', + ArrowRight = '→', + Meta = 'Win', + Control = 'Ctrl', + Backspace = '⌫', + Delete = 'Del', + Escape = 'Esc', + PageUp = 'PgUp', + PageDown = 'PgDn', + ' ' = '␣', +} + +function tap(value: T, cb: (value: T) => void) { + cb(value) + return value +} + +function useKeyDisplay() { + let [mounted, setMounted] = useState(false) + + useEffect(() => { + setMounted(true) + }, []) + + if (!mounted) return {} + let isMac = navigator.userAgent.indexOf('Mac OS X') !== -1 + return isMac ? KeyDisplayMac : KeyDisplayWindows +} + +function KeyCaster() { + let [keys, setKeys] = useState([]) + let d = useDisposables() + let KeyDisplay = useKeyDisplay() + + useEffect(() => { + function handler(event: KeyboardEvent) { + setKeys(current => [ + event.shiftKey && event.key !== 'Shift' + ? KeyDisplay[`Shift${event.key}`] ?? event.key + : KeyDisplay[event.key] ?? event.key, + ...current, + ]) + d.setTimeout(() => setKeys(current => tap(current.slice(), clone => clone.pop())), 2000) + } + + window.addEventListener('keydown', handler, true) + return () => window.removeEventListener('keydown', handler, true) + }, [d, KeyDisplay]) + + if (keys.length <= 0) return null + + return ( +
+ {keys + .slice() + .reverse() + .join(' ')} +
+ ) +} + +function MyApp({ Component, pageProps }) { + return ( + <> + + + + + + + + + + + +
+
+ + + +
+ + + +
+ +
+
+ + ) +} + +function Logo({ className }) { + return ( + + + + + + + + + + + + + + + + + + ) +} + +export default MyApp diff --git a/packages/playground-react/pages/_error.tsx b/packages/playground-react/pages/_error.tsx new file mode 100644 index 0000000000..21c1a8adad --- /dev/null +++ b/packages/playground-react/pages/_error.tsx @@ -0,0 +1,63 @@ +import React from 'react' +import ErrorPage from 'next/error' +import Head from 'next/head' +import Link from 'next/link' + +import { ExamplesType, resolveAllExamples } from '../utils/resolve-all-examples' + +function NextLink(props: React.ComponentProps<'a'>) { + let { href, children, ...rest } = props + return ( + + {children} + + ) +} + +export async function getStaticProps() { + return { + props: { + examples: await resolveAllExamples('pages'), + }, + } +} + +export default function Page(props: { examples: false | ExamplesType[] }) { + if (props.examples === false) { + return + } + + return ( + <> + + Examples + + +
+
+

Examples

+ +
+
+ + ) +} + +export function Examples(props: { examples: ExamplesType[] }) { + return ( +
    + {props.examples.map(example => ( +
  • + {example.children ? ( +

    {example.name}

    + ) : ( + + {example.name} + + )} + {example.children && } +
  • + ))} +
+ ) +} diff --git a/packages/playground-react/pages/combobox/combobox-with-pure-tailwind.tsx b/packages/playground-react/pages/combobox/combobox-with-pure-tailwind.tsx new file mode 100644 index 0000000000..a1e6e872ea --- /dev/null +++ b/packages/playground-react/pages/combobox/combobox-with-pure-tailwind.tsx @@ -0,0 +1,136 @@ +import React, { useState, useEffect } from 'react' +import { Combobox } from '@headlessui/react' + +import { classNames } from '../../utils/class-names' + +let everybody = [ + 'Wade Cooper', + 'Arlene Mccoy', + 'Devon Webb', + 'Tom Cook', + 'Tanya Fox', + 'Hellen Schmidt', + 'Caroline Schultz', + 'Mason Heaney', + 'Claudie Smitham', + 'Emil Schaefer', +] + +function useDebounce(value: T, delay: number) { + let [debouncedValue, setDebouncedValue] = useState(value) + useEffect(() => { + let timer = setTimeout(() => setDebouncedValue(value), delay) + return () => clearTimeout(timer) + }, [value, delay]) + return debouncedValue +} +export default function Home() { + let [query, setQuery] = useState('') + let [activePerson, setActivePerson] = useState(everybody[2]) + + // Mimic delayed response from an API + let actualQuery = useDebounce(query, 0 /* Change to higher value like 100 for testing purposes */) + + // Choose a random person on mount + useEffect(() => { + setActivePerson(everybody[Math.floor(Math.random() * everybody.length)]) + }, []) + + let people = + actualQuery === '' + ? everybody + : everybody.filter(person => person.toLowerCase().includes(actualQuery.toLowerCase())) + + return ( +
+
+
Selected person: {activePerson}
+
+ { + setActivePerson(value) + }} + as="div" + > + + Assigned to + + +
+ + setQuery(e.target.value)} + className="border-none outline-none px-3 py-1" + /> + + + + + + + + + +
+ + {people.map(name => ( + { + return classNames( + 'relative py-2 pl-3 cursor-default select-none pr-9 focus:outline-none', + active ? 'text-white bg-indigo-600' : 'text-gray-900' + ) + }} + > + {({ active, selected }) => ( + <> + + {name} + + {selected && ( + + + + + + )} + + )} + + ))} + +
+
+
+
+
+
+ ) +} diff --git a/packages/playground-react/pages/combobox/command-palette-with-groups.tsx b/packages/playground-react/pages/combobox/command-palette-with-groups.tsx new file mode 100644 index 0000000000..ee432ca6d4 --- /dev/null +++ b/packages/playground-react/pages/combobox/command-palette-with-groups.tsx @@ -0,0 +1,150 @@ +import React, { useState, useEffect, Fragment } from 'react' +import { Combobox } from '@headlessui/react' + +import { classNames } from '../../utils/class-names' + +let everybody = [ + { id: 1, img: 'https://github.com/adamwathan.png', name: 'Adam Wathan' }, + { id: 2, img: 'https://github.com/sschoger.png', name: 'Steve Schoger' }, + { id: 3, img: 'https://github.com/bradlc.png', name: 'Brad Cornes' }, + { id: 4, img: 'https://github.com/simonswiss.png', name: 'Simon Vrachliotis' }, + { id: 5, img: 'https://github.com/robinmalfait.png', name: 'Robin Malfait' }, + { + id: 6, + img: 'https://pbs.twimg.com/profile_images/1478879681491394569/eV2PyCnm_400x400.jpg', + name: 'James McDonald', + }, + { id: 7, img: 'https://github.com/reinink.png', name: 'Jonathan Reinink' }, + { id: 8, img: 'https://github.com/thecrypticace.png', name: 'Jordan Pittman' }, +] + +export default function Home() { + let [query, setQuery] = useState('') + let [activePerson, setActivePerson] = useState(everybody[2]) + + function setPerson(person) { + setActivePerson(person) + setQuery(person.name ?? '') + } + + // Choose a random person on mount + useEffect(() => { + setPerson(everybody[Math.floor(Math.random() * everybody.length)]) + }, []) + + let people = + query === '' + ? everybody + : everybody.filter(person => person.name.toLowerCase().includes(query.toLowerCase())) + + let groups = people.reduce((groups, person) => { + let lastNameLetter = person.name.split(' ')[1][0] + + groups.set(lastNameLetter, [...(groups.get(lastNameLetter) || []), person]) + + return groups + }, new Map()) + + return ( +
+
+
+ setPerson(person)} + className="bg-white w-full shadow-sm border border-black/5 bg-clip-padding rounded overflow-hidden" + > + {({ activeOption }) => { + return ( +
+ setQuery(e.target.value)} + className="border-none outline-none px-3 py-1 bg-none rounded-none w-full" + placeholder="Search users…" + displayValue={item => item?.name} + /> +
+ + {Array.from(groups.entries()) + .sort(([letterA], [letterZ]) => letterA.localeCompare(letterZ)) + .map(([letter, people]) => ( + +
{letter}
+ {people.map(person => ( + { + return classNames( + 'flex relative py-2 pl-3 cursor-default select-none pr-9 focus:outline-none space-x-4', + active ? 'text-white bg-indigo-600' : 'text-gray-900' + ) + }} + > + {({ active, selected }) => ( + <> + + + {person.name} + + {active && ( + + + + + + )} + + )} + + ))} +
+ ))} +
+ + {people.length === 0 ? ( +
No person selected
+ ) : activeOption === null ? null : ( +
+
+
+ +
{activeOption.name}
+
Obviously cool person
+
+
+
+ )} +
+
+ ) + }} +
+
+
+
+ ) +} diff --git a/packages/playground-react/pages/combobox/command-palette.tsx b/packages/playground-react/pages/combobox/command-palette.tsx new file mode 100644 index 0000000000..f68ba929b0 --- /dev/null +++ b/packages/playground-react/pages/combobox/command-palette.tsx @@ -0,0 +1,135 @@ +import React, { useState, useEffect } from 'react' +import { Combobox } from '@headlessui/react' + +import { classNames } from '../../utils/class-names' + +let everybody = [ + { id: 1, img: 'https://github.com/adamwathan.png', name: 'Adam Wathan' }, + { id: 2, img: 'https://github.com/sschoger.png', name: 'Steve Schoger' }, + { id: 3, img: 'https://github.com/bradlc.png', name: 'Brad Cornes' }, + { id: 4, img: 'https://github.com/simonswiss.png', name: 'Simon Vrachliotis' }, + { id: 5, img: 'https://github.com/robinmalfait.png', name: 'Robin Malfait' }, + { + id: 6, + img: 'https://pbs.twimg.com/profile_images/1478879681491394569/eV2PyCnm_400x400.jpg', + name: 'James McDonald', + }, + { id: 7, img: 'https://github.com/reinink.png', name: 'Jonathan Reinink' }, + { id: 8, img: 'https://github.com/thecrypticace.png', name: 'Jordan Pittman' }, +] + +export default function Home() { + let [query, setQuery] = useState('') + let [activePerson, setActivePerson] = useState(everybody[2]) + + // Choose a random person on mount + useEffect(() => { + setActivePerson(everybody[Math.floor(Math.random() * everybody.length)]) + }, []) + + let people = + query === '' + ? everybody + : everybody.filter(person => person.name.toLowerCase().includes(query.toLowerCase())) + + return ( +
+
+
+ setActivePerson(person)} + className="bg-white w-full shadow-sm border border-black/5 bg-clip-padding rounded overflow-hidden" + > + {({ activeOption, open }) => { + return ( +
+ setQuery(e.target.value)} + className="border-none outline-none px-3 py-1 rounded-none w-full" + placeholder="Search users…" + displayValue={item => item?.name} + /> +
+ + {people.map(person => ( + { + return classNames( + 'flex relative py-2 pl-3 cursor-default select-none pr-9 focus:outline-none space-x-4', + active ? 'text-white bg-indigo-600' : 'text-gray-900' + ) + }} + > + {({ active, selected }) => ( + <> + + + {person.name} + + {active && ( + + + + + + )} + + )} + + ))} + + + {people.length === 0 ? ( +
No person selected
+ ) : activeOption === null ? null : ( +
+
+
+ +
{activeOption.name}
+
Obviously cool person
+
+
+
+ )} +
+
+ ) + }} +
+
+
+
+ ) +} diff --git a/packages/playground-react/pages/dialog/dialog.tsx b/packages/playground-react/pages/dialog/dialog.tsx new file mode 100644 index 0000000000..7187c97478 --- /dev/null +++ b/packages/playground-react/pages/dialog/dialog.tsx @@ -0,0 +1,238 @@ +import React, { useState, Fragment } from 'react' +import { Dialog, Menu, Portal, Transition } from '@headlessui/react' +import { usePopper } from '../../utils/hooks/use-popper' +import { classNames } from '../../utils/class-names' + +function resolveClass({ active, disabled }) { + return classNames( + 'flex justify-between w-full px-4 py-2 text-sm leading-5 text-left', + active ? 'bg-gray-100 text-gray-900' : 'text-gray-700', + disabled && 'cursor-not-allowed opacity-50' + ) +} + +function Nested({ onClose, level = 0 }) { + let [showChild, setShowChild] = useState(false) + + return ( + <> + + +
+

Level: {level}

+
+ + + +
+
+ {showChild && setShowChild(false)} level={level + 1} />} +
+ + ) +} + +export default function Home() { + let [isOpen, setIsOpen] = useState(false) + let [nested, setNested] = useState(false) + + let [trigger, container] = usePopper({ + placement: 'bottom-end', + strategy: 'fixed', + modifiers: [{ name: 'offset', options: { offset: [0, 10] } }], + }) + + return ( + <> + + + + {nested && setNested(false)} />} + + console.log('done')}> + +
+
+ + + + + + {/* This element is to trick the browser into centering the modal contents. */} + +
+
+
+
+ {/* Heroicon name: exclamation */} + +
+
+ + Deactivate account + +
+

+ Are you sure you want to deactivate your account? All of your data will + be permanently removed. This action cannot be undone. +

+
+ + + + Choose a reason + + + + + + + + + +
+

Signed in as

+

+ tom@example.com +

+
+ +
+ + Account settings + + + Support + + + New feature (soon) + + + License + +
+ +
+ + Sign out + +
+
+
+
+
+
+
+
+
+
+
+ + +
+
+
+
+
+
+
+ + ) +} diff --git a/packages/playground-react/pages/disclosure/disclosure.tsx b/packages/playground-react/pages/disclosure/disclosure.tsx new file mode 100644 index 0000000000..856beb2053 --- /dev/null +++ b/packages/playground-react/pages/disclosure/disclosure.tsx @@ -0,0 +1,25 @@ +import React from 'react' +import { Disclosure, Transition } from '@headlessui/react' + +export default function Home() { + return ( +
+
+ + Trigger + + + Content + + +
+
+ ) +} diff --git a/packages/playground-react/pages/listbox/listbox-with-pure-tailwind.tsx b/packages/playground-react/pages/listbox/listbox-with-pure-tailwind.tsx new file mode 100644 index 0000000000..f7f632f592 --- /dev/null +++ b/packages/playground-react/pages/listbox/listbox-with-pure-tailwind.tsx @@ -0,0 +1,115 @@ +import React, { useState, useEffect } from 'react' +import { Listbox } from '@headlessui/react' + +import { classNames } from '../../utils/class-names' + +let people = [ + 'Wade Cooper', + 'Arlene Mccoy', + 'Devon Webb', + 'Tom Cook', + 'Tanya Fox', + 'Hellen Schmidt', + 'Caroline Schultz', + 'Mason Heaney', + 'Claudie Smitham', + 'Emil Schaefer', +] + +export default function Home() { + let [active, setActivePerson] = useState(people[2]) + + // Choose a random person on mount + useEffect(() => { + setActivePerson(people[Math.floor(Math.random() * people.length)]) + }, []) + + return ( +
+
+
+ { + console.log('value:', value) + setActivePerson(value) + }} + > + + Assigned to + + +
+ + + {active} + + + + + + + + +
+ + {people.map(name => ( + { + return classNames( + 'relative py-2 pl-3 cursor-default select-none pr-9 focus:outline-none', + active ? 'text-white bg-indigo-600' : 'text-gray-900' + ) + }} + > + {({ active, selected }) => ( + <> + + {name} + + {selected && ( + + + + + + )} + + )} + + ))} + +
+
+
+
+
+
+ ) +} diff --git a/packages/playground-react/pages/listbox/multiple-elements.tsx b/packages/playground-react/pages/listbox/multiple-elements.tsx new file mode 100644 index 0000000000..b68eb758c1 --- /dev/null +++ b/packages/playground-react/pages/listbox/multiple-elements.tsx @@ -0,0 +1,134 @@ +import React, { useState, useEffect } from 'react' +import { Listbox } from '@headlessui/react' +import { classNames } from '../../utils/class-names' + +let people = [ + 'Wade Cooper', + 'Arlene Mccoy', + 'Devon Webb', + 'Tom Cook', + 'Tanya Fox', + 'Hellen Schmidt', + 'Caroline Schultz', + 'Mason Heaney', + 'Claudie Smitham', + 'Emil Schaefer', +] + +export default function Home() { + return ( +
+ + +
+ +
+ +
+
+ + +
+ ) +} + +function PeopleList() { + let [active, setActivePerson] = useState(people[2]) + + // Choose a random person on mount + useEffect(() => { + setActivePerson(people[Math.floor(Math.random() * people.length)]) + }, []) + + return ( +
+
+ { + console.log('value:', value) + setActivePerson(value) + }} + > + + Assigned to + + +
+ + + {active} + + + + + + + + +
+ + {people.map(name => ( + { + return classNames( + 'relative py-2 pl-3 cursor-default select-none pr-9 focus:outline-none', + active ? 'text-white bg-indigo-600' : 'text-gray-900' + ) + }} + > + {({ active, selected }) => ( + <> + + {name} + + {selected && ( + + + + + + )} + + )} + + ))} + +
+
+
+
+
+ ) +} diff --git a/packages/playground-react/pages/menu/menu-with-framer-motion.tsx b/packages/playground-react/pages/menu/menu-with-framer-motion.tsx new file mode 100644 index 0000000000..c14674069f --- /dev/null +++ b/packages/playground-react/pages/menu/menu-with-framer-motion.tsx @@ -0,0 +1,111 @@ +import React from 'react' +import Link from 'next/link' +import { Menu } from '@headlessui/react' +import { AnimatePresence, motion } from 'framer-motion' + +import { classNames } from '../../utils/class-names' + +export default function Home() { + return ( +
+
+ + {({ open }) => ( + <> + + + Options + + + + + + + + {open && ( + +
+

Signed in as

+

+ tom@example.com +

+
+ +
+ Account settings + + Support + + + New feature (soon) + + License +
+ +
+ +
+
+ )} +
+ + )} +
+
+
+ ) +} + +function NextLink(props: React.ComponentProps<'a'>) { + let { href, children, ...rest } = props + return ( + + {children} + + ) +} + +function SignOutButton(props) { + return ( +
{ + e.preventDefault() + alert('SIGNED OUT') + }} + className="w-full" + > + +
+ ) +} + +function Item(props: React.ComponentProps) { + return ( + + classNames( + 'block w-full text-left px-4 py-2 text-sm leading-5 text-gray-700', + active && 'bg-gray-100 text-gray-900', + disabled && 'cursor-not-allowed opacity-50' + ) + } + {...props} + /> + ) +} diff --git a/packages/playground-react/pages/menu/menu-with-popper.tsx b/packages/playground-react/pages/menu/menu-with-popper.tsx new file mode 100644 index 0000000000..cd38588ca4 --- /dev/null +++ b/packages/playground-react/pages/menu/menu-with-popper.tsx @@ -0,0 +1,95 @@ +import React, { ReactNode, useState, useEffect } from 'react' +import { createPortal } from 'react-dom' +import { Menu } from '@headlessui/react' + +import { usePopper } from '../../utils/hooks/use-popper' +import { classNames } from '../../utils/class-names' + +export default function Home() { + let [trigger, container] = usePopper({ + placement: 'bottom-end', + strategy: 'fixed', + modifiers: [{ name: 'offset', options: { offset: [0, 10] } }], + }) + + function resolveClass({ active, disabled }) { + return classNames( + 'block w-full text-left px-4 py-2 text-sm leading-5 text-gray-700', + active && 'bg-gray-100 text-gray-900', + disabled && 'cursor-not-allowed opacity-50' + ) + } + + return ( +
+
+ + + + Options + + + + + + + + +
+

Signed in as

+

+ tom@example.com +

+
+ +
+ + Account settings + + + {data => ( + + Support + + )} + + + New feature (soon) + + + License + +
+ +
+ + Sign out + +
+
+
+
+
+
+ ) +} + +function Portal(props: { children: ReactNode }) { + let { children } = props + let [mounted, setMounted] = useState(false) + + useEffect(() => setMounted(true), []) + + if (!mounted) return null + return createPortal(children, document.body) +} diff --git a/packages/playground-react/pages/menu/menu-with-transition-and-popper.tsx b/packages/playground-react/pages/menu/menu-with-transition-and-popper.tsx new file mode 100644 index 0000000000..c80143193a --- /dev/null +++ b/packages/playground-react/pages/menu/menu-with-transition-and-popper.tsx @@ -0,0 +1,89 @@ +import React from 'react' +import { Menu, Transition } from '@headlessui/react' + +import { usePopper } from '../../utils/hooks/use-popper' +import { classNames } from '../../utils/class-names' + +export default function Home() { + let [trigger, container] = usePopper({ + placement: 'bottom-end', + strategy: 'fixed', + modifiers: [{ name: 'offset', options: { offset: [0, 10] } }], + }) + + function resolveClass({ active, disabled }) { + return classNames( + 'flex justify-between w-full px-4 py-2 text-sm leading-5 text-left', + active ? 'bg-gray-100 text-gray-900' : 'text-gray-700', + disabled && 'cursor-not-allowed opacity-50' + ) + } + + return ( +
+
+ + + + Options + + + + + + +
+ + +
+

Signed in as

+

+ tom@example.com +

+
+ +
+ + Account settings + + + {data => ( + + Support + + )} + + + New feature (soon) + + + License + +
+
+ + Sign out + +
+
+
+
+
+
+
+ ) +} diff --git a/packages/playground-react/pages/menu/menu-with-transition.tsx b/packages/playground-react/pages/menu/menu-with-transition.tsx new file mode 100644 index 0000000000..950d651d70 --- /dev/null +++ b/packages/playground-react/pages/menu/menu-with-transition.tsx @@ -0,0 +1,73 @@ +import React from 'react' +import { Menu, Transition } from '@headlessui/react' +import { classNames } from '../../utils/class-names' + +export default function Home() { + function resolveClass({ active, disabled }) { + return classNames( + 'flex justify-between w-full px-4 py-2 text-sm leading-5 text-left', + active ? 'bg-gray-100 text-gray-900' : 'text-gray-700', + disabled && 'cursor-not-allowed opacity-50' + ) + } + + return ( +
+
+ + + + Options + + + + + + + + +
+

Signed in as

+

+ tom@example.com +

+
+ +
+ + Account settings + + + Support + + + New feature (soon) + + + License + +
+ +
+ + Sign out + +
+
+
+
+
+
+ ) +} diff --git a/packages/playground-react/pages/menu/menu.tsx b/packages/playground-react/pages/menu/menu.tsx new file mode 100644 index 0000000000..acade92cb5 --- /dev/null +++ b/packages/playground-react/pages/menu/menu.tsx @@ -0,0 +1,72 @@ +import React from 'react' +import { Menu } from '@headlessui/react' + +import { classNames } from '../../utils/class-names' + +export default function Home() { + return ( +
+
+ + + + Options + + + + + + + +
+

Signed in as

+

+ tom@example.com +

+
+ +
+ Account settings + Support + + New feature (soon) + + License +
+
+ Sign out +
+
+
+
+
+ ) +} + +function CustomMenuItem(props: React.ComponentProps) { + return ( + + {({ active, disabled }) => ( + + {props.children} + ⌘K + + )} + + ) +} diff --git a/packages/playground-react/pages/menu/multiple-elements.tsx b/packages/playground-react/pages/menu/multiple-elements.tsx new file mode 100644 index 0000000000..7110acfe07 --- /dev/null +++ b/packages/playground-react/pages/menu/multiple-elements.tsx @@ -0,0 +1,83 @@ +import React from 'react' +import { Menu } from '@headlessui/react' +import { classNames } from '../../utils/class-names' + +export default function Home() { + return ( +
+ + +
+
+ +
+
+ + +
+ ) +} + +function Dropdown() { + function resolveClass({ active, disabled }) { + return classNames( + 'block w-full text-left px-4 py-2 text-sm leading-5 text-gray-700', + active && 'bg-gray-100 text-gray-900', + disabled && 'cursor-not-allowed opacity-50' + ) + } + + return ( +
+ + + + Options + + + + + + + +
+

Signed in as

+

tom@example.com

+
+ +
+ + Account settings + + + {data => ( + + Support + + )} + + + New feature (soon) + + + License + +
+ +
+ + Sign out + +
+
+
+
+ ) +} diff --git a/packages/playground-react/pages/popover/popover.tsx b/packages/playground-react/pages/popover/popover.tsx new file mode 100644 index 0000000000..c11be5e4b2 --- /dev/null +++ b/packages/playground-react/pages/popover/popover.tsx @@ -0,0 +1,116 @@ +import React, { forwardRef, Fragment } from 'react' +import { Popover, Portal, Transition } from '@headlessui/react' +import { usePopper } from '../../utils/hooks/use-popper' + +let Button = forwardRef( + (props: React.ComponentProps<'button'>, ref: React.MutableRefObject) => { + return ( + + ) + } +) + +function Link(props: React.ComponentProps<'a'>) { + return ( + + {props.children} + + ) +} + +export default function Home() { + let options = { + placement: 'bottom-start' as const, + strategy: 'fixed' as const, + modifiers: [], + } + + let [reference1, popper1] = usePopper(options) + let [reference2, popper2] = usePopper(options) + + let links = ['First', 'Second', 'Third', 'Fourth'] + + return ( +
+ + + + + + + + + + Normal + + + {links.map((link, i) => ( + + Normal - {link} + + ))} + + + + + + + {links.map((link, i) => ( + Focus - {link} + ))} + + + + + + + + {links.map(link => ( + Portal - {link} + ))} + + + + + + + + + {links.map(link => ( + Focus in Portal - {link} + ))} + + + + + + +
+ ) +} diff --git a/packages/playground-react/pages/radio-group/radio-group.tsx b/packages/playground-react/pages/radio-group/radio-group.tsx new file mode 100644 index 0000000000..e92c54f0e7 --- /dev/null +++ b/packages/playground-react/pages/radio-group/radio-group.tsx @@ -0,0 +1,101 @@ +import React, { useState } from 'react' +import { RadioGroup } from '@headlessui/react' +import { classNames } from '../../utils/class-names' + +export default function Home() { + let access = [ + { + id: 'access-1', + name: 'Public access', + description: 'This project would be available to anyone who has the link', + }, + { + id: 'access-2', + name: 'Private to Project Members', + description: 'Only members of this project would be able to access', + }, + { + id: 'access-3', + name: 'Private to you', + description: 'You are the only one able to access this project', + }, + ] + let [active, setActive] = useState() + + return ( +
+ Link before + +
+ +

Privacy setting

+
+ +
+ {access.map(({ id, name, description }, i) => { + return ( + + classNames( + // Rounded corners + i === 0 && 'rounded-tl-md rounded-tr-md', + access.length - 1 === i && 'rounded-bl-md rounded-br-md', + + // Shared + 'relative border p-4 flex focus:outline-none', + active ? 'bg-indigo-50 border-indigo-200 z-10' : 'border-gray-200' + ) + } + > + {({ active, checked }) => ( +
+
+ + {name} + + + {description} + +
+
+ {checked && ( + + + + )} +
+
+ )} +
+ ) + })} +
+
+
+ Link after +
+ ) +} diff --git a/packages/playground-react/pages/switch/switch-with-pure-tailwind.tsx b/packages/playground-react/pages/switch/switch-with-pure-tailwind.tsx new file mode 100644 index 0000000000..65cb003fe8 --- /dev/null +++ b/packages/playground-react/pages/switch/switch-with-pure-tailwind.tsx @@ -0,0 +1,39 @@ +import React, { useState } from 'react' +import { Switch } from '@headlessui/react' + +import { classNames } from '../../utils/class-names' + +export default function Home() { + let [state, setState] = useState(false) + + return ( +
+ + Enable notifications + + + classNames( + 'relative inline-flex flex-shrink-0 h-6 border-2 border-transparent rounded-full cursor-pointer w-11 focus:outline-none focus:shadow-outline transition-colors ease-in-out duration-200', + checked ? 'bg-indigo-600' : 'bg-gray-200' + ) + } + > + {({ checked }) => ( + <> + + + )} + + +
+ ) +} diff --git a/packages/playground-react/pages/tabs/tabs-with-pure-tailwind.tsx b/packages/playground-react/pages/tabs/tabs-with-pure-tailwind.tsx new file mode 100644 index 0000000000..beac2e2b61 --- /dev/null +++ b/packages/playground-react/pages/tabs/tabs-with-pure-tailwind.tsx @@ -0,0 +1,86 @@ +import React, { useState } from 'react' +import { Tab, Switch } from '@headlessui/react' + +import { classNames } from '../../utils/class-names' + +export default function Home() { + let tabs = [ + { name: 'My Account', content: 'Tab content for my account' }, + { name: 'Company', content: 'Tab content for company', disabled: true }, + { name: 'Team Members', content: 'Tab content for team members' }, + { name: 'Billing', content: 'Tab content for billing' }, + ] + + let [manual, setManual] = useState(false) + + return ( +
+ + Manual keyboard activation + + + classNames( + 'relative inline-flex flex-shrink-0 h-6 border-2 border-transparent rounded-full cursor-pointer w-11 focus:outline-none focus:shadow-outline transition-colors ease-in-out duration-200', + checked ? 'bg-indigo-600' : 'bg-gray-200' + ) + } + > + {({ checked }) => ( + + )} + + + + + + {tabs.map((tab, tabIdx) => ( + + classNames( + selected ? 'text-gray-900' : 'text-gray-500 hover:text-gray-700', + tabIdx === 0 ? 'rounded-l-lg' : '', + tabIdx === tabs.length - 1 ? 'rounded-r-lg' : '', + tab.disabled && 'opacity-50', + 'group relative min-w-0 flex-1 overflow-hidden bg-white py-4 px-4 text-sm font-medium text-center hover:bg-gray-50 focus:z-10' + ) + } + > + {({ selected }) => ( + <> + {tab.name} + {tab.disabled && (disabled)} + + ))} + + + + {tabs.map(tab => ( + + {tab.content} + + ))} + + +
+ ) +} diff --git a/packages/playground-react/pages/transitions/component-examples/dropdown.tsx b/packages/playground-react/pages/transitions/component-examples/dropdown.tsx new file mode 100644 index 0000000000..e391543f13 --- /dev/null +++ b/packages/playground-react/pages/transitions/component-examples/dropdown.tsx @@ -0,0 +1,98 @@ +import React, { useState } from 'react' +import Head from 'next/head' +import { Transition } from '@headlessui/react' + +export default function Home() { + return ( + <> + + Transition Component - Playground + + +
+ +
+ + ) +} + +function Dropdown() { + let [isOpen, setIsOpen] = useState(false) + + return ( +
+
+ + + +
+ + + + +
+ ) +} diff --git a/packages/playground-react/pages/transitions/component-examples/modal.tsx b/packages/playground-react/pages/transitions/component-examples/modal.tsx new file mode 100644 index 0000000000..cd03991b42 --- /dev/null +++ b/packages/playground-react/pages/transitions/component-examples/modal.tsx @@ -0,0 +1,168 @@ +import React, { useRef, useState } from 'react' +import { Transition } from '@headlessui/react' + +export default function Home() { + let [isOpen, setIsOpen] = useState(false) + function toggle() { + setIsOpen(v => !v) + } + + let [email, setEmail] = useState('') + let [events, setEvents] = useState([]) + let inputRef = useRef(null) + + function addEvent(name) { + setEvents(existing => [...existing, `${new Date().toJSON()} - ${name}`]) + } + + return ( +
+
+
+ + + +
+ +
    +

    Events:

    + {events.map((event, i) => ( +
  • + {event} +
  • + ))} +
+
+ + { + addEvent('Before enter') + }} + afterEnter={() => { + inputRef.current.focus() + addEvent('After enter') + }} + beforeLeave={() => { + addEvent('Before leave (before confirm)') + window.confirm('Are you sure?') + addEvent('Before leave (after confirm)') + }} + afterLeave={() => { + addEvent('After leave (before alert)') + window.alert('Consider it done!') + addEvent('After leave (after alert)') + setEmail('') + }} + > +
+ +
+
+
+
+ {/* This element is to trick the browser into centering the modal contents. */} + ​ + +
+
+
+ {/* Heroicon name: exclamation */} + + + +
+
+ +
+

+ Are you sure you want to deactivate your account? All of your data will be + permanently removed. This action cannot be undone. +

+
+
+
+ +
+ setEmail(event.target.value)} + id="email" + className="block w-full px-3 form-input sm:text-sm sm:leading-5" + placeholder="name@example.com" + /> +
+
+
+
+
+
+
+ + + + + + +
+
+
+
+
+ ) +} diff --git a/packages/playground-react/pages/transitions/component-examples/nested/hidden.tsx b/packages/playground-react/pages/transitions/component-examples/nested/hidden.tsx new file mode 100644 index 0000000000..989f266d18 --- /dev/null +++ b/packages/playground-react/pages/transitions/component-examples/nested/hidden.tsx @@ -0,0 +1,60 @@ +import React, { useState, ReactNode } from 'react' +import { Transition } from '@headlessui/react' + +export default function Home() { + let [isOpen, setIsOpen] = useState(true) + + return ( + <> +
+
+ + + + + + + + + + + + + + + + + + + + +
+
+ + ) +} + +function Box({ children }: { children?: ReactNode }) { + return ( + +
+ This is a box + {children} +
+
+ ) +} diff --git a/packages/playground-react/pages/transitions/component-examples/nested/unmount.tsx b/packages/playground-react/pages/transitions/component-examples/nested/unmount.tsx new file mode 100644 index 0000000000..cf74435854 --- /dev/null +++ b/packages/playground-react/pages/transitions/component-examples/nested/unmount.tsx @@ -0,0 +1,60 @@ +import React, { useState, ReactNode } from 'react' +import { Transition } from '@headlessui/react' + +export default function Home() { + let [isOpen, setIsOpen] = useState(true) + + return ( + <> +
+
+ + + + + + + + + + + + + + + + + + + + +
+
+ + ) +} + +function Box({ children }: { children?: ReactNode }) { + return ( + +
+ This is a box + {children} +
+
+ ) +} diff --git a/packages/playground-react/pages/transitions/component-examples/peek-a-boo.tsx b/packages/playground-react/pages/transitions/component-examples/peek-a-boo.tsx new file mode 100644 index 0000000000..29b4b232b5 --- /dev/null +++ b/packages/playground-react/pages/transitions/component-examples/peek-a-boo.tsx @@ -0,0 +1,38 @@ +import React, { useState } from 'react' +import { Transition } from '@headlessui/react' + +export default function Home() { + let [isOpen, setIsOpen] = useState(true) + + return ( + <> +
+
+ + + + + + Contents to show and hide + +
+
+ + ) +} diff --git a/packages/playground-react/pages/transitions/full-page-examples/full-page-transition.tsx b/packages/playground-react/pages/transitions/full-page-examples/full-page-transition.tsx new file mode 100644 index 0000000000..49cc646e2c --- /dev/null +++ b/packages/playground-react/pages/transitions/full-page-examples/full-page-transition.tsx @@ -0,0 +1,181 @@ +import React, { useEffect, useRef, useState } from 'react' +import Head from 'next/head' +import { Transition } from '@headlessui/react' + +import { classNames } from '../../../utils/class-names' +import { match } from '../../../utils/match' + +export default function Shell() { + return ( + <> + + Transition Component - Full Page Transition + +
+
+ +
+
+ + ) +} + +function usePrevious(value: T) { + let ref = useRef(value) + useEffect(() => { + ref.current = value + }, [value]) + return ref.current +} + +enum Direction { + Forwards = ' -> ', + Backwards = ' <- ', +} + +let pages = ['Dashboard', 'Team', 'Projects', 'Calendar', 'Reports'] +let colors = [ + 'bg-gradient-to-r from-teal-400 to-blue-400', + 'bg-gradient-to-r from-blue-400 to-orange-400', + 'bg-gradient-to-r from-orange-400 to-purple-400', + 'bg-gradient-to-r from-purple-400 to-green-400', + 'bg-gradient-to-r from-green-400 to-teal-400', +] + +function FullPageTransition() { + let [activePage, setActivePage] = useState(0) + let previousPage = usePrevious(activePage) + + let direction = activePage > previousPage ? Direction.Forwards : Direction.Backwards + + let transitions = match(direction, { + [Direction.Forwards]: { + enter: 'transition transform ease-in-out duration-500', + enterFrom: 'translate-x-full', + enterTo: 'translate-x-0', + leave: 'transition transform ease-in-out duration-500', + leaveFrom: 'translate-x-0', + leaveTo: '-translate-x-full', + }, + [Direction.Backwards]: { + enter: 'transition transform ease-in-out duration-500', + enterFrom: '-translate-x-full', + enterTo: 'translate-x-0', + leave: 'transition transform ease-in-out duration-500', + leaveFrom: 'translate-x-0', + leaveTo: 'translate-x-full', + }, + }) + + return ( +
+
+ +
+
+

+ {pages[activePage]} +

+
+
+
+ +
+
+
+
+ {pages.map((page, i) => ( + + {page} page content + + ))} +
+
+
+
+
+ ) +} diff --git a/packages/playground-react/pages/transitions/full-page-examples/layout-with-sidebar.tsx b/packages/playground-react/pages/transitions/full-page-examples/layout-with-sidebar.tsx new file mode 100644 index 0000000000..0cc33e0fd8 --- /dev/null +++ b/packages/playground-react/pages/transitions/full-page-examples/layout-with-sidebar.tsx @@ -0,0 +1,170 @@ +import React, { useEffect, useState } from 'react' +import Head from 'next/head' +import { Transition } from '@headlessui/react' + +export default function App() { + let [mobileOpen, setMobileOpen] = useState(false) + + useEffect(() => { + function handleEscape(event) { + if (!mobileOpen) return + + if (event.key === 'Escape') { + setMobileOpen(false) + } + } + + document.addEventListener('keyup', handleEscape) + return () => document.removeEventListener('keyup', handleEscape) + }, [mobileOpen]) + + return ( + <> + + Transition Component - Layout with sidebar + + +
+ {/* Off-canvas menu for mobile */} + + {/* Off-canvas menu overlay, show/hide based on off-canvas menu state. */} + + {() => ( +
+
setMobileOpen(false)} + className="absolute inset-0 opacity-75 bg-cool-gray-600" + /> +
+ )} + + + {/* Off-canvas menu, show/hide based on off-canvas menu state. */} + +
+ setMobileOpen(false)} + > + + + + +
+
+ Easywire logo +
+
+
+ {/* Dummy element to force sidebar to shrink to fit close icon */} +
+ + + {/* Static sidebar for desktop */} +
+
+ {/* Sidebar component, swap this element with another sidebar if you like */} +
+
+ Easywire logo +
+
+
+
+
+
+ + {/* Search bar */} +
+
+
+ +
+
+ + + +
+ +
+
+
+
+
+
+ {/* Replace with your content */} +
+ {/* /End replace */} +
+
+
+ + ) +} diff --git a/packages/playground-react/public/favicon.ico b/packages/playground-react/public/favicon.ico new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/playground-react/tsconfig.json b/packages/playground-react/tsconfig.json new file mode 100644 index 0000000000..6db37c02f4 --- /dev/null +++ b/packages/playground-react/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "strict": false, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "incremental": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve" + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/packages/playground-react/utils/class-names.ts b/packages/playground-react/utils/class-names.ts new file mode 100644 index 0000000000..159b03ce39 --- /dev/null +++ b/packages/playground-react/utils/class-names.ts @@ -0,0 +1,3 @@ +export function classNames(...classes: (false | null | undefined | string)[]): string { + return classes.filter(Boolean).join(' ') +} diff --git a/packages/playground-react/utils/hooks/use-popper.ts b/packages/playground-react/utils/hooks/use-popper.ts new file mode 100644 index 0000000000..4d10885a72 --- /dev/null +++ b/packages/playground-react/utils/hooks/use-popper.ts @@ -0,0 +1,37 @@ +import { RefCallback, useRef, useCallback, useMemo } from 'react' +import { createPopper, Options } from '@popperjs/core' + +/** + * Example implementation to use Popper: https://popper.js.org/ + */ +export function usePopper( + options?: Partial +): [RefCallback, RefCallback] { + let reference = useRef(null) + let popper = useRef(null) + + let cleanupCallback = useRef(() => {}) + + let instantiatePopper = useCallback(() => { + if (!reference.current) return + if (!popper.current) return + + if (cleanupCallback.current) cleanupCallback.current() + + cleanupCallback.current = createPopper(reference.current, popper.current, options).destroy + }, [reference, popper, cleanupCallback, options]) + + return useMemo( + () => [ + referenceDomNode => { + reference.current = referenceDomNode + instantiatePopper() + }, + popperDomNode => { + popper.current = popperDomNode + instantiatePopper() + }, + ], + [reference, popper, instantiatePopper] + ) +} diff --git a/packages/playground-react/utils/match.ts b/packages/playground-react/utils/match.ts new file mode 100644 index 0000000000..80496d12a2 --- /dev/null +++ b/packages/playground-react/utils/match.ts @@ -0,0 +1,20 @@ +export function match( + value: TValue, + lookup: Record TReturnValue)>, + ...args: any[] +): TReturnValue { + if (value in lookup) { + let returnValue = lookup[value] + return typeof returnValue === 'function' ? returnValue(...args) : returnValue + } + + let error = new Error( + `Tried to handle "${value}" but there is no handler defined. Only defined handlers are: ${Object.keys( + lookup + ) + .map(key => `"${key}"`) + .join(', ')}.` + ) + if (Error.captureStackTrace) Error.captureStackTrace(error, match) + throw error +} diff --git a/packages/playground-react/utils/resolve-all-examples.ts b/packages/playground-react/utils/resolve-all-examples.ts new file mode 100644 index 0000000000..328da9020f --- /dev/null +++ b/packages/playground-react/utils/resolve-all-examples.ts @@ -0,0 +1,50 @@ +import fs from 'fs' +import path from 'path' +export type ExamplesType = { + name: string + path: string + children?: ExamplesType[] +} + +export async function resolveAllExamples(...paths: string[]) { + let base = path.resolve(process.cwd(), ...paths) + + if (!fs.existsSync(base)) { + return false + } + + let files = await fs.promises.readdir(base, { withFileTypes: true }) + let items: ExamplesType[] = [] + + for (let file of files) { + if (file.name === '.DS_Store') { + continue + } + + // Skip reserved filenames from Next. E.g.: _app.tsx, _error.tsx + if (file.name.startsWith('_')) { + continue + } + + let bucket: ExamplesType = { + name: file.name.replace(/-/g, ' ').replace(/\.tsx?/g, ''), + path: [...paths, file.name] + .join('/') + .replace(/^pages/, '') + .replace(/\.tsx?/g, '') + .replace(/\/+/g, '/'), + } + + if (file.isDirectory()) { + let children = await resolveAllExamples(...paths, file.name) + + if (children) { + bucket.children = children + } + } + + items.push(bucket) + } + + return items +} diff --git a/yarn.lock b/yarn.lock index c693813338..0739cde08b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -195,6 +195,11 @@ resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz#2f75a831269d4f677de49986dff59927533cf375" integrity sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg== +"@babel/helper-plugin-utils@^7.14.5": + version "7.16.7" + resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.16.7.tgz#aa3a8ab4c3cceff8e65eb9e73d87dc4ff320b2f5" + integrity sha512-Qg3Nk7ZxpgMrsox6HreY1ZNKdBq7K72tDSliA6dCl5f007jR4ne8iD5UzuNnCJH2xBf2BEEVGr+/OL6Gdp7RxA== + "@babel/helper-regex@^7.10.4": version "7.10.5" resolved "https://registry.yarnpkg.com/@babel/helper-regex/-/helper-regex-7.10.5.tgz#32dfbb79899073c415557053a19bd055aae50ae0" @@ -244,7 +249,7 @@ dependencies: "@babel/types" "^7.11.0" -"@babel/helper-validator-identifier@^7.10.4", "@babel/helper-validator-identifier@^7.16.7": +"@babel/helper-validator-identifier@^7.10.4", "@babel/helper-validator-identifier@^7.14.9", "@babel/helper-validator-identifier@^7.16.7": version "7.16.7" resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz#e8c602438c4a8195751243da9031d1607d247cad" integrity sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw== @@ -443,6 +448,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.8.0" +"@babel/plugin-syntax-jsx@7.14.5": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.14.5.tgz#000e2e25d8673cce49300517a3eda44c263e4201" + integrity sha512-ohuFIsOMXJnbOMRfX7/w7LocdR6R7whhuRD4ax8IipLcLPlZGJKkBxgHp++U4N/vKyU16/YDQr2f5seajD3jIw== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + "@babel/plugin-syntax-logical-assignment-operators@^7.10.4", "@babel/plugin-syntax-logical-assignment-operators@^7.8.3": version "7.10.4" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz#ca91ef46303530448b906652bac2e9fe9941f699" @@ -874,6 +886,14 @@ globals "^11.1.0" lodash "^4.17.19" +"@babel/types@7.15.0": + version "7.15.0" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.15.0.tgz#61af11f2286c4e9c69ca8deb5f4375a73c72dcbd" + integrity sha512-OBvfqnllOIdX4ojTHpwZbpvz4j3EWyjkZEdmjH0/cgsd6QOdSgU8rLSk6ard/pcW7rlmjdVSX/AWOaORR1uNOQ== + dependencies: + "@babel/helper-validator-identifier" "^7.14.9" + to-fast-properties "^2.0.0" + "@babel/types@^7.0.0", "@babel/types@^7.10.4", "@babel/types@^7.10.5", "@babel/types@^7.11.0", "@babel/types@^7.11.5", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4", "@babel/types@^7.7.0": version "7.11.5" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.11.5.tgz#d9de577d01252d77c6800cee039ee64faf75662d" @@ -904,6 +924,18 @@ exec-sh "^0.3.2" minimist "^1.2.0" +"@emotion/is-prop-valid@^0.8.2": + version "0.8.8" + resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz#db28b1c4368a259b60a97311d6a952d4fd01ac1a" + integrity sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA== + dependencies: + "@emotion/memoize" "0.7.4" + +"@emotion/memoize@0.7.4": + version "0.7.4" + resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.7.4.tgz#19bf0f5af19149111c40d98bb0cf82119f5d9eeb" + integrity sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw== + "@istanbuljs/load-nyc-config@^1.0.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced" @@ -1099,6 +1131,76 @@ "@types/yargs" "^15.0.0" chalk "^4.0.0" +"@next/env@12.0.8": + version "12.0.8" + resolved "https://registry.yarnpkg.com/@next/env/-/env-12.0.8.tgz#a32ca0a97d464307f2e6ff106ce09b19aac108cf" + integrity sha512-Wa0gOeioB9PHap9wtZDZEhgOSE3/+qE/UALWjJHuNvH4J3oE+13EjVOiEsr1JcPCXUN8ESQE+phDKlo6qJ8P9g== + +"@next/react-refresh-utils@12.0.8": + version "12.0.8" + resolved "https://registry.yarnpkg.com/@next/react-refresh-utils/-/react-refresh-utils-12.0.8.tgz#481760a95ef442abd091663db6582d4dc1b31f06" + integrity sha512-Bq4T/aOOFQUkCF9b8k9x+HpjOevu65ZPxsYJOpgEtBuJyvb+sZREtDDLKb/RtjUeLMrWrsGD0aLteyFFtiS8Og== + +"@next/swc-android-arm64@12.0.8": + version "12.0.8" + resolved "https://registry.yarnpkg.com/@next/swc-android-arm64/-/swc-android-arm64-12.0.8.tgz#f8dc9663da367a75982730cac058339fb310d79a" + integrity sha512-BiXMcOZNnXSIXv+FQvbRgbMb+iYayLX/Sb2MwR0wja+eMs46BY1x/ssXDwUBADP1M8YtrGTlSPHZqUiCU94+Mg== + +"@next/swc-darwin-arm64@12.0.8": + version "12.0.8" + resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-12.0.8.tgz#d6aced7d0a04815dd1324e7982accb3de6a643e8" + integrity sha512-6EGMmvcIwPpwt0/iqLbXDGx6oKHAXzbowyyVXK8cqmIvhoghRFjqfiNGBs+ar6wEBGt68zhwn/77vE3iQWoFJw== + +"@next/swc-darwin-x64@12.0.8": + version "12.0.8" + resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-12.0.8.tgz#f4fe58d2ed852538410b15a0c80d78908050c716" + integrity sha512-todxgQOGP/ucz5UH2kKR3XGDdkWmWr0VZAAbzgTbiFm45Ol4ih602k2nNR3xSbza9IqNhxNuUVsMpBgeo19CFQ== + +"@next/swc-linux-arm-gnueabihf@12.0.8": + version "12.0.8" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm-gnueabihf/-/swc-linux-arm-gnueabihf-12.0.8.tgz#2c02d824fb46e8c6094d7e758c5d7e965070f574" + integrity sha512-KULmdrfI+DJxBuhEyV47MQllB/WpC3P2xbwhHezxL/LkC2nkz5SbV4k432qpx2ebjIRf9SjdQ5Oz1FjD8Urayw== + +"@next/swc-linux-arm64-gnu@12.0.8": + version "12.0.8" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-12.0.8.tgz#fc32caf3373b299558ede1d889e8555b9ba10ffb" + integrity sha512-1XO87wgIVPvt5fx5i8CqdhksRdcpqyzCOLW4KrE0f9pUCIT04EbsFiKdmsH9c73aqjNZMnCMXpbV+cn4hN8x1w== + +"@next/swc-linux-arm64-musl@12.0.8": + version "12.0.8" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-12.0.8.tgz#c2d3d7bc2c34da81412b74bdd6e11d0615ae1886" + integrity sha512-NStRZEy/rkk2G18Yhc/Jzi1Q2Dv+zH176oO8479zlDQ5syRfc6AvRHVV4iNRc8Pai58If83r/nOJkwFgGwkKLw== + +"@next/swc-linux-x64-gnu@12.0.8": + version "12.0.8" + resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-12.0.8.tgz#029d84f856801b818e5525ab1406f2446821d48c" + integrity sha512-rHxTGtTEDFsdT9/VjewzxE19S7W1NE+aZpm4TwbT1pSNGK9KQxQGcXjqoHMeB+VZCFknzNEoIU/vydbjZMlAuw== + +"@next/swc-linux-x64-musl@12.0.8": + version "12.0.8" + resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-12.0.8.tgz#db572da90ab3bce0bc58595c6b8c2d32ec64a2d3" + integrity sha512-1F4kuFRQE10GSx7LMSvRmjMXFGpxT30g8rZzq9r/p/WKdErA4WB4uxaKEX0P8AINfuN63i4luKdR+LoacgBhYw== + +"@next/swc-win32-arm64-msvc@12.0.8": + version "12.0.8" + resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-12.0.8.tgz#f33e2e56a96489935f87c6dd28f79a7b7ed3778f" + integrity sha512-QuRe49jqCV61TysGopC1P0HPqFAMZMWe1nbIQLyOkDLkULmZR8N2eYZq7fwqvZE5YwhMmJA/grwWFVBqSEh5Kg== + +"@next/swc-win32-ia32-msvc@12.0.8": + version "12.0.8" + resolved "https://registry.yarnpkg.com/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-12.0.8.tgz#0f6c7f3e50fc1a4752aed5c862f53c86ce77e3b8" + integrity sha512-0RV3/julybJr1IlPCowIWrJJZyAl+sOakJEM15y1NOOsbwTQ5eKZZXSi+7e23TN4wmy5HwNvn2dKzgOEVJ+jbA== + +"@next/swc-win32-x64-msvc@12.0.8": + version "12.0.8" + resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-12.0.8.tgz#eae6d4c94dc8aae8ba177e2de02080339d0d4563" + integrity sha512-tTga6OFfO2JS+Yt5hdryng259c/tzNgSWkdiU2E+RBHiysAIOta57n4PJ8iPahOSqEqjaToPI76wM+o441GaNQ== + +"@popperjs/core@^2.6.0": + version "2.11.2" + resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.2.tgz#830beaec4b4091a9e9398ac50f865ddea52186b9" + integrity sha512-92FRmppjjqz29VMJ2dn+xdyXZBrMlE42AV6Kq6BwjWV7CNUW1hs2FtxSNLQE+gJhaZ6AAmYuO9y8dshhcBl7vA== + "@rollup/plugin-babel@^5.1.0": version "5.2.1" resolved "https://registry.yarnpkg.com/@rollup/plugin-babel/-/plugin-babel-5.2.1.tgz#20fc8f8864dc0eaa1c5578408459606808f72924" @@ -1936,6 +2038,11 @@ bcrypt-pbkdf@^1.0.0: dependencies: tweetnacl "^0.14.3" +big.js@^5.2.2: + version "5.2.2" + resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" + integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ== + brace-expansion@^1.1.7: version "1.1.11" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" @@ -2048,6 +2155,11 @@ caniuse-lite@^1.0.30001131: resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001191.tgz" integrity sha512-xJJqzyd+7GCJXkcoBiQ1GuxEiOBCLQ0aVW9HMekifZsAVGdj5eJ4mFB9fEhSHipq9IOk/QXFJUiIr9lZT+EsGw== +caniuse-lite@^1.0.30001283: + version "1.0.30001300" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001300.tgz#11ab6c57d3eb6f964cba950401fd00a146786468" + integrity sha512-cVjiJHWGcNlJi8TZVKNMnvMid3Z3TTdDHmLDzlOdIiZq138Exvo0G+G0wTdVYolxKb4AYwC+38pxodiInVtJSA== + capture-exit@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/capture-exit/-/capture-exit-2.0.0.tgz#fb953bfaebeb781f62898239dabb426d08a509a4" @@ -2211,6 +2323,11 @@ color-name@~1.1.4: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== +colorette@^1.2.2: + version "1.4.0" + resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.4.0.tgz#5190fbb87276259a86ad700bff2c6d6faa3fca40" + integrity sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g== + colorette@^2.0.16: version "2.0.16" resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.16.tgz#713b9af84fdb000139f04546bd4a93f62a5085da" @@ -2280,7 +2397,7 @@ contains-path@^0.1.0: resolved "https://registry.yarnpkg.com/contains-path/-/contains-path-0.1.0.tgz#fe8cf184ff6670b6baef01a9d4861a5cbec4120a" integrity sha1-/ozxhP9mcLa67wGp1IYaXL7EEgo= -convert-source-map@^1.4.0, convert-source-map@^1.6.0, convert-source-map@^1.7.0: +convert-source-map@1.7.0, convert-source-map@^1.4.0, convert-source-map@^1.6.0, convert-source-map@^1.7.0: version "1.7.0" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.7.0.tgz#17a2cb882d7f77d3490585e2ce6c524424a3a442" integrity sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA== @@ -2603,6 +2720,11 @@ emoji-regex@^9.2.2: resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== +emojis-list@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-2.1.0.tgz#4daa4d9db00f9819880c79fa457ae5b09a1fd389" + integrity sha1-TapNnbAPmBmIDHn6RXrlsJof04k= + end-of-stream@^1.1.0: version "1.4.4" resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" @@ -3221,6 +3343,26 @@ fragment-cache@^0.2.1: dependencies: map-cache "^0.2.2" +framer-motion@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/framer-motion/-/framer-motion-6.0.0.tgz#08e44c42b67c967774a197b3994f8475cd486c32" + integrity sha512-wVI+hVRkvQeWSvkxk8z5bTg+jBs9vfEZOist2s0e9tQzZvt+OBuAoAcvCvl+ADmFd4ncC2934vkwiJPZ8nSvMg== + dependencies: + framesync "6.0.1" + hey-listen "^1.0.8" + popmotion "11.0.3" + style-value-types "5.0.0" + tslib "^2.1.0" + optionalDependencies: + "@emotion/is-prop-valid" "^0.8.2" + +framesync@6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/framesync/-/framesync-6.0.1.tgz#5e32fc01f1c42b39c654c35b16440e07a25d6f20" + integrity sha512-fUY88kXvGiIItgNC7wcTOl0SNRCVXMKSWW2Yzfmn7EKNc+MpCzcz9DhdHcdjbrtN3c6R4H5dTY2jiCpPdysEjA== + dependencies: + tslib "^2.1.0" + fs-extra@8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0" @@ -3440,6 +3582,11 @@ has@^1.0.3: dependencies: function-bind "^1.1.1" +hey-listen@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/hey-listen/-/hey-listen-1.0.8.tgz#8e59561ff724908de1aa924ed6ecc84a56a9aa68" + integrity sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q== + hosted-git-info@^2.1.4: version "2.8.8" resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.8.tgz#7539bd4bc1e0e0a895815a2e0262420b12858488" @@ -3551,7 +3698,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@^2.0.4: +inherits@2, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -4383,6 +4530,15 @@ jest-watcher@^25.2.4, jest-watcher@^25.5.0: jest-util "^25.5.0" string-length "^3.1.0" +jest-worker@27.0.0-next.5: + version "27.0.0-next.5" + resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-27.0.0-next.5.tgz#5985ee29b12a4e191f4aae4bb73b97971d86ec28" + integrity sha512-mk0umAQ5lT+CaOJ+Qp01N6kz48sJG2kr2n1rX0koqKf6FIygQV0qLOdN9SCYID4IVeSigDOcPeGLozdMLYfb5g== + dependencies: + "@types/node" "*" + merge-stream "^2.0.0" + supports-color "^8.0.0" + jest-worker@^24.9.0: version "24.9.0" resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-24.9.0.tgz#5dbfdb5b2d322e98567898238a9697bcce67b3e5" @@ -4679,6 +4835,15 @@ load-json-file@^2.0.0: pify "^2.0.0" strip-bom "^3.0.0" +loader-utils@1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.2.3.tgz#1ff5dc6911c9f0a062531a4c04b609406108c2c7" + integrity sha512-fkpz8ejdnEMG3s37wGL07iSBDg99O9D5yflE9RGNH3hRdx9SOwYfnGYdZOUIZitN8E+E2vkq3MUMYMvPYl5ZZA== + dependencies: + big.js "^5.2.2" + emojis-list "^2.0.0" + json5 "^1.0.1" + locate-path@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e" @@ -4943,6 +5108,11 @@ mute-stream@0.0.8: resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d" integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA== +nanoid@^3.1.23: + version "3.2.0" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.2.0.tgz#62667522da6673971cca916a6d3eff3f415ff80c" + integrity sha512-fmsZYa9lpn69Ad5eDn7FMcnnSR+8R34W9qJEijxYhTbfOWzr22n1QxCMzXLK+ODyW2973V3Fux959iQoUxzUIA== + nanomatch@^1.2.9: version "1.2.13" resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119" @@ -4965,6 +5135,35 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= +next@^12.0.8: + version "12.0.8" + resolved "https://registry.yarnpkg.com/next/-/next-12.0.8.tgz#29138f7cdd045e4bbba466af45bf430e769634b4" + integrity sha512-g5c1Kuh1F8tSXJn2rVvzYBzqe9EXaR6+rY3/KrQ7y0D9FueRLfHI35wM0DRadDcPSc3+vncspfhYH3jnYE/KjA== + dependencies: + "@next/env" "12.0.8" + "@next/react-refresh-utils" "12.0.8" + caniuse-lite "^1.0.30001283" + jest-worker "27.0.0-next.5" + node-fetch "2.6.1" + postcss "8.2.15" + react-is "17.0.2" + react-refresh "0.8.3" + stream-browserify "3.0.0" + styled-jsx "5.0.0-beta.6" + use-subscription "1.5.1" + optionalDependencies: + "@next/swc-android-arm64" "12.0.8" + "@next/swc-darwin-arm64" "12.0.8" + "@next/swc-darwin-x64" "12.0.8" + "@next/swc-linux-arm-gnueabihf" "12.0.8" + "@next/swc-linux-arm64-gnu" "12.0.8" + "@next/swc-linux-arm64-musl" "12.0.8" + "@next/swc-linux-x64-gnu" "12.0.8" + "@next/swc-linux-x64-musl" "12.0.8" + "@next/swc-win32-arm64-msvc" "12.0.8" + "@next/swc-win32-ia32-msvc" "12.0.8" + "@next/swc-win32-x64-msvc" "12.0.8" + nice-try@^1.0.4: version "1.0.5" resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" @@ -4978,6 +5177,11 @@ no-case@^3.0.3: lower-case "^2.0.1" tslib "^1.10.0" +node-fetch@2.6.1: + version "2.6.1" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" + integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== + node-int64@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" @@ -5414,11 +5618,30 @@ pn@^1.1.0: resolved "https://registry.yarnpkg.com/pn/-/pn-1.1.0.tgz#e2f4cef0e219f463c179ab37463e4e1ecdccbafb" integrity sha512-2qHaIQr2VLRFoxe2nASzsV6ef4yOOH+Fi9FBOVH6cqeSgUnoyySPZkxzLuzd+RYOQTRpROA0ztTMqxROKSb/nA== +popmotion@11.0.3: + version "11.0.3" + resolved "https://registry.yarnpkg.com/popmotion/-/popmotion-11.0.3.tgz#565c5f6590bbcddab7a33a074bb2ba97e24b0cc9" + integrity sha512-Y55FLdj3UxkR7Vl3s7Qr4e9m0onSnP8W7d/xQLsoJM40vs6UKHFdygs6SWryasTZYqugMjm3BepCF4CWXDiHgA== + dependencies: + framesync "6.0.1" + hey-listen "^1.0.8" + style-value-types "5.0.0" + tslib "^2.1.0" + posix-character-classes@^0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab" integrity sha1-AerA/jta9xoqbAL+q7jB/vfgDqs= +postcss@8.2.15: + version "8.2.15" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.2.15.tgz#9e66ccf07292817d226fc315cbbf9bc148fbca65" + integrity sha512-2zO3b26eJD/8rb106Qu2o7Qgg52ND5HPjcyQiK2B98O388h43A448LCslC0dI2P97wCAQRJsFvwTRcXxTKds+Q== + dependencies: + colorette "^1.2.2" + nanoid "^3.1.23" + source-map "^0.6.1" + prelude-ls@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" @@ -5556,7 +5779,7 @@ randombytes@^2.1.0: dependencies: safe-buffer "^5.1.0" -react-dom@^16.14.0: +react-dom@16.14.0, react-dom@^16.14.0: version "16.14.0" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.14.0.tgz#7ad838ec29a777fb3c75c3a190f661cf92ab8b89" integrity sha512-1gCeQXDLoIqMgqD3IO2Ah9bnf0w9kzhwN5q4FGnHZ67hBm9yePzB5JJAIQCc8x3pFnNlwFq4RidZggNAAkzWWw== @@ -5566,17 +5789,22 @@ react-dom@^16.14.0: prop-types "^15.6.2" scheduler "^0.19.1" +react-is@17.0.2, react-is@^17.0.1: + version "17.0.2" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" + integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== + react-is@^16.12.0, react-is@^16.8.1: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== -react-is@^17.0.1: - version "17.0.2" - resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" - integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== +react-refresh@0.8.3: + version "0.8.3" + resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.8.3.tgz#721d4657672d400c5e3c75d063c4a85fb2d5d68f" + integrity sha512-X8jZHc7nCMjaCqoU+V2I0cOhNW+QMBwSUkeXnTi8IPe6zaRWfn60ZzvFDZqWPfmSJfjub7dDW1SP0jaHWLu/hg== -react@^16.14.0: +react@16.14.0, react@^16.14.0: version "16.14.0" resolved "https://registry.yarnpkg.com/react/-/react-16.14.0.tgz#94d776ddd0aaa37da3eda8fc5b6b18a4c9a3114d" integrity sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g== @@ -5621,6 +5849,15 @@ read-pkg@^5.2.0: parse-json "^5.0.0" type-fest "^0.6.0" +readable-stream@^3.5.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198" + integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + realpath-native@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/realpath-native/-/realpath-native-2.0.0.tgz#7377ac429b6e1fd599dc38d08ed942d0d7beb866" @@ -5938,7 +6175,7 @@ sade@^1.4.2: dependencies: mri "^1.1.0" -safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.2: +safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.2, safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== @@ -6219,6 +6456,11 @@ source-map-url@^0.4.0: resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.4.0.tgz#3e935d7ddd73631b97659956d55128e87b5084a3" integrity sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM= +source-map@0.7.3, source-map@^0.7.3: + version "0.7.3" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383" + integrity sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ== + source-map@^0.5.0, source-map@^0.5.6: version "0.5.7" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" @@ -6229,11 +6471,6 @@ source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1: resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== -source-map@^0.7.3: - version "0.7.3" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383" - integrity sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ== - sourcemap-codec@^1.4.4: version "1.4.8" resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4" @@ -6317,11 +6554,24 @@ stealthy-require@^1.1.1: resolved "https://registry.yarnpkg.com/stealthy-require/-/stealthy-require-1.1.1.tgz#35b09875b4ff49f26a777e509b3090a3226bf24b" integrity sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks= +stream-browserify@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-3.0.0.tgz#22b0a2850cdf6503e73085da1fc7b7d0c2122f2f" + integrity sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA== + dependencies: + inherits "~2.0.4" + readable-stream "^3.5.0" + string-argv@^0.3.1: version "0.3.1" resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.1.tgz#95e2fbec0427ae19184935f816d74aaa4c5c19da" integrity sha512-a1uQGz7IyVy9YwhqjZIZu1c8JO8dNIe20xBmSS6qu9kv++k3JGzCVmprbNN5Kn+BgzD5E7YYwg1CcjuJMRNsvg== +string-hash@1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/string-hash/-/string-hash-1.1.3.tgz#e8aafc0ac1855b4666929ed7dd1275df5d6c811b" + integrity sha1-6Kr8CsGFW0Zmkp7X3RJ1311sgRs= + string-length@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/string-length/-/string-length-3.1.0.tgz#107ef8c23456e187a8abd4a61162ff4ac6e25837" @@ -6393,6 +6643,13 @@ string.prototype.trimstart@^1.0.1: define-properties "^1.1.3" es-abstract "^1.17.5" +string_decoder@^1.1.1: + version "1.3.0" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" + integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== + dependencies: + safe-buffer "~5.2.0" + strip-ansi@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-4.0.0.tgz#a8479022eb1ac368a871389b635262c505ee368f" @@ -6453,6 +6710,38 @@ strip-json-comments@^3.0.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== +style-value-types@5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/style-value-types/-/style-value-types-5.0.0.tgz#76c35f0e579843d523187989da866729411fc8ad" + integrity sha512-08yq36Ikn4kx4YU6RD7jWEv27v4V+PUsOGa4n/as8Et3CuODMJQ00ENeAVXAeydX4Z2j1XHZF1K2sX4mGl18fA== + dependencies: + hey-listen "^1.0.8" + tslib "^2.1.0" + +styled-jsx@5.0.0-beta.6: + version "5.0.0-beta.6" + resolved "https://registry.yarnpkg.com/styled-jsx/-/styled-jsx-5.0.0-beta.6.tgz#666552f8831a06f80c9084a47afc4b32b0c9f461" + integrity sha512-b1cM7Xyp2r1lsNpvoZ6wmTI8qxD0557vH2feHakNU8LMkzfJDgTQMul6O7sSYY0GxQ73pKEN69hCDp71w6Q0nA== + dependencies: + "@babel/plugin-syntax-jsx" "7.14.5" + "@babel/types" "7.15.0" + convert-source-map "1.7.0" + loader-utils "1.2.3" + source-map "0.7.3" + string-hash "1.1.3" + stylis "3.5.4" + stylis-rule-sheet "0.0.10" + +stylis-rule-sheet@0.0.10: + version "0.0.10" + resolved "https://registry.yarnpkg.com/stylis-rule-sheet/-/stylis-rule-sheet-0.0.10.tgz#44e64a2b076643f4b52e5ff71efc04d8c3c4a430" + integrity sha512-nTbZoaqoBnmK+ptANthb10ZRZOGC+EmTLLUxeYIuHNkEKcmKgXX1XWKkUBT2Ac4es3NybooPe0SmvKdhKJZAuw== + +stylis@3.5.4: + version "3.5.4" + resolved "https://registry.yarnpkg.com/stylis/-/stylis-3.5.4.tgz#f665f25f5e299cf3d64654ab949a57c768b73fbe" + integrity sha512-8/3pSmthWM7lsPBKv7NXkzn2Uc9W7NotcwGNpJaa3k7WMM1XDCA4MgT5k/8BIexd5ydZdboXtU90XH9Ec4Bv/Q== + supports-color@^5.3.0: version "5.5.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" @@ -6474,6 +6763,13 @@ supports-color@^7.0.0, supports-color@^7.1.0: dependencies: has-flag "^4.0.0" +supports-color@^8.0.0: + version "8.1.1" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" + integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== + dependencies: + has-flag "^4.0.0" + supports-color@^9.2.1: version "9.2.1" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-9.2.1.tgz#599dc9d45acf74c6176e0d880bab1d7d718fe891" @@ -6863,11 +7159,23 @@ urix@^0.1.0: resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72" integrity sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI= +use-subscription@1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/use-subscription/-/use-subscription-1.5.1.tgz#73501107f02fad84c6dd57965beb0b75c68c42d1" + integrity sha512-Xv2a1P/yReAjAbhylMfFplFKj9GssgTwN7RlcTxBujFQcloStWNDQdc4g4NRWH9xS4i/FDk04vQBptAXoF3VcA== + dependencies: + object-assign "^4.1.1" + use@^3.1.0: version "3.1.1" resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" integrity sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ== +util-deprecate@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" + integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= + uuid@^3.3.2: version "3.4.0" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" From fdd26297953080d5ec905dda0bf5ec9607897d86 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Thu, 27 Jan 2022 17:07:38 +0100 Subject: [PATCH 2/9] Improve overal codebase, use modern tech like `esbuild` and TypeScript 4! (#1055) * use esbuild for React instead of tsdx * remove tsdx from Vue * use consistent names * add jest and prettier * update scripts * ignore some folders for prettier * run lint script instead of tsdx lint * run prettier en-masse This has a few changes because of the new prettier version. * bump typescript to latest version * make typescript happy * cleanup playground package.json * make esbuild a dev dependency * make scripts consistent * fix husky hooks * add dedicated watch script * add `yarn playground-react` and `yarn react-playground` (alias) This will make sure to run a watcher for the actual @headlessui/react package, and start a development server in the playground-react package. * ignore formatting in the .next folder * run prettier on playground-react package * setup playground-vue Still not 100% working, but getting there! * add playground aliases in @headlessui/vue and @headlessui/react This allows you to run `yarn react playground` or `yarn vue playground` from the root. * add `clean` script * move examples folder in playground-vue to root * ensure new lines for consistency in scripts * fix typescript issue * fix typescript issues in playgrounds * make sure to run prettier on everything it can * run prettier on all files * improve error output If you minify the code, then it could happen that the errors are a bit obscure. This will hardcode the component name to improve errors. * add the `prettier-plugin-tailwindcss` plugin, party! * update changelog --- .github/CONTRIBUTING.md | 1 - .github/ISSUE_TEMPLATE/config.yml | 1 - .github/workflows/release-insiders.yml | 3 +- .prettierignore | 4 + .swcrc | 11 + CHANGELOG.md | 10 +- README.md | 1 - jest/create-jest-config.js | 11 +- package.json | 24 +- packages/@headlessui-react/README.md | 1 - packages/@headlessui-react/build/index.js | 7 + packages/@headlessui-react/package.json | 22 +- .../src/components/combobox/combobox.test.tsx | 72 +- .../src/components/combobox/combobox.tsx | 51 +- .../components/description/description.tsx | 4 +- .../src/components/dialog/dialog.test.tsx | 24 +- .../src/components/dialog/dialog.tsx | 35 +- .../components/disclosure/disclosure.test.tsx | 2 +- .../src/components/disclosure/disclosure.tsx | 10 +- .../src/components/label/label.tsx | 4 +- .../src/components/listbox/listbox.test.tsx | 51 +- .../src/components/listbox/listbox.tsx | 49 +- .../src/components/menu/menu.test.tsx | 48 +- .../src/components/menu/menu.tsx | 37 +- .../src/components/popover/popover.test.tsx | 2 +- .../src/components/popover/popover.tsx | 33 +- .../src/components/portal/portal.test.tsx | 18 +- .../radio-group/radio-group.test.tsx | 10 +- .../components/radio-group/radio-group.tsx | 34 +- .../src/components/switch/switch.test.tsx | 8 +- .../src/components/tabs/tabs.test.tsx | 24 +- .../src/components/tabs/tabs.tsx | 30 +- .../transitions/transition.test.tsx | 34 +- .../src/components/transitions/transition.tsx | 55 +- .../transitions/utils/transition.test.ts | 28 +- .../transitions/utils/transition.ts | 6 +- .../@headlessui-react/src/hooks/use-flags.ts | 6 +- .../src/hooks/use-focus-trap.ts | 4 +- .../src/hooks/use-inert-others.test.tsx | 8 +- .../src/hooks/use-inert-others.ts | 4 +- .../src/hooks/use-tree-walker.ts | 1 + .../test-utils/accessibility-assertions.ts | 4 +- .../src/test-utils/execute-timeline.ts | 10 +- .../src/test-utils/interactions.test.tsx | 2 +- .../src/test-utils/interactions.ts | 8 +- .../src/test-utils/suppress-console-logs.ts | 4 +- .../src/utils/calculate-active-index.ts | 6 +- .../src/utils/focus-management.ts | 4 +- packages/@headlessui-react/src/utils/match.ts | 2 +- .../src/utils/render.test.tsx | 2 +- .../@headlessui-react/src/utils/render.ts | 16 +- packages/@headlessui-react/tsconfig.json | 5 +- packages/@headlessui-react/tsconfig.tsdx.json | 7 - packages/@headlessui-react/tsdx.config.js | 10 - packages/@headlessui-react/types/jest.d.ts | 9 + packages/@headlessui-vue/.eslintrc.js | 6 - packages/@headlessui-vue/build/index.js | 7 + packages/@headlessui-vue/package.json | 21 +- packages/@headlessui-vue/postcss.config.js | 1 - .../src/components/combobox/combobox.test.tsx | 336 +- .../src/components/combobox/combobox.ts | 10 +- .../description/description.test.ts | 169 +- .../src/components/dialog/dialog.test.ts | 8 +- .../src/components/dialog/dialog.ts | 10 +- .../components/disclosure/disclosure.test.ts | 28 +- .../components/focus-trap/focus-trap.test.ts | 44 +- .../src/components/label/label.test.ts | 169 +- .../src/components/listbox/listbox.test.tsx | 330 +- .../src/components/listbox/listbox.ts | 15 +- .../src/components/menu/menu.test.tsx | 50 +- .../src/components/menu/menu.ts | 15 +- .../src/components/popover/popover.test.ts | 36 +- .../src/components/popover/popover.ts | 4 +- .../src/components/portal/portal.test.ts | 44 +- .../radio-group/radio-group.test.ts | 10 +- .../src/components/radio-group/radio-group.ts | 18 +- .../src/components/switch/switch.test.tsx | 70 +- .../src/components/tabs/tabs.test.ts | 4 +- .../src/components/tabs/tabs.ts | 8 +- .../components/transitions/transition.test.ts | 78 +- .../src/components/transitions/transition.ts | 6 +- .../transitions/utils/transition.test.ts | 28 +- .../transitions/utils/transition.ts | 6 +- .../src/hooks/use-focus-trap.ts | 4 +- .../src/hooks/use-inert-others.test.ts | 12 +- .../src/hooks/use-inert-others.ts | 6 +- .../src/hooks/use-tree-walker.ts | 1 + .../src/hooks/use-window-event.ts | 2 +- .../src/internal/stack-context.ts | 2 +- .../test-utils/accessibility-assertions.ts | 4 +- .../src/test-utils/execute-timeline.ts | 10 +- .../src/test-utils/interactions.test.ts | 8 +- .../src/test-utils/interactions.ts | 8 +- .../src/test-utils/suppress-console-logs.ts | 4 +- .../src/utils/calculate-active-index.ts | 6 +- .../src/utils/focus-management.ts | 4 +- packages/@headlessui-vue/src/utils/match.ts | 2 +- .../@headlessui-vue/src/utils/render.test.ts | 8 +- packages/@headlessui-vue/src/utils/render.ts | 4 +- packages/@headlessui-vue/tsconfig.json | 1 - packages/@headlessui-vue/tsconfig.tsdx.json | 3 - packages/@headlessui-vue/tsdx.config.js | 15 - packages/@headlessui-vue/types/jest.d.ts | 9 + packages/playground-react/package.json | 15 +- packages/playground-react/pages/_app.tsx | 15 +- packages/playground-react/pages/_error.tsx | 2 +- .../combobox/combobox-with-pure-tailwind.tsx | 34 +- .../combobox/command-palette-with-groups.tsx | 36 +- .../pages/combobox/command-palette.tsx | 36 +- .../playground-react/pages/dialog/dialog.tsx | 42 +- .../pages/disclosure/disclosure.tsx | 6 +- .../listbox/listbox-with-pure-tailwind.tsx | 24 +- .../pages/listbox/multiple-elements.tsx | 24 +- .../pages/menu/menu-with-framer-motion.tsx | 14 +- .../pages/menu/menu-with-popper.tsx | 14 +- .../menu/menu-with-transition-and-popper.tsx | 14 +- .../pages/menu/menu-with-transition.tsx | 10 +- packages/playground-react/pages/menu/menu.tsx | 12 +- .../pages/menu/multiple-elements.tsx | 14 +- .../pages/popover/popover.tsx | 22 +- .../pages/radio-group/radio-group.tsx | 14 +- .../switch/switch-with-pure-tailwind.tsx | 6 +- .../pages/tabs/tabs-with-pure-tailwind.tsx | 16 +- .../component-examples/dropdown.tsx | 20 +- .../transitions/component-examples/modal.tsx | 36 +- .../component-examples/nested/hidden.tsx | 10 +- .../component-examples/nested/unmount.tsx | 10 +- .../component-examples/peek-a-boo.tsx | 10 +- .../full-page-transition.tsx | 38 +- .../layout-with-sidebar.tsx | 46 +- packages/playground-react/tsconfig.json | 16 +- .../utils/hooks/use-popper.ts | 4 +- packages/playground-react/utils/match.ts | 2 +- packages/playground-vue/index.html | 15 + packages/playground-vue/package.json | 25 + packages/playground-vue/public/favicon.ico | Bin 0 -> 4286 bytes .../playground-vue/src/.generated/.gitignore | 2 + packages/playground-vue/src/App.vue | 81 + packages/playground-vue/src/KeyCaster.vue | 69 + .../playground-vue/src/components/Home.vue | 46 + .../src/components/dialog/dialog.vue | 242 + .../src/components/disclosure/disclosure.vue | 33 + .../src/components/focus-trap/focus-trap.vue | 27 + .../src/components/listbox/listbox.vue | 126 + .../components/listbox/multiple-elements.vue | 208 + .../src/components/menu/menu-with-popper.vue | 86 + .../menu/menu-with-transition-and-popper.vue | 94 + .../components/menu/menu-with-transition.vue | 83 + .../src/components/menu/menu.vue | 84 + .../src/components/menu/multiple-elements.vue | 125 + .../src/components/popover/popover.vue | 144 + .../src/components/portal/portal.vue | 35 + .../components/radio-group/radio-group.vue | 104 + .../src/components/switch/switch.vue | 40 + .../src/components/tabs/tabs.vue | 90 + packages/playground-vue/src/main.js | 5 + .../src/playground-utils/hooks/use-popper.js | 26 + packages/playground-vue/src/router.js | 23 + packages/playground-vue/src/routes.json | 141 + packages/playground-vue/vercel.json | 3 + packages/playground-vue/vite.config.js | 32 + scripts/build.sh | 35 +- scripts/lint.sh | 18 +- scripts/test.sh | 3 +- scripts/watch.sh | 23 +- yarn.lock | 5575 ++++++----------- 166 files changed, 5151 insertions(+), 5575 deletions(-) create mode 100644 .prettierignore create mode 100644 .swcrc create mode 100644 packages/@headlessui-react/build/index.js delete mode 100644 packages/@headlessui-react/tsconfig.tsdx.json delete mode 100644 packages/@headlessui-react/tsdx.config.js create mode 100644 packages/@headlessui-react/types/jest.d.ts delete mode 100644 packages/@headlessui-vue/.eslintrc.js create mode 100644 packages/@headlessui-vue/build/index.js delete mode 100644 packages/@headlessui-vue/postcss.config.js delete mode 100644 packages/@headlessui-vue/tsconfig.tsdx.json delete mode 100644 packages/@headlessui-vue/tsdx.config.js create mode 100644 packages/@headlessui-vue/types/jest.d.ts create mode 100644 packages/playground-vue/index.html create mode 100644 packages/playground-vue/package.json create mode 100644 packages/playground-vue/public/favicon.ico create mode 100644 packages/playground-vue/src/.generated/.gitignore create mode 100644 packages/playground-vue/src/App.vue create mode 100644 packages/playground-vue/src/KeyCaster.vue create mode 100644 packages/playground-vue/src/components/Home.vue create mode 100644 packages/playground-vue/src/components/dialog/dialog.vue create mode 100644 packages/playground-vue/src/components/disclosure/disclosure.vue create mode 100644 packages/playground-vue/src/components/focus-trap/focus-trap.vue create mode 100644 packages/playground-vue/src/components/listbox/listbox.vue create mode 100644 packages/playground-vue/src/components/listbox/multiple-elements.vue create mode 100644 packages/playground-vue/src/components/menu/menu-with-popper.vue create mode 100644 packages/playground-vue/src/components/menu/menu-with-transition-and-popper.vue create mode 100644 packages/playground-vue/src/components/menu/menu-with-transition.vue create mode 100644 packages/playground-vue/src/components/menu/menu.vue create mode 100644 packages/playground-vue/src/components/menu/multiple-elements.vue create mode 100644 packages/playground-vue/src/components/popover/popover.vue create mode 100644 packages/playground-vue/src/components/portal/portal.vue create mode 100644 packages/playground-vue/src/components/radio-group/radio-group.vue create mode 100644 packages/playground-vue/src/components/switch/switch.vue create mode 100644 packages/playground-vue/src/components/tabs/tabs.vue create mode 100644 packages/playground-vue/src/main.js create mode 100644 packages/playground-vue/src/playground-utils/hooks/use-popper.js create mode 100644 packages/playground-vue/src/router.js create mode 100644 packages/playground-vue/src/routes.json create mode 100644 packages/playground-vue/vercel.json create mode 100644 packages/playground-vue/vite.config.js diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index ddc20f2d84..fc2d525bcd 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -46,4 +46,3 @@ yarn vue test ``` Please ensure that the tests are passing when submitting a pull request. If you're adding new features to Headless UI, please include tests. - diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index fbdfa1eeb7..347a88b719 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -12,4 +12,3 @@ contact_links: - name: Documentation Issue url: https://github.com/tailwindlabs/headlessui/issues/new?title=%5BDOCS%5D:%20 about: 'For documentation issues, suggest changes on our documentation repository.' - diff --git a/.github/workflows/release-insiders.yml b/.github/workflows/release-insiders.yml index b7bbc85525..e6891facb4 100644 --- a/.github/workflows/release-insiders.yml +++ b/.github/workflows/release-insiders.yml @@ -44,7 +44,7 @@ jobs: id: vars run: echo "::set-output name=sha_short::$(git rev-parse --short HEAD)" - - name: "Version based on commit: 0.0.0-insiders.${{ steps.vars.outputs.sha_short }}" + - name: 'Version based on commit: 0.0.0-insiders.${{ steps.vars.outputs.sha_short }}' run: npm version -w packages 0.0.0-insiders.${{ steps.vars.outputs.sha_short }} --force --no-git-tag-version - name: Publish @@ -52,4 +52,3 @@ jobs: env: CI: true NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000000..62815c2732 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,4 @@ +dist/ +node_modules/ +coverage/ +.next/ diff --git a/.swcrc b/.swcrc new file mode 100644 index 0000000000..0c4dc7af87 --- /dev/null +++ b/.swcrc @@ -0,0 +1,11 @@ +{ + "minify": false, + "jsc": { + "parser": { + "syntax": "typescript", + "tsx": true, + "decorators": false, + "dynamicImport": false + } + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 46dedd4452..e02468b748 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Ensure correct order when conditionally rendering `Menu.Item`, `Listbox.Option` and `RadioGroup.Option` ([#1045](https://github.com/tailwindlabs/headlessui/pull/1045)) - Improve controlled Tabs behaviour ([#1050](https://github.com/tailwindlabs/headlessui/pull/1050)) - Improve typeahead search logic ([#1051](https://github.com/tailwindlabs/headlessui/pull/1051)) +- Improve overal codebase, use modern tech like `esbuild` and TypeScript 4! ([#1055](https://github.com/tailwindlabs/headlessui/pull/1055)) ### Added @@ -23,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Ensure correct order when conditionally rendering `MenuItem`, `ListboxOption` and `RadioGroupOption` ([#1045](https://github.com/tailwindlabs/headlessui/pull/1045)) - Improve typeahead search logic ([#1051](https://github.com/tailwindlabs/headlessui/pull/1051)) +- Improve overal codebase, use modern tech like `esbuild` and TypeScript 4! ([#1055](https://github.com/tailwindlabs/headlessui/pull/1055)) ### Added @@ -107,7 +109,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [@headlessui/react@v1.3.0] - 2021-06-21 -### Added +### Added - Ensure that you can use `Transition.Child` when using implicit Transitions ([#503](https://github.com/tailwindlabs/headlessui/pull/503)) - Add new `entered` prop for `Transition` and `Transition.Child` components ([#504](https://github.com/tailwindlabs/headlessui/pull/504)) @@ -127,7 +129,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [@headlessui/vue@v1.3.0] - 2021-06-21 -### Added +### Added - Ensure that you can use `TransitionChild` when using implicit Transitions ([#503](https://github.com/tailwindlabs/headlessui/pull/503)) - Add new `entered` prop for `Transition` and `TransitionChild` components ([#504](https://github.com/tailwindlabs/headlessui/pull/504)) @@ -141,7 +143,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [@headlessui/react@v1.2.0] - 2021-05-10 -### Added +### Added - Introduce Open/Closed state, to simplify component communication ([#466](https://github.com/tailwindlabs/headlessui/pull/466)) @@ -153,7 +155,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [@headlessui/vue@v1.2.0] - 2021-05-10 -### Added +### Added - Introduce Open/Closed state, to simplify component communication ([#466](https://github.com/tailwindlabs/headlessui/pull/466)) diff --git a/README.md b/README.md index 30a518bf84..5f42a558a9 100644 --- a/README.md +++ b/README.md @@ -49,4 +49,3 @@ For casual chit-chat with others using the library: ## Contributing If you're interested in contributing to Headless UI, please read our [contributing docs](https://github.com/tailwindlabs/headlessui/blob/main/.github/CONTRIBUTING.md) **before submitting a pull request**. - diff --git a/jest/create-jest-config.js b/jest/create-jest-config.js index 5cbedca808..bbeb0b88a0 100644 --- a/jest/create-jest-config.js +++ b/jest/create-jest-config.js @@ -1,17 +1,10 @@ -const { createJestConfig: create } = require('tsdx/dist/createJestConfig') - module.exports = function createJestConfig(root, options) { return Object.assign( - {}, - create(undefined, root), { rootDir: root, setupFilesAfterEnv: ['../../jest/custom-matchers.ts'], - globals: { - 'ts-jest': { - isolatedModules: true, - tsConfig: '/tsconfig.tsdx.json', - }, + transform: { + '^.+\\.(t|j)sx?$': '@swc/jest', }, }, options diff --git a/package.json b/package.json index 7f6b020967..120d84e4ca 100644 --- a/package.json +++ b/package.json @@ -14,10 +14,13 @@ "react-playground": "yarn workspace playground-react dev", "playground-react": "yarn workspace playground-react dev", "vue": "yarn workspace @headlessui/vue", - "shared": "yarn workspace @headlessui/shared", + "playground-vue": "yarn workspace playground-vue dev", + "vue-playground": "yarn workspace playground-vue dev", + "clean": "yarn workspaces run clean", "build": "yarn workspaces run build", "test": "./scripts/test.sh", - "lint": "./scripts/lint.sh" + "lint": "./scripts/lint.sh", + "lint-check": "CI=true ./scripts/lint.sh" }, "husky": { "hooks": { @@ -25,7 +28,7 @@ } }, "lint-staged": { - "*.{js,jsx,ts,tsx}": "tsdx lint" + "*": "yarn lint-check" }, "prettier": { "printWidth": 100, @@ -34,12 +37,21 @@ "trailingComma": "es5" }, "devDependencies": { + "@swc/core": "^1.2.131", + "@swc/jest": "^0.2.17", "@testing-library/jest-dom": "^5.11.9", "@types/node": "^14.14.22", + "esbuild": "^0.14.11", "husky": "^4.3.8", + "jest": "26", "lint-staged": "^12.2.1", - "tsdx": "^0.14.1", - "tslib": "^2.1.0", - "typescript": "^3.9.7" + "npm-run-all": "^4.1.5", + "prettier": "^2.5.1", + "rimraf": "^3.0.2", + "tslib": "^2.3.1", + "typescript": "^4.5.4" + }, + "dependencies": { + "prettier-plugin-tailwindcss": "^0.1.4" } } diff --git a/packages/@headlessui-react/README.md b/packages/@headlessui-react/README.md index fa2a630f49..733df5c86a 100644 --- a/packages/@headlessui-react/README.md +++ b/packages/@headlessui-react/README.md @@ -36,4 +36,3 @@ For help, discussion about best practices, or any other conversation that would For casual chit-chat with others using the library: [Join the Tailwind CSS Discord Server](https://discord.gg/7NF8GNe) - diff --git a/packages/@headlessui-react/build/index.js b/packages/@headlessui-react/build/index.js new file mode 100644 index 0000000000..473f46702e --- /dev/null +++ b/packages/@headlessui-react/build/index.js @@ -0,0 +1,7 @@ +'use strict' + +if (process.env.NODE_ENV === 'production') { + module.exports = require('./headlessui.prod.cjs.js') +} else { + module.exports = require('./headlessui.dev.cjs.js') +} diff --git a/packages/@headlessui-react/package.json b/packages/@headlessui-react/package.json index a638b2d81a..63d805cfe5 100644 --- a/packages/@headlessui-react/package.json +++ b/packages/@headlessui-react/package.json @@ -4,12 +4,21 @@ "description": "A set of completely unstyled, fully accessible UI components for React, designed to integrate beautifully with Tailwind CSS.", "main": "dist/index.js", "typings": "dist/index.d.ts", - "module": "dist/index.esm.js", + "module": "dist/headlessui.esm.js", "license": "MIT", "files": [ "README.md", "dist" ], + "exports": { + ".": { + "import": { + "default": "./dist/headlessui.esm.js" + }, + "require": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, "sideEffects": false, "engines": { "node": ">=10" @@ -24,10 +33,12 @@ }, "scripts": { "prepublishOnly": "npm run build", + "build": "../../scripts/build.sh --external:react --external:react-dom", + "watch": "../../scripts/watch.sh --external:react --external:react-dom", "test": "../../scripts/test.sh", - "build": "../../scripts/build.sh", - "watch": "../../scripts/watch.sh", - "lint": "../../scripts/lint.sh" + "lint": "../../scripts/lint.sh", + "playground": "yarn workspace playground-react dev", + "clean": "rimraf ./dist" }, "peerDependencies": { "react": "^16 || ^17 || ^18", @@ -39,6 +50,7 @@ "@types/react-dom": "^16.9.10", "react": "^16.14.0", "react-dom": "^16.14.0", - "snapshot-diff": "^0.8.1" + "snapshot-diff": "^0.8.1", + "esbuild": "^0.11.18" } } diff --git a/packages/@headlessui-react/src/components/combobox/combobox.test.tsx b/packages/@headlessui-react/src/components/combobox/combobox.test.tsx index 60c6375b12..e5f79c50fc 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.test.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.test.tsx @@ -481,7 +481,7 @@ describe('Rendering', () => { Trigger - {data => ( + {(data) => ( <> {JSON.stringify(data)} @@ -639,10 +639,10 @@ describe('Rendering composition', () => { Trigger - JSON.stringify(bag)}> + JSON.stringify(bag)}> Option A - JSON.stringify(bag)}> + JSON.stringify(bag)}> Option B @@ -738,7 +738,7 @@ describe('Rendering composition', () => { await click(getComboboxButton()) // Verify options are buttons now - getComboboxOptions().forEach(option => assertComboboxOption(option, { tag: 'button' })) + getComboboxOptions().forEach((option) => assertComboboxOption(option, { tag: 'button' })) }) ) }) @@ -767,7 +767,7 @@ describe('Composition', () => { - {data => ( + {(data) => ( <> {JSON.stringify(data)} @@ -855,7 +855,7 @@ describe('Keyboard interactions', () => { // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertComboboxOption(option, { selected: false })) + options.forEach((option) => assertComboboxOption(option, { selected: false })) assertNoActiveComboboxOption() assertNoSelectedComboboxOption() @@ -1026,7 +1026,7 @@ describe('Keyboard interactions', () => { Trigger - {myOptions.map(myOption => ( + {myOptions.map((myOption) => ( {myOption.name} @@ -1142,7 +1142,7 @@ describe('Keyboard interactions', () => { // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertComboboxOption(option)) + options.forEach((option) => assertComboboxOption(option)) assertNoActiveComboboxOption() }) ) @@ -1383,7 +1383,7 @@ describe('Keyboard interactions', () => { // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertComboboxOption(option)) + options.forEach((option) => assertComboboxOption(option)) // Verify that the first combobox option is active assertNoActiveComboboxOption() @@ -1539,7 +1539,7 @@ describe('Keyboard interactions', () => { // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertComboboxOption(option)) + options.forEach((option) => assertComboboxOption(option)) // Verify that the first combobox option is active assertNoActiveComboboxOption() @@ -1695,7 +1695,7 @@ describe('Keyboard interactions', () => { // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertComboboxOption(option)) + options.forEach((option) => assertComboboxOption(option)) // ! ALERT: The LAST option should now be active assertActiveComboboxOption(options[2]) @@ -1843,7 +1843,7 @@ describe('Keyboard interactions', () => { // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertComboboxOption(option)) + options.forEach((option) => assertComboboxOption(option)) assertActiveComboboxOption(options[0]) }) ) @@ -1890,7 +1890,7 @@ describe('Keyboard interactions', () => { // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertComboboxOption(option)) + options.forEach((option) => assertComboboxOption(option)) // ! ALERT: The LAST option should now be active assertActiveComboboxOption(options[2]) @@ -2039,7 +2039,7 @@ describe('Keyboard interactions', () => { // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertComboboxOption(option)) + options.forEach((option) => assertComboboxOption(option)) assertActiveComboboxOption(options[0]) }) ) @@ -2059,7 +2059,7 @@ describe('Keyboard interactions', () => { return ( { + onChange={(value) => { setValue(value) handleChange(value) }} @@ -2305,7 +2305,7 @@ describe('Keyboard interactions', () => { // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertComboboxOption(option)) + options.forEach((option) => assertComboboxOption(option)) // Verify that the first combobox option is active assertNoActiveComboboxOption() @@ -2446,7 +2446,7 @@ describe('Keyboard interactions', () => { // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertComboboxOption(option)) + options.forEach((option) => assertComboboxOption(option)) assertNoActiveComboboxOption() // We should be able to go down once @@ -2496,7 +2496,7 @@ describe('Keyboard interactions', () => { // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertComboboxOption(option)) + options.forEach((option) => assertComboboxOption(option)) assertNoActiveComboboxOption() // We should be able to go down once @@ -2536,7 +2536,7 @@ describe('Keyboard interactions', () => { // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertComboboxOption(option)) + options.forEach((option) => assertComboboxOption(option)) assertNoActiveComboboxOption() // Open combobox @@ -2587,7 +2587,7 @@ describe('Keyboard interactions', () => { // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertComboboxOption(option)) + options.forEach((option) => assertComboboxOption(option)) // Verify that the first combobox option is active assertNoActiveComboboxOption() @@ -2729,7 +2729,7 @@ describe('Keyboard interactions', () => { // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertComboboxOption(option)) + options.forEach((option) => assertComboboxOption(option)) assertNoActiveComboboxOption() // We should be able to go down once @@ -2779,7 +2779,7 @@ describe('Keyboard interactions', () => { // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertComboboxOption(option)) + options.forEach((option) => assertComboboxOption(option)) assertNoActiveComboboxOption() // We should be able to go down once @@ -2819,7 +2819,7 @@ describe('Keyboard interactions', () => { // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertComboboxOption(option)) + options.forEach((option) => assertComboboxOption(option)) assertNoActiveComboboxOption() // Open combobox @@ -2869,7 +2869,7 @@ describe('Keyboard interactions', () => { // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertComboboxOption(option)) + options.forEach((option) => assertComboboxOption(option)) // ! ALERT: The LAST option should now be active assertActiveComboboxOption(options[2]) @@ -3017,7 +3017,7 @@ describe('Keyboard interactions', () => { // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertComboboxOption(option)) + options.forEach((option) => assertComboboxOption(option)) assertActiveComboboxOption(options[0]) }) ) @@ -3053,7 +3053,7 @@ describe('Keyboard interactions', () => { // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertComboboxOption(option)) + options.forEach((option) => assertComboboxOption(option)) assertNoActiveComboboxOption() // Going up or down should select the single available option @@ -3108,7 +3108,7 @@ describe('Keyboard interactions', () => { // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertComboboxOption(option)) + options.forEach((option) => assertComboboxOption(option)) assertActiveComboboxOption(options[2]) // We should be able to go down once @@ -3167,7 +3167,7 @@ describe('Keyboard interactions', () => { // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertComboboxOption(option)) + options.forEach((option) => assertComboboxOption(option)) // ! ALERT: The LAST option should now be active assertActiveComboboxOption(options[2]) @@ -3316,7 +3316,7 @@ describe('Keyboard interactions', () => { // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertComboboxOption(option)) + options.forEach((option) => assertComboboxOption(option)) assertActiveComboboxOption(options[0]) }) ) @@ -3894,14 +3894,16 @@ describe('Keyboard interactions', () => { let filteredPeople = query === '' ? props.people - : props.people.filter(person => person.name.toLowerCase().includes(query.toLowerCase())) + : props.people.filter((person) => + person.name.toLowerCase().includes(query.toLowerCase()) + ) return ( - setQuery(event.target.value)} /> + setQuery(event.target.value)} /> Trigger - {filteredPeople.map(person => ( + {filteredPeople.map((person) => ( {person.name} @@ -4207,7 +4209,7 @@ describe('Mouse interactions', () => { // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertComboboxOption(option)) + options.forEach((option) => assertComboboxOption(option)) }) ) @@ -4752,7 +4754,7 @@ describe('Mouse interactions', () => { return ( { + onChange={(value) => { setValue(value) handleChange(value) }} @@ -4804,7 +4806,7 @@ describe('Mouse interactions', () => { return ( { + onChange={(value) => { setValue(value) handleChange(value) }} diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx index d0f327b940..614113221a 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx @@ -123,8 +123,8 @@ let reducers: { let activeOptionIndex = calculateActiveIndex(action, { resolveItems: () => state.options, resolveActiveIndex: () => state.activeOptionIndex, - resolveId: item => item.id, - resolveDisabled: item => item.dataRef.current.disabled, + resolveId: (item) => item.id, + resolveDisabled: (item) => item.dataRef.current.disabled, }) if (state.activeOptionIndex === activeOptionIndex) return state @@ -163,7 +163,7 @@ let reducers: { let currentActiveOption = state.activeOptionIndex !== null ? nextOptions[state.activeOptionIndex] : null - let idx = nextOptions.findIndex(a => a.id === action.id) + let idx = nextOptions.findIndex((a) => a.id === action.id) if (idx !== -1) nextOptions.splice(idx, 1) @@ -284,12 +284,13 @@ export function Combobox dispatch({ type: ActionTypes.SetDisabled, disabled }), [disabled]) - useIsoMorphicEffect(() => dispatch({ type: ActionTypes.SetOrientation, orientation }), [ - orientation, - ]) + useIsoMorphicEffect( + () => dispatch({ type: ActionTypes.SetOrientation, orientation }), + [orientation] + ) // Handle outside click - useWindowEvent('mousedown', event => { + useWindowEvent('mousedown', (event) => { let target = event.target as HTMLElement if (comboboxState !== ComboboxStates.Open) return @@ -333,7 +334,7 @@ export function Combobox { - let option = options.find(item => item.id === id) + let option = options.find((item) => item.id === id) if (!option) return let { dataRef } = option @@ -418,7 +419,7 @@ let Input = forwardRefWithAs(function Input< ref: Ref ) { let { value, onChange, displayValue, ...passThroughProps } = props - let [state, dispatch] = useComboboxContext([Combobox.name, Input.name].join('.')) + let [state, dispatch] = useComboboxContext('Combobox.Input') let actions = useComboboxActions() let inputRef = useSyncRefs(state.inputRef, ref) @@ -579,7 +580,7 @@ let Button = forwardRefWithAs(function Button, ref: Ref ) { - let [state, dispatch] = useComboboxContext([Combobox.name, Button.name].join('.')) + let [state, dispatch] = useComboboxContext('Combobox.Button') let actions = useComboboxActions() let buttonRef = useSyncRefs(state.buttonRef, ref) @@ -693,12 +694,13 @@ type LabelPropsWeControl = 'id' | 'ref' | 'onClick' function Label( props: Props ) { - let [state] = useComboboxContext([Combobox.name, Label.name].join('.')) + let [state] = useComboboxContext('Combobox.Label') let id = `headlessui-combobox-label-${useId()}` - let handleClick = useCallback(() => state.inputRef.current?.focus({ preventScroll: true }), [ - state.inputRef, - ]) + let handleClick = useCallback( + () => state.inputRef.current?.focus({ preventScroll: true }), + [state.inputRef] + ) let slot = useMemo( () => ({ open: state.comboboxState === ComboboxStates.Open, disabled: state.disabled }), @@ -737,7 +739,7 @@ let Options = forwardRefWithAs(function Options< PropsForFeatures, ref: Ref ) { - let [state, dispatch] = useComboboxContext([Combobox.name, Options.name].join('.')) + let [state, dispatch] = useComboboxContext('Combobox.Options') let optionsRef = useSyncRefs(state.optionsRef, ref) let id = `headlessui-combobox-options-${useId()}` @@ -751,10 +753,10 @@ let Options = forwardRefWithAs(function Options< return state.comboboxState === ComboboxStates.Open })() - let labelledby = useComputed(() => state.labelRef.current?.id ?? state.buttonRef.current?.id, [ - state.labelRef.current, - state.buttonRef.current, - ]) + let labelledby = useComputed( + () => state.labelRef.current?.id ?? state.buttonRef.current?.id, + [state.labelRef.current, state.buttonRef.current] + ) let handleLeave = useCallback(() => { if (state.comboboxState !== ComboboxStates.Open) return @@ -820,7 +822,7 @@ function Option< } ) { let { disabled = false, value, ...passthroughProps } = props - let [state, dispatch] = useComboboxContext([Combobox.name, Option.name].join('.')) + let [state, dispatch] = useComboboxContext('Combobox.Option') let actions = useComboboxActions() let id = `headlessui-combobox-option-${useId()}` let active = @@ -886,11 +888,10 @@ function Option< dispatch({ type: ActionTypes.GoToOption, focus: Focus.Nothing }) }, [disabled, active, dispatch]) - let slot = useMemo(() => ({ active, selected, disabled }), [ - active, - selected, - disabled, - ]) + let slot = useMemo( + () => ({ active, selected, disabled }), + [active, selected, disabled] + ) let propsWeControl = { id, diff --git a/packages/@headlessui-react/src/components/description/description.tsx b/packages/@headlessui-react/src/components/description/description.tsx index 716a9b2813..b0dc312e2b 100644 --- a/packages/@headlessui-react/src/components/description/description.tsx +++ b/packages/@headlessui-react/src/components/description/description.tsx @@ -57,10 +57,10 @@ export function useDescriptions(): [ useMemo(() => { return function DescriptionProvider(props: DescriptionProviderProps) { let register = useCallback((value: string) => { - setDescriptionIds(existing => [...existing, value]) + setDescriptionIds((existing) => [...existing, value]) return () => - setDescriptionIds(existing => { + setDescriptionIds((existing) => { let clone = existing.slice() let idx = clone.indexOf(value) if (idx !== -1) clone.splice(idx, 1) diff --git a/packages/@headlessui-react/src/components/dialog/dialog.test.tsx b/packages/@headlessui-react/src/components/dialog/dialog.test.tsx index 4a07169dd0..908023b600 100644 --- a/packages/@headlessui-react/src/components/dialog/dialog.test.tsx +++ b/packages/@headlessui-react/src/components/dialog/dialog.test.tsx @@ -143,7 +143,7 @@ describe('Rendering', () => { Trigger - {data => ( + {(data) => ( <>
{JSON.stringify(data)}
@@ -204,7 +204,7 @@ describe('Rendering', () => { return ( <> - @@ -239,7 +239,7 @@ describe('Rendering', () => { return ( <> - @@ -277,7 +277,7 @@ describe('Rendering', () => { let [isOpen, setIsOpen] = useState(false) return ( <> - @@ -400,7 +400,7 @@ describe('Keyboard interactions', () => { let [isOpen, setIsOpen] = useState(false) return ( <> - @@ -438,7 +438,7 @@ describe('Keyboard interactions', () => { let [isOpen, setIsOpen] = useState(false) return ( <> - @@ -477,14 +477,14 @@ describe('Keyboard interactions', () => { let [isOpen, setIsOpen] = useState(false) return ( <> - Contents { + onKeyDown={(event) => { event.preventDefault() event.stopPropagation() }} @@ -525,7 +525,7 @@ describe('Mouse interactions', () => { let [isOpen, setIsOpen] = useState(false) return ( <> - @@ -559,7 +559,7 @@ describe('Mouse interactions', () => { let [isOpen, setIsOpen] = useState(false) return ( <> - @@ -595,7 +595,7 @@ describe('Mouse interactions', () => { let [isOpen, setIsOpen] = useState(false) return ( <> - + Contents @@ -630,7 +630,7 @@ describe('Mouse interactions', () => { return ( <> - + Contents diff --git a/packages/@headlessui-react/src/components/dialog/dialog.tsx b/packages/@headlessui-react/src/components/dialog/dialog.tsx index aa62837ac1..edf3e3a0fb 100644 --- a/packages/@headlessui-react/src/components/dialog/dialog.tsx +++ b/packages/@headlessui-react/src/components/dialog/dialog.tsx @@ -206,7 +206,7 @@ let DialogRoot = forwardRefWithAs(function Dialog< useInertOthers(internalDialogRef, hasNestedDialogs ? enabled : false) // Handle outside click - useWindowEvent('mousedown', event => { + useWindowEvent('mousedown', (event) => { let target = event.target as HTMLElement if (dialogState !== DialogStates.Open) return @@ -217,7 +217,7 @@ let DialogRoot = forwardRefWithAs(function Dialog< }) // Handle `Escape` to close - useWindowEvent('keydown', event => { + useWindowEvent('keydown', (event) => { if (event.key !== Keys.Escape) return if (dialogState !== DialogStates.Open) return if (hasNestedDialogs) return @@ -250,7 +250,7 @@ let DialogRoot = forwardRefWithAs(function Dialog< if (dialogState !== DialogStates.Open) return if (!internalDialogRef.current) return - let observer = new IntersectionObserver(entries => { + let observer = new IntersectionObserver((entries) => { for (let entry of entries) { if ( entry.boundingClientRect.x === 0 && @@ -277,9 +277,10 @@ let DialogRoot = forwardRefWithAs(function Dialog< [dialogState, state, close, setTitleId] ) - let slot = useMemo(() => ({ open: dialogState === DialogStates.Open }), [ - dialogState, - ]) + let slot = useMemo( + () => ({ open: dialogState === DialogStates.Open }), + [dialogState] + ) let propsWeControl = { ref: dialogRef, @@ -304,11 +305,11 @@ let DialogRoot = forwardRefWithAs(function Dialog< match(message, { [StackMessage.Add]() { containers.current.add(element) - setNestedDialogCount(count => count + 1) + setNestedDialogCount((count) => count + 1) }, [StackMessage.Remove]() { containers.current.add(element) - setNestedDialogCount(count => count - 1) + setNestedDialogCount((count) => count - 1) }, }) }, [])} @@ -348,7 +349,7 @@ type OverlayPropsWeControl = 'id' | 'aria-hidden' | 'onClick' let Overlay = forwardRefWithAs(function Overlay< TTag extends ElementType = typeof DEFAULT_OVERLAY_TAG >(props: Props, ref: Ref) { - let [{ dialogState, close }] = useDialogContext([Dialog.displayName, Overlay.name].join('.')) + let [{ dialogState, close }] = useDialogContext('Dialog.Overlay') let overlayRef = useSyncRefs(ref) let id = `headlessui-dialog-overlay-${useId()}` @@ -364,9 +365,10 @@ let Overlay = forwardRefWithAs(function Overlay< [close] ) - let slot = useMemo(() => ({ open: dialogState === DialogStates.Open }), [ - dialogState, - ]) + let slot = useMemo( + () => ({ open: dialogState === DialogStates.Open }), + [dialogState] + ) let propsWeControl = { ref: overlayRef, id, @@ -394,7 +396,7 @@ type TitlePropsWeControl = 'id' function Title( props: Props ) { - let [{ dialogState, setTitleId }] = useDialogContext([Dialog.displayName, Title.name].join('.')) + let [{ dialogState, setTitleId }] = useDialogContext('Dialog.Title') let id = `headlessui-dialog-title-${useId()}` @@ -403,9 +405,10 @@ function Title( return () => setTitleId(null) }, [id, setTitleId]) - let slot = useMemo(() => ({ open: dialogState === DialogStates.Open }), [ - dialogState, - ]) + let slot = useMemo( + () => ({ open: dialogState === DialogStates.Open }), + [dialogState] + ) let propsWeControl = { id } let passthroughProps = props diff --git a/packages/@headlessui-react/src/components/disclosure/disclosure.test.tsx b/packages/@headlessui-react/src/components/disclosure/disclosure.test.tsx index 6c72cff4e1..1d93d260b5 100644 --- a/packages/@headlessui-react/src/components/disclosure/disclosure.test.tsx +++ b/packages/@headlessui-react/src/components/disclosure/disclosure.test.tsx @@ -20,7 +20,7 @@ jest.mock('../../hooks/use-id') afterAll(() => jest.restoreAllMocks()) function nextFrame() { - return new Promise(resolve => { + return new Promise((resolve) => { requestAnimationFrame(() => { requestAnimationFrame(() => { resolve() diff --git a/packages/@headlessui-react/src/components/disclosure/disclosure.tsx b/packages/@headlessui-react/src/components/disclosure/disclosure.tsx index 587df365f0..5dcd19b609 100644 --- a/packages/@headlessui-react/src/components/disclosure/disclosure.tsx +++ b/packages/@headlessui-react/src/components/disclosure/disclosure.tsx @@ -68,14 +68,14 @@ let reducers: { action: Extract ) => StateDefinition } = { - [ActionTypes.ToggleDisclosure]: state => ({ + [ActionTypes.ToggleDisclosure]: (state) => ({ ...state, disclosureState: match(state.disclosureState, { [DisclosureStates.Open]: DisclosureStates.Closed, [DisclosureStates.Closed]: DisclosureStates.Open, }), }), - [ActionTypes.CloseDisclosure]: state => { + [ActionTypes.CloseDisclosure]: (state) => { if (state.disclosureState === DisclosureStates.Closed) return state return { ...state, disclosureState: DisclosureStates.Closed } }, @@ -227,7 +227,7 @@ let Button = forwardRefWithAs(function Button, ref: Ref ) { - let [state, dispatch] = useDisclosureContext([Disclosure.name, Button.name].join('.')) + let [state, dispatch] = useDisclosureContext('Disclosure.Button') let internalButtonRef = useRef(null) let buttonRef = useSyncRefs(internalButtonRef, ref) @@ -334,8 +334,8 @@ let Panel = forwardRefWithAs(function Panel, ref: Ref ) { - let [state, dispatch] = useDisclosureContext([Disclosure.name, Panel.name].join('.')) - let { close } = useDisclosureAPIContext([Disclosure.name, Panel.name].join('.')) + let [state, dispatch] = useDisclosureContext('Disclosure.Panel') + let { close } = useDisclosureAPIContext('Disclosure.Panel') let panelRef = useSyncRefs(ref, () => { if (state.linkedPanel) return diff --git a/packages/@headlessui-react/src/components/label/label.tsx b/packages/@headlessui-react/src/components/label/label.tsx index 0d23754556..871ef93c6b 100644 --- a/packages/@headlessui-react/src/components/label/label.tsx +++ b/packages/@headlessui-react/src/components/label/label.tsx @@ -52,10 +52,10 @@ export function useLabels(): [string | undefined, (props: LabelProviderProps) => useMemo(() => { return function LabelProvider(props: LabelProviderProps) { let register = useCallback((value: string) => { - setLabelIds(existing => [...existing, value]) + setLabelIds((existing) => [...existing, value]) return () => - setLabelIds(existing => { + setLabelIds((existing) => { let clone = existing.slice() let idx = clone.indexOf(value) if (idx !== -1) clone.splice(idx, 1) diff --git a/packages/@headlessui-react/src/components/listbox/listbox.test.tsx b/packages/@headlessui-react/src/components/listbox/listbox.test.tsx index 90011989f2..eea9fc118c 100644 --- a/packages/@headlessui-react/src/components/listbox/listbox.test.tsx +++ b/packages/@headlessui-react/src/components/listbox/listbox.test.tsx @@ -56,6 +56,7 @@ 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.` ) @@ -396,7 +397,7 @@ describe('Rendering', () => { Trigger - {data => ( + {(data) => ( <> {JSON.stringify(data)} @@ -547,10 +548,10 @@ describe('Rendering composition', () => { Trigger - JSON.stringify(bag)}> + JSON.stringify(bag)}> Option A - JSON.stringify(bag)}> + JSON.stringify(bag)}> Option B @@ -645,7 +646,7 @@ describe('Rendering composition', () => { await click(getListboxButton()) // Verify options are buttons now - getListboxOptions().forEach(option => assertListboxOption(option, { tag: 'button' })) + getListboxOptions().forEach((option) => assertListboxOption(option, { tag: 'button' })) }) ) }) @@ -673,7 +674,7 @@ describe('Composition', () => { - {data => ( + {(data) => ( <> {JSON.stringify(data)} @@ -756,7 +757,7 @@ describe('Keyboard interactions', () => { // Verify we have listbox options let options = getListboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertListboxOption(option, { selected: false })) + options.forEach((option) => assertListboxOption(option, { selected: false })) // Verify that the first listbox option is active assertActiveListboxOption(options[0]) @@ -918,7 +919,7 @@ describe('Keyboard interactions', () => { Trigger - {myOptions.map(myOption => ( + {myOptions.map((myOption) => ( {myOption.name} @@ -1139,7 +1140,7 @@ describe('Keyboard interactions', () => { return ( { + onChange={(value) => { setValue(value) handleChange(value) }} @@ -1234,7 +1235,7 @@ describe('Keyboard interactions', () => { // Verify we have listbox options let options = getListboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertListboxOption(option)) + options.forEach((option) => assertListboxOption(option)) assertActiveListboxOption(options[0]) }) ) @@ -1462,7 +1463,7 @@ describe('Keyboard interactions', () => { return ( { + onChange={(value) => { setValue(value) handleChange(value) }} @@ -1600,7 +1601,7 @@ describe('Keyboard interactions', () => { // Verify we have listbox options let options = getListboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertListboxOption(option)) + options.forEach((option) => assertListboxOption(option)) assertActiveListboxOption(options[0]) // Try to tab @@ -1651,7 +1652,7 @@ describe('Keyboard interactions', () => { // Verify we have listbox options let options = getListboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertListboxOption(option)) + options.forEach((option) => assertListboxOption(option)) assertActiveListboxOption(options[0]) // Try to Shift+Tab @@ -1704,7 +1705,7 @@ describe('Keyboard interactions', () => { // Verify we have listbox options let options = getListboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertListboxOption(option)) + options.forEach((option) => assertListboxOption(option)) // Verify that the first listbox option is active assertActiveListboxOption(options[0]) @@ -1844,7 +1845,7 @@ describe('Keyboard interactions', () => { // Verify we have listbox options let options = getListboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertListboxOption(option)) + options.forEach((option) => assertListboxOption(option)) assertActiveListboxOption(options[0]) // We should be able to go down once @@ -1892,7 +1893,7 @@ describe('Keyboard interactions', () => { // Verify we have listbox options let options = getListboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertListboxOption(option)) + options.forEach((option) => assertListboxOption(option)) assertActiveListboxOption(options[1]) // We should be able to go down once @@ -1934,7 +1935,7 @@ describe('Keyboard interactions', () => { // Verify we have listbox options let options = getListboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertListboxOption(option)) + options.forEach((option) => assertListboxOption(option)) assertActiveListboxOption(options[2]) }) ) @@ -1970,7 +1971,7 @@ describe('Keyboard interactions', () => { // Verify we have listbox options let options = getListboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertListboxOption(option)) + options.forEach((option) => assertListboxOption(option)) assertActiveListboxOption(options[0]) // We should be able to go right once @@ -2027,7 +2028,7 @@ describe('Keyboard interactions', () => { // Verify we have listbox options let options = getListboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertListboxOption(option)) + options.forEach((option) => assertListboxOption(option)) // ! ALERT: The LAST option should now be active assertActiveListboxOption(options[2]) @@ -2171,7 +2172,7 @@ describe('Keyboard interactions', () => { // Verify we have listbox options let options = getListboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertListboxOption(option)) + options.forEach((option) => assertListboxOption(option)) assertActiveListboxOption(options[0]) }) ) @@ -2209,7 +2210,7 @@ describe('Keyboard interactions', () => { // Verify we have listbox options let options = getListboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertListboxOption(option)) + options.forEach((option) => assertListboxOption(option)) assertActiveListboxOption(options[2]) // We should not be able to go up (because those are disabled) @@ -2260,7 +2261,7 @@ describe('Keyboard interactions', () => { // Verify we have listbox options let options = getListboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertListboxOption(option)) + options.forEach((option) => assertListboxOption(option)) assertActiveListboxOption(options[2]) // We should be able to go down once @@ -2318,7 +2319,7 @@ describe('Keyboard interactions', () => { // Verify we have listbox options let options = getListboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertListboxOption(option)) + options.forEach((option) => assertListboxOption(option)) assertActiveListboxOption(options[2]) // We should be able to go left once @@ -3198,7 +3199,7 @@ describe('Mouse interactions', () => { // Verify we have listbox options let options = getListboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertListboxOption(option)) + options.forEach((option) => assertListboxOption(option)) }) ) @@ -3726,7 +3727,7 @@ describe('Mouse interactions', () => { return ( { + onChange={(value) => { setValue(value) handleChange(value) }} @@ -3777,7 +3778,7 @@ describe('Mouse interactions', () => { return ( { + onChange={(value) => { setValue(value) handleChange(value) }} diff --git a/packages/@headlessui-react/src/components/listbox/listbox.tsx b/packages/@headlessui-react/src/components/listbox/listbox.tsx index 26432b85b4..da2741cef6 100644 --- a/packages/@headlessui-react/src/components/listbox/listbox.tsx +++ b/packages/@headlessui-react/src/components/listbox/listbox.tsx @@ -119,8 +119,8 @@ let reducers: { let activeOptionIndex = calculateActiveIndex(action, { resolveItems: () => state.options, resolveActiveIndex: () => state.activeOptionIndex, - resolveId: item => item.id, - resolveDisabled: item => item.dataRef.current.disabled, + resolveId: (item) => item.id, + resolveDisabled: (item) => item.dataRef.current.disabled, }) if (state.searchQuery === '' && state.activeOptionIndex === activeOptionIndex) return state @@ -140,7 +140,7 @@ let reducers: { : state.options let matchingOption = reOrderedOptions.find( - option => + (option) => !option.dataRef.current.disabled && option.dataRef.current.textValue?.startsWith(searchQuery) ) @@ -175,7 +175,7 @@ let reducers: { let currentActiveOption = state.activeOptionIndex !== null ? nextOptions[state.activeOptionIndex] : null - let idx = nextOptions.findIndex(a => a.id === action.id) + let idx = nextOptions.findIndex((a) => a.id === action.id) if (idx !== -1) nextOptions.splice(idx, 1) @@ -251,12 +251,13 @@ export function Listbox dispatch({ type: ActionTypes.SetDisabled, disabled }), [disabled]) - useIsoMorphicEffect(() => dispatch({ type: ActionTypes.SetOrientation, orientation }), [ - orientation, - ]) + useIsoMorphicEffect( + () => dispatch({ type: ActionTypes.SetOrientation, orientation }), + [orientation] + ) // Handle outside click - useWindowEvent('mousedown', event => { + useWindowEvent('mousedown', (event) => { let target = event.target as HTMLElement if (listboxState !== ListboxStates.Open) return @@ -318,7 +319,7 @@ let Button = forwardRefWithAs(function Button, ref: Ref ) { - let [state, dispatch] = useListboxContext([Listbox.name, Button.name].join('.')) + let [state, dispatch] = useListboxContext('Listbox.Button') let buttonRef = useSyncRefs(state.buttonRef, ref) let id = `headlessui-listbox-button-${useId()}` @@ -422,12 +423,13 @@ type LabelPropsWeControl = 'id' | 'ref' | 'onClick' function Label( props: Props ) { - let [state] = useListboxContext([Listbox.name, Label.name].join('.')) + let [state] = useListboxContext('Listbox.Label') let id = `headlessui-listbox-label-${useId()}` - let handleClick = useCallback(() => state.buttonRef.current?.focus({ preventScroll: true }), [ - state.buttonRef, - ]) + let handleClick = useCallback( + () => state.buttonRef.current?.focus({ preventScroll: true }), + [state.buttonRef] + ) let slot = useMemo( () => ({ open: state.listboxState === ListboxStates.Open, disabled: state.disabled }), @@ -466,7 +468,7 @@ let Options = forwardRefWithAs(function Options< PropsForFeatures, ref: Ref ) { - let [state, dispatch] = useListboxContext([Listbox.name, Options.name].join('.')) + let [state, dispatch] = useListboxContext('Listbox.Options') let optionsRef = useSyncRefs(state.optionsRef, ref) let id = `headlessui-listbox-options-${useId()}` @@ -561,10 +563,10 @@ let Options = forwardRefWithAs(function Options< [d, dispatch, searchDisposables, state] ) - let labelledby = useComputed(() => state.labelRef.current?.id ?? state.buttonRef.current?.id, [ - state.labelRef.current, - state.buttonRef.current, - ]) + let labelledby = useComputed( + () => state.labelRef.current?.id ?? state.buttonRef.current?.id, + [state.labelRef.current, state.buttonRef.current] + ) let slot = useMemo( () => ({ open: state.listboxState === ListboxStates.Open }), @@ -625,7 +627,7 @@ function Option< } ) { let { disabled = false, value, ...passthroughProps } = props - let [state, dispatch] = useListboxContext([Listbox.name, Option.name].join('.')) + let [state, dispatch] = useListboxContext('Listbox.Option') let id = `headlessui-listbox-option-${useId()}` let active = state.activeOptionIndex !== null ? state.options[state.activeOptionIndex].id === id : false @@ -692,11 +694,10 @@ function Option< dispatch({ type: ActionTypes.GoToOption, focus: Focus.Nothing }) }, [disabled, active, dispatch]) - let slot = useMemo(() => ({ active, selected, disabled }), [ - active, - selected, - disabled, - ]) + let slot = useMemo( + () => ({ active, selected, disabled }), + [active, selected, disabled] + ) let propsWeControl = { id, role: 'option', diff --git a/packages/@headlessui-react/src/components/menu/menu.test.tsx b/packages/@headlessui-react/src/components/menu/menu.test.tsx index a5a2a9b67c..1fc853d9b6 100644 --- a/packages/@headlessui-react/src/components/menu/menu.test.tsx +++ b/packages/@headlessui-react/src/components/menu/menu.test.tsx @@ -253,7 +253,7 @@ describe('Rendering', () => { Trigger - {data => ( + {(data) => ( <> {JSON.stringify(data)} @@ -403,10 +403,10 @@ describe('Rendering composition', () => { Trigger - JSON.stringify(bag)}> + JSON.stringify(bag)}> Item A - JSON.stringify(bag)}> + JSON.stringify(bag)}> Item B @@ -484,7 +484,7 @@ describe('Rendering composition', () => { // Verify items are buttons now let items = getMenuItems() - items.forEach(item => assertMenuItem(item, { tag: 'button' })) + items.forEach((item) => assertMenuItem(item, { tag: 'button' })) }) ) @@ -496,11 +496,11 @@ describe('Rendering composition', () => { Trigger
-
+
Item A Item B
-
+
Item C
@@ -508,7 +508,7 @@ describe('Rendering composition', () => {
-
+
Item E
@@ -523,11 +523,11 @@ describe('Rendering composition', () => { expect.hasAssertions() - document.querySelectorAll('.outer').forEach(element => { + document.querySelectorAll('.outer').forEach((element) => { expect(element).not.toHaveAttribute('role', 'none') }) - document.querySelectorAll('.inner').forEach(element => { + document.querySelectorAll('.inner').forEach((element) => { expect(element).toHaveAttribute('role', 'none') }) }) @@ -557,7 +557,7 @@ describe('Composition', () => { - {data => ( + {(data) => ( <> {JSON.stringify(data)} @@ -611,7 +611,7 @@ describe('Composition', () => { - {data => ( + {(data) => ( <> {JSON.stringify(data)} @@ -693,7 +693,7 @@ describe('Keyboard interactions', () => { // Verify we have menu items let items = getMenuItems() expect(items).toHaveLength(3) - items.forEach(item => assertMenuItem(item)) + items.forEach((item) => assertMenuItem(item)) // Verify that the first menu item is active assertMenuLinkedWithMenuItem(items[0]) @@ -1057,7 +1057,7 @@ describe('Keyboard interactions', () => { // Verify we have menu items let items = getMenuItems() expect(items).toHaveLength(3) - items.forEach(item => assertMenuItem(item)) + items.forEach((item) => assertMenuItem(item)) assertMenuLinkedWithMenuItem(items[0]) }) ) @@ -1395,7 +1395,7 @@ describe('Keyboard interactions', () => { // Verify we have menu items let items = getMenuItems() expect(items).toHaveLength(3) - items.forEach(item => assertMenuItem(item)) + items.forEach((item) => assertMenuItem(item)) assertMenuLinkedWithMenuItem(items[0]) // Try to tab @@ -1444,7 +1444,7 @@ describe('Keyboard interactions', () => { // Verify we have menu items let items = getMenuItems() expect(items).toHaveLength(3) - items.forEach(item => assertMenuItem(item)) + items.forEach((item) => assertMenuItem(item)) assertMenuLinkedWithMenuItem(items[0]) // Try to Shift+Tab @@ -1495,7 +1495,7 @@ describe('Keyboard interactions', () => { // Verify we have menu items let items = getMenuItems() expect(items).toHaveLength(3) - items.forEach(item => assertMenuItem(item)) + items.forEach((item) => assertMenuItem(item)) // Verify that the first menu item is active assertMenuLinkedWithMenuItem(items[0]) @@ -1589,7 +1589,7 @@ describe('Keyboard interactions', () => { // Verify we have menu items let items = getMenuItems() expect(items).toHaveLength(3) - items.forEach(item => assertMenuItem(item)) + items.forEach((item) => assertMenuItem(item)) assertMenuLinkedWithMenuItem(items[0]) // We should be able to go down once @@ -1637,7 +1637,7 @@ describe('Keyboard interactions', () => { // Verify we have menu items let items = getMenuItems() expect(items).toHaveLength(3) - items.forEach(item => assertMenuItem(item)) + items.forEach((item) => assertMenuItem(item)) assertMenuLinkedWithMenuItem(items[1]) // We should be able to go down once @@ -1679,7 +1679,7 @@ describe('Keyboard interactions', () => { // Verify we have menu items let items = getMenuItems() expect(items).toHaveLength(3) - items.forEach(item => assertMenuItem(item)) + items.forEach((item) => assertMenuItem(item)) assertMenuLinkedWithMenuItem(items[2]) }) ) @@ -1723,7 +1723,7 @@ describe('Keyboard interactions', () => { // Verify we have menu items let items = getMenuItems() expect(items).toHaveLength(3) - items.forEach(item => assertMenuItem(item)) + items.forEach((item) => assertMenuItem(item)) // ! ALERT: The LAST item should now be active assertMenuLinkedWithMenuItem(items[2]) @@ -1821,7 +1821,7 @@ describe('Keyboard interactions', () => { // Verify we have menu items let items = getMenuItems() expect(items).toHaveLength(3) - items.forEach(item => assertMenuItem(item)) + items.forEach((item) => assertMenuItem(item)) assertMenuLinkedWithMenuItem(items[0]) }) ) @@ -1859,7 +1859,7 @@ describe('Keyboard interactions', () => { // Verify we have menu items let items = getMenuItems() expect(items).toHaveLength(3) - items.forEach(item => assertMenuItem(item)) + items.forEach((item) => assertMenuItem(item)) assertMenuLinkedWithMenuItem(items[2]) // We should not be able to go up (because those are disabled) @@ -1909,7 +1909,7 @@ describe('Keyboard interactions', () => { // Verify we have menu items let items = getMenuItems() expect(items).toHaveLength(3) - items.forEach(item => assertMenuItem(item)) + items.forEach((item) => assertMenuItem(item)) assertMenuLinkedWithMenuItem(items[2]) // We should be able to go down once @@ -2736,7 +2736,7 @@ describe('Mouse interactions', () => { // Verify we have menu items let items = getMenuItems() expect(items).toHaveLength(3) - items.forEach(item => assertMenuItem(item)) + items.forEach((item) => assertMenuItem(item)) }) ) diff --git a/packages/@headlessui-react/src/components/menu/menu.tsx b/packages/@headlessui-react/src/components/menu/menu.tsx index 3e871fe15a..962549fdf6 100644 --- a/packages/@headlessui-react/src/components/menu/menu.tsx +++ b/packages/@headlessui-react/src/components/menu/menu.tsx @@ -91,8 +91,8 @@ let reducers: { let activeItemIndex = calculateActiveIndex(action, { resolveItems: () => state.items, resolveActiveIndex: () => state.activeItemIndex, - resolveId: item => item.id, - resolveDisabled: item => item.dataRef.current.disabled, + resolveId: (item) => item.id, + resolveDisabled: (item) => item.dataRef.current.disabled, }) if (state.searchQuery === '' && state.activeItemIndex === activeItemIndex) return state @@ -109,7 +109,7 @@ let reducers: { : state.items let matchingItem = reOrderedItems.find( - item => + (item) => item.dataRef.current.textValue?.startsWith(searchQuery) && !item.dataRef.current.disabled ) @@ -139,7 +139,7 @@ let reducers: { let nextItems = state.items.slice() let currentActiveItem = state.activeItemIndex !== null ? nextItems[state.activeItemIndex] : null - let idx = nextItems.findIndex(a => a.id === action.id) + let idx = nextItems.findIndex((a) => a.id === action.id) if (idx !== -1) nextItems.splice(idx, 1) @@ -196,7 +196,7 @@ export function Menu( let [{ menuState, itemsRef, buttonRef }, dispatch] = reducerBag // Handle outside click - useWindowEvent('mousedown', event => { + useWindowEvent('mousedown', (event) => { let target = event.target as HTMLElement if (menuState !== MenuStates.Open) return @@ -212,9 +212,10 @@ export function Menu( } }) - let slot = useMemo(() => ({ open: menuState === MenuStates.Open }), [ - menuState, - ]) + let slot = useMemo( + () => ({ open: menuState === MenuStates.Open }), + [menuState] + ) return ( @@ -249,7 +250,7 @@ let Button = forwardRefWithAs(function Button, ref: Ref ) { - let [state, dispatch] = useMenuContext([Menu.name, Button.name].join('.')) + let [state, dispatch] = useMenuContext('Menu.Button') let buttonRef = useSyncRefs(state.buttonRef, ref) let id = `headlessui-menu-button-${useId()}` @@ -307,9 +308,10 @@ let Button = forwardRefWithAs(function Button(() => ({ open: state.menuState === MenuStates.Open }), [ - state, - ]) + let slot = useMemo( + () => ({ open: state.menuState === MenuStates.Open }), + [state] + ) let passthroughProps = props let propsWeControl = { ref: buttonRef, @@ -352,7 +354,7 @@ let Items = forwardRefWithAs(function Items, ref: Ref ) { - let [state, dispatch] = useMenuContext([Menu.name, Items.name].join('.')) + let [state, dispatch] = useMenuContext('Menu.Items') let itemsRef = useSyncRefs(state.itemsRef, ref) let id = `headlessui-menu-items-${useId()}` @@ -471,9 +473,10 @@ let Items = forwardRefWithAs(function Items(() => ({ open: state.menuState === MenuStates.Open }), [ - state, - ]) + let slot = useMemo( + () => ({ open: state.menuState === MenuStates.Open }), + [state] + ) let propsWeControl = { 'aria-activedescendant': state.activeItemIndex === null ? undefined : state.items[state.activeItemIndex]?.id, @@ -522,7 +525,7 @@ function Item( } ) { let { disabled = false, onClick, ...passthroughProps } = props - let [state, dispatch] = useMenuContext([Menu.name, Item.name].join('.')) + let [state, dispatch] = useMenuContext('Menu.Item') let id = `headlessui-menu-item-${useId()}` let active = state.activeItemIndex !== null ? state.items[state.activeItemIndex].id === id : false diff --git a/packages/@headlessui-react/src/components/popover/popover.test.tsx b/packages/@headlessui-react/src/components/popover/popover.test.tsx index c39fc8a43a..f8f73a2d00 100644 --- a/packages/@headlessui-react/src/components/popover/popover.test.tsx +++ b/packages/@headlessui-react/src/components/popover/popover.test.tsx @@ -23,7 +23,7 @@ jest.mock('../../hooks/use-id') afterAll(() => jest.restoreAllMocks()) function nextFrame() { - return new Promise(resolve => { + return new Promise((resolve) => { requestAnimationFrame(() => { requestAnimationFrame(() => { resolve() diff --git a/packages/@headlessui-react/src/components/popover/popover.tsx b/packages/@headlessui-react/src/components/popover/popover.tsx index f0e7264543..779a122917 100644 --- a/packages/@headlessui-react/src/components/popover/popover.tsx +++ b/packages/@headlessui-react/src/components/popover/popover.tsx @@ -75,7 +75,7 @@ let reducers: { action: Extract ) => StateDefinition } = { - [ActionTypes.TogglePopover]: state => ({ + [ActionTypes.TogglePopover]: (state) => ({ ...state, popoverState: match(state.popoverState, { [PopoverStates.Open]: PopoverStates.Closed, @@ -217,7 +217,7 @@ export function Popover( ) // Handle outside click - useWindowEvent('mousedown', event => { + useWindowEvent('mousedown', (event) => { let target = event.target as HTMLElement if (popoverState !== PopoverStates.Open) return @@ -296,7 +296,7 @@ let Button = forwardRefWithAs(function Button, ref: Ref ) { - let [state, dispatch] = usePopoverContext([Popover.name, Button.name].join('.')) + let [state, dispatch] = usePopoverContext('Popover.Button') let internalButtonRef = useRef(null) let groupContext = usePopoverGroupContext() @@ -308,7 +308,7 @@ let Button = forwardRefWithAs(function Button dispatch({ type: ActionTypes.SetButton, button }) + isWithinPanel ? null : (button) => dispatch({ type: ActionTypes.SetButton, button }) ) let withinPanelButtonRef = useSyncRefs(internalButtonRef, ref) @@ -517,7 +517,7 @@ let Overlay = forwardRefWithAs(function Overlay< PropsForFeatures, ref: Ref ) { - let [{ popoverState }, dispatch] = usePopoverContext([Popover.name, Overlay.name].join('.')) + let [{ popoverState }, dispatch] = usePopoverContext('Popover.Overlay') let overlayRef = useSyncRefs(ref) let id = `headlessui-popover-overlay-${useId()}` @@ -539,9 +539,10 @@ let Overlay = forwardRefWithAs(function Overlay< [dispatch] ) - let slot = useMemo(() => ({ open: popoverState === PopoverStates.Open }), [ - popoverState, - ]) + let slot = useMemo( + () => ({ open: popoverState === PopoverStates.Open }), + [popoverState] + ) let propsWeControl = { ref: overlayRef, id, @@ -580,11 +581,11 @@ let Panel = forwardRefWithAs(function Panel(null) - let panelRef = useSyncRefs(internalPanelRef, ref, panel => { + let panelRef = useSyncRefs(internalPanelRef, ref, (panel) => { dispatch({ type: ActionTypes.SetPanel, panel }) }) @@ -639,7 +640,7 @@ let Panel = forwardRefWithAs(function Panel { + useWindowEvent('keydown', (event) => { if (state.popoverState !== PopoverStates.Open) return if (!internalPanelRef.current) return if (event.key !== Keys.Tab) return @@ -665,7 +666,7 @@ let Panel = forwardRefWithAs(function Panel !internalPanelRef.current?.contains(element)) // Ignore items in panel + .filter((element) => !internalPanelRef.current?.contains(element)) // Ignore items in panel // Try to focus the next element, however it could fail if we are in a // Portal that happens to be the very last one in the DOM. In that @@ -730,7 +731,7 @@ function Group( let unregisterPopover = useCallback( (registerbag: PopoverRegisterBag) => { - setPopovers(existing => { + setPopovers((existing) => { let idx = existing.indexOf(registerbag) if (idx !== -1) { let clone = existing.slice() @@ -745,7 +746,7 @@ function Group( let registerPopover = useCallback( (registerbag: PopoverRegisterBag) => { - setPopovers(existing => [...existing, registerbag]) + setPopovers((existing) => [...existing, registerbag]) return () => unregisterPopover(registerbag) }, [setPopovers, unregisterPopover] @@ -757,7 +758,7 @@ function Group( if (groupRef.current?.contains(element)) return true // Check if the focus is in one of the button or panel elements. This is important in case you are rendering inside a Portal. - return popovers.some(bag => { + return popovers.some((bag) => { return ( document.getElementById(bag.buttonId)?.contains(element) || document.getElementById(bag.panelId)?.contains(element) diff --git a/packages/@headlessui-react/src/components/portal/portal.test.tsx b/packages/@headlessui-react/src/components/portal/portal.test.tsx index eea7c4cb06..6b21ea7a5c 100644 --- a/packages/@headlessui-react/src/components/portal/portal.test.tsx +++ b/packages/@headlessui-react/src/components/portal/portal.test.tsx @@ -82,10 +82,10 @@ it('should cleanup the Portal root when the last Portal is unmounted', async () return (
- - @@ -151,21 +151,21 @@ it('should be possible to render multiple portals at the same time', async () => return (
- - - - diff --git a/packages/@headlessui-react/src/components/radio-group/radio-group.test.tsx b/packages/@headlessui-react/src/components/radio-group/radio-group.test.tsx index f9f7076c70..010602e64f 100644 --- a/packages/@headlessui-react/src/components/radio-group/radio-group.test.tsx +++ b/packages/@headlessui-react/src/components/radio-group/radio-group.test.tsx @@ -113,7 +113,7 @@ describe('Rendering', () => { return ( <> - + Pizza Delivery {showFirst && Pickup} @@ -145,7 +145,7 @@ describe('Rendering', () => { let [disabled, setDisabled] = useState(true) return ( <> - + Pizza Delivery Pickup @@ -208,7 +208,7 @@ describe('Rendering', () => { let [disabled, setDisabled] = useState(true) return ( <> - + Pizza Delivery Pickup @@ -745,7 +745,7 @@ describe('Keyboard interactions', () => { { + onChange={(v) => { setValue(v) changeFn(v) }} @@ -815,7 +815,7 @@ describe('Mouse interactions', () => { { + onChange={(v) => { setValue(v) changeFn(v) }} diff --git a/packages/@headlessui-react/src/components/radio-group/radio-group.tsx b/packages/@headlessui-react/src/components/radio-group/radio-group.tsx index b65bb64a8a..3c81a9e9a2 100644 --- a/packages/@headlessui-react/src/components/radio-group/radio-group.tsx +++ b/packages/@headlessui-react/src/components/radio-group/radio-group.tsx @@ -61,7 +61,7 @@ let reducers: { }, [ActionTypes.UnregisterOption](state, action) { let options = state.options.slice() - let idx = state.options.findIndex(radio => radio.id === action.id) + let idx = state.options.findIndex((radio) => radio.id === action.id) if (idx === -1) return state options.splice(idx, 1) return { ...state, options } @@ -123,23 +123,23 @@ export function RadioGroup< let firstOption = useMemo( () => - options.find(option => { + options.find((option) => { if (option.propsRef.current.disabled) return false return true }), [options] ) let containsCheckedOption = useMemo( - () => options.some(option => option.propsRef.current.value === value), + () => options.some((option) => option.propsRef.current.value === value), [options, value] ) let triggerChange = useCallback( - nextValue => { + (nextValue) => { if (disabled) return false if (nextValue === value) return false - let nextOption = options.find(option => option.propsRef.current.value === nextValue)?.propsRef - .current + let nextOption = options.find((option) => option.propsRef.current.value === nextValue) + ?.propsRef.current if (nextOption?.disabled) return false onChange(nextValue) @@ -166,8 +166,8 @@ export function RadioGroup< if (!container) return let all = options - .filter(option => option.propsRef.current.disabled === false) - .map(radio => radio.element.current) as HTMLElement[] + .filter((option) => option.propsRef.current.disabled === false) + .map((radio) => radio.element.current) as HTMLElement[] switch (event.key) { case Keys.ArrowLeft: @@ -180,7 +180,7 @@ export function RadioGroup< if (result === FocusResult.Success) { let activeOption = options.find( - option => option.element.current === document.activeElement + (option) => option.element.current === document.activeElement ) if (activeOption) triggerChange(activeOption.propsRef.current.value) } @@ -197,7 +197,7 @@ export function RadioGroup< if (result === FocusResult.Success) { let activeOption = options.find( - option => option.element.current === document.activeElement + (option) => option.element.current === document.activeElement ) if (activeOption) triggerChange(activeOption.propsRef.current.value) } @@ -210,7 +210,7 @@ export function RadioGroup< event.stopPropagation() let activeOption = options.find( - option => option.element.current === document.activeElement + (option) => option.element.current === document.activeElement ) if (activeOption) triggerChange(activeOption.propsRef.current.value) } @@ -322,14 +322,12 @@ function Option< firstOption, containsCheckedOption, value: radioGroupValue, - } = useRadioGroupContext([RadioGroup.name, Option.name].join('.')) + } = useRadioGroupContext('RadioGroup.Option') - useIsoMorphicEffect(() => registerOption({ id, element: optionRef, propsRef }), [ - id, - registerOption, - optionRef, - props, - ]) + useIsoMorphicEffect( + () => registerOption({ id, element: optionRef, propsRef }), + [id, registerOption, optionRef, props] + ) let handleClick = useCallback(() => { if (!change(value)) return diff --git a/packages/@headlessui-react/src/components/switch/switch.test.tsx b/packages/@headlessui-react/src/components/switch/switch.test.tsx index d566a48261..986664b79d 100644 --- a/packages/@headlessui-react/src/components/switch/switch.test.tsx +++ b/packages/@headlessui-react/src/components/switch/switch.test.tsx @@ -214,7 +214,7 @@ describe('Keyboard interactions', () => { return ( { + onChange={(value) => { setState(value) handleChange(value) }} @@ -297,7 +297,7 @@ describe('Mouse interactions', () => { return ( { + onChange={(value) => { setState(value) handleChange(value) }} @@ -331,7 +331,7 @@ describe('Mouse interactions', () => { { + onChange={(value) => { setState(value) handleChange(value) }} @@ -373,7 +373,7 @@ describe('Mouse interactions', () => { { + onChange={(value) => { setState(value) handleChange(value) }} diff --git a/packages/@headlessui-react/src/components/tabs/tabs.test.tsx b/packages/@headlessui-react/src/components/tabs/tabs.test.tsx index b58f138035..d824817405 100644 --- a/packages/@headlessui-react/src/components/tabs/tabs.test.tsx +++ b/packages/@headlessui-react/src/components/tabs/tabs.test.tsx @@ -81,7 +81,7 @@ describe('Rendering', () => { return ( <> - + Tab 1 @@ -118,7 +118,7 @@ describe('Rendering', () => { it('should expose the `selectedIndex` on the `Tab.Group` component', async () => { render( - {data => ( + {(data) => ( <>
{JSON.stringify(data)}
@@ -153,7 +153,7 @@ describe('Rendering', () => { render( - {data => ( + {(data) => ( <>
{JSON.stringify(data)}
Tab 1 @@ -192,7 +192,7 @@ describe('Rendering', () => {
- {data => ( + {(data) => ( <>
{JSON.stringify(data)}
Content 1 @@ -220,7 +220,7 @@ describe('Rendering', () => { - {data => ( + {(data) => ( <>
{JSON.stringify(data)}
Tab 1 @@ -228,7 +228,7 @@ describe('Rendering', () => { )}
- {data => ( + {(data) => ( <>
{JSON.stringify(data)}
Tab 2 @@ -236,7 +236,7 @@ describe('Rendering', () => { )}
- {data => ( + {(data) => ( <>
{JSON.stringify(data)}
Tab 3 @@ -287,7 +287,7 @@ describe('Rendering', () => { - {data => ( + {(data) => ( <>
{JSON.stringify(data)}
Content 1 @@ -295,7 +295,7 @@ describe('Rendering', () => { )}
- {data => ( + {(data) => ( <>
{JSON.stringify(data)}
Content 2 @@ -303,7 +303,7 @@ describe('Rendering', () => { )}
- {data => ( + {(data) => ( <>
{JSON.stringify(data)}
Content 3 @@ -514,7 +514,7 @@ describe('Rendering', () => { <> { + onChange={(value) => { setSelectedIndex(value) handleChange(value) }} @@ -533,7 +533,7 @@ describe('Rendering', () => { - + ) } diff --git a/packages/@headlessui-react/src/components/tabs/tabs.tsx b/packages/@headlessui-react/src/components/tabs/tabs.tsx index 5e057e55d5..4f8c000ecd 100644 --- a/packages/@headlessui-react/src/components/tabs/tabs.tsx +++ b/packages/@headlessui-react/src/components/tabs/tabs.tsx @@ -83,14 +83,14 @@ let reducers: { return { ...state, tabs: [...state.tabs, action.tab] } }, [ActionTypes.UnregisterTab](state, action) { - return { ...state, tabs: state.tabs.filter(tab => tab !== action.tab) } + return { ...state, tabs: state.tabs.filter((tab) => tab !== action.tab) } }, [ActionTypes.RegisterPanel](state, action) { if (state.panels.includes(action.panel)) return state return { ...state, panels: [...state.panels, action.panel] } }, [ActionTypes.UnregisterPanel](state, action) { - return { ...state, panels: state.panels.filter(panel => panel !== action.panel) } + return { ...state, panels: state.panels.filter((panel) => panel !== action.panel) } }, [ActionTypes.ForceRerender](state) { return { ...state } @@ -171,8 +171,8 @@ function Tabs( if (state.tabs.length <= 0) return if (selectedIndex === null && state.selectedIndex !== null) return - let tabs = state.tabs.map(tab => tab.current).filter(Boolean) as HTMLElement[] - let focusableTabs = tabs.filter(tab => !tab.hasAttribute('disabled')) + let tabs = state.tabs.map((tab) => tab.current).filter(Boolean) as HTMLElement[] + let focusableTabs = tabs.filter((tab) => !tab.hasAttribute('disabled')) let indexToSet = selectedIndex ?? defaultIndex @@ -194,7 +194,7 @@ function Tabs( let before = tabs.slice(0, indexToSet) let after = tabs.slice(indexToSet) - let next = [...after, ...before].find(tab => focusableTabs.includes(tab)) + let next = [...after, ...before].find((tab) => focusableTabs.includes(tab)) if (!next) return dispatch({ type: ActionTypes.SetSelectedIndex, index: tabs.indexOf(next) }) @@ -245,7 +245,7 @@ type ListPropsWeControl = 'role' | 'aria-orientation' function List( props: Props & {} ) { - let [{ selectedIndex, orientation }] = useTabsContext([Tab.name, List.name].join('.')) + let [{ selectedIndex, orientation }] = useTabsContext('Tab.List') let slot = { selectedIndex } let propsWeControl = { @@ -275,13 +275,11 @@ export function Tab( ) { let id = `headlessui-tabs-tab-${useId()}` - let [ - { selectedIndex, tabs, panels, orientation, activation }, - { dispatch, change }, - ] = useTabsContext(Tab.name) + let [{ selectedIndex, tabs, panels, orientation, activation }, { dispatch, change }] = + useTabsContext(Tab.name) let internalTabRef = useRef(null) - let tabRef = useSyncRefs(internalTabRef, element => { + let tabRef = useSyncRefs(internalTabRef, (element) => { if (!element) return dispatch({ type: ActionTypes.ForceRerender }) }) @@ -296,7 +294,7 @@ export function Tab( let handleKeyDown = useCallback( (event: ReactKeyboardEvent) => { - let list = tabs.map(tab => tab.current).filter(Boolean) as HTMLElement[] + let list = tabs.map((tab) => tab.current).filter(Boolean) as HTMLElement[] if (event.key === Keys.Space || event.key === Keys.Enter) { event.preventDefault() @@ -380,7 +378,7 @@ interface PanelsRenderPropArg { function Panels( props: Props ) { - let [{ selectedIndex }] = useTabsContext([Tab.name, Panels.name].join('.')) + let [{ selectedIndex }] = useTabsContext('Tab.Panels') let slot = useMemo(() => ({ selectedIndex }), [selectedIndex]) @@ -405,13 +403,11 @@ function Panel( props: Props & PropsForFeatures ) { - let [{ selectedIndex, tabs, panels }, { dispatch }] = useTabsContext( - [Tab.name, Panel.name].join('.') - ) + let [{ selectedIndex, tabs, panels }, { dispatch }] = useTabsContext('Tab.Panel') let id = `headlessui-tabs-panel-${useId()}` let internalPanelRef = useRef(null) - let panelRef = useSyncRefs(internalPanelRef, element => { + let panelRef = useSyncRefs(internalPanelRef, (element) => { if (!element) return dispatch({ type: ActionTypes.ForceRerender }) }) diff --git a/packages/@headlessui-react/src/components/transitions/transition.test.tsx b/packages/@headlessui-react/src/components/transitions/transition.test.tsx index 3a3aeb0f0c..8dfcc004c4 100644 --- a/packages/@headlessui-react/src/components/transitions/transition.test.tsx +++ b/packages/@headlessui-react/src/components/transitions/transition.test.tsx @@ -26,8 +26,6 @@ it( expect(() => { render( - // @ts-expect-error Disabling TS because it does require us to use a show prop. But non - // TypeScript projects won't benefit from this.
Children
@@ -445,7 +443,7 @@ describe('Transitions', () => { Hello! - @@ -488,14 +486,15 @@ describe('Transitions', () => { return ( <> - + Hello! - @@ -538,14 +537,15 @@ describe('Transitions', () => { return ( <> - + Hello! - @@ -591,7 +591,7 @@ describe('Transitions', () => { Hello! - @@ -642,7 +642,7 @@ describe('Transitions', () => { Hello! - @@ -696,7 +696,7 @@ describe('Transitions', () => { Hello! - @@ -757,7 +757,7 @@ describe('Transitions', () => { Hello! - @@ -843,7 +843,7 @@ describe('Transitions', () => { Hello! - @@ -943,7 +943,7 @@ describe('Transitions', () => { - @@ -1027,7 +1027,7 @@ describe('Transitions', () => { - @@ -1138,7 +1138,7 @@ describe('Events', () => { Hello! - diff --git a/packages/@headlessui-react/src/components/transitions/transition.tsx b/packages/@headlessui-react/src/components/transitions/transition.tsx index c3446f3caf..f3f8595273 100644 --- a/packages/@headlessui-react/src/components/transitions/transition.tsx +++ b/packages/@headlessui-react/src/components/transitions/transition.tsx @@ -28,9 +28,10 @@ import { useServerHandoffComplete } from '../../hooks/use-server-handoff-complet type ID = ReturnType function useSplitClasses(classes: string = '') { - return useMemo(() => classes.split(' ').filter(className => className.trim().length > 1), [ - classes, - ]) + return useMemo( + () => classes.split(' ').filter((className) => className.trim().length > 1), + [classes] + ) } interface TransitionContextValues { @@ -287,23 +288,37 @@ function TransitionChild { - isTransitioning.current = false - if (reason === Reason.Finished) events.current.afterEnter() - }) - : transition(node, leaveClasses, leaveFromClasses, leaveToClasses, enteredClasses, reason => { - isTransitioning.current = false - - if (reason !== Reason.Finished) return - - // When we don't have children anymore we can safely unregister from the parent and hide - // ourselves. - if (!hasChildren(nesting)) { - setState(TreeStates.Hidden) - unregister(id) - events.current.afterLeave() + ? transition( + node, + enterClasses, + enterFromClasses, + enterToClasses, + enteredClasses, + (reason) => { + isTransitioning.current = false + if (reason === Reason.Finished) events.current.afterEnter() + } + ) + : transition( + node, + leaveClasses, + leaveFromClasses, + leaveToClasses, + enteredClasses, + (reason) => { + isTransitioning.current = false + + if (reason !== Reason.Finished) return + + // When we don't have children anymore we can safely unregister from the parent and hide + // ourselves. + if (!hasChildren(nesting)) { + setState(TreeStates.Hidden) + unregister(id) + events.current.afterLeave() + } } - }) + ) }, [ events, id, @@ -359,7 +374,7 @@ export function Transition is used but it is missing a `show={true | false}` prop.') } diff --git a/packages/@headlessui-react/src/components/transitions/utils/transition.test.ts b/packages/@headlessui-react/src/components/transitions/utils/transition.test.ts index 9918716179..58cd91a0aa 100644 --- a/packages/@headlessui-react/src/components/transitions/utils/transition.test.ts +++ b/packages/@headlessui-react/src/components/transitions/utils/transition.test.ts @@ -17,7 +17,7 @@ it('should be possible to transition', async () => { d.add( reportChanges( () => document.body.innerHTML, - content => { + (content) => { snapshots.push({ content, recordedAt: process.hrtime.bigint(), @@ -26,11 +26,11 @@ it('should be possible to transition', async () => { ) ) - await new Promise(resolve => { + await new Promise((resolve) => { transition(element, ['enter'], ['enterFrom'], ['enterTo'], ['entered'], resolve) }) - await new Promise(resolve => d.nextFrame(resolve)) + await new Promise((resolve) => d.nextFrame(resolve)) // Initial render: expect(snapshots[0].content).toEqual('
') @@ -61,7 +61,7 @@ it('should wait the correct amount of time to finish a transition', async () => d.add( reportChanges( () => document.body.innerHTML, - content => { + (content) => { snapshots.push({ content, recordedAt: process.hrtime.bigint(), @@ -70,11 +70,11 @@ it('should wait the correct amount of time to finish a transition', async () => ) ) - let reason = await new Promise(resolve => { + let reason = await new Promise((resolve) => { transition(element, ['enter'], ['enterFrom'], ['enterTo'], ['entered'], resolve) }) - await new Promise(resolve => d.nextFrame(resolve)) + await new Promise((resolve) => d.nextFrame(resolve)) expect(reason).toBe(Reason.Finished) // Initial render: @@ -118,7 +118,7 @@ it('should keep the delay time into account', async () => { d.add( reportChanges( () => document.body.innerHTML, - content => { + (content) => { snapshots.push({ content, recordedAt: process.hrtime.bigint(), @@ -127,11 +127,11 @@ it('should keep the delay time into account', async () => { ) ) - let reason = await new Promise(resolve => { + let reason = await new Promise((resolve) => { transition(element, ['enter'], ['enterFrom'], ['enterTo'], ['entered'], resolve) }) - await new Promise(resolve => d.nextFrame(resolve)) + await new Promise((resolve) => d.nextFrame(resolve)) expect(reason).toBe(Reason.Finished) let estimatedDuration = Number( @@ -161,7 +161,7 @@ it('should be possible to cancel a transition at any time', async () => { d.add( reportChanges( () => document.body.innerHTML, - content => { + (content) => { let recordedAt = process.hrtime.bigint() let total = snapshots.length @@ -178,16 +178,16 @@ it('should be possible to cancel a transition at any time', async () => { expect.assertions(2) // Setup the transition - let cancel = transition(element, ['enter'], ['enterFrom'], ['enterTo'], ['entered'], reason => { + let cancel = transition(element, ['enter'], ['enterFrom'], ['enterTo'], ['entered'], (reason) => { expect(reason).toBe(Reason.Cancelled) }) // Wait for a bit - await new Promise(resolve => setTimeout(resolve, 20)) + await new Promise((resolve) => setTimeout(resolve, 20)) // Cancel the transition cancel() - await new Promise(resolve => d.nextFrame(resolve)) + await new Promise((resolve) => d.nextFrame(resolve)) - expect(snapshots.map(snapshot => snapshot.content).join('\n')).not.toContain('enterTo') + expect(snapshots.map((snapshot) => snapshot.content).join('\n')).not.toContain('enterTo') }) diff --git a/packages/@headlessui-react/src/components/transitions/utils/transition.ts b/packages/@headlessui-react/src/components/transitions/utils/transition.ts index 6201eae0cb..01d657c2c7 100644 --- a/packages/@headlessui-react/src/components/transitions/utils/transition.ts +++ b/packages/@headlessui-react/src/components/transitions/utils/transition.ts @@ -22,13 +22,13 @@ function waitForTransition(node: HTMLElement, done: (reason: Reason) => void) { // Safari returns a comma separated list of values, so let's sort them and take the highest value. let { transitionDuration, transitionDelay } = getComputedStyle(node) - let [durationMs, delaysMs] = [transitionDuration, transitionDelay].map(value => { + let [durationMs, delaysMs] = [transitionDuration, transitionDelay].map((value) => { let [resolvedValue = 0] = value .split(',') // Remove falsy we can't work with .filter(Boolean) // Values are returned as `0.3s` or `75ms` - .map(v => (v.includes('ms') ? parseFloat(v) : parseFloat(v) * 1000)) + .map((v) => (v.includes('ms') ? parseFloat(v) : parseFloat(v) * 1000)) .sort((a, z) => z - a) return resolvedValue @@ -74,7 +74,7 @@ export function transition( addClasses(node, ...to) d.add( - waitForTransition(node, reason => { + waitForTransition(node, (reason) => { removeClasses(node, ...to, ...base) addClasses(node, ...entered) return _done(reason) diff --git a/packages/@headlessui-react/src/hooks/use-flags.ts b/packages/@headlessui-react/src/hooks/use-flags.ts index 1cb5448e25..7096093a3d 100644 --- a/packages/@headlessui-react/src/hooks/use-flags.ts +++ b/packages/@headlessui-react/src/hooks/use-flags.ts @@ -3,10 +3,10 @@ import { useState, useCallback } from 'react' export function useFlags(initialFlags = 0) { let [flags, setFlags] = useState(initialFlags) - let addFlag = useCallback((flag: number) => setFlags(flags => flags | flag), [setFlags]) + let addFlag = useCallback((flag: number) => setFlags((flags) => flags | flag), [setFlags]) let hasFlag = useCallback((flag: number) => Boolean(flags & flag), [flags]) - let removeFlag = useCallback((flag: number) => setFlags(flags => flags & ~flag), [setFlags]) - let toggleFlag = useCallback((flag: number) => setFlags(flags => flags ^ flag), [setFlags]) + let removeFlag = useCallback((flag: number) => setFlags((flags) => flags & ~flag), [setFlags]) + let toggleFlag = useCallback((flag: number) => setFlags((flags) => flags ^ flag), [setFlags]) return { addFlag, hasFlag, removeFlag, toggleFlag } } diff --git a/packages/@headlessui-react/src/hooks/use-focus-trap.ts b/packages/@headlessui-react/src/hooks/use-focus-trap.ts index 68b4c282ac..fd99685552 100644 --- a/packages/@headlessui-react/src/hooks/use-focus-trap.ts +++ b/packages/@headlessui-react/src/hooks/use-focus-trap.ts @@ -97,7 +97,7 @@ export function useFocusTrap( }, [container, initialFocus, featuresInitialFocus]) // Handle `Tab` & `Shift+Tab` keyboard events - useWindowEvent('keydown', event => { + useWindowEvent('keydown', (event) => { if (!(features & Features.TabLock)) return if (!container.current) return @@ -118,7 +118,7 @@ export function useFocusTrap( // Prevent programmatically escaping the container useWindowEvent( 'focus', - event => { + (event) => { if (!(features & Features.FocusLock)) return let allContainers = new Set(containers?.current) diff --git a/packages/@headlessui-react/src/hooks/use-inert-others.test.tsx b/packages/@headlessui-react/src/hooks/use-inert-others.test.tsx index 15b6f0274e..f03c3c957a 100644 --- a/packages/@headlessui-react/src/hooks/use-inert-others.test.tsx +++ b/packages/@headlessui-react/src/hooks/use-inert-others.test.tsx @@ -17,7 +17,7 @@ it('should be possible to inert other elements', async () => { return (
- +
) } @@ -61,7 +61,7 @@ it('should restore inert elements, when all useInertOthers calls are disabled', return (
- +
) } @@ -136,7 +136,7 @@ it('should restore inert elements, when all useInertOthers calls are disabled (i return (
- +
) @@ -221,7 +221,7 @@ it('should handle inert others correctly when 2 useInertOthers are used in a sha return (
- +
) } diff --git a/packages/@headlessui-react/src/hooks/use-inert-others.ts b/packages/@headlessui-react/src/hooks/use-inert-others.ts index 49537b83e8..a90f87686b 100644 --- a/packages/@headlessui-react/src/hooks/use-inert-others.ts +++ b/packages/@headlessui-react/src/hooks/use-inert-others.ts @@ -42,7 +42,7 @@ export function useInertOthers( } // Collect direct children of the body - document.querySelectorAll('body > *').forEach(child => { + document.querySelectorAll('body > *').forEach((child) => { if (!(child instanceof HTMLElement)) return // Skip non-HTMLElements // Skip the interactables, and the parents of the interactables @@ -71,7 +71,7 @@ export function useInertOthers( // will become inert as well. if (interactables.size > 0) { // Collect direct children of the body - document.querySelectorAll('body > *').forEach(child => { + document.querySelectorAll('body > *').forEach((child) => { if (!(child instanceof HTMLElement)) return // Skip non-HTMLElements // Skip already inert parents diff --git a/packages/@headlessui-react/src/hooks/use-tree-walker.ts b/packages/@headlessui-react/src/hooks/use-tree-walker.ts index 4dedafb7c1..dfe0123830 100644 --- a/packages/@headlessui-react/src/hooks/use-tree-walker.ts +++ b/packages/@headlessui-react/src/hooks/use-tree-walker.ts @@ -35,6 +35,7 @@ export function useTreeWalker({ let walk = walkRef.current let acceptNode = Object.assign((node: HTMLElement) => accept(node), { acceptNode: accept }) + // @ts-expect-error This `false` is a simple small fix for older browsers let walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, acceptNode, false) while (walker.nextNode()) walk(walker.currentNode as HTMLElement) diff --git a/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts b/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts index f478642b9a..d2ef0479ed 100644 --- a/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts +++ b/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts @@ -1256,7 +1256,7 @@ export function assertLabelValue(element: HTMLElement | null, value: string) { if (element.hasAttribute('aria-labelledby')) { let ids = element.getAttribute('aria-labelledby')!.split(' ') - expect(ids.map(id => document.getElementById(id)?.textContent).join(' ')).toEqual(value) + expect(ids.map((id) => document.getElementById(id)?.textContent).join(' ')).toEqual(value) return } @@ -1612,7 +1612,7 @@ export function assertTabs( expect(list).toHaveAttribute('aria-orientation', orientation) let activeTab = Array.from(list.querySelectorAll('[id^="headlessui-tabs-tab-"]'))[active] - let activePanel = panels.find(panel => panel.id === activeTab.getAttribute('aria-controls')) + let activePanel = panels.find((panel) => panel.id === activeTab.getAttribute('aria-controls')) for (let tab of tabs) { expect(tab).toHaveAttribute('id') diff --git a/packages/@headlessui-react/src/test-utils/execute-timeline.ts b/packages/@headlessui-react/src/test-utils/execute-timeline.ts index 8b032c1721..0c308a7321 100644 --- a/packages/@headlessui-react/src/test-utils/execute-timeline.ts +++ b/packages/@headlessui-react/src/test-utils/execute-timeline.ts @@ -17,7 +17,7 @@ function redentSnapshot(input: string) { return input .split('\n') - .map(line => + .map((line) => line.trim() === '---' ? line : line.replace(replacer, (_, sign, rest) => `${sign} ${rest}`) ) .join('\n') @@ -69,13 +69,13 @@ export async function executeTimeline( .reduce((total, current) => total + current, 0) // Changes happen in the next frame - await new Promise(resolve => d.nextFrame(resolve)) + await new Promise((resolve) => d.nextFrame(resolve)) // We wait for the amount of the duration - await new Promise(resolve => d.setTimeout(resolve, totalDuration)) + await new Promise((resolve) => d.setTimeout(resolve, totalDuration)) // We wait an additional next frame so that we know that we are done - await new Promise(resolve => d.nextFrame(resolve)) + await new Promise((resolve) => d.nextFrame(resolve)) }, Promise.resolve()) if (snapshots.length <= 0) { @@ -127,7 +127,7 @@ export async function executeTimeline( .replace(/Snapshot Diff:\n/g, '') ) .split('\n') - .map(line => ` ${line}`) + .map((line) => ` ${line}`) .join('\n')}` }) .filter(Boolean) diff --git a/packages/@headlessui-react/src/test-utils/interactions.test.tsx b/packages/@headlessui-react/src/test-utils/interactions.test.tsx index 0e6925fc66..e4701f1556 100644 --- a/packages/@headlessui-react/src/test-utils/interactions.test.tsx +++ b/packages/@headlessui-react/src/test-utils/interactions.test.tsx @@ -175,7 +175,7 @@ describe('Keyboard', () => { await type([key(input)]) - let expected = result.map(e => event(e)) + let expected = result.map((e) => event(e)) expect(fired.length).toEqual(result.length) diff --git a/packages/@headlessui-react/src/test-utils/interactions.ts b/packages/@headlessui-react/src/test-utils/interactions.ts index b65ece9968..71fef368c7 100644 --- a/packages/@headlessui-react/src/test-utils/interactions.ts +++ b/packages/@headlessui-react/src/test-utils/interactions.ts @@ -36,7 +36,7 @@ export function shift(event: Partial) { } export function word(input: string): Partial[] { - let result = input.split('').map(key => ({ key })) + let result = input.split('').map((key) => ({ key })) d.enqueue(() => { let element = document.activeElement @@ -152,7 +152,7 @@ export async function type(events: Partial[], element = document. let actions = order[event.key!] ?? order[Default as any] for (let action of actions) { let checks = action.name.split('And') - if (checks.some(check => skip.has(check))) continue + if (checks.some((check) => skip.has(check))) continue let result = action(element, { type: action.name, @@ -344,8 +344,8 @@ let focusableSelector = [ ? // TODO: Remove this once JSDOM fixes the issue where an element that is // "hidden" can be the document.activeElement, because this is not possible // in real browsers. - selector => `${selector}:not([tabindex='-1']):not([style*='display: none'])` - : selector => `${selector}:not([tabindex='-1'])` + (selector) => `${selector}:not([tabindex='-1']):not([style*='display: none'])` + : (selector) => `${selector}:not([tabindex='-1'])` ) .join(',') diff --git a/packages/@headlessui-react/src/test-utils/suppress-console-logs.ts b/packages/@headlessui-react/src/test-utils/suppress-console-logs.ts index da8e6d1216..0de4a0b4e3 100644 --- a/packages/@headlessui-react/src/test-utils/suppress-console-logs.ts +++ b/packages/@headlessui-react/src/test-utils/suppress-console-logs.ts @@ -5,10 +5,10 @@ type FunctionPropertyNames = { export function suppressConsoleLogs( cb: (...args: T) => unknown, - type: FunctionPropertyNames = 'error' + type: FunctionPropertyNames = 'error' ) { return (...args: T) => { - let spy = jest.spyOn(global.console, type).mockImplementation(jest.fn()) + let spy = jest.spyOn(globalThis.console, type).mockImplementation(jest.fn()) return new Promise((resolve, reject) => { Promise.resolve(cb(...args)).then(resolve, reject) diff --git a/packages/@headlessui-react/src/utils/calculate-active-index.ts b/packages/@headlessui-react/src/utils/calculate-active-index.ts index cc296a9068..16ed66ffb0 100644 --- a/packages/@headlessui-react/src/utils/calculate-active-index.ts +++ b/packages/@headlessui-react/src/utils/calculate-active-index.ts @@ -40,7 +40,7 @@ export function calculateActiveIndex( let nextActiveIndex = (() => { switch (action.focus) { case Focus.First: - return items.findIndex(item => !resolvers.resolveDisabled(item)) + return items.findIndex((item) => !resolvers.resolveDisabled(item)) case Focus.Previous: { let idx = items @@ -64,13 +64,13 @@ export function calculateActiveIndex( let idx = items .slice() .reverse() - .findIndex(item => !resolvers.resolveDisabled(item)) + .findIndex((item) => !resolvers.resolveDisabled(item)) if (idx === -1) return idx return items.length - 1 - idx } case Focus.Specific: - return items.findIndex(item => resolvers.resolveId(item) === action.id) + return items.findIndex((item) => resolvers.resolveId(item) === action.id) case Focus.Nothing: return null diff --git a/packages/@headlessui-react/src/utils/focus-management.ts b/packages/@headlessui-react/src/utils/focus-management.ts index f48158b1da..703e87d5c0 100644 --- a/packages/@headlessui-react/src/utils/focus-management.ts +++ b/packages/@headlessui-react/src/utils/focus-management.ts @@ -18,8 +18,8 @@ let focusableSelector = [ ? // TODO: Remove this once JSDOM fixes the issue where an element that is // "hidden" can be the document.activeElement, because this is not possible // in real browsers. - selector => `${selector}:not([tabindex='-1']):not([style*='display: none'])` - : selector => `${selector}:not([tabindex='-1'])` + (selector) => `${selector}:not([tabindex='-1']):not([style*='display: none'])` + : (selector) => `${selector}:not([tabindex='-1'])` ) .join(',') diff --git a/packages/@headlessui-react/src/utils/match.ts b/packages/@headlessui-react/src/utils/match.ts index 80496d12a2..c4becd32cd 100644 --- a/packages/@headlessui-react/src/utils/match.ts +++ b/packages/@headlessui-react/src/utils/match.ts @@ -12,7 +12,7 @@ export function match `"${key}"`) + .map((key) => `"${key}"`) .join(', ')}.` ) if (Error.captureStackTrace) Error.captureStackTrace(error, match) diff --git a/packages/@headlessui-react/src/utils/render.test.tsx b/packages/@headlessui-react/src/utils/render.test.tsx index cef084a2cd..9c743c4520 100644 --- a/packages/@headlessui-react/src/utils/render.test.tsx +++ b/packages/@headlessui-react/src/utils/render.test.tsx @@ -45,7 +45,7 @@ describe('Default functionality', () => { testRender( - {data => { + {(data) => { expect(data).toBe(slot) return Contents diff --git a/packages/@headlessui-react/src/utils/render.ts b/packages/@headlessui-react/src/utils/render.ts index 1fe004787b..b353e13923 100644 --- a/packages/@headlessui-react/src/utils/render.ts +++ b/packages/@headlessui-react/src/utils/render.ts @@ -102,10 +102,12 @@ function _render( tag: ElementType, name: string ) { - let { as: Component = tag, children, refName = 'ref', ...passThroughProps } = omit(props, [ - 'unmount', - 'static', - ]) + let { + as: Component = tag, + children, + refName = 'ref', + ...passThroughProps + } = omit(props, ['unmount', 'static']) // This allows us to use `` let refRelatedProps = props.ref !== undefined ? { [refName]: props.ref } : {} @@ -132,7 +134,7 @@ function _render( `The current component <${name} /> is rendering a "Fragment".`, `However we need to passthrough the following props:`, Object.keys(passThroughProps) - .map(line => ` - ${line}`) + .map((line) => ` - ${line}`) .join('\n'), '', 'You can apply a few solutions:', @@ -140,7 +142,7 @@ function _render( 'Add an `as="..."` prop, to ensure that we render an actual element instead of a "Fragment".', 'Render a single element as the child so that we can forward the props onto that element.', ] - .map(line => ` - ${line}`) + .map((line) => ` - ${line}`) .join('\n'), ].join('\n') ) @@ -211,7 +213,7 @@ function mergeEventFunctions( export function forwardRefWithAs( component: T ): T & { displayName: string } { - return Object.assign(forwardRef((component as unknown) as any) as any, { + return Object.assign(forwardRef(component as unknown as any) as any, { displayName: component.displayName ?? component.name, }) } diff --git a/packages/@headlessui-react/tsconfig.json b/packages/@headlessui-react/tsconfig.json index cac92c1223..5fb92fde38 100644 --- a/packages/@headlessui-react/tsconfig.json +++ b/packages/@headlessui-react/tsconfig.json @@ -21,13 +21,12 @@ }, "jsx": "preserve", "esModuleInterop": true, - "target": "es5", + "target": "ESNext", "allowJs": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, - "noEmit": true, "resolveJsonModule": true, "isolatedModules": true }, - "exclude": ["node_modules"] + "exclude": ["node_modules", "**/*.test.tsx?"] } diff --git a/packages/@headlessui-react/tsconfig.tsdx.json b/packages/@headlessui-react/tsconfig.tsdx.json deleted file mode 100644 index 1f5d11b1cc..0000000000 --- a/packages/@headlessui-react/tsconfig.tsdx.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "./tsconfig.json", - - "compilerOptions": { - "jsx": "react" - } -} diff --git a/packages/@headlessui-react/tsdx.config.js b/packages/@headlessui-react/tsdx.config.js deleted file mode 100644 index 7ee7a38af3..0000000000 --- a/packages/@headlessui-react/tsdx.config.js +++ /dev/null @@ -1,10 +0,0 @@ -module.exports = { - rollup(config, opts) { - if (opts.format === 'esm') { - config = { ...config, preserveModules: true } - config.output = { ...config.output, dir: 'dist/', entryFileNames: '[name].esm.js' } - delete config.output.file - } - return config - }, -} diff --git a/packages/@headlessui-react/types/jest.d.ts b/packages/@headlessui-react/types/jest.d.ts new file mode 100644 index 0000000000..61902a8f06 --- /dev/null +++ b/packages/@headlessui-react/types/jest.d.ts @@ -0,0 +1,9 @@ +export {} + +declare global { + namespace jest { + interface Matchers { + toBeWithinRenderFrame(actual: number): R + } + } +} diff --git a/packages/@headlessui-vue/.eslintrc.js b/packages/@headlessui-vue/.eslintrc.js deleted file mode 100644 index 48cefd8c41..0000000000 --- a/packages/@headlessui-vue/.eslintrc.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - rules: { - 'react-hooks/rules-of-hooks': 'off', - 'react-hooks/exhaustive-deps': 'off', - }, -} diff --git a/packages/@headlessui-vue/build/index.js b/packages/@headlessui-vue/build/index.js new file mode 100644 index 0000000000..473f46702e --- /dev/null +++ b/packages/@headlessui-vue/build/index.js @@ -0,0 +1,7 @@ +'use strict' + +if (process.env.NODE_ENV === 'production') { + module.exports = require('./headlessui.prod.cjs.js') +} else { + module.exports = require('./headlessui.dev.cjs.js') +} diff --git a/packages/@headlessui-vue/package.json b/packages/@headlessui-vue/package.json index 2f99f390d7..f6c49d682d 100644 --- a/packages/@headlessui-vue/package.json +++ b/packages/@headlessui-vue/package.json @@ -4,12 +4,21 @@ "description": "A set of completely unstyled, fully accessible UI components for Vue 3, designed to integrate beautifully with Tailwind CSS.", "main": "dist/index.js", "typings": "dist/index.d.ts", - "module": "dist/index.esm.js", + "module": "dist/headlessui.esm.js", "license": "MIT", "files": [ "README.md", "dist" ], + "exports": { + ".": { + "import": { + "default": "./dist/headlessui.esm.js" + }, + "require": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, "sideEffects": false, "engines": { "node": ">=10" @@ -27,14 +36,16 @@ "build": "../../scripts/build.sh", "watch": "../../scripts/watch.sh", "test": "../../scripts/test.sh", - "lint": "../../scripts/lint.sh" + "lint": "../../scripts/lint.sh", + "playground": "yarn workspace playground-vue dev", + "clean": "rimraf ./dist" }, "peerDependencies": { "vue": "^3.0.0" }, "devDependencies": { - "@testing-library/vue": "^5.1.0", - "@vue/test-utils": "^2.0.0-beta.7", - "vue": "3.0.7" + "@testing-library/vue": "^5.8.2", + "@vue/test-utils": "^2.0.0-rc.18", + "vue": "^3.2.27" } } diff --git a/packages/@headlessui-vue/postcss.config.js b/packages/@headlessui-vue/postcss.config.js deleted file mode 100644 index 8db70a9528..0000000000 --- a/packages/@headlessui-vue/postcss.config.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = require('../../postcss.config.js') diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.test.tsx b/packages/@headlessui-vue/src/components/combobox/combobox.test.tsx index fbe2047a64..7e67c93c84 100644 --- a/packages/@headlessui-vue/src/components/combobox/combobox.test.tsx +++ b/packages/@headlessui-vue/src/components/combobox/combobox.test.tsx @@ -66,7 +66,7 @@ beforeAll(() => { afterAll(() => jest.restoreAllMocks()) function nextFrame() { - return new Promise(resolve => { + return new Promise((resolve) => { requestAnimationFrame(() => { requestAnimationFrame(() => { resolve() @@ -95,9 +95,9 @@ function renderTemplate(input: string | Partial) { return render( defineComponent( - (Object.assign({}, input, { + Object.assign({}, input, { components: { ...defaultComponents, ...input.components }, - }) as unknown) as DefineComponent + }) as Parameters[0] ) ) } @@ -491,9 +491,7 @@ describe('Rendering', () => { template: html` - - Trigger - + Trigger `, setup: () => ({ value: ref(null) }), @@ -506,16 +504,14 @@ describe('Rendering', () => { 'should set the `type` to "button" when using the `as` prop which resolves to a "button"', suppressConsoleLogs(async () => { let CustomButton = defineComponent({ - setup: props => () => h('button', { ...props }), + setup: (props) => () => h('button', { ...props }), }) renderTemplate({ template: html` - - Trigger - + Trigger `, setup: () => ({ @@ -535,9 +531,7 @@ describe('Rendering', () => { template: html` - - Trigger - + Trigger `, setup: () => ({ value: ref(null) }), @@ -550,16 +544,14 @@ describe('Rendering', () => { 'should not set the `type` to "button" when using the `as` prop which resolves to a "div"', suppressConsoleLogs(async () => { let CustomButton = defineComponent({ - setup: props => () => h('div', props), + setup: (props) => () => h('div', props), }) renderTemplate({ template: html` - - Trigger - + Trigger `, setup: () => ({ @@ -765,15 +757,9 @@ describe('Rendering composition', () => { Trigger - - Option A - - - Option B - - - Option C - + Option A + Option B + Option C `, @@ -790,7 +776,7 @@ describe('Rendering composition', () => { await click(getComboboxButton()) // Verify options are buttons now - getComboboxOptions().forEach(option => assertComboboxOption(option, { tag: 'button' })) + getComboboxOptions().forEach((option) => assertComboboxOption(option, { tag: 'button' })) }) ) }) @@ -823,9 +809,7 @@ describe('Composition', () => { Trigger - - {{JSON.stringify(data)}} - + {{JSON.stringify(data)}} `, @@ -854,9 +838,7 @@ describe('Composition', () => { Trigger - - {{JSON.stringify(data)}} - + {{JSON.stringify(data)}} `, @@ -966,7 +948,7 @@ describe('Keyboard interactions', () => { // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertComboboxOption(option, { selected: false })) + options.forEach((option) => assertComboboxOption(option, { selected: false })) assertNoActiveComboboxOption() assertNoSelectedComboboxOption() @@ -1274,7 +1256,7 @@ describe('Keyboard interactions', () => { // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertComboboxOption(option)) + options.forEach((option) => assertComboboxOption(option)) assertNoActiveComboboxOption() }) ) @@ -1408,15 +1390,9 @@ describe('Keyboard interactions', () => { Trigger - - Option A - - - Option B - - - Option C - + Option A + Option B + Option C `, @@ -1533,7 +1509,7 @@ describe('Keyboard interactions', () => { // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertComboboxOption(option)) + options.forEach((option) => assertComboboxOption(option)) // Verify that the first combobox option is active assertNoActiveComboboxOption() @@ -1701,7 +1677,7 @@ describe('Keyboard interactions', () => { // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertComboboxOption(option)) + options.forEach((option) => assertComboboxOption(option)) // Verify that the first combobox option is active assertNoActiveComboboxOption() @@ -1869,7 +1845,7 @@ describe('Keyboard interactions', () => { // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertComboboxOption(option)) + options.forEach((option) => assertComboboxOption(option)) // ! ALERT: The LAST option should now be active assertActiveComboboxOption(options[2]) @@ -2002,12 +1978,8 @@ describe('Keyboard interactions', () => { Trigger Option A - - Option B - - - Option C - + Option B + Option C `, @@ -2029,7 +2001,7 @@ describe('Keyboard interactions', () => { // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertComboboxOption(option)) + options.forEach((option) => assertComboboxOption(option)) assertActiveComboboxOption(options[0]) }) ) @@ -2079,7 +2051,7 @@ describe('Keyboard interactions', () => { // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertComboboxOption(option)) + options.forEach((option) => assertComboboxOption(option)) // ! ALERT: The LAST option should now be active assertActiveComboboxOption(options[2]) @@ -2213,12 +2185,8 @@ describe('Keyboard interactions', () => { Trigger Option A - - Option B - - - Option C - + Option B + Option C `, @@ -2240,7 +2208,7 @@ describe('Keyboard interactions', () => { // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertComboboxOption(option)) + options.forEach((option) => assertComboboxOption(option)) assertActiveComboboxOption(options[0]) }) ) @@ -2496,7 +2464,7 @@ describe('Keyboard interactions', () => { // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertComboboxOption(option)) + options.forEach((option) => assertComboboxOption(option)) // Verify that the first combobox option is active assertNoActiveComboboxOption() @@ -2649,7 +2617,7 @@ describe('Keyboard interactions', () => { // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertComboboxOption(option)) + options.forEach((option) => assertComboboxOption(option)) assertNoActiveComboboxOption() // We should be able to go down once @@ -2679,9 +2647,7 @@ describe('Keyboard interactions', () => { Trigger - - Option A - + Option A Option B Option C @@ -2702,7 +2668,7 @@ describe('Keyboard interactions', () => { // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertComboboxOption(option)) + options.forEach((option) => assertComboboxOption(option)) assertNoActiveComboboxOption() // We should be able to go down once @@ -2720,12 +2686,8 @@ describe('Keyboard interactions', () => { Trigger - - Option A - - - Option B - + Option A + Option B Option C @@ -2745,7 +2707,7 @@ describe('Keyboard interactions', () => { // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertComboboxOption(option)) + options.forEach((option) => assertComboboxOption(option)) assertNoActiveComboboxOption() // Open combobox @@ -2799,7 +2761,7 @@ describe('Keyboard interactions', () => { // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertComboboxOption(option)) + options.forEach((option) => assertComboboxOption(option)) // Verify that the first combobox option is active assertNoActiveComboboxOption() @@ -2968,7 +2930,7 @@ describe('Keyboard interactions', () => { // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertComboboxOption(option)) + options.forEach((option) => assertComboboxOption(option)) assertNoActiveComboboxOption() // We should be able to go down once @@ -2998,9 +2960,7 @@ describe('Keyboard interactions', () => { Trigger - - Option A - + Option A Option B Option C @@ -3021,7 +2981,7 @@ describe('Keyboard interactions', () => { // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertComboboxOption(option)) + options.forEach((option) => assertComboboxOption(option)) assertNoActiveComboboxOption() // We should be able to go down once @@ -3039,12 +2999,8 @@ describe('Keyboard interactions', () => { Trigger - - Option A - - - Option B - + Option A + Option B Option C @@ -3064,7 +3020,7 @@ describe('Keyboard interactions', () => { // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertComboboxOption(option)) + options.forEach((option) => assertComboboxOption(option)) assertNoActiveComboboxOption() // Open combobox @@ -3117,7 +3073,7 @@ describe('Keyboard interactions', () => { // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertComboboxOption(option)) + options.forEach((option) => assertComboboxOption(option)) // ! ALERT: The LAST option should now be active assertActiveComboboxOption(options[2]) @@ -3250,12 +3206,8 @@ describe('Keyboard interactions', () => { Trigger Option A - - Option B - - - Option C - + Option B + Option C `, @@ -3277,7 +3229,7 @@ describe('Keyboard interactions', () => { // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertComboboxOption(option)) + options.forEach((option) => assertComboboxOption(option)) assertActiveComboboxOption(options[0]) }) ) @@ -3291,12 +3243,8 @@ describe('Keyboard interactions', () => { Trigger - - Option A - - - Option B - + Option A + Option B Option C @@ -3316,7 +3264,7 @@ describe('Keyboard interactions', () => { // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertComboboxOption(option)) + options.forEach((option) => assertComboboxOption(option)) assertNoActiveComboboxOption() // Going up or down should select the single available option @@ -3374,7 +3322,7 @@ describe('Keyboard interactions', () => { // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertComboboxOption(option)) + options.forEach((option) => assertComboboxOption(option)) assertActiveComboboxOption(options[2]) // We should be able to go down once @@ -3436,7 +3384,7 @@ describe('Keyboard interactions', () => { // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertComboboxOption(option)) + options.forEach((option) => assertComboboxOption(option)) // ! ALERT: The LAST option should now be active assertActiveComboboxOption(options[2]) @@ -3570,12 +3518,8 @@ describe('Keyboard interactions', () => { Trigger Option A - - Option B - - - Option C - + Option B + Option C `, @@ -3597,7 +3541,7 @@ describe('Keyboard interactions', () => { // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertComboboxOption(option)) + options.forEach((option) => assertComboboxOption(option)) assertActiveComboboxOption(options[0]) }) ) @@ -3647,12 +3591,8 @@ describe('Keyboard interactions', () => { Option A Option B - - Option C - - - Option D - + Option C + Option D `, @@ -3683,15 +3623,9 @@ describe('Keyboard interactions', () => { Trigger Option A - - Option B - - - Option C - - - Option D - + Option B + Option C + Option D `, @@ -3721,18 +3655,10 @@ describe('Keyboard interactions', () => { Trigger - - Option A - - - Option B - - - Option C - - - Option D - + Option A + Option B + Option C + Option D `, @@ -3797,12 +3723,8 @@ describe('Keyboard interactions', () => { Option A Option B - - Option C - - - Option D - + Option C + Option D `, @@ -3836,15 +3758,9 @@ describe('Keyboard interactions', () => { Trigger Option A - - Option B - - - Option C - - - Option D - + Option B + Option C + Option D `, @@ -3874,18 +3790,10 @@ describe('Keyboard interactions', () => { Trigger - - Option A - - - Option B - - - Option C - - - Option D - + Option A + Option B + Option C + Option D `, @@ -3951,12 +3859,8 @@ describe('Keyboard interactions', () => { Trigger - - Option A - - - Option B - + Option A + Option B Option C Option D @@ -3990,15 +3894,9 @@ describe('Keyboard interactions', () => { Trigger - - Option A - - - Option B - - - Option C - + Option A + Option B + Option C Option D @@ -4029,18 +3927,10 @@ describe('Keyboard interactions', () => { Trigger - - Option A - - - Option B - - - Option C - - - Option D - + Option A + Option B + Option C + Option D `, @@ -4106,12 +3996,8 @@ describe('Keyboard interactions', () => { Trigger - - Option A - - - Option B - + Option A + Option B Option C Option D @@ -4145,15 +4031,9 @@ describe('Keyboard interactions', () => { Trigger - - Option A - - - Option B - - - Option C - + Option A + Option B + Option C Option D @@ -4184,18 +4064,10 @@ describe('Keyboard interactions', () => { Trigger - - Option A - - - Option B - - - Option C - - - Option D - + Option A + Option B + Option C + Option D `, @@ -4250,7 +4122,7 @@ describe('Keyboard interactions', () => { let filteredPeople = computed(() => { return query.value === '' ? props.people - : props.people.filter(person => + : props.people.filter((person) => person.name.toLowerCase().includes(query.value.toLowerCase()) ) }) @@ -4586,7 +4458,7 @@ describe('Mouse interactions', () => { // Verify we have combobox options let options = getComboboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertComboboxOption(option)) + options.forEach((option) => assertComboboxOption(option)) }) ) @@ -5036,9 +4908,7 @@ describe('Mouse interactions', () => { Trigger alice - - bob - + bob charlie @@ -5066,9 +4936,7 @@ describe('Mouse interactions', () => { Trigger alice - - bob - + bob charlie @@ -5145,9 +5013,7 @@ describe('Mouse interactions', () => { Trigger alice - - bob - + bob charlie @@ -5227,9 +5093,7 @@ describe('Mouse interactions', () => { Trigger alice - - bob - + bob charlie @@ -5309,9 +5173,7 @@ describe('Mouse interactions', () => { Trigger alice - - bob - + bob charlie diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.ts b/packages/@headlessui-vue/src/components/combobox/combobox.ts index 784ee1cb43..4bb3d67496 100644 --- a/packages/@headlessui-vue/src/components/combobox/combobox.ts +++ b/packages/@headlessui-vue/src/components/combobox/combobox.ts @@ -131,8 +131,8 @@ export let Combobox = defineComponent({ { resolveItems: () => options.value, resolveActiveIndex: () => activeOptionIndex.value, - resolveId: option => option.id, - resolveDisabled: option => option.dataRef.disabled, + resolveId: (option) => option.id, + resolveDisabled: (option) => option.dataRef.disabled, } ) @@ -152,7 +152,7 @@ export let Combobox = defineComponent({ } }, selectOption(id: string) { - let option = options.value.find(item => item.id === id) + let option = options.value.find((item) => item.id === id) if (!option) return let { dataRef } = option @@ -193,7 +193,7 @@ export let Combobox = defineComponent({ let nextOptions = options.value.slice() let currentActiveOption = activeOptionIndex.value !== null ? nextOptions[activeOptionIndex.value] : null - let idx = nextOptions.findIndex(a => a.id === id) + let idx = nextOptions.findIndex((a) => a.id === id) if (idx !== -1) nextOptions.splice(idx, 1) options.value = nextOptions activeOptionIndex.value = (() => { @@ -207,7 +207,7 @@ export let Combobox = defineComponent({ }, } - useWindowEvent('mousedown', event => { + useWindowEvent('mousedown', (event) => { let target = event.target as HTMLElement let active = document.activeElement diff --git a/packages/@headlessui-vue/src/components/description/description.test.ts b/packages/@headlessui-vue/src/components/description/description.test.ts index 2a5ba68640..65e6722d0c 100644 --- a/packages/@headlessui-vue/src/components/description/description.test.ts +++ b/packages/@headlessui-vue/src/components/description/description.test.ts @@ -8,7 +8,8 @@ import { html } from '../../test-utils/html' import { click } from '../../test-utils/interactions' import { getByText } from '../../test-utils/accessibility-assertions' -function format(input: Element | string) { +function format(input: Element | null | string) { + if (input === null) throw new Error('input is null') let contents = (typeof input === 'string' ? input : (input as HTMLElement).outerHTML).trim() return prettier.format(contents, { parser: 'babel' }) } @@ -22,59 +23,47 @@ beforeAll(() => { afterAll(() => jest.restoreAllMocks()) -function renderTemplate(input: string | Partial[0]>) { - let defaultComponents = { Description } - - if (typeof input === 'string') { - return render(defineComponent({ template: input, components: defaultComponents })) - } - - return render( - defineComponent( - Object.assign({}, input, { - components: { ...defaultComponents, ...input.components }, - }) as Parameters[0] - ) - ) -} - it('should be possible to use useDescriptions without using a Description', async () => { - let { container } = renderTemplate({ - render() { - return h('div', [h('div', { 'aria-describedby': this.describedby }, ['No description'])]) - }, - setup() { - let describedby = useDescriptions() - return { describedby } - }, - }) + let { container } = render( + defineComponent({ + components: { Description }, + render() { + return h('div', [h('div', { 'aria-describedby': this.describedby }, ['No description'])]) + }, + setup() { + let describedby = useDescriptions() + return { describedby } + }, + }) + ) expect(format(container.firstElementChild)).toEqual( format(html`
-
- No description -
+
No description
`) ) }) it('should be possible to use useDescriptions and a single Description, and have them linked', async () => { - let { container } = renderTemplate({ - render() { - return h('div', [ - h('div', { 'aria-describedby': this.describedby }, [ - h(Description, () => 'I am a description'), - h('span', 'Contents'), - ]), - ]) - }, - setup() { - let describedby = useDescriptions() - return { describedby } - }, - }) + let { container } = render( + defineComponent({ + components: { Description }, + render() { + return h('div', [ + h('div', { 'aria-describedby': this.describedby }, [ + h(Description, () => 'I am a description'), + h('span', 'Contents'), + ]), + ]) + }, + setup() { + let describedby = useDescriptions() + return { describedby } + }, + }) + ) await new Promise(nextTick) @@ -82,12 +71,8 @@ it('should be possible to use useDescriptions and a single Description, and have format(html`
-

- I am a description -

- - Contents - +

I am a description

+ Contents
`) @@ -95,21 +80,24 @@ it('should be possible to use useDescriptions and a single Description, and have }) it('should be possible to use useDescriptions and multiple Description components, and have them linked', async () => { - let { container } = renderTemplate({ - render() { - return h('div', [ - h('div', { 'aria-describedby': this.describedby }, [ - h(Description, () => 'I am a description'), - h('span', 'Contents'), - h(Description, () => 'I am also a description'), - ]), - ]) - }, - setup() { - let describedby = useDescriptions() - return { describedby } - }, - }) + let { container } = render( + defineComponent({ + components: { Description }, + render() { + return h('div', [ + h('div', { 'aria-describedby': this.describedby }, [ + h(Description, () => 'I am a description'), + h('span', 'Contents'), + h(Description, () => 'I am also a description'), + ]), + ]) + }, + setup() { + let describedby = useDescriptions() + return { describedby } + }, + }) + ) await new Promise(nextTick) @@ -117,15 +105,9 @@ it('should be possible to use useDescriptions and multiple Description component format(html`
-

- I am a description -

- - Contents - -

- I am also a description -

+

I am a description

+ Contents +

I am also a description

`) @@ -133,21 +115,24 @@ it('should be possible to use useDescriptions and multiple Description component }) it('should be possible to update a prop from the parent and it should reflect in the Description component', async () => { - let { container } = renderTemplate({ - render() { - return h('div', [ - h('div', { 'aria-describedby': this.describedby }, [ - h(Description, () => 'I am a description'), - h('button', { onClick: () => this.count++ }, '+1'), - ]), - ]) - }, - setup() { - let count = ref(0) - let describedby = useDescriptions({ props: { 'data-count': count } }) - return { count, describedby } - }, - }) + let { container } = render( + defineComponent({ + components: { Description }, + render() { + return h('div', [ + h('div', { 'aria-describedby': this.describedby }, [ + h(Description, () => 'I am a description'), + h('button', { onClick: () => this.count++ }, '+1'), + ]), + ]) + }, + setup() { + let count = ref(0) + let describedby = useDescriptions({ props: { 'data-count': count } }) + return { count, describedby } + }, + }) + ) await new Promise(nextTick) @@ -155,9 +140,7 @@ it('should be possible to update a prop from the parent and it should reflect in format(html`
-

- I am a description -

+

I am a description

@@ -170,9 +153,7 @@ it('should be possible to update a prop from the parent and it should reflect in format(html`
-

- I am a description -

+

I am a description

diff --git a/packages/@headlessui-vue/src/components/dialog/dialog.test.ts b/packages/@headlessui-vue/src/components/dialog/dialog.test.ts index 206c4328cf..94e8b83e81 100644 --- a/packages/@headlessui-vue/src/components/dialog/dialog.test.ts +++ b/packages/@headlessui-vue/src/components/dialog/dialog.test.ts @@ -1,4 +1,4 @@ -import { defineComponent, ref, nextTick, h } from 'vue' +import { defineComponent, ref, nextTick, h, ComponentOptionsWithoutProps } from 'vue' import { render } from '../../test-utils/vue-testing-library' import { Dialog, DialogOverlay, DialogTitle, DialogDescription } from './dialog' @@ -30,9 +30,7 @@ afterAll(() => jest.restoreAllMocks()) let TabSentinel = defineComponent({ name: 'TabSentinel', - template: html` -
- `, + template: html`
`, }) jest.mock('../../hooks/use-id') @@ -44,7 +42,7 @@ beforeAll(() => { afterAll(() => jest.restoreAllMocks()) -function renderTemplate(input: string | Partial[0]>) { +function renderTemplate(input: string | ComponentOptionsWithoutProps) { let defaultComponents = { Dialog, DialogOverlay, DialogTitle, DialogDescription, TabSentinel } if (typeof input === 'string') { diff --git a/packages/@headlessui-vue/src/components/dialog/dialog.ts b/packages/@headlessui-vue/src/components/dialog/dialog.ts index 31d7e8808b..5fe71c986d 100644 --- a/packages/@headlessui-vue/src/components/dialog/dialog.ts +++ b/packages/@headlessui-vue/src/components/dialog/dialog.ts @@ -193,7 +193,7 @@ export let Dialog = defineComponent({ provide(DialogContext, api) // Handle outside click - useWindowEvent('mousedown', event => { + useWindowEvent('mousedown', (event) => { let target = event.target as HTMLElement if (dialogState.value !== DialogStates.Open) return @@ -205,7 +205,7 @@ export let Dialog = defineComponent({ }) // Handle `Escape` to close - useWindowEvent('keydown', event => { + useWindowEvent('keydown', (event) => { if (event.key !== Keys.Escape) return if (dialogState.value !== DialogStates.Open) return if (containers.value.size > 1) return // 1 is myself, otherwise other elements in the Stack @@ -215,7 +215,7 @@ export let Dialog = defineComponent({ }) // Scroll lock - watchEffect(onInvalidate => { + watchEffect((onInvalidate) => { if (dialogState.value !== DialogStates.Open) return let overflow = document.documentElement.style.overflow @@ -233,12 +233,12 @@ export let Dialog = defineComponent({ }) // Trigger close when the FocusTrap gets hidden - watchEffect(onInvalidate => { + watchEffect((onInvalidate) => { if (dialogState.value !== DialogStates.Open) return let container = dom(internalDialogRef) if (!container) return - let observer = new IntersectionObserver(entries => { + let observer = new IntersectionObserver((entries) => { for (let entry of entries) { if ( entry.boundingClientRect.x === 0 && diff --git a/packages/@headlessui-vue/src/components/disclosure/disclosure.test.ts b/packages/@headlessui-vue/src/components/disclosure/disclosure.test.ts index 415513357b..36cb854405 100644 --- a/packages/@headlessui-vue/src/components/disclosure/disclosure.test.ts +++ b/packages/@headlessui-vue/src/components/disclosure/disclosure.test.ts @@ -1,4 +1,4 @@ -import { defineComponent, nextTick, ref, watch, h } from 'vue' +import { defineComponent, nextTick, ref, watch, h, ComponentOptionsWithoutProps } from 'vue' import { render } from '../../test-utils/vue-testing-library' import { Disclosure, DisclosureButton, DisclosurePanel } from './disclosure' import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs' @@ -19,7 +19,7 @@ jest.mock('../../hooks/use-id') afterAll(() => jest.restoreAllMocks()) -function renderTemplate(input: string | Partial[0]>) { +function renderTemplate(input: string | ComponentOptionsWithoutProps) { let defaultComponents = { Disclosure, DisclosureButton, DisclosurePanel } if (typeof input === 'string') { @@ -297,9 +297,7 @@ describe('Rendering', () => { renderTemplate( html` - - Trigger - + Trigger ` ) @@ -311,9 +309,7 @@ describe('Rendering', () => { renderTemplate( html` - - Trigger - + Trigger ` ) @@ -327,14 +323,12 @@ describe('Rendering', () => { renderTemplate({ template: html` - - Trigger - + Trigger `, setup: () => ({ CustomButton: defineComponent({ - setup: props => () => h('button', { ...props }), + setup: (props) => () => h('button', { ...props }), }), }), }) @@ -349,9 +343,7 @@ describe('Rendering', () => { renderTemplate( html` - - Trigger - + Trigger ` ) @@ -365,14 +357,12 @@ describe('Rendering', () => { renderTemplate({ template: html` - - Trigger - + Trigger `, setup: () => ({ CustomButton: defineComponent({ - setup: props => () => h('div', props), + setup: (props) => () => h('div', props), }), }), }) diff --git a/packages/@headlessui-vue/src/components/focus-trap/focus-trap.test.ts b/packages/@headlessui-vue/src/components/focus-trap/focus-trap.test.ts index 8243a82ff4..8d6707250f 100644 --- a/packages/@headlessui-vue/src/components/focus-trap/focus-trap.test.ts +++ b/packages/@headlessui-vue/src/components/focus-trap/focus-trap.test.ts @@ -1,4 +1,4 @@ -import { defineComponent, ref, nextTick, onMounted } from 'vue' +import { defineComponent, ref, nextTick, onMounted, ComponentOptionsWithoutProps } from 'vue' import { FocusTrap } from './focus-trap' import { assertActiveElement, getByText } from '../../test-utils/accessibility-assertions' @@ -16,7 +16,7 @@ beforeAll(() => { afterAll(() => jest.restoreAllMocks()) -function renderTemplate(input: string | Partial[0]>) { +function renderTemplate(input: string | ComponentOptionsWithoutProps) { let defaultComponents = { FocusTrap } if (typeof input === 'string') { @@ -41,7 +41,7 @@ it('should focus the first focusable element inside the FocusTrap', async () => ` ) - await new Promise(nextTick) + await new Promise(nextTick) assertActiveElement(getByText('Trigger')) }) @@ -64,7 +64,7 @@ it('should focus the autoFocus element inside the FocusTrap if that exists', asy }, }) - await new Promise(nextTick) + await new Promise(nextTick) assertActiveElement(document.getElementById('b')) }) @@ -84,7 +84,7 @@ it('should focus the initialFocus element inside the FocusTrap if that exists', }, }) - await new Promise(nextTick) + await new Promise(nextTick) assertActiveElement(document.getElementById('c')) }) @@ -104,7 +104,7 @@ it('should focus the initialFocus element inside the FocusTrap even if another e }, }) - await new Promise(nextTick) + await new Promise(nextTick) assertActiveElement(document.getElementById('c')) }) @@ -121,7 +121,7 @@ it('should warn when there is no focusable element inside the FocusTrap', async ` ) - await new Promise(nextTick) + await new Promise(nextTick) expect(spy.mock.calls[0][0]).toBe('There are no focusable elements inside the ') }) @@ -143,7 +143,7 @@ it( `, }) - await new Promise(nextTick) + await new Promise(nextTick) let [a, b, c, d] = Array.from(document.querySelectorAll('input')) @@ -193,14 +193,10 @@ it('should restore the previously focused element, before entering the FocusTrap template: html`
- + - +
`, @@ -214,7 +210,7 @@ it('should restore the previously focused element, before entering the FocusTrap }, }) - await new Promise(nextTick) + await new Promise(nextTick) // The input should have focus by default because of the autoFocus prop assertActiveElement(document.getElementById('item-1')) @@ -247,7 +243,7 @@ it('should be possible to tab to the next focusable element within the focus tra ` ) - await new Promise(nextTick) + await new Promise(nextTick) // Item A should be focused because the FocusTrap will focus the first item assertActiveElement(document.getElementById('item-a')) @@ -302,12 +298,8 @@ it('should skip the initial "hidden" elements within the focus trap', async () =
- - + + @@ -328,9 +320,7 @@ it('should be possible skip "hidden" elements within the focus trap', async () = - + @@ -364,9 +354,7 @@ it('should be possible skip disabled elements within the focus trap', async () = - + diff --git a/packages/@headlessui-vue/src/components/label/label.test.ts b/packages/@headlessui-vue/src/components/label/label.test.ts index f2184e9b66..67a4113ea3 100644 --- a/packages/@headlessui-vue/src/components/label/label.test.ts +++ b/packages/@headlessui-vue/src/components/label/label.test.ts @@ -8,7 +8,8 @@ import { html } from '../../test-utils/html' import { click } from '../../test-utils/interactions' import { getByText } from '../../test-utils/accessibility-assertions' -function format(input: Element | string) { +function format(input: Element | null | string) { + if (input === null) throw new Error('input is null') let contents = (typeof input === 'string' ? input : (input as HTMLElement).outerHTML).trim() return prettier.format(contents, { parser: 'babel' }) } @@ -22,59 +23,47 @@ beforeAll(() => { afterAll(() => jest.restoreAllMocks()) -function renderTemplate(input: string | Partial[0]>) { - let defaultComponents = { Label } - - if (typeof input === 'string') { - return render(defineComponent({ template: input, components: defaultComponents })) - } - - return render( - defineComponent( - Object.assign({}, input, { - components: { ...defaultComponents, ...input.components }, - }) as Parameters[0] - ) - ) -} - it('should be possible to use useLabels without using a Label', async () => { - let { container } = renderTemplate({ - render() { - return h('div', [h('div', { 'aria-labelledby': this.labelledby }, ['No label'])]) - }, - setup() { - let labelledby = useLabels() - return { labelledby } - }, - }) + let { container } = render( + defineComponent({ + components: { Label }, + render() { + return h('div', [h('div', { 'aria-labelledby': this.labelledby }, ['No label'])]) + }, + setup() { + let labelledby = useLabels() + return { labelledby } + }, + }) + ) expect(format(container.firstElementChild)).toEqual( format(html`
-
- No label -
+
No label
`) ) }) it('should be possible to use useLabels and a single Label, and have them linked', async () => { - let { container } = renderTemplate({ - render() { - return h('div', [ - h('div', { 'aria-labelledby': this.labelledby }, [ - h(Label, () => 'I am a label'), - h('span', 'Contents'), - ]), - ]) - }, - setup() { - let labelledby = useLabels() - return { labelledby } - }, - }) + let { container } = render( + defineComponent({ + components: { Label }, + render() { + return h('div', [ + h('div', { 'aria-labelledby': this.labelledby }, [ + h(Label, () => 'I am a label'), + h('span', 'Contents'), + ]), + ]) + }, + setup() { + let labelledby = useLabels() + return { labelledby } + }, + }) + ) await new Promise(nextTick) @@ -82,12 +71,8 @@ it('should be possible to use useLabels and a single Label, and have them linked format(html`
- - - Contents - + + Contents
`) @@ -95,21 +80,24 @@ it('should be possible to use useLabels and a single Label, and have them linked }) it('should be possible to use useLabels and multiple Label components, and have them linked', async () => { - let { container } = renderTemplate({ - render() { - return h('div', [ - h('div', { 'aria-labelledby': this.labelledby }, [ - h(Label, () => 'I am a label'), - h('span', 'Contents'), - h(Label, () => 'I am also a label'), - ]), - ]) - }, - setup() { - let labelledby = useLabels() - return { labelledby } - }, - }) + let { container } = render( + defineComponent({ + components: { Label }, + render() { + return h('div', [ + h('div', { 'aria-labelledby': this.labelledby }, [ + h(Label, () => 'I am a label'), + h('span', 'Contents'), + h(Label, () => 'I am also a label'), + ]), + ]) + }, + setup() { + let labelledby = useLabels() + return { labelledby } + }, + }) + ) await new Promise(nextTick) @@ -117,15 +105,9 @@ it('should be possible to use useLabels and multiple Label components, and have format(html`
- - - Contents - - + + Contents +
`) @@ -133,21 +115,24 @@ it('should be possible to use useLabels and multiple Label components, and have }) it('should be possible to update a prop from the parent and it should reflect in the Label component', async () => { - let { container } = renderTemplate({ - render() { - return h('div', [ - h('div', { 'aria-labelledby': this.labelledby }, [ - h(Label, () => 'I am a label'), - h('button', { onClick: () => this.count++ }, '+1'), - ]), - ]) - }, - setup() { - let count = ref(0) - let labelledby = useLabels({ props: { 'data-count': count } }) - return { count, labelledby } - }, - }) + let { container } = render( + defineComponent({ + components: { Label }, + render() { + return h('div', [ + h('div', { 'aria-labelledby': this.labelledby }, [ + h(Label, () => 'I am a label'), + h('button', { onClick: () => this.count++ }, '+1'), + ]), + ]) + }, + setup() { + let count = ref(0) + let labelledby = useLabels({ props: { 'data-count': count } }) + return { count, labelledby } + }, + }) + ) await new Promise(nextTick) @@ -155,9 +140,7 @@ it('should be possible to update a prop from the parent and it should reflect in format(html`
- +
@@ -170,9 +153,7 @@ it('should be possible to update a prop from the parent and it should reflect in format(html`
- +
diff --git a/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx b/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx index c0a437e995..c3cd561fef 100644 --- a/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx +++ b/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx @@ -1,4 +1,12 @@ -import { defineComponent, nextTick, ref, watch, h, reactive } from 'vue' +import { + defineComponent, + nextTick, + ref, + watch, + h, + reactive, + ComponentOptionsWithoutProps, +} from 'vue' import { render } from '../../test-utils/vue-testing-library' import { Listbox, ListboxLabel, ListboxButton, ListboxOptions, ListboxOption } from './listbox' import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs' @@ -48,7 +56,7 @@ beforeAll(() => { afterAll(() => jest.restoreAllMocks()) function nextFrame() { - return new Promise(resolve => { + return new Promise((resolve) => { requestAnimationFrame(() => { requestAnimationFrame(() => { resolve() @@ -57,7 +65,7 @@ function nextFrame() { }) } -function renderTemplate(input: string | Partial[0]>) { +function renderTemplate(input: string | ComponentOptionsWithoutProps) { let defaultComponents = { Listbox, ListboxLabel, ListboxButton, ListboxOptions, ListboxOption } if (typeof input === 'string') { @@ -388,9 +396,7 @@ describe('Rendering', () => { renderTemplate({ template: html` - - Trigger - + Trigger `, setup: () => ({ value: ref(null) }), @@ -405,15 +411,13 @@ describe('Rendering', () => { renderTemplate({ template: html` - - Trigger - + Trigger `, setup: () => ({ value: ref(null), CustomButton: defineComponent({ - setup: props => () => h('button', { ...props }), + setup: (props) => () => h('button', { ...props }), }), }), }) @@ -428,9 +432,7 @@ describe('Rendering', () => { renderTemplate({ template: html` - - Trigger - + Trigger `, setup: () => ({ value: ref(null) }), @@ -445,15 +447,13 @@ describe('Rendering', () => { renderTemplate({ template: html` - - Trigger - + Trigger `, setup: () => ({ value: ref(null), CustomButton: defineComponent({ - setup: props => () => h('div', props), + setup: (props) => () => h('div', props), }), }), }) @@ -647,15 +647,9 @@ describe('Rendering composition', () => { Trigger - - Option A - - - Option B - - - Option C - + Option A + Option B + Option C `, @@ -672,7 +666,7 @@ describe('Rendering composition', () => { await click(getListboxButton()) // Verify options are buttons now - getListboxOptions().forEach(option => assertListboxOption(option, { tag: 'button' })) + getListboxOptions().forEach((option) => assertListboxOption(option, { tag: 'button' })) }) ) }) @@ -704,9 +698,7 @@ describe('Composition', () => { Trigger - - {{JSON.stringify(data)}} - + {{JSON.stringify(data)}} `, @@ -734,9 +726,7 @@ describe('Composition', () => { Trigger - - {{JSON.stringify(data)}} - + {{JSON.stringify(data)}} `, @@ -840,7 +830,7 @@ describe('Keyboard interactions', () => { // Verify we have listbox options let options = getListboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertListboxOption(option, { selected: false })) + options.forEach((option) => assertListboxOption(option, { selected: false })) // Verify that the first listbox option is active assertActiveListboxOption(options[0]) @@ -1092,9 +1082,7 @@ describe('Keyboard interactions', () => { Trigger - - Option A - + Option A Option B Option C @@ -1130,12 +1118,8 @@ describe('Keyboard interactions', () => { Trigger - - Option A - - - Option B - + Option A + Option B Option C @@ -1170,15 +1154,9 @@ describe('Keyboard interactions', () => { Trigger - - Option A - - - Option B - - - Option C - + Option A + Option B + Option C `, @@ -1345,7 +1323,7 @@ describe('Keyboard interactions', () => { // Verify we have listbox options let options = getListboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertListboxOption(option)) + options.forEach((option) => assertListboxOption(option)) assertActiveListboxOption(options[0]) }) ) @@ -1471,9 +1449,7 @@ describe('Keyboard interactions', () => { Trigger - - Option A - + Option A Option B Option C @@ -1509,12 +1485,8 @@ describe('Keyboard interactions', () => { Trigger - - Option A - - - Option B - + Option A + Option B Option C @@ -1549,15 +1521,9 @@ describe('Keyboard interactions', () => { Trigger - - Option A - - - Option B - - - Option C - + Option A + Option B + Option C `, @@ -1729,7 +1695,7 @@ describe('Keyboard interactions', () => { // Verify we have listbox options let options = getListboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertListboxOption(option)) + options.forEach((option) => assertListboxOption(option)) assertActiveListboxOption(options[0]) // Try to tab @@ -1783,7 +1749,7 @@ describe('Keyboard interactions', () => { // Verify we have listbox options let options = getListboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertListboxOption(option)) + options.forEach((option) => assertListboxOption(option)) assertActiveListboxOption(options[0]) // Try to Shift+Tab @@ -1839,7 +1805,7 @@ describe('Keyboard interactions', () => { // Verify we have listbox options let options = getListboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertListboxOption(option)) + options.forEach((option) => assertListboxOption(option)) // Verify that the first listbox option is active assertActiveListboxOption(options[0]) @@ -1991,7 +1957,7 @@ describe('Keyboard interactions', () => { // Verify we have listbox options let options = getListboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertListboxOption(option)) + options.forEach((option) => assertListboxOption(option)) assertActiveListboxOption(options[0]) // We should be able to go down once @@ -2016,9 +1982,7 @@ describe('Keyboard interactions', () => { Trigger - - Option A - + Option A Option B Option C @@ -2042,7 +2006,7 @@ describe('Keyboard interactions', () => { // Verify we have listbox options let options = getListboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertListboxOption(option)) + options.forEach((option) => assertListboxOption(option)) assertActiveListboxOption(options[1]) // We should be able to go down once @@ -2059,12 +2023,8 @@ describe('Keyboard interactions', () => { Trigger - - Option A - - - Option B - + Option A + Option B Option C @@ -2087,7 +2047,7 @@ describe('Keyboard interactions', () => { // Verify we have listbox options let options = getListboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertListboxOption(option)) + options.forEach((option) => assertListboxOption(option)) assertActiveListboxOption(options[2]) }) ) @@ -2126,7 +2086,7 @@ describe('Keyboard interactions', () => { // Verify we have listbox options let options = getListboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertListboxOption(option)) + options.forEach((option) => assertListboxOption(option)) assertActiveListboxOption(options[0]) // We should be able to go right once @@ -2186,7 +2146,7 @@ describe('Keyboard interactions', () => { // Verify we have listbox options let options = getListboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertListboxOption(option)) + options.forEach((option) => assertListboxOption(option)) // ! ALERT: The LAST option should now be active assertActiveListboxOption(options[2]) @@ -2315,12 +2275,8 @@ describe('Keyboard interactions', () => { Trigger Option A - - Option B - - - Option C - + Option B + Option C `, @@ -2342,7 +2298,7 @@ describe('Keyboard interactions', () => { // Verify we have listbox options let options = getListboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertListboxOption(option)) + options.forEach((option) => assertListboxOption(option)) assertActiveListboxOption(options[0]) }) ) @@ -2355,12 +2311,8 @@ describe('Keyboard interactions', () => { Trigger - - Option A - - - Option B - + Option A + Option B Option C @@ -2383,7 +2335,7 @@ describe('Keyboard interactions', () => { // Verify we have listbox options let options = getListboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertListboxOption(option)) + options.forEach((option) => assertListboxOption(option)) assertActiveListboxOption(options[2]) // We should not be able to go up (because those are disabled) @@ -2437,7 +2389,7 @@ describe('Keyboard interactions', () => { // Verify we have listbox options let options = getListboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertListboxOption(option)) + options.forEach((option) => assertListboxOption(option)) assertActiveListboxOption(options[2]) // We should be able to go down once @@ -2498,7 +2450,7 @@ describe('Keyboard interactions', () => { // Verify we have listbox options let options = getListboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertListboxOption(option)) + options.forEach((option) => assertListboxOption(option)) assertActiveListboxOption(options[2]) // We should be able to go left once @@ -2561,12 +2513,8 @@ describe('Keyboard interactions', () => { Option A Option B - - Option C - - - Option D - + Option C + Option D `, @@ -2599,15 +2547,9 @@ describe('Keyboard interactions', () => { Trigger Option A - - Option B - - - Option C - - - Option D - + Option B + Option C + Option D `, @@ -2636,18 +2578,10 @@ describe('Keyboard interactions', () => { Trigger - - Option A - - - Option B - - - Option C - - - Option D - + Option A + Option B + Option C + Option D `, @@ -2713,12 +2647,8 @@ describe('Keyboard interactions', () => { Option A Option B - - Option C - - - Option D - + Option C + Option D `, @@ -2751,15 +2681,9 @@ describe('Keyboard interactions', () => { Trigger Option A - - Option B - - - Option C - - - Option D - + Option B + Option C + Option D `, @@ -2788,18 +2712,10 @@ describe('Keyboard interactions', () => { Trigger - - Option A - - - Option B - - - Option C - - - Option D - + Option A + Option B + Option C + Option D `, @@ -2863,12 +2779,8 @@ describe('Keyboard interactions', () => { Trigger - - Option A - - - Option B - + Option A + Option B Option C Option D @@ -2901,15 +2813,9 @@ describe('Keyboard interactions', () => { Trigger - - Option A - - - Option B - - - Option C - + Option A + Option B + Option C Option D @@ -2939,18 +2845,10 @@ describe('Keyboard interactions', () => { Trigger - - Option A - - - Option B - - - Option C - - - Option D - + Option A + Option B + Option C + Option D `, @@ -3014,12 +2912,8 @@ describe('Keyboard interactions', () => { Trigger - - Option A - - - Option B - + Option A + Option B Option C Option D @@ -3052,15 +2946,9 @@ describe('Keyboard interactions', () => { Trigger - - Option A - - - Option B - - - Option C - + Option A + Option B + Option C Option D @@ -3090,18 +2978,10 @@ describe('Keyboard interactions', () => { Trigger - - Option A - - - Option B - - - Option C - - - Option D - + Option A + Option B + Option C + Option D `, @@ -3252,9 +3132,7 @@ describe('Keyboard interactions', () => { Trigger alice - - bob - + bob charlie @@ -3453,7 +3331,7 @@ describe('Mouse interactions', () => { // Verify we have listbox options let options = getListboxOptions() expect(options).toHaveLength(3) - options.forEach(option => assertListboxOption(option)) + options.forEach((option) => assertListboxOption(option)) }) ) @@ -3845,9 +3723,7 @@ describe('Mouse interactions', () => { Trigger alice - - bob - + bob charlie @@ -3874,9 +3750,7 @@ describe('Mouse interactions', () => { Trigger alice - - bob - + bob charlie @@ -3951,9 +3825,7 @@ describe('Mouse interactions', () => { Trigger alice - - bob - + bob charlie @@ -4031,9 +3903,7 @@ describe('Mouse interactions', () => { Trigger alice - - bob - + bob charlie @@ -4111,9 +3981,7 @@ describe('Mouse interactions', () => { Trigger alice - - bob - + bob charlie diff --git a/packages/@headlessui-vue/src/components/listbox/listbox.ts b/packages/@headlessui-vue/src/components/listbox/listbox.ts index cbc244d0de..680a2f477d 100644 --- a/packages/@headlessui-vue/src/components/listbox/listbox.ts +++ b/packages/@headlessui-vue/src/components/listbox/listbox.ts @@ -130,8 +130,8 @@ export let Listbox = defineComponent({ { resolveItems: () => options.value, resolveActiveIndex: () => activeOptionIndex.value, - resolveId: option => option.id, - resolveDisabled: option => option.dataRef.disabled, + resolveId: (option) => option.id, + resolveDisabled: (option) => option.dataRef.disabled, } ) @@ -153,7 +153,7 @@ export let Listbox = defineComponent({ : options.value let matchingOption = reOrderedOptions.find( - option => + (option) => option.dataRef.textValue.startsWith(searchQuery.value) && !option.dataRef.disabled ) @@ -186,7 +186,7 @@ export let Listbox = defineComponent({ let nextOptions = options.value.slice() let currentActiveOption = activeOptionIndex.value !== null ? nextOptions[activeOptionIndex.value] : null - let idx = nextOptions.findIndex(a => a.id === id) + let idx = nextOptions.findIndex((a) => a.id === id) if (idx !== -1) nextOptions.splice(idx, 1) options.value = nextOptions activeOptionIndex.value = (() => { @@ -204,7 +204,7 @@ export let Listbox = defineComponent({ }, } - useWindowEvent('mousedown', event => { + useWindowEvent('mousedown', (event) => { let target = event.target as HTMLElement let active = document.activeElement @@ -529,10 +529,7 @@ export let ListboxOption = defineComponent({ textValue: '', }) onMounted(() => { - let textValue = document - .getElementById(id) - ?.textContent?.toLowerCase() - .trim() + let textValue = document.getElementById(id)?.textContent?.toLowerCase().trim() if (textValue !== undefined) dataRef.value.textValue = textValue }) diff --git a/packages/@headlessui-vue/src/components/menu/menu.test.tsx b/packages/@headlessui-vue/src/components/menu/menu.test.tsx index fe34508d35..4d033b457d 100644 --- a/packages/@headlessui-vue/src/components/menu/menu.test.tsx +++ b/packages/@headlessui-vue/src/components/menu/menu.test.tsx @@ -1,4 +1,12 @@ -import { defineComponent, h, nextTick, reactive, ref, watch } from 'vue' +import { + ComponentOptionsWithoutProps, + defineComponent, + h, + nextTick, + reactive, + ref, + watch, +} from 'vue' import { render } from '../../test-utils/vue-testing-library' import { Menu, MenuButton, MenuItems, MenuItem } from './menu' import { TransitionChild } from '../transitions/transition' @@ -44,7 +52,7 @@ beforeAll(() => { afterAll(() => jest.restoreAllMocks()) function nextFrame() { - return new Promise(resolve => { + return new Promise((resolve) => { requestAnimationFrame(() => { requestAnimationFrame(() => { resolve() @@ -53,7 +61,7 @@ function nextFrame() { }) } -function renderTemplate(input: string | Partial[0]>) { +function renderTemplate(input: string | ComponentOptionsWithoutProps) { let defaultComponents = { Menu, MenuButton, MenuItems, MenuItem } if (typeof input === 'string') { @@ -395,7 +403,7 @@ describe('Rendering', () => { `, setup: () => ({ CustomButton: defineComponent({ - setup: props => () => h('button', { ...props }), + setup: (props) => () => h('button', { ...props }), }), }), }) @@ -433,7 +441,7 @@ describe('Rendering', () => { `, setup: () => ({ CustomButton: defineComponent({ - setup: props => () => h('div', props), + setup: (props) => () => h('div', props), }), }), }) @@ -810,7 +818,7 @@ describe('Rendering composition', () => { // Verify items are buttons now let items = getMenuItems() - items.forEach(item => + items.forEach((item) => assertMenuItem(item, { tag: 'button', attributes: { 'data-my-custom-button': 'true' } }) ) }) @@ -853,11 +861,11 @@ describe('Rendering composition', () => { expect.hasAssertions() - document.querySelectorAll('.outer').forEach(element => { + document.querySelectorAll('.outer').forEach((element) => { expect(element).not.toHaveAttribute('role', 'none') }) - document.querySelectorAll('.inner').forEach(element => { + document.querySelectorAll('.inner').forEach((element) => { expect(element).toHaveAttribute('role', 'none') }) }) @@ -1069,7 +1077,7 @@ describe('Keyboard interactions', () => { // Verify we have menu items let items = getMenuItems() expect(items).toHaveLength(3) - items.forEach(item => assertMenuItem(item)) + items.forEach((item) => assertMenuItem(item)) // Verify that the first menu item is active assertMenuLinkedWithMenuItem(items[0]) @@ -1398,7 +1406,7 @@ describe('Keyboard interactions', () => { // Verify we have menu items let items = getMenuItems() expect(items).toHaveLength(3) - items.forEach(item => assertMenuItem(item)) + items.forEach((item) => assertMenuItem(item)) assertMenuLinkedWithMenuItem(items[0]) }) @@ -1704,7 +1712,7 @@ describe('Keyboard interactions', () => { // Verify we have menu items let items = getMenuItems() expect(items).toHaveLength(3) - items.forEach(item => assertMenuItem(item)) + items.forEach((item) => assertMenuItem(item)) assertMenuLinkedWithMenuItem(items[0]) // Try to tab @@ -1750,7 +1758,7 @@ describe('Keyboard interactions', () => { // Verify we have menu items let items = getMenuItems() expect(items).toHaveLength(3) - items.forEach(item => assertMenuItem(item)) + items.forEach((item) => assertMenuItem(item)) assertMenuLinkedWithMenuItem(items[0]) // Try to Shift+Tab @@ -1798,7 +1806,7 @@ describe('Keyboard interactions', () => { // Verify we have menu items let items = getMenuItems() expect(items).toHaveLength(3) - items.forEach(item => assertMenuItem(item)) + items.forEach((item) => assertMenuItem(item)) // Verify that the first menu item is active assertMenuLinkedWithMenuItem(items[0]) @@ -1883,7 +1891,7 @@ describe('Keyboard interactions', () => { // Verify we have menu items let items = getMenuItems() expect(items).toHaveLength(3) - items.forEach(item => assertMenuItem(item)) + items.forEach((item) => assertMenuItem(item)) assertMenuLinkedWithMenuItem(items[0]) // We should be able to go down once @@ -1926,7 +1934,7 @@ describe('Keyboard interactions', () => { // Verify we have menu items let items = getMenuItems() expect(items).toHaveLength(3) - items.forEach(item => assertMenuItem(item)) + items.forEach((item) => assertMenuItem(item)) assertMenuLinkedWithMenuItem(items[1]) // We should be able to go down once @@ -1961,7 +1969,7 @@ describe('Keyboard interactions', () => { // Verify we have menu items let items = getMenuItems() expect(items).toHaveLength(3) - items.forEach(item => assertMenuItem(item)) + items.forEach((item) => assertMenuItem(item)) assertMenuLinkedWithMenuItem(items[2]) }) }) @@ -2002,7 +2010,7 @@ describe('Keyboard interactions', () => { // Verify we have menu items let items = getMenuItems() expect(items).toHaveLength(3) - items.forEach(item => assertMenuItem(item)) + items.forEach((item) => assertMenuItem(item)) // ! ALERT: The LAST item should now be active assertMenuLinkedWithMenuItem(items[2]) @@ -2055,7 +2063,7 @@ describe('Keyboard interactions', () => { // Verify we have menu items let items = getMenuItems() expect(items).toHaveLength(3) - items.forEach(item => assertMenuItem(item)) + items.forEach((item) => assertMenuItem(item)) assertMenuLinkedWithMenuItem(items[0]) }) @@ -2086,7 +2094,7 @@ describe('Keyboard interactions', () => { // Verify we have menu items let items = getMenuItems() expect(items).toHaveLength(3) - items.forEach(item => assertMenuItem(item)) + items.forEach((item) => assertMenuItem(item)) assertMenuLinkedWithMenuItem(items[2]) // We should not be able to go up (because those are disabled) @@ -2133,7 +2141,7 @@ describe('Keyboard interactions', () => { // Verify we have menu items let items = getMenuItems() expect(items).toHaveLength(3) - items.forEach(item => assertMenuItem(item)) + items.forEach((item) => assertMenuItem(item)) assertMenuLinkedWithMenuItem(items[2]) // We should be able to go down once @@ -2820,7 +2828,7 @@ describe('Mouse interactions', () => { // Verify we have menu items let items = getMenuItems() expect(items).toHaveLength(3) - items.forEach(item => assertMenuItem(item)) + items.forEach((item) => assertMenuItem(item)) }) it( diff --git a/packages/@headlessui-vue/src/components/menu/menu.ts b/packages/@headlessui-vue/src/components/menu/menu.ts index 1b25dcbc09..3ffe7bdd33 100644 --- a/packages/@headlessui-vue/src/components/menu/menu.ts +++ b/packages/@headlessui-vue/src/components/menu/menu.ts @@ -96,8 +96,8 @@ export let Menu = defineComponent({ { resolveItems: () => items.value, resolveActiveIndex: () => activeItemIndex.value, - resolveId: item => item.id, - resolveDisabled: item => item.dataRef.disabled, + resolveId: (item) => item.id, + resolveDisabled: (item) => item.dataRef.disabled, } ) @@ -116,7 +116,7 @@ export let Menu = defineComponent({ : items.value let matchingItem = reOrderedItems.find( - item => item.dataRef.textValue.startsWith(searchQuery.value) && !item.dataRef.disabled + (item) => item.dataRef.textValue.startsWith(searchQuery.value) && !item.dataRef.disabled ) let matchIdx = matchingItem ? items.value.indexOf(matchingItem) : -1 @@ -144,7 +144,7 @@ export let Menu = defineComponent({ let nextItems = items.value.slice() let currentActiveItem = activeItemIndex.value !== null ? nextItems[activeItemIndex.value] : null - let idx = nextItems.findIndex(a => a.id === id) + let idx = nextItems.findIndex((a) => a.id === id) if (idx !== -1) nextItems.splice(idx, 1) items.value = nextItems activeItemIndex.value = (() => { @@ -158,7 +158,7 @@ export let Menu = defineComponent({ }, } - useWindowEvent('mousedown', event => { + useWindowEvent('mousedown', (event) => { let target = event.target as HTMLElement let active = document.activeElement @@ -452,10 +452,7 @@ export let MenuItem = defineComponent({ let dataRef = ref({ disabled: props.disabled, textValue: '' }) onMounted(() => { - let textValue = document - .getElementById(id) - ?.textContent?.toLowerCase() - .trim() + let textValue = document.getElementById(id)?.textContent?.toLowerCase().trim() if (textValue !== undefined) dataRef.value.textValue = textValue }) diff --git a/packages/@headlessui-vue/src/components/popover/popover.test.ts b/packages/@headlessui-vue/src/components/popover/popover.test.ts index 7ba73f697d..3fabac6c15 100644 --- a/packages/@headlessui-vue/src/components/popover/popover.test.ts +++ b/packages/@headlessui-vue/src/components/popover/popover.test.ts @@ -1,4 +1,4 @@ -import { defineComponent, nextTick, ref, watch, h } from 'vue' +import { defineComponent, nextTick, ref, watch, h, ComponentOptionsWithoutProps } from 'vue' import { render } from '../../test-utils/vue-testing-library' import { Popover, PopoverGroup, PopoverButton, PopoverPanel, PopoverOverlay } from './popover' @@ -28,7 +28,7 @@ beforeAll(() => { afterAll(() => jest.restoreAllMocks()) -function renderTemplate(input: string | Partial[0]>) { +function renderTemplate(input: string | ComponentOptionsWithoutProps) { let defaultComponents = { Popover, PopoverGroup, @@ -345,9 +345,7 @@ describe('Rendering', () => { renderTemplate( html` - - Trigger - + Trigger ` ) @@ -361,14 +359,12 @@ describe('Rendering', () => { renderTemplate({ template: html` - - Trigger - + Trigger `, setup: () => ({ CustomButton: defineComponent({ - setup: props => () => h('button', { ...props }), + setup: (props) => () => h('button', { ...props }), }), }), }) @@ -383,9 +379,7 @@ describe('Rendering', () => { renderTemplate( html` - - Trigger - + Trigger ` ) @@ -399,14 +393,12 @@ describe('Rendering', () => { renderTemplate({ template: html` - - Trigger - + Trigger `, setup: () => ({ CustomButton: defineComponent({ - setup: props => () => h('div', props), + setup: (props) => () => h('div', props), }), }), }) @@ -569,9 +561,7 @@ describe('Rendering', () => { Trigger - - Link 1 - + Link 1 Link 2 @@ -757,9 +747,7 @@ describe('Composition', () => { Trigger - - {{JSON.stringify(data)}} - + {{JSON.stringify(data)}} `, @@ -787,9 +775,7 @@ describe('Composition', () => { Trigger - - {{JSON.stringify(data)}} - + {{JSON.stringify(data)}} `, diff --git a/packages/@headlessui-vue/src/components/popover/popover.ts b/packages/@headlessui-vue/src/components/popover/popover.ts index 2349ec2326..f4cba1b756 100644 --- a/packages/@headlessui-vue/src/components/popover/popover.ts +++ b/packages/@headlessui-vue/src/components/popover/popover.ts @@ -528,7 +528,7 @@ export let PopoverPanel = defineComponent({ let nextElements = elements .splice(buttonIdx + 1) // Elements after button - .filter(element => !dom(api.panel)?.contains(element)) // Ignore items in panel + .filter((element) => !dom(api.panel)?.contains(element)) // Ignore items in panel // Try to focus the next element, however it could fail if we are in a // Portal that happens to be the very last one in the DOM. In that @@ -624,7 +624,7 @@ export let PopoverGroup = defineComponent({ if (dom(groupRef)?.contains(element)) return true // Check if the focus is in one of the button or panel elements. This is important in case you are rendering inside a Portal. - return popovers.value.some(bag => { + return popovers.value.some((bag) => { return ( document.getElementById(bag.buttonId)?.contains(element) || document.getElementById(bag.panelId)?.contains(element) diff --git a/packages/@headlessui-vue/src/components/portal/portal.test.ts b/packages/@headlessui-vue/src/components/portal/portal.test.ts index ee03bab472..0c1055e783 100644 --- a/packages/@headlessui-vue/src/components/portal/portal.test.ts +++ b/packages/@headlessui-vue/src/components/portal/portal.test.ts @@ -1,4 +1,4 @@ -import { defineComponent, ref, nextTick } from 'vue' +import { defineComponent, ref, nextTick, ComponentOptionsWithoutProps } from 'vue' import { render } from '../../test-utils/vue-testing-library' import { Portal, PortalGroup } from './portal' @@ -22,7 +22,7 @@ beforeAll(() => { afterAll(() => jest.restoreAllMocks()) -function renderTemplate(input: string | Partial[0]>) { +function renderTemplate(input: string | ComponentOptionsWithoutProps) { let defaultComponents = { Portal, PortalGroup } if (typeof input === 'string') { @@ -108,12 +108,8 @@ it('should cleanup the Portal root when the last Portal is unmounted', async () renderTemplate({ template: html`
- - + +

Contents 1 ...

@@ -182,19 +178,11 @@ it('should be possible to render multiple portals at the same time', async () => renderTemplate({ template: html`
- - - - - + + + + +

Contents 1 ...

@@ -269,12 +257,8 @@ it('should be possible to tamper with the modal root and restore correctly', asy renderTemplate({ template: html`
- - + +

Contents 1 ...

@@ -325,9 +309,7 @@ it('should be possible to force the Portal into a specific element using PortalG renderTemplate({ template: html`
- +
@@ -346,6 +328,6 @@ it('should be possible to force the Portal into a specific element using PortalG await new Promise(nextTick) expect(document.body.innerHTML).toMatchInlineSnapshot( - `"
B
"` + `"
B
"` ) }) diff --git a/packages/@headlessui-vue/src/components/radio-group/radio-group.test.ts b/packages/@headlessui-vue/src/components/radio-group/radio-group.test.ts index 1701836541..aacac780d7 100644 --- a/packages/@headlessui-vue/src/components/radio-group/radio-group.test.ts +++ b/packages/@headlessui-vue/src/components/radio-group/radio-group.test.ts @@ -1,4 +1,4 @@ -import { defineComponent, nextTick, ref, watch, reactive } from 'vue' +import { defineComponent, nextTick, ref, watch, reactive, ComponentOptionsWithoutProps } from 'vue' import { render } from '../../test-utils/vue-testing-library' import { RadioGroup, RadioGroupOption, RadioGroupLabel, RadioGroupDescription } from './radio-group' @@ -25,7 +25,7 @@ beforeAll(() => { afterAll(() => jest.restoreAllMocks()) function nextFrame() { - return new Promise(resolve => { + return new Promise((resolve) => { requestAnimationFrame(() => { requestAnimationFrame(() => { resolve() @@ -34,7 +34,7 @@ function nextFrame() { }) } -function renderTemplate(input: string | Partial[0]>) { +function renderTemplate(input: string | ComponentOptionsWithoutProps) { let defaultComponents = { RadioGroup, RadioGroupOption, RadioGroupLabel, RadioGroupDescription } if (typeof input === 'string') { @@ -86,9 +86,7 @@ describe('Safe guards', () => { it('should be possible to render a RadioGroup without options and without crashing', () => { renderTemplate({ - template: html` - - `, + template: html` `, setup() { let deliveryMethod = ref(undefined) return { deliveryMethod } diff --git a/packages/@headlessui-vue/src/components/radio-group/radio-group.ts b/packages/@headlessui-vue/src/components/radio-group/radio-group.ts index dba087a802..13557f2bbe 100644 --- a/packages/@headlessui-vue/src/components/radio-group/radio-group.ts +++ b/packages/@headlessui-vue/src/components/radio-group/radio-group.ts @@ -99,19 +99,19 @@ export let RadioGroup = defineComponent({ value, disabled: computed(() => props.disabled), firstOption: computed(() => - options.value.find(option => { + options.value.find((option) => { if (option.propsRef.disabled) return false return true }) ), containsCheckedOption: computed(() => - options.value.some(option => toRaw(option.propsRef.value) === toRaw(props.modelValue)) + options.value.some((option) => toRaw(option.propsRef.value) === toRaw(props.modelValue)) ), change(nextValue: unknown) { if (props.disabled) return false if (value.value === nextValue) return false let nextOption = options.value.find( - option => toRaw(option.propsRef.value) === toRaw(nextValue) + (option) => toRaw(option.propsRef.value) === toRaw(nextValue) )?.propsRef if (nextOption?.disabled) return false emit('update:modelValue', nextValue) @@ -129,7 +129,7 @@ export let RadioGroup = defineComponent({ options.value.sort((a, z) => orderMap[a.id] - orderMap[z.id]) }, unregisterOption(id: Option['id']) { - let idx = options.value.findIndex(radio => radio.id === id) + let idx = options.value.findIndex((radio) => radio.id === id) if (idx === -1) return options.value.splice(idx, 1) }, @@ -155,8 +155,8 @@ export let RadioGroup = defineComponent({ if (!radioGroupRef.value.contains(event.target as HTMLElement)) return let all = options.value - .filter(option => option.propsRef.disabled === false) - .map(radio => radio.element) as HTMLElement[] + .filter((option) => option.propsRef.disabled === false) + .map((radio) => radio.element) as HTMLElement[] switch (event.key) { case Keys.ArrowLeft: @@ -169,7 +169,7 @@ export let RadioGroup = defineComponent({ if (result === FocusResult.Success) { let activeOption = options.value.find( - option => option.element === document.activeElement + (option) => option.element === document.activeElement ) if (activeOption) api.change(activeOption.propsRef.value) } @@ -186,7 +186,7 @@ export let RadioGroup = defineComponent({ if (result === FocusResult.Success) { let activeOption = options.value.find( - option => option.element === document.activeElement + (option) => option.element === document.activeElement ) if (activeOption) api.change(activeOption.propsRef.value) } @@ -199,7 +199,7 @@ export let RadioGroup = defineComponent({ event.stopPropagation() let activeOption = options.value.find( - option => option.element === document.activeElement + (option) => option.element === document.activeElement ) if (activeOption) api.change(activeOption.propsRef.value) } diff --git a/packages/@headlessui-vue/src/components/switch/switch.test.tsx b/packages/@headlessui-vue/src/components/switch/switch.test.tsx index 7cc092f559..207239c57c 100644 --- a/packages/@headlessui-vue/src/components/switch/switch.test.tsx +++ b/packages/@headlessui-vue/src/components/switch/switch.test.tsx @@ -1,4 +1,4 @@ -import { defineComponent, ref, watch, h } from 'vue' +import { defineComponent, ref, watch, h, ComponentOptionsWithoutProps } from 'vue' import { render } from '../../test-utils/vue-testing-library' import { Switch, SwitchLabel, SwitchDescription, SwitchGroup } from './switch' @@ -16,7 +16,7 @@ import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs' jest.mock('../../hooks/use-id') -function renderTemplate(input: string | Partial[0]>) { +function renderTemplate(input: string | ComponentOptionsWithoutProps) { let defaultComponents = { Switch, SwitchLabel, SwitchDescription, SwitchGroup } if (typeof input === 'string') { @@ -35,9 +35,7 @@ function renderTemplate(input: string | Partial { it('should be possible to render a Switch without crashing', () => { renderTemplate({ - template: html` - - `, + template: html` `, setup: () => ({ checked: ref(false) }), }) }) @@ -72,9 +70,7 @@ describe('Rendering', () => { it('should be possible to render an (on) Switch using an `as` prop', () => { renderTemplate({ - template: html` - - `, + template: html` `, setup: () => ({ checked: ref(true) }), }) assertSwitch({ state: SwitchState.On, tag: 'span' }) @@ -82,9 +78,7 @@ describe('Rendering', () => { it('should be possible to render an (off) Switch using an `as` prop', () => { renderTemplate({ - template: html` - - `, + template: html` `, setup: () => ({ checked: ref(false) }), }) assertSwitch({ state: SwitchState.Off, tag: 'span' }) @@ -106,11 +100,7 @@ describe('Rendering', () => { describe('`type` attribute', () => { it('should set the `type` to "button" by default', async () => { renderTemplate({ - template: html` - - Trigger - - `, + template: html` Trigger `, setup: () => ({ checked: ref(false) }), }) @@ -119,11 +109,7 @@ describe('Rendering', () => { it('should not set the `type` to "button" if it already contains a `type`', async () => { renderTemplate({ - template: html` - - Trigger - - `, + template: html` Trigger `, setup: () => ({ checked: ref(false) }), }) @@ -134,15 +120,11 @@ describe('Rendering', () => { 'should set the `type` to "button" when using the `as` prop which resolves to a "button"', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - Trigger - - `, + template: html` Trigger `, setup: () => ({ checked: ref(false), CustomButton: defineComponent({ - setup: props => () => h('button', { ...props }), + setup: (props) => () => h('button', { ...props }), }), }), }) @@ -155,11 +137,7 @@ describe('Rendering', () => { it('should not set the type if the "as" prop is not a "button"', async () => { renderTemplate({ - template: html` - - Trigger - - `, + template: html` Trigger `, setup: () => ({ checked: ref(false) }), }) @@ -170,15 +148,11 @@ describe('Rendering', () => { 'should not set the `type` to "button" when using the `as` prop which resolves to a "div"', suppressConsoleLogs(async () => { renderTemplate({ - template: html` - - Trigger - - `, + template: html` Trigger `, setup: () => ({ checked: ref(false), CustomButton: defineComponent({ - setup: props => () => h('div', props), + setup: (props) => () => h('div', props), }), }), }) @@ -213,9 +187,7 @@ describe('Render composition', () => { template: html` Label B - - Label A - + Label A `, setup: () => ({ checked: ref(false) }), @@ -234,9 +206,7 @@ describe('Render composition', () => { renderTemplate({ template: html` - - Label A - + Label A Label B `, @@ -370,9 +340,7 @@ describe('Keyboard interactions', () => { it('should be possible to toggle the Switch with Space', async () => { let handleChange = jest.fn() renderTemplate({ - template: html` - - `, + template: html` `, setup() { let checked = ref(false) watch([checked], () => handleChange(checked.value)) @@ -404,9 +372,7 @@ describe('Keyboard interactions', () => { it('should not be possible to use Enter to toggle the Switch', async () => { let handleChange = jest.fn() renderTemplate({ - template: html` - - `, + template: html` `, setup() { let checked = ref(false) watch([checked], () => handleChange(checked.value)) @@ -461,9 +427,7 @@ describe('Mouse interactions', () => { it('should be possible to toggle the Switch with a click', async () => { let handleChange = jest.fn() renderTemplate({ - template: html` - - `, + template: html` `, setup() { let checked = ref(false) watch([checked], () => handleChange(checked.value)) diff --git a/packages/@headlessui-vue/src/components/tabs/tabs.test.ts b/packages/@headlessui-vue/src/components/tabs/tabs.test.ts index e4573d0368..2ec3f3027a 100644 --- a/packages/@headlessui-vue/src/components/tabs/tabs.test.ts +++ b/packages/@headlessui-vue/src/components/tabs/tabs.test.ts @@ -1,4 +1,4 @@ -import { defineComponent, nextTick, ref } from 'vue' +import { ComponentOptionsWithoutProps, defineComponent, nextTick, ref } from 'vue' import { render } from '../../test-utils/vue-testing-library' import { TabGroup, TabList, Tab, TabPanels, TabPanel } from './tabs' import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs' @@ -20,7 +20,7 @@ beforeAll(() => { afterAll(() => jest.restoreAllMocks()) -function renderTemplate(input: string | Partial[0]>) { +function renderTemplate(input: string | ComponentOptionsWithoutProps) { let defaultComponents = { TabGroup, TabList, Tab, TabPanels, TabPanel } if (typeof input === 'string') { diff --git a/packages/@headlessui-vue/src/components/tabs/tabs.ts b/packages/@headlessui-vue/src/components/tabs/tabs.ts index d7f8a1888c..4799dfb4b9 100644 --- a/packages/@headlessui-vue/src/components/tabs/tabs.ts +++ b/packages/@headlessui-vue/src/components/tabs/tabs.ts @@ -102,8 +102,8 @@ export let TabGroup = defineComponent({ if (api.tabs.value.length <= 0) return if (props.selectedIndex === null && selectedIndex.value !== null) return - let tabs = api.tabs.value.map(tab => dom(tab)).filter(Boolean) as HTMLElement[] - let focusableTabs = tabs.filter(tab => !tab.hasAttribute('disabled')) + let tabs = api.tabs.value.map((tab) => dom(tab)).filter(Boolean) as HTMLElement[] + let focusableTabs = tabs.filter((tab) => !tab.hasAttribute('disabled')) let indexToSet = props.selectedIndex ?? props.defaultIndex @@ -122,7 +122,7 @@ export let TabGroup = defineComponent({ let before = tabs.slice(0, indexToSet) let after = tabs.slice(indexToSet) - let next = [...after, ...before].find(tab => focusableTabs.includes(tab)) + let next = [...after, ...before].find((tab) => focusableTabs.includes(tab)) if (!next) return selectedIndex.value = tabs.indexOf(next) @@ -220,7 +220,7 @@ export let Tab = defineComponent({ let selected = computed(() => myIndex.value === api.selectedIndex.value) function handleKeyDown(event: KeyboardEvent) { - let list = api.tabs.value.map(tab => dom(tab)).filter(Boolean) as HTMLElement[] + let list = api.tabs.value.map((tab) => dom(tab)).filter(Boolean) as HTMLElement[] if (event.key === Keys.Space || event.key === Keys.Enter) { event.preventDefault() diff --git a/packages/@headlessui-vue/src/components/transitions/transition.test.ts b/packages/@headlessui-vue/src/components/transitions/transition.test.ts index fad8025c74..ea01ade06b 100644 --- a/packages/@headlessui-vue/src/components/transitions/transition.test.ts +++ b/packages/@headlessui-vue/src/components/transitions/transition.test.ts @@ -1,4 +1,4 @@ -import { defineComponent, ref, onMounted } from 'vue' +import { defineComponent, ref, onMounted, ComponentOptionsWithoutProps } from 'vue' import { render, fireEvent } from '../../test-utils/vue-testing-library' import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs' @@ -11,7 +11,7 @@ jest.mock('../../hooks/use-id') afterAll(() => jest.restoreAllMocks()) -function renderTemplate(input: string | Partial[0]>) { +function renderTemplate(input: string | ComponentOptionsWithoutProps) { let defaultComponents = { TransitionRoot, TransitionChild } if (typeof input === 'string') { @@ -58,9 +58,7 @@ it('should render without crashing', () => { it('should be possible to render a Transition without children', () => { renderTemplate({ - template: html` - - `, + template: html` `, }) expect(document.getElementsByClassName('transition')).not.toBeNull() }) @@ -91,12 +89,10 @@ describe('Setup API', () => { describe('shallow', () => { it('should render a div and its children by default', () => { let { container } = renderTemplate({ - template: html` - Children - `, + template: html`Children`, }) - expect(container.firstChild).toMatchInlineSnapshot(html` + expect(container.firstChild).toMatchInlineSnapshot(`
Children
@@ -106,9 +102,7 @@ describe('Setup API', () => { it('should passthrough all the props (that we do not use internally)', () => { let { container } = renderTemplate({ template: html` - - Children - + Children `, }) @@ -124,11 +118,7 @@ describe('Setup API', () => { it('should render another component if the `as` prop is used and its children by default', () => { let { container } = renderTemplate({ - template: html` - - Children - - `, + template: html` Children `, }) expect(container.firstChild).toMatchInlineSnapshot(` @@ -159,9 +149,7 @@ describe('Setup API', () => { it('should render nothing when the show prop is false', () => { let { container } = renderTemplate({ - template: html` - Children - `, + template: html` Children `, }) expect(container.firstChild).toMatchInlineSnapshot(``) @@ -169,11 +157,7 @@ describe('Setup API', () => { it('should be possible to change the underlying DOM tag', () => { let { container } = renderTemplate({ - template: html` - - Children - - `, + template: html` Children `, }) expect(container.firstChild).toMatchInlineSnapshot(` @@ -470,9 +454,7 @@ describe('Transitions', () => { Hello!
- + `, setup() { let show = ref(false) @@ -525,9 +507,7 @@ describe('Transitions', () => { Hello!
- + `, setup() { let show = ref(false) @@ -580,9 +560,7 @@ describe('Transitions', () => { Hello! - + `, setup() { let show = ref(false) @@ -630,9 +608,7 @@ describe('Transitions', () => { Hello! - + `, setup() { let show = ref(false) @@ -687,9 +663,7 @@ describe('Transitions', () => { Hello! - + `, setup() { let show = ref(true) @@ -753,9 +727,7 @@ describe('Transitions', () => { Hello! - + `, setup() { let show = ref(true) @@ -822,9 +794,7 @@ describe('Transitions', () => { Hello! - + `, setup() { let show = ref(false) @@ -918,9 +888,7 @@ describe('Transitions', () => { Hello! - + `, setup() { let show = ref(false) @@ -1021,9 +989,7 @@ describe('Transitions', () => { - + `, setup() { let show = ref(true) @@ -1113,9 +1079,7 @@ describe('Transitions', () => { - + `, setup() { let show = ref(true) @@ -1227,9 +1191,7 @@ describe('Events', () => { Hello! - + `, setup() { let show = ref(false) diff --git a/packages/@headlessui-vue/src/components/transitions/transition.ts b/packages/@headlessui-vue/src/components/transitions/transition.ts index baed2a359f..46dd51b50e 100644 --- a/packages/@headlessui-vue/src/components/transitions/transition.ts +++ b/packages/@headlessui-vue/src/components/transitions/transition.ts @@ -31,7 +31,7 @@ import { type ID = ReturnType function splitClasses(classes: string = '') { - return classes.split(' ').filter(className => className.trim().length > 1) + return classes.split(' ').filter((className) => className.trim().length > 1) } interface TransitionContextValues { @@ -292,7 +292,7 @@ export let TransitionChild = defineComponent({ enterFromClasses, enterToClasses, enteredClasses, - reason => { + (reason) => { isTransitioning.value = false if (reason === Reason.Finished) emit('afterEnter') } @@ -303,7 +303,7 @@ export let TransitionChild = defineComponent({ leaveFromClasses, leaveToClasses, enteredClasses, - reason => { + (reason) => { isTransitioning.value = false if (reason !== Reason.Finished) return diff --git a/packages/@headlessui-vue/src/components/transitions/utils/transition.test.ts b/packages/@headlessui-vue/src/components/transitions/utils/transition.test.ts index 9918716179..58cd91a0aa 100644 --- a/packages/@headlessui-vue/src/components/transitions/utils/transition.test.ts +++ b/packages/@headlessui-vue/src/components/transitions/utils/transition.test.ts @@ -17,7 +17,7 @@ it('should be possible to transition', async () => { d.add( reportChanges( () => document.body.innerHTML, - content => { + (content) => { snapshots.push({ content, recordedAt: process.hrtime.bigint(), @@ -26,11 +26,11 @@ it('should be possible to transition', async () => { ) ) - await new Promise(resolve => { + await new Promise((resolve) => { transition(element, ['enter'], ['enterFrom'], ['enterTo'], ['entered'], resolve) }) - await new Promise(resolve => d.nextFrame(resolve)) + await new Promise((resolve) => d.nextFrame(resolve)) // Initial render: expect(snapshots[0].content).toEqual('
') @@ -61,7 +61,7 @@ it('should wait the correct amount of time to finish a transition', async () => d.add( reportChanges( () => document.body.innerHTML, - content => { + (content) => { snapshots.push({ content, recordedAt: process.hrtime.bigint(), @@ -70,11 +70,11 @@ it('should wait the correct amount of time to finish a transition', async () => ) ) - let reason = await new Promise(resolve => { + let reason = await new Promise((resolve) => { transition(element, ['enter'], ['enterFrom'], ['enterTo'], ['entered'], resolve) }) - await new Promise(resolve => d.nextFrame(resolve)) + await new Promise((resolve) => d.nextFrame(resolve)) expect(reason).toBe(Reason.Finished) // Initial render: @@ -118,7 +118,7 @@ it('should keep the delay time into account', async () => { d.add( reportChanges( () => document.body.innerHTML, - content => { + (content) => { snapshots.push({ content, recordedAt: process.hrtime.bigint(), @@ -127,11 +127,11 @@ it('should keep the delay time into account', async () => { ) ) - let reason = await new Promise(resolve => { + let reason = await new Promise((resolve) => { transition(element, ['enter'], ['enterFrom'], ['enterTo'], ['entered'], resolve) }) - await new Promise(resolve => d.nextFrame(resolve)) + await new Promise((resolve) => d.nextFrame(resolve)) expect(reason).toBe(Reason.Finished) let estimatedDuration = Number( @@ -161,7 +161,7 @@ it('should be possible to cancel a transition at any time', async () => { d.add( reportChanges( () => document.body.innerHTML, - content => { + (content) => { let recordedAt = process.hrtime.bigint() let total = snapshots.length @@ -178,16 +178,16 @@ it('should be possible to cancel a transition at any time', async () => { expect.assertions(2) // Setup the transition - let cancel = transition(element, ['enter'], ['enterFrom'], ['enterTo'], ['entered'], reason => { + let cancel = transition(element, ['enter'], ['enterFrom'], ['enterTo'], ['entered'], (reason) => { expect(reason).toBe(Reason.Cancelled) }) // Wait for a bit - await new Promise(resolve => setTimeout(resolve, 20)) + await new Promise((resolve) => setTimeout(resolve, 20)) // Cancel the transition cancel() - await new Promise(resolve => d.nextFrame(resolve)) + await new Promise((resolve) => d.nextFrame(resolve)) - expect(snapshots.map(snapshot => snapshot.content).join('\n')).not.toContain('enterTo') + expect(snapshots.map((snapshot) => snapshot.content).join('\n')).not.toContain('enterTo') }) diff --git a/packages/@headlessui-vue/src/components/transitions/utils/transition.ts b/packages/@headlessui-vue/src/components/transitions/utils/transition.ts index 8382141da9..008a01375a 100644 --- a/packages/@headlessui-vue/src/components/transitions/utils/transition.ts +++ b/packages/@headlessui-vue/src/components/transitions/utils/transition.ts @@ -22,13 +22,13 @@ function waitForTransition(node: HTMLElement, done: (reason: Reason) => void) { // Safari returns a comma separated list of values, so let's sort them and take the highest value. let { transitionDuration, transitionDelay } = getComputedStyle(node) - let [durationMs, delaysMs] = [transitionDuration, transitionDelay].map(value => { + let [durationMs, delaysMs] = [transitionDuration, transitionDelay].map((value) => { let [resolvedValue = 0] = value .split(',') // Remove falseys we can't work with .filter(Boolean) // Values are returned as `0.3s` or `75ms` - .map(v => (v.includes('ms') ? parseFloat(v) : parseFloat(v) * 1000)) + .map((v) => (v.includes('ms') ? parseFloat(v) : parseFloat(v) * 1000)) .sort((a, z) => z - a) return resolvedValue @@ -72,7 +72,7 @@ export function transition( addClasses(node, ...to) d.add( - waitForTransition(node, reason => { + waitForTransition(node, (reason) => { removeClasses(node, ...to, ...base) addClasses(node, ...entered) return _done(reason) diff --git a/packages/@headlessui-vue/src/hooks/use-focus-trap.ts b/packages/@headlessui-vue/src/hooks/use-focus-trap.ts index e5a070e721..034b64760e 100644 --- a/packages/@headlessui-vue/src/hooks/use-focus-trap.ts +++ b/packages/@headlessui-vue/src/hooks/use-focus-trap.ts @@ -75,7 +75,7 @@ export function useFocusTrap( onUnmounted(restore) // Handle Tab & Shift+Tab keyboard events - useWindowEvent('keydown', event => { + useWindowEvent('keydown', (event) => { if (!enabled.value) return if (event.key !== Keys.Tab) return if (!document.activeElement) return @@ -99,7 +99,7 @@ export function useFocusTrap( // Prevent programmatically escaping useWindowEvent( 'focus', - event => { + (event) => { if (!enabled.value) return if (containers.value.size !== 1) return diff --git a/packages/@headlessui-vue/src/hooks/use-inert-others.test.ts b/packages/@headlessui-vue/src/hooks/use-inert-others.test.ts index 46b5ed6643..3b82253a55 100644 --- a/packages/@headlessui-vue/src/hooks/use-inert-others.test.ts +++ b/packages/@headlessui-vue/src/hooks/use-inert-others.test.ts @@ -1,4 +1,4 @@ -import { defineComponent, ref, nextTick } from 'vue' +import { defineComponent, ref, nextTick, ComponentOptionsWithoutProps } from 'vue' import { render } from '../test-utils/vue-testing-library' import { useInertOthers } from './use-inert-others' @@ -14,7 +14,7 @@ beforeAll(() => { afterAll(() => jest.restoreAllMocks()) -function renderTemplate(input: string | Partial[0]>) { +function renderTemplate(input: string | ComponentOptionsWithoutProps) { let defaultComponents = {} if (typeof input === 'string') { @@ -35,16 +35,12 @@ function renderTemplate(input: string | Partialbefore
- `, + template: html`
before
`, }) let After = defineComponent({ name: 'After', - template: html` -
after
- `, + template: html`
after
`, }) it('should be possible to inert other elements', async () => { diff --git a/packages/@headlessui-vue/src/hooks/use-inert-others.ts b/packages/@headlessui-vue/src/hooks/use-inert-others.ts index ae85338561..5d28bf7d2a 100644 --- a/packages/@headlessui-vue/src/hooks/use-inert-others.ts +++ b/packages/@headlessui-vue/src/hooks/use-inert-others.ts @@ -32,7 +32,7 @@ export function useInertOthers( container: Ref, enabled: Ref = ref(true) ) { - watchEffect(onInvalidate => { + watchEffect((onInvalidate) => { if (!enabled.value) return if (!container.value) return @@ -50,7 +50,7 @@ export function useInertOthers( } // Collect direct children of the body - document.querySelectorAll(CHILDREN_SELECTOR).forEach(child => { + document.querySelectorAll(CHILDREN_SELECTOR).forEach((child) => { if (!(child instanceof HTMLElement)) return // Skip non-HTMLElements // Skip the interactables, and the parents of the interactables @@ -79,7 +79,7 @@ export function useInertOthers( // will become inert as well. if (interactables.size > 0) { // Collect direct children of the body - document.querySelectorAll(CHILDREN_SELECTOR).forEach(child => { + document.querySelectorAll(CHILDREN_SELECTOR).forEach((child) => { if (!(child instanceof HTMLElement)) return // Skip non-HTMLElements // Skip already inert parents diff --git a/packages/@headlessui-vue/src/hooks/use-tree-walker.ts b/packages/@headlessui-vue/src/hooks/use-tree-walker.ts index 9b216e337b..27f45bd5a2 100644 --- a/packages/@headlessui-vue/src/hooks/use-tree-walker.ts +++ b/packages/@headlessui-vue/src/hooks/use-tree-walker.ts @@ -24,6 +24,7 @@ export function useTreeWalker({ if (enabled !== undefined && !enabled.value) return let acceptNode = Object.assign((node: HTMLElement) => accept(node), { acceptNode: accept }) + // @ts-expect-error This `false` is a simple small fix for older browsers let walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, acceptNode, false) while (walker.nextNode()) walk(walker.currentNode as HTMLElement) diff --git a/packages/@headlessui-vue/src/hooks/use-window-event.ts b/packages/@headlessui-vue/src/hooks/use-window-event.ts index 8929c0dd89..987802e92e 100644 --- a/packages/@headlessui-vue/src/hooks/use-window-event.ts +++ b/packages/@headlessui-vue/src/hooks/use-window-event.ts @@ -7,7 +7,7 @@ export function useWindowEvent( ) { if (typeof window === 'undefined') return - watchEffect(onInvalidate => { + watchEffect((onInvalidate) => { window.addEventListener(type, listener, options) onInvalidate(() => { diff --git a/packages/@headlessui-vue/src/internal/stack-context.ts b/packages/@headlessui-vue/src/internal/stack-context.ts index 1f468469c3..595a8756fd 100644 --- a/packages/@headlessui-vue/src/internal/stack-context.ts +++ b/packages/@headlessui-vue/src/internal/stack-context.ts @@ -24,7 +24,7 @@ export function useStackContext() { export function useElemenStack(element: Ref | null) { let notify = useStackContext() - watchEffect(onInvalidate => { + watchEffect((onInvalidate) => { let domElement = element?.value if (!domElement) return diff --git a/packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts b/packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts index f478642b9a..d2ef0479ed 100644 --- a/packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts +++ b/packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts @@ -1256,7 +1256,7 @@ export function assertLabelValue(element: HTMLElement | null, value: string) { if (element.hasAttribute('aria-labelledby')) { let ids = element.getAttribute('aria-labelledby')!.split(' ') - expect(ids.map(id => document.getElementById(id)?.textContent).join(' ')).toEqual(value) + expect(ids.map((id) => document.getElementById(id)?.textContent).join(' ')).toEqual(value) return } @@ -1612,7 +1612,7 @@ export function assertTabs( expect(list).toHaveAttribute('aria-orientation', orientation) let activeTab = Array.from(list.querySelectorAll('[id^="headlessui-tabs-tab-"]'))[active] - let activePanel = panels.find(panel => panel.id === activeTab.getAttribute('aria-controls')) + let activePanel = panels.find((panel) => panel.id === activeTab.getAttribute('aria-controls')) for (let tab of tabs) { expect(tab).toHaveAttribute('id') diff --git a/packages/@headlessui-vue/src/test-utils/execute-timeline.ts b/packages/@headlessui-vue/src/test-utils/execute-timeline.ts index 0fbec32088..5e9f9c7d08 100644 --- a/packages/@headlessui-vue/src/test-utils/execute-timeline.ts +++ b/packages/@headlessui-vue/src/test-utils/execute-timeline.ts @@ -18,7 +18,7 @@ function redentSnapshot(input: string) { return input .split('\n') - .map(line => + .map((line) => line.trim() === '---' ? line : line.replace(replacer, (_, sign, rest) => `${sign} ${rest}`) ) .join('\n') @@ -70,13 +70,13 @@ export async function executeTimeline( .reduce((total, current) => total + current, 0) // Changes happen in the next frame - await new Promise(resolve => d.nextFrame(resolve)) + await new Promise((resolve) => d.nextFrame(resolve)) // We wait for the amount of the duration - await new Promise(resolve => d.setTimeout(resolve, totalDuration)) + await new Promise((resolve) => d.setTimeout(resolve, totalDuration)) // We wait an additional next frame so that we know that we are done - await new Promise(resolve => d.nextFrame(resolve)) + await new Promise((resolve) => d.nextFrame(resolve)) }, Promise.resolve()) if (snapshots.length <= 0) { @@ -128,7 +128,7 @@ export async function executeTimeline( .replace(/Snapshot Diff:\n/g, '') ) .split('\n') - .map(line => ` ${line}`) + .map((line) => ` ${line}`) .join('\n')}` }) .filter(Boolean) diff --git a/packages/@headlessui-vue/src/test-utils/interactions.test.ts b/packages/@headlessui-vue/src/test-utils/interactions.test.ts index 3dab1e8a0f..990276d573 100644 --- a/packages/@headlessui-vue/src/test-utils/interactions.test.ts +++ b/packages/@headlessui-vue/src/test-utils/interactions.test.ts @@ -1,12 +1,12 @@ import { render } from './vue-testing-library' import { type, shift, Keys } from './interactions' -import { defineComponent, h } from 'vue' +import { ComponentOptionsWithoutProps, defineComponent, h } from 'vue' type Events = 'onKeydown' | 'onKeyup' | 'onKeypress' | 'onClick' | 'onBlur' | 'onFocus' let events: Events[] = ['onKeydown', 'onKeyup', 'onKeypress', 'onClick', 'onBlur', 'onFocus'] -function renderTemplate(input: string | Partial[0]>) { +function renderTemplate(input: string | ComponentOptionsWithoutProps) { let defaultComponents = {} if (typeof input === 'string') { @@ -164,7 +164,7 @@ describe('Keyboard', () => { let state = { readyToCapture: false } function createProps(id: string) { - return events.reduce( + return events.reduce void)>>( (props, name) => { props[name] = (event: any) => { if (!state.readyToCapture) return @@ -202,7 +202,7 @@ describe('Keyboard', () => { await type([key(input)]) - let expected = result.map(e => event(e)) + let expected = result.map((e) => event(e)) expect(fired.length).toEqual(result.length) diff --git a/packages/@headlessui-vue/src/test-utils/interactions.ts b/packages/@headlessui-vue/src/test-utils/interactions.ts index 8e93518bb1..600e605d1a 100644 --- a/packages/@headlessui-vue/src/test-utils/interactions.ts +++ b/packages/@headlessui-vue/src/test-utils/interactions.ts @@ -36,7 +36,7 @@ export function shift(event: Partial) { } export function word(input: string): Partial[] { - let result = input.split('').map(key => ({ key })) + let result = input.split('').map((key) => ({ key })) d.enqueue(() => { let element = document.activeElement @@ -152,7 +152,7 @@ export async function type(events: Partial[], element = document. let actions = order[event.key!] ?? order[Default as any] for (let action of actions) { let checks = action.name.split('And') - if (checks.some(check => skip.has(check))) continue + if (checks.some((check) => skip.has(check))) continue let result = action(element, { type: action.name, @@ -344,8 +344,8 @@ let focusableSelector = [ ? // TODO: Remove this once JSDOM fixes the issue where an element that is // "hidden" can be the document.activeElement, because this is not possible // in real browsers. - selector => `${selector}:not([tabindex='-1']):not([style*='display: none'])` - : selector => `${selector}:not([tabindex='-1'])` + (selector) => `${selector}:not([tabindex='-1']):not([style*='display: none'])` + : (selector) => `${selector}:not([tabindex='-1'])` ) .join(',') diff --git a/packages/@headlessui-vue/src/test-utils/suppress-console-logs.ts b/packages/@headlessui-vue/src/test-utils/suppress-console-logs.ts index 8e63bfeb05..b2833c996a 100644 --- a/packages/@headlessui-vue/src/test-utils/suppress-console-logs.ts +++ b/packages/@headlessui-vue/src/test-utils/suppress-console-logs.ts @@ -5,10 +5,10 @@ type FunctionPropertyNames = { export function suppressConsoleLogs( cb: (...args: T) => void, - type: FunctionPropertyNames = 'warn' + type: FunctionPropertyNames = 'warn' ) { return (...args: T) => { - let spy = jest.spyOn(global.console, type).mockImplementation(jest.fn()) + let spy = jest.spyOn(globalThis.console, type).mockImplementation(jest.fn()) return new Promise((resolve, reject) => { Promise.resolve(cb(...args)).then(resolve, reject) diff --git a/packages/@headlessui-vue/src/utils/calculate-active-index.ts b/packages/@headlessui-vue/src/utils/calculate-active-index.ts index cc296a9068..16ed66ffb0 100644 --- a/packages/@headlessui-vue/src/utils/calculate-active-index.ts +++ b/packages/@headlessui-vue/src/utils/calculate-active-index.ts @@ -40,7 +40,7 @@ export function calculateActiveIndex( let nextActiveIndex = (() => { switch (action.focus) { case Focus.First: - return items.findIndex(item => !resolvers.resolveDisabled(item)) + return items.findIndex((item) => !resolvers.resolveDisabled(item)) case Focus.Previous: { let idx = items @@ -64,13 +64,13 @@ export function calculateActiveIndex( let idx = items .slice() .reverse() - .findIndex(item => !resolvers.resolveDisabled(item)) + .findIndex((item) => !resolvers.resolveDisabled(item)) if (idx === -1) return idx return items.length - 1 - idx } case Focus.Specific: - return items.findIndex(item => resolvers.resolveId(item) === action.id) + return items.findIndex((item) => resolvers.resolveId(item) === action.id) case Focus.Nothing: return null diff --git a/packages/@headlessui-vue/src/utils/focus-management.ts b/packages/@headlessui-vue/src/utils/focus-management.ts index c2dca21ae3..06ac5bd6d3 100644 --- a/packages/@headlessui-vue/src/utils/focus-management.ts +++ b/packages/@headlessui-vue/src/utils/focus-management.ts @@ -18,8 +18,8 @@ let focusableSelector = [ ? // TODO: Remove this once JSDOM fixes the issue where an element that is // "hidden" can be the document.activeElement, because this is not possible // in real browsers. - selector => `${selector}:not([tabindex='-1']):not([style*='display: none'])` - : selector => `${selector}:not([tabindex='-1'])` + (selector) => `${selector}:not([tabindex='-1']):not([style*='display: none'])` + : (selector) => `${selector}:not([tabindex='-1'])` ) .join(',') diff --git a/packages/@headlessui-vue/src/utils/match.ts b/packages/@headlessui-vue/src/utils/match.ts index 80496d12a2..c4becd32cd 100644 --- a/packages/@headlessui-vue/src/utils/match.ts +++ b/packages/@headlessui-vue/src/utils/match.ts @@ -12,7 +12,7 @@ export function match `"${key}"`) + .map((key) => `"${key}"`) .join(', ')}.` ) if (Error.captureStackTrace) Error.captureStackTrace(error, match) diff --git a/packages/@headlessui-vue/src/utils/render.test.ts b/packages/@headlessui-vue/src/utils/render.test.ts index 0bc0e026a8..287ce0ff30 100644 --- a/packages/@headlessui-vue/src/utils/render.test.ts +++ b/packages/@headlessui-vue/src/utils/render.test.ts @@ -1,4 +1,4 @@ -import { defineComponent } from 'vue' +import { defineComponent, ComponentOptionsWithoutProps } from 'vue' import { render as testRender } from '../test-utils/vue-testing-library' import { render } from './render' @@ -13,7 +13,7 @@ let Dummy = defineComponent({ }, }) -function renderTemplate(input: string | Partial[0]>) { +function renderTemplate(input: string | ComponentOptionsWithoutProps) { let defaultComponents = { Dummy } if (typeof input === 'string') { @@ -34,9 +34,7 @@ describe('Validation', () => { expect.hasAssertions() renderTemplate({ - template: html` - Contents - `, + template: html` Contents `, errorCaptured(err) { expect(err as Error).toEqual( new Error( diff --git a/packages/@headlessui-vue/src/utils/render.ts b/packages/@headlessui-vue/src/utils/render.ts index 27c3b77428..b8e3ffd3f8 100644 --- a/packages/@headlessui-vue/src/utils/render.ts +++ b/packages/@headlessui-vue/src/utils/render.ts @@ -98,7 +98,7 @@ function _render({ `However we need to passthrough the following props:`, Object.keys(passThroughProps) .concat(Object.keys(attrs)) - .map(line => ` - ${line}`) + .map((line) => ` - ${line}`) .join('\n'), '', 'You can apply a few solutions:', @@ -106,7 +106,7 @@ function _render({ 'Add an `as="..."` prop, to ensure that we render an actual element instead of a "template".', 'Render a single element as the child so that we can forward the props onto that element.', ] - .map(line => ` - ${line}`) + .map((line) => ` - ${line}`) .join('\n'), ].join('\n') ) diff --git a/packages/@headlessui-vue/tsconfig.json b/packages/@headlessui-vue/tsconfig.json index a62faa1a0b..cc575b4b35 100644 --- a/packages/@headlessui-vue/tsconfig.json +++ b/packages/@headlessui-vue/tsconfig.json @@ -24,7 +24,6 @@ "allowJs": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, - "noEmit": true, "resolveJsonModule": true, "isolatedModules": true }, diff --git a/packages/@headlessui-vue/tsconfig.tsdx.json b/packages/@headlessui-vue/tsconfig.tsdx.json deleted file mode 100644 index fc8520e737..0000000000 --- a/packages/@headlessui-vue/tsconfig.tsdx.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "extends": "./tsconfig.json" -} diff --git a/packages/@headlessui-vue/tsdx.config.js b/packages/@headlessui-vue/tsdx.config.js deleted file mode 100644 index 8a2668d598..0000000000 --- a/packages/@headlessui-vue/tsdx.config.js +++ /dev/null @@ -1,15 +0,0 @@ -const globals = { - vue: 'Vue', -} - -module.exports = { - rollup(config, opts) { - for (let key in globals) config.output.globals[key] = globals[key] - if (opts.format === 'esm') { - config = { ...config, preserveModules: true } - config.output = { ...config.output, dir: 'dist/', entryFileNames: '[name].esm.js' } - delete config.output.file - } - return config - }, -} diff --git a/packages/@headlessui-vue/types/jest.d.ts b/packages/@headlessui-vue/types/jest.d.ts new file mode 100644 index 0000000000..61902a8f06 --- /dev/null +++ b/packages/@headlessui-vue/types/jest.d.ts @@ -0,0 +1,9 @@ +export {} + +declare global { + namespace jest { + interface Matchers { + toBeWithinRenderFrame(actual: number): R + } + } +} diff --git a/packages/playground-react/package.json b/packages/playground-react/package.json index 40c43256a8..ad6f0012d6 100644 --- a/packages/playground-react/package.json +++ b/packages/playground-react/package.json @@ -1,17 +1,16 @@ { "name": "playground-react", - "version": "1.0.0", - "main": "next.config.js", + "private": true, + "version": "0.0.0", "scripts": { "prebuild": "yarn workspace @headlessui/react build", - "dev": "next dev", + "dev:headlessui": "yarn workspace @headlessui/react watch", + "dev:next": "next dev", + "dev": "npm-run-all -p dev:*", "build": "next build", - "start": "next start" + "start": "next start", + "clean": "rimraf ./.next" }, - "keywords": [], - "author": "Robin Malfait", - "license": "ISC", - "description": "", "dependencies": { "@headlessui/react": "*", "@popperjs/core": "^2.6.0", diff --git a/packages/playground-react/pages/_app.tsx b/packages/playground-react/pages/_app.tsx index 671e2ee472..ed848be441 100644 --- a/packages/playground-react/pages/_app.tsx +++ b/packages/playground-react/pages/_app.tsx @@ -113,13 +113,13 @@ function KeyCaster() { useEffect(() => { function handler(event: KeyboardEvent) { - setKeys(current => [ + setKeys((current) => [ event.shiftKey && event.key !== 'Shift' ? KeyDisplay[`Shift${event.key}`] ?? event.key : KeyDisplay[event.key] ?? event.key, ...current, ]) - d.setTimeout(() => setKeys(current => tap(current.slice(), clone => clone.pop())), 2000) + d.setTimeout(() => setKeys((current) => tap(current.slice(), (clone) => clone.pop())), 2000) } window.addEventListener('keydown', handler, true) @@ -129,11 +129,8 @@ function KeyCaster() { if (keys.length <= 0) return null return ( -
- {keys - .slice() - .reverse() - .join(' ')} +
+ {keys.slice().reverse().join(' ')}
) } @@ -162,8 +159,8 @@ function MyApp({ Component, pageProps }) { /> -
-
+
+
diff --git a/packages/playground-react/pages/_error.tsx b/packages/playground-react/pages/_error.tsx index 21c1a8adad..d20a86ce54 100644 --- a/packages/playground-react/pages/_error.tsx +++ b/packages/playground-react/pages/_error.tsx @@ -46,7 +46,7 @@ export default function Page(props: { examples: false | ExamplesType[] }) { export function Examples(props: { examples: ExamplesType[] }) { return (