diff --git a/jest.config.js b/jest.config.js index 5133826..1acc735 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,15 +1,3 @@ -const path = require('path') - -function relativeToPackage(configPath) { - return path.resolve(process.cwd(), process.env.npm_package_repository_directory, configPath) -} - module.exports = { - setupFilesAfterEnv: ['/jest/custom-matchers.ts'], - globals: { - 'ts-jest': { - isolatedModules: true, - tsConfig: relativeToPackage('./tsconfig.tsdx.json'), - }, - }, + projects: ['/packages/*/jest.config.js'], } diff --git a/jest/create-jest-config.js b/jest/create-jest-config.js new file mode 100644 index 0000000..5cbedca --- /dev/null +++ b/jest/create-jest-config.js @@ -0,0 +1,19 @@ +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', + }, + }, + }, + options + ) +} diff --git a/jest/custom-matchers.ts b/jest/custom-matchers.ts index 31d1e93..7c16750 100644 --- a/jest/custom-matchers.ts +++ b/jest/custom-matchers.ts @@ -1,31 +1,31 @@ +import '@testing-library/jest-dom/extend-expect' + // Assuming requestAnimationFrame is roughly 60 frames per second const frame = 1000 / 60 +const amountOfFrames = 2 const formatter = new Intl.NumberFormat('en') expect.extend({ toBeWithinRenderFrame(actual, expected) { - const min = expected - frame - const max = expected + frame + const min = expected - frame * amountOfFrames + const max = expected + frame * amountOfFrames const pass = actual >= min && actual <= max - if (pass) { - return { - message: () => - `expected ${actual} not to be within range of a frame ${formatter.format( - min - )} - ${formatter.format(max)}`, - pass: true, - } - } else { - return { - message: () => - `expected ${actual} to be within range of a frame ${formatter.format( - min - )} - ${formatter.format(max)}`, - pass: false, - } + return { + message: pass + ? () => { + return `expected ${actual} not to be within range of a frame ${formatter.format( + min + )} - ${formatter.format(max)}` + } + : () => { + return `expected ${actual} not to be within range of a frame ${formatter.format( + min + )} - ${formatter.format(max)}` + }, + pass, } }, }) diff --git a/package.json b/package.json index 94c3b1d..803cfdd 100644 --- a/package.json +++ b/package.json @@ -14,14 +14,17 @@ "vue": "yarn workspace @headlessui/vue", "shared": "yarn workspace @headlessui/shared", "build": "yarn workspaces run build", - "test": "yarn workspaces run test", + "test": "./scripts/test.sh", "lint": "./scripts/lint.sh" }, "husky": { "hooks": { - "pre-commit": "yarn lint" + "pre-commit": "lint-staged" } }, + "lint-staged": { + "*.{js,jsx,ts,tsx,md,html,css,vue}": "tsdx lint" + }, "prettier": { "printWidth": 100, "semi": false, @@ -29,11 +32,15 @@ "trailingComma": "es5" }, "devDependencies": { - "@types/node": "^14.10.1", + "@tailwindcss/ui": "^0.6.2", + "@testing-library/jest-dom": "^5.11.4", + "@types/node": "^14.11.2", "babel-jest": "^26.3.0", "husky": "^4.3.0", "jest": "^26.4.2", + "lint-staged": "^10.4.0", "prismjs": "^1.21.0", + "tailwindcss": "^1.8.10", "tsdx": "^0.13.3", "tslib": "^2.0.1", "typescript": "^3.9.7" diff --git a/packages/@headlessui-react/jest.config.js b/packages/@headlessui-react/jest.config.js new file mode 100644 index 0000000..95fe4bf --- /dev/null +++ b/packages/@headlessui-react/jest.config.js @@ -0,0 +1,3 @@ +const create = require('../../jest/create-jest-config.js') + +module.exports = create(__dirname, { displayName: 'React' }) diff --git a/packages/@headlessui-react/package.json b/packages/@headlessui-react/package.json index ad2440f..2270c0e 100644 --- a/packages/@headlessui-react/package.json +++ b/packages/@headlessui-react/package.json @@ -33,9 +33,9 @@ "devDependencies": { "@types/react": "^16.9.49", "@types/react-dom": "^16.9.8", - "@popperjs/core": "^2.4.4", - "@testing-library/react": "^11.0.2", - "framer-motion": "^2.6.13", + "@popperjs/core": "^2.5.3", + "@testing-library/react": "^11.0.4", + "framer-motion": "^2.7.6", "next": "9.5.3", "react": "^16.13.1", "react-dom": "^16.13.1", diff --git a/packages/@headlessui-react/pages/listbox/listbox-with-pure-tailwind.tsx b/packages/@headlessui-react/pages/listbox/listbox-with-pure-tailwind.tsx new file mode 100644 index 0000000..b36365e --- /dev/null +++ b/packages/@headlessui-react/pages/listbox/listbox-with-pure-tailwind.tsx @@ -0,0 +1,115 @@ +import * as React from 'react' +import { Listbox } from '@headlessui/react' + +import { classNames } from '../../src/utils/class-names' + +const people = [ + 'Wade Cooper', + 'Arlene Mccoy', + 'Devon Webb', + 'Tom Cook', + 'Tanya Fox', + 'Hellen Schmidt', + 'Caronline Schultz', + 'Mason Heaney', + 'Claudie Smitham', + 'Emil Schaefer', +] + +export default function Home() { + const [active, setActivePerson] = React.useState(people[2]) + + // Choose a random person on mount + React.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/@headlessui-react/pages/menu/menu-with-transition-and-popper.tsx b/packages/@headlessui-react/pages/menu/menu-with-transition-and-popper.tsx index c45d2d6..d9ad495 100644 --- a/packages/@headlessui-react/pages/menu/menu-with-transition-and-popper.tsx +++ b/packages/@headlessui-react/pages/menu/menu-with-transition-and-popper.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import { Menu } from '@headlessui/react' +import { Menu, Transition } from '@headlessui/react' import { usePopper } from '../../playground-utils/hooks/use-popper' @@ -26,64 +26,73 @@ export default function Home() {
- - - Options - - - - - + {({ open }) => ( + <> + + + Options + + + + + -
- -
-

Signed in as

-

- tom@example.com -

-
+
+ + +
+

Signed in as

+

+ tom@example.com +

+
-
- - Account settings - - - {data => ( - - Support - - )} - - - New feature (soon) - - - License - +
+ + Account settings + + + {data => ( + + Support + + )} + + + New feature (soon) + + + License + +
+
+ + Sign out + +
+ +
-
- - Sign out - -
-
-
+ + )}
diff --git a/packages/@headlessui-react/pages/menu/menu-with-transition.tsx b/packages/@headlessui-react/pages/menu/menu-with-transition.tsx index 5be1407..7f2c53c 100644 --- a/packages/@headlessui-react/pages/menu/menu-with-transition.tsx +++ b/packages/@headlessui-react/pages/menu/menu-with-transition.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import { Menu } from '@headlessui/react' +import { Menu, Transition } from '@headlessui/react' function classNames(...classes) { return classes.filter(Boolean).join(' ') @@ -18,56 +18,65 @@ export default function Home() {
- - - Options - - - - - + {({ open }) => ( + <> + + + Options + + + + + - -
-

Signed in as

-

- tom@example.com -

-
+ + +
+

Signed in as

+

+ tom@example.com +

+
-
- - Account settings - - - Support - - - New feature (soon) - - - License - -
+
+ + Account settings + + + Support + + + New feature (soon) + + + License + +
-
- - Sign out - -
-
+
+ + Sign out + +
+
+ + + )}
diff --git a/packages/@headlessui-react/src/components/keyboard.ts b/packages/@headlessui-react/src/components/keyboard.ts new file mode 100644 index 0000000..41d6fee --- /dev/null +++ b/packages/@headlessui-react/src/components/keyboard.ts @@ -0,0 +1,19 @@ +// TODO: This must already exist somewhere, right? 🤔 +// Ref: https://www.w3.org/TR/uievents-key/#named-key-attribute-values +export enum Keys { + Space = ' ', + Enter = 'Enter', + Escape = 'Escape', + Backspace = 'Backspace', + + ArrowUp = 'ArrowUp', + ArrowDown = 'ArrowDown', + + Home = 'Home', + End = 'End', + + PageUp = 'PageUp', + PageDown = 'PageDown', + + Tab = 'Tab', +} diff --git a/packages/@headlessui-react/src/components/listbox/listbox.test.tsx b/packages/@headlessui-react/src/components/listbox/listbox.test.tsx new file mode 100644 index 0000000..eb559bd --- /dev/null +++ b/packages/@headlessui-react/src/components/listbox/listbox.test.tsx @@ -0,0 +1,3101 @@ +import React from 'react' +import { render } from '@testing-library/react' + +import { Listbox } from './listbox' +import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs' +import { + click, + focus, + mouseMove, + mouseLeave, + press, + shift, + type, + word, + Keys, +} from '../../test-utils/interactions' +import { + assertActiveElement, + assertActiveListboxOption, + assertListbox, + assertListboxButton, + assertListboxButtonLinkedWithListbox, + assertListboxButtonLinkedWithListboxLabel, + assertListboxOption, + assertListboxLabel, + assertListboxLabelLinkedWithListbox, + assertNoActiveListboxOption, + assertNoSelectedListboxOption, + getListbox, + getListboxButton, + getListboxButtons, + getListboxes, + getListboxOptions, + getListboxLabel, + ListboxState, +} from '../../test-utils/accessibility-assertions' + +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([ + ['Listbox.Button', Listbox.Button], + ['Listbox.Label', Listbox.Label], + ['Listbox.Options', Listbox.Options], + ['Listbox.Option', Listbox.Option], + ])( + 'should error when we are using a <%s /> without a parent ', + suppressConsoleLogs((name, Component) => { + expect(() => render(React.createElement(Component))).toThrowError( + `<${name} /> is missing a parent component.` + ) + }) + ) + + it( + 'should be possible to render a Listbox without crashing', + suppressConsoleLogs(async () => { + render( + + Trigger + + Option A + Option B + Option C + + + ) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + }) + ) +}) + +describe('Rendering', () => { + describe('Listbox', () => { + it( + 'should be possible to render a Listbox using a render prop', + suppressConsoleLogs(async () => { + render( + + {({ open }) => ( + <> + Trigger + {open && ( + + Option A + Option B + Option C + + )} + + )} + + ) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + await click(getListboxButton()) + + assertListboxButton({ + state: ListboxState.Open, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Open }) + }) + ) + }) + + describe('Listbox.Label', () => { + it( + 'should be possible to render a Listbox.Label using a render prop', + suppressConsoleLogs(async () => { + render( + + {JSON.stringify} + Trigger + + Option A + Option B + Option C + + + ) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-2' }, + }) + assertListboxLabel({ + attributes: { id: 'headlessui-listbox-label-1' }, + textContent: JSON.stringify({ open: false }), + }) + assertListbox({ state: ListboxState.Closed }) + + await click(getListboxButton()) + + assertListboxLabel({ + attributes: { id: 'headlessui-listbox-label-1' }, + textContent: JSON.stringify({ open: true }), + }) + assertListbox({ state: ListboxState.Open }) + assertListboxLabelLinkedWithListbox() + assertListboxButtonLinkedWithListboxLabel() + }) + ) + + it( + 'should be possible to render a Listbox.Label using a render prop and an `as` prop', + suppressConsoleLogs(async () => { + render( + + {JSON.stringify} + Trigger + + Option A + Option B + Option C + + + ) + + assertListboxLabel({ + attributes: { id: 'headlessui-listbox-label-1' }, + textContent: JSON.stringify({ open: false }), + tag: 'p', + }) + assertListbox({ state: ListboxState.Closed }) + + await click(getListboxButton()) + assertListboxLabel({ + attributes: { id: 'headlessui-listbox-label-1' }, + textContent: JSON.stringify({ open: true }), + tag: 'p', + }) + assertListbox({ state: ListboxState.Open }) + }) + ) + }) + + describe('Listbox.Button', () => { + it( + 'should be possible to render a Listbox.Button using a render prop', + suppressConsoleLogs(async () => { + render( + + {JSON.stringify} + + Option A + Option B + Option C + + + ) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + textContent: JSON.stringify({ open: false, focused: false }), + }) + assertListbox({ state: ListboxState.Closed }) + + await click(getListboxButton()) + + assertListboxButton({ + state: ListboxState.Open, + attributes: { id: 'headlessui-listbox-button-1' }, + textContent: JSON.stringify({ open: true, focused: false }), + }) + assertListbox({ state: ListboxState.Open }) + }) + ) + + it( + 'should be possible to render a Listbox.Button using a render prop and an `as` prop', + suppressConsoleLogs(async () => { + render( + + + {JSON.stringify} + + + Option A + Option B + Option C + + + ) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + textContent: JSON.stringify({ open: false, focused: false }), + }) + assertListbox({ state: ListboxState.Closed }) + + await click(getListboxButton()) + + assertListboxButton({ + state: ListboxState.Open, + attributes: { id: 'headlessui-listbox-button-1' }, + textContent: JSON.stringify({ open: true, focused: false }), + }) + assertListbox({ state: ListboxState.Open }) + }) + ) + + it( + 'should be possible to render a Listbox.Button and a Listbox.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) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-2' }, + }) + assertListbox({ state: ListboxState.Closed }) + assertListboxButtonLinkedWithListboxLabel() + }) + ) + }) + + describe('Listbox.Options', () => { + it( + 'should be possible to render Listbox.Options using a render prop', + suppressConsoleLogs(async () => { + render( + + Trigger + + {data => ( + <> + {JSON.stringify(data)} + + )} + + + ) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + await click(getListboxButton()) + + assertListboxButton({ + state: ListboxState.Open, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ + state: ListboxState.Open, + textContent: JSON.stringify({ open: true }), + }) + assertActiveElement(getListbox()) + }) + ) + + it('should be possible to always render the Listbox.Options if we provide it a `static` prop', () => { + render( + + Trigger + + Option A + Option B + Option C + + + ) + + // Let's verify that the Listbox is already there + expect(getListbox()).not.toBe(null) + }) + }) + + describe('Listbox.Option', () => { + it( + 'should be possible to render a Listbox.Option using a render prop', + suppressConsoleLogs(async () => { + render( + + Trigger + + {JSON.stringify} + + + ) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + await click(getListboxButton()) + + assertListboxButton({ + state: ListboxState.Open, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ + state: ListboxState.Open, + textContent: JSON.stringify({ active: false, selected: false, disabled: false }), + }) + }) + ) + }) +}) + +describe('Rendering composition', () => { + it( + 'should be possible to conditionally render classNames (aka className can be a function?!)', + suppressConsoleLogs(async () => { + render( + + Trigger + + JSON.stringify(bag)}> + Option A + + JSON.stringify(bag)}> + Option B + + + Option C + + + + ) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Open Listbox + await click(getListboxButton()) + + const options = getListboxOptions() + + // Verify correct classNames + expect('' + options[0].classList).toEqual( + JSON.stringify({ active: false, selected: false, disabled: false }) + ) + expect('' + options[1].classList).toEqual( + JSON.stringify({ active: false, selected: false, disabled: true }) + ) + expect('' + options[2].classList).toEqual('no-special-treatment') + + // Double check that nothing is active + assertNoActiveListboxOption(getListbox()) + + // Make the first option active + await press(Keys.ArrowDown) + + // Verify the classNames + expect('' + options[0].classList).toEqual( + JSON.stringify({ active: true, selected: false, disabled: false }) + ) + expect('' + options[1].classList).toEqual( + JSON.stringify({ active: false, selected: false, disabled: true }) + ) + expect('' + options[2].classList).toEqual('no-special-treatment') + + // Double check that the first option is the active one + assertActiveListboxOption(options[0]) + + // Let's go down, this should go to the third option since the second option is disabled! + await press(Keys.ArrowDown) + + // Verify the classNames + expect('' + options[0].classList).toEqual( + JSON.stringify({ active: false, selected: false, disabled: false }) + ) + expect('' + options[1].classList).toEqual( + JSON.stringify({ active: false, selected: false, disabled: true }) + ) + expect('' + options[2].classList).toEqual('no-special-treatment') + + // Double check that the last option is the active one + assertActiveListboxOption(options[2]) + }) + ) + + it( + 'should be possible to swap the Listbox option with a button for example', + suppressConsoleLogs(async () => { + render( + + Trigger + + + Option A + + + Option B + + + Option C + + + + ) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Open Listbox + await click(getListboxButton()) + + // Verify options are buttons now + getListboxOptions().forEach(option => assertListboxOption(option, { tag: 'button' })) + }) + ) +}) + +describe('Keyboard interactions', () => { + describe('`Enter` key', () => { + it( + 'should be possible to open the listbox with Enter', + suppressConsoleLogs(async () => { + render( + + Trigger + + Option A + Option B + Option C + + + ) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.Enter) + + // Verify it is open + assertListboxButton({ state: ListboxState.Open }) + assertListbox({ + state: ListboxState.Open, + attributes: { id: 'headlessui-listbox-options-2' }, + }) + assertActiveElement(getListbox()) + assertListboxButtonLinkedWithListbox() + + // Verify we have listbox options + const options = getListboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertListboxOption(option, { selected: false })) + + // Verify that the first listbox option is active + assertActiveListboxOption(options[0]) + assertNoSelectedListboxOption() + }) + ) + + it( + 'should be possible to open the listbox with Enter, and focus the selected option', + suppressConsoleLogs(async () => { + render( + + Trigger + + Option A + Option B + Option C + + + ) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.Enter) + + // Verify it is open + assertListboxButton({ state: ListboxState.Open }) + assertListbox({ + state: ListboxState.Open, + attributes: { id: 'headlessui-listbox-options-2' }, + }) + assertActiveElement(getListbox()) + assertListboxButtonLinkedWithListbox() + + // Verify we have listbox options + const options = getListboxOptions() + expect(options).toHaveLength(3) + options.forEach((option, i) => assertListboxOption(option, { selected: i === 1 })) + + // Verify that the second listbox option is active (because it is already selected) + assertActiveListboxOption(options[1]) + }) + ) + + it( + 'should have no active listbox option when there are no listbox options at all', + suppressConsoleLogs(async () => { + render( + + Trigger + + + ) + + assertListbox({ state: ListboxState.Closed }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.Enter) + assertListbox({ state: ListboxState.Open }) + assertActiveElement(getListbox()) + + assertNoActiveListboxOption() + }) + ) + + it( + 'should focus the first non disabled listbox option when opening with Enter', + suppressConsoleLogs(async () => { + render( + + Trigger + + + Option A + + Option B + Option C + + + ) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.Enter) + + const options = getListboxOptions() + + // Verify that the first non-disabled listbox option is active + assertActiveListboxOption(options[1]) + }) + ) + + it( + 'should focus the first non disabled listbox option when opening with Enter (jump over multiple disabled ones)', + suppressConsoleLogs(async () => { + render( + + Trigger + + + Option A + + + Option B + + Option C + + + ) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.Enter) + + const options = getListboxOptions() + + // Verify that the first non-disabled listbox option is active + assertActiveListboxOption(options[2]) + }) + ) + + it( + 'should have no active listbox option upon Enter key press, when there are no non-disabled listbox options', + suppressConsoleLogs(async () => { + render( + + Trigger + + + Option A + + + Option B + + + Option C + + + + ) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.Enter) + + assertNoActiveListboxOption() + }) + ) + + it( + 'should be possible to close the listbox with Enter when there is no active listboxoption', + suppressConsoleLogs(async () => { + render( + + Trigger + + Option A + Option B + Option C + + + ) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Open listbox + await click(getListboxButton()) + + // Verify it is open + assertListboxButton({ state: ListboxState.Open }) + + // Close listbox + await press(Keys.Enter) + + // Verify it is closed + assertListboxButton({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.Closed }) + }) + ) + + it( + 'should be possible to close the listbox with Enter and choose the active listbox option', + suppressConsoleLogs(async () => { + const handleChange = jest.fn() + + function Example() { + const [value, setValue] = React.useState(undefined) + + return ( + { + setValue(value) + handleChange(value) + }} + > + Trigger + + Option A + Option B + Option C + + + ) + } + + render() + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Open listbox + await click(getListboxButton()) + + // Verify it is open + assertListboxButton({ state: ListboxState.Open }) + + // Activate the first listbox option + const options = getListboxOptions() + await mouseMove(options[0]) + + // Choose option, and close listbox + await press(Keys.Enter) + + // Verify it is closed + assertListboxButton({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.Closed }) + + // Verify we got the change event + expect(handleChange).toHaveBeenCalledTimes(1) + expect(handleChange).toHaveBeenCalledWith('a') + + // Open listbox again + await click(getListboxButton()) + + // Verify the active option is the previously selected one + assertActiveListboxOption(getListboxOptions()[0]) + }) + ) + }) + + describe('`Space` key', () => { + it( + 'should be possible to open the listbox with Space', + suppressConsoleLogs(async () => { + render( + + Trigger + + Option A + Option B + Option C + + + ) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.Space) + + // Verify it is open + assertListboxButton({ state: ListboxState.Open }) + assertListbox({ + state: ListboxState.Open, + attributes: { id: 'headlessui-listbox-options-2' }, + }) + assertActiveElement(getListbox()) + assertListboxButtonLinkedWithListbox() + + // Verify we have listbox options + const options = getListboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertListboxOption(option)) + assertActiveListboxOption(options[0]) + }) + ) + + it( + 'should be possible to open the listbox with Space, and focus the selected option', + suppressConsoleLogs(async () => { + render( + + Trigger + + Option A + Option B + Option C + + + ) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.Space) + + // Verify it is open + assertListboxButton({ state: ListboxState.Open }) + assertListbox({ + state: ListboxState.Open, + attributes: { id: 'headlessui-listbox-options-2' }, + }) + assertActiveElement(getListbox()) + assertListboxButtonLinkedWithListbox() + + // Verify we have listbox options + const options = getListboxOptions() + expect(options).toHaveLength(3) + options.forEach((option, i) => assertListboxOption(option, { selected: i === 1 })) + + // Verify that the second listbox option is active (because it is already selected) + assertActiveListboxOption(options[1]) + }) + ) + + it( + 'should have no active listbox option when there are no listbox options at all', + suppressConsoleLogs(async () => { + render( + + Trigger + + + ) + + assertListbox({ state: ListboxState.Closed }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.Space) + assertListbox({ state: ListboxState.Open }) + assertActiveElement(getListbox()) + + assertNoActiveListboxOption() + }) + ) + + it( + 'should focus the first non disabled listbox option when opening with Space', + suppressConsoleLogs(async () => { + render( + + Trigger + + + Option A + + Option B + Option C + + + ) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.Space) + + const options = getListboxOptions() + + // Verify that the first non-disabled listbox option is active + assertActiveListboxOption(options[1]) + }) + ) + + it( + 'should focus the first non disabled listbox option when opening with Space (jump over multiple disabled ones)', + suppressConsoleLogs(async () => { + render( + + Trigger + + + Option A + + + Option B + + Option C + + + ) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.Space) + + const options = getListboxOptions() + + // Verify that the first non-disabled listbox option is active + assertActiveListboxOption(options[2]) + }) + ) + + it( + 'should have no active listbox option upon Space key press, when there are no non-disabled listbox options', + suppressConsoleLogs(async () => { + render( + + Trigger + + + Option A + + + Option B + + + Option C + + + + ) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.Space) + + assertNoActiveListboxOption() + }) + ) + + it( + 'should be possible to close the listbox with Space and choose the active listbox option', + suppressConsoleLogs(async () => { + const handleChange = jest.fn() + + function Example() { + const [value, setValue] = React.useState(undefined) + + return ( + { + setValue(value) + handleChange(value) + }} + > + Trigger + + Option A + Option B + Option C + + + ) + } + + render() + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Open listbox + await click(getListboxButton()) + + // Verify it is open + assertListboxButton({ state: ListboxState.Open }) + + // Activate the first listbox option + const options = getListboxOptions() + await mouseMove(options[0]) + + // Choose option, and close listbox + await press(Keys.Space) + + // Verify it is closed + assertListboxButton({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.Closed }) + + // Verify we got the change event + expect(handleChange).toHaveBeenCalledTimes(1) + expect(handleChange).toHaveBeenCalledWith('a') + + // Open listbox again + await click(getListboxButton()) + + // Verify the active option is the previously selected one + assertActiveListboxOption(getListboxOptions()[0]) + }) + ) + }) + + describe('`Escape` key', () => { + it( + 'should be possible to close an open listbox with Escape', + suppressConsoleLogs(async () => { + render( + + Trigger + + Option A + Option B + Option C + + + ) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.Space) + + // Verify it is open + assertListboxButton({ state: ListboxState.Open }) + assertListbox({ + state: ListboxState.Open, + attributes: { id: 'headlessui-listbox-options-2' }, + }) + assertActiveElement(getListbox()) + assertListboxButtonLinkedWithListbox() + + // Close listbox + await press(Keys.Escape) + + // Verify it is closed + assertListboxButton({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.Closed }) + }) + ) + }) + + describe('`Tab` key', () => { + it( + 'should focus trap when we use Tab', + suppressConsoleLogs(async () => { + render( + + Trigger + + Option A + Option B + Option C + + + ) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.Enter) + + // Verify it is open + assertListboxButton({ state: ListboxState.Open }) + assertListbox({ + state: ListboxState.Open, + attributes: { id: 'headlessui-listbox-options-2' }, + }) + assertActiveElement(getListbox()) + assertListboxButtonLinkedWithListbox() + + // Verify we have listbox options + const options = getListboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertListboxOption(option)) + assertActiveListboxOption(options[0]) + + // Try to tab + await press(Keys.Tab) + + // Verify it is still open + assertListboxButton({ state: ListboxState.Open }) + assertListbox({ state: ListboxState.Open }) + assertActiveElement(getListbox()) + }) + ) + + it( + 'should focus trap when we use Shift+Tab', + suppressConsoleLogs(async () => { + render( + + Trigger + + Option A + Option B + Option C + + + ) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.Enter) + + // Verify it is open + assertListboxButton({ state: ListboxState.Open }) + assertListbox({ + state: ListboxState.Open, + attributes: { id: 'headlessui-listbox-options-2' }, + }) + assertActiveElement(getListbox()) + assertListboxButtonLinkedWithListbox() + + // Verify we have listbox options + const options = getListboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertListboxOption(option)) + assertActiveListboxOption(options[0]) + + // Try to Shift+Tab + await press(shift(Keys.Tab)) + + // Verify it is still open + assertListboxButton({ state: ListboxState.Open }) + assertListbox({ state: ListboxState.Open }) + assertActiveElement(getListbox()) + }) + ) + }) + + describe('`ArrowDown` key', () => { + it( + 'should be possible to open the listbox with ArrowDown', + suppressConsoleLogs(async () => { + render( + + Trigger + + Option A + Option B + Option C + + + ) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.ArrowDown) + + // Verify it is open + assertListboxButton({ state: ListboxState.Open }) + assertListbox({ + state: ListboxState.Open, + attributes: { id: 'headlessui-listbox-options-2' }, + }) + assertActiveElement(getListbox()) + assertListboxButtonLinkedWithListbox() + + // Verify we have listbox options + const options = getListboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertListboxOption(option)) + + // Verify that the first listbox option is active + assertActiveListboxOption(options[0]) + }) + ) + + it( + 'should be possible to open the listbox with ArrowDown, and focus the selected option', + suppressConsoleLogs(async () => { + render( + + Trigger + + Option A + Option B + Option C + + + ) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.ArrowDown) + + // Verify it is open + assertListboxButton({ state: ListboxState.Open }) + assertListbox({ + state: ListboxState.Open, + attributes: { id: 'headlessui-listbox-options-2' }, + }) + assertActiveElement(getListbox()) + assertListboxButtonLinkedWithListbox() + + // Verify we have listbox options + const options = getListboxOptions() + expect(options).toHaveLength(3) + options.forEach((option, i) => assertListboxOption(option, { selected: i === 1 })) + + // Verify that the second listbox option is active (because it is already selected) + assertActiveListboxOption(options[1]) + }) + ) + + it( + 'should have no active listbox option when there are no listbox options at all', + suppressConsoleLogs(async () => { + render( + + Trigger + + + ) + + assertListbox({ state: ListboxState.Closed }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.ArrowDown) + assertListbox({ state: ListboxState.Open }) + assertActiveElement(getListbox()) + + assertNoActiveListboxOption() + }) + ) + + it( + 'should be possible to use ArrowDown to navigate the listbox options', + suppressConsoleLogs(async () => { + render( + + Trigger + + Option A + Option B + Option C + + + ) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.Enter) + + // Verify we have listbox options + const options = getListboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertListboxOption(option)) + assertActiveListboxOption(options[0]) + + // We should be able to go down once + await press(Keys.ArrowDown) + assertActiveListboxOption(options[1]) + + // We should be able to go down again + await press(Keys.ArrowDown) + assertActiveListboxOption(options[2]) + + // We should NOT be able to go down again (because last option). Current implementation won't go around. + await press(Keys.ArrowDown) + assertActiveListboxOption(options[2]) + }) + ) + + it( + 'should be possible to use ArrowDown to navigate the listbox options and skip the first disabled one', + suppressConsoleLogs(async () => { + render( + + Trigger + + + Option A + + Option B + Option C + + + ) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.Enter) + + // Verify we have listbox options + const options = getListboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertListboxOption(option)) + assertActiveListboxOption(options[1]) + + // We should be able to go down once + await press(Keys.ArrowDown) + assertActiveListboxOption(options[2]) + }) + ) + + it( + 'should be possible to use ArrowDown to navigate the listbox options and jump to the first non-disabled one', + suppressConsoleLogs(async () => { + render( + + Trigger + + + Option A + + + Option B + + Option C + + + ) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.Enter) + + // Verify we have listbox options + const options = getListboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertListboxOption(option)) + assertActiveListboxOption(options[2]) + }) + ) + }) + + describe('`ArrowUp` key', () => { + it( + 'should be possible to open the listbox with ArrowUp and the last option should be active', + suppressConsoleLogs(async () => { + render( + + Trigger + + Option A + Option B + Option C + + + ) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.ArrowUp) + + // Verify it is open + assertListboxButton({ state: ListboxState.Open }) + assertListbox({ + state: ListboxState.Open, + attributes: { id: 'headlessui-listbox-options-2' }, + }) + assertActiveElement(getListbox()) + assertListboxButtonLinkedWithListbox() + + // Verify we have listbox options + const options = getListboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertListboxOption(option)) + + // ! ALERT: The LAST option should now be active + assertActiveListboxOption(options[2]) + }) + ) + + it( + 'should be possible to open the listbox with ArrowUp, and focus the selected option', + suppressConsoleLogs(async () => { + render( + + Trigger + + Option A + Option B + Option C + + + ) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.ArrowUp) + + // Verify it is open + assertListboxButton({ state: ListboxState.Open }) + assertListbox({ + state: ListboxState.Open, + attributes: { id: 'headlessui-listbox-options-2' }, + }) + assertActiveElement(getListbox()) + assertListboxButtonLinkedWithListbox() + + // Verify we have listbox options + const options = getListboxOptions() + expect(options).toHaveLength(3) + options.forEach((option, i) => assertListboxOption(option, { selected: i === 1 })) + + // Verify that the second listbox option is active (because it is already selected) + assertActiveListboxOption(options[1]) + }) + ) + + it( + 'should have no active listbox option when there are no listbox options at all', + suppressConsoleLogs(async () => { + render( + + Trigger + + + ) + + assertListbox({ state: ListboxState.Closed }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.ArrowUp) + assertListbox({ state: ListboxState.Open }) + assertActiveElement(getListbox()) + + assertNoActiveListboxOption() + }) + ) + + it( + 'should be possible to use ArrowUp to navigate the listbox options and jump to the first non-disabled one', + suppressConsoleLogs(async () => { + render( + + Trigger + + Option A + + Option B + + + Option C + + + + ) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.ArrowUp) + + // Verify we have listbox options + const options = getListboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertListboxOption(option)) + assertActiveListboxOption(options[0]) + }) + ) + + it( + 'should not be possible to navigate up or down if there is only a single non-disabled option', + suppressConsoleLogs(async () => { + render( + + Trigger + + + Option A + + + Option B + + Option C + + + ) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.Enter) + + // Verify we have listbox options + const options = getListboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertListboxOption(option)) + assertActiveListboxOption(options[2]) + + // We should not be able to go up (because those are disabled) + await press(Keys.ArrowUp) + assertActiveListboxOption(options[2]) + + // We should not be able to go down (because this is the last option) + await press(Keys.ArrowDown) + assertActiveListboxOption(options[2]) + }) + ) + + it( + 'should be possible to use ArrowUp to navigate the listbox options', + suppressConsoleLogs(async () => { + render( + + Trigger + + Option A + Option B + Option C + + + ) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.ArrowUp) + + // Verify it is open + assertListboxButton({ state: ListboxState.Open }) + assertListbox({ + state: ListboxState.Open, + attributes: { id: 'headlessui-listbox-options-2' }, + }) + assertActiveElement(getListbox()) + assertListboxButtonLinkedWithListbox() + + // Verify we have listbox options + const options = getListboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertListboxOption(option)) + assertActiveListboxOption(options[2]) + + // We should be able to go down once + await press(Keys.ArrowUp) + assertActiveListboxOption(options[1]) + + // We should be able to go down again + await press(Keys.ArrowUp) + assertActiveListboxOption(options[0]) + + // We should NOT be able to go up again (because first option). Current implementation won't go around. + await press(Keys.ArrowUp) + assertActiveListboxOption(options[0]) + }) + ) + }) + + describe('`End` key', () => { + it( + 'should be possible to use the End key to go to the last listbox option', + suppressConsoleLogs(async () => { + render( + + Trigger + + Option A + Option B + Option C + + + ) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.Enter) + + const options = getListboxOptions() + + // We should be on the first option + assertActiveListboxOption(options[0]) + + // We should be able to go to the last option + await press(Keys.End) + assertActiveListboxOption(options[2]) + }) + ) + + it( + 'should be possible to use the End key to go to the last non disabled listbox option', + suppressConsoleLogs(async () => { + render( + + Trigger + + Option A + Option B + + Option C + + + Option D + + + + ) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.Enter) + + const options = getListboxOptions() + + // We should be on the first option + assertActiveListboxOption(options[0]) + + // We should be able to go to the last non-disabled option + await press(Keys.End) + assertActiveListboxOption(options[1]) + }) + ) + + it( + 'should be possible to use the End key to go to the first listbox option if that is the only non-disabled listbox option', + suppressConsoleLogs(async () => { + render( + + Trigger + + Option A + + Option B + + + Option C + + + Option D + + + + ) + + // Open listbox + await click(getListboxButton()) + + // We opened via click, we don't have an active option + assertNoActiveListboxOption() + + // We should not be able to go to the end + await press(Keys.End) + + const options = getListboxOptions() + assertActiveListboxOption(options[0]) + }) + ) + + it( + 'should have no active listbox option upon End key press, when there are no non-disabled listbox options', + suppressConsoleLogs(async () => { + render( + + Trigger + + + Option A + + + Option B + + + Option C + + + Option D + + + + ) + + // Open listbox + await click(getListboxButton()) + + // We opened via click, we don't have an active option + assertNoActiveListboxOption() + + // We should not be able to go to the end + await press(Keys.End) + + assertNoActiveListboxOption() + }) + ) + }) + + describe('`PageDown` key', () => { + it( + 'should be possible to use the PageDown key to go to the last listbox option', + suppressConsoleLogs(async () => { + render( + + Trigger + + Option A + Option B + Option C + + + ) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.Enter) + + const options = getListboxOptions() + + // We should be on the first option + assertActiveListboxOption(options[0]) + + // We should be able to go to the last option + await press(Keys.PageDown) + assertActiveListboxOption(options[2]) + }) + ) + + it( + 'should be possible to use the PageDown key to go to the last non disabled listbox option', + suppressConsoleLogs(async () => { + render( + + Trigger + + Option A + Option B + + Option C + + + Option D + + + + ) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.Enter) + + const options = getListboxOptions() + + // We should be on the first option + assertActiveListboxOption(options[0]) + + // We should be able to go to the last non-disabled option + await press(Keys.PageDown) + assertActiveListboxOption(options[1]) + }) + ) + + it( + 'should be possible to use the PageDown key to go to the first listbox option if that is the only non-disabled listbox option', + suppressConsoleLogs(async () => { + render( + + Trigger + + Option A + + Option B + + + Option C + + + Option D + + + + ) + + // Open listbox + await click(getListboxButton()) + + // We opened via click, we don't have an active option + assertNoActiveListboxOption() + + // We should not be able to go to the end + await press(Keys.PageDown) + + const options = getListboxOptions() + assertActiveListboxOption(options[0]) + }) + ) + + it( + 'should have no active listbox option upon PageDown key press, when there are no non-disabled listbox options', + suppressConsoleLogs(async () => { + render( + + Trigger + + + Option A + + + Option B + + + Option C + + + Option D + + + + ) + + // Open listbox + await click(getListboxButton()) + + // We opened via click, we don't have an active option + assertNoActiveListboxOption() + + // We should not be able to go to the end + await press(Keys.PageDown) + + assertNoActiveListboxOption() + }) + ) + }) + + describe('`Home` key', () => { + it( + 'should be possible to use the Home key to go to the first listbox option', + suppressConsoleLogs(async () => { + render( + + Trigger + + Option A + Option B + Option C + + + ) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.ArrowUp) + + const options = getListboxOptions() + + // We should be on the last option + assertActiveListboxOption(options[2]) + + // We should be able to go to the first option + await press(Keys.Home) + assertActiveListboxOption(options[0]) + }) + ) + + it( + 'should be possible to use the Home key to go to the first non disabled listbox option', + suppressConsoleLogs(async () => { + render( + + Trigger + + + Option A + + + Option B + + Option C + Option D + + + ) + + // Open listbox + await click(getListboxButton()) + + // We opened via click, we don't have an active option + assertNoActiveListboxOption() + + // We should not be able to go to the end + await press(Keys.Home) + + const options = getListboxOptions() + + // We should be on the first non-disabled option + assertActiveListboxOption(options[2]) + }) + ) + + it( + 'should be possible to use the Home key to go to the last listbox option if that is the only non-disabled listbox option', + suppressConsoleLogs(async () => { + render( + + Trigger + + + Option A + + + Option B + + + Option C + + Option D + + + ) + + // Open listbox + await click(getListboxButton()) + + // We opened via click, we don't have an active option + assertNoActiveListboxOption() + + // We should not be able to go to the end + await press(Keys.Home) + + const options = getListboxOptions() + assertActiveListboxOption(options[3]) + }) + ) + + it( + 'should have no active listbox option upon Home key press, when there are no non-disabled listbox options', + suppressConsoleLogs(async () => { + render( + + Trigger + + + Option A + + + Option B + + + Option C + + + Option D + + + + ) + + // Open listbox + await click(getListboxButton()) + + // We opened via click, we don't have an active option + assertNoActiveListboxOption() + + // We should not be able to go to the end + await press(Keys.Home) + + assertNoActiveListboxOption() + }) + ) + }) + + describe('`PageUp` key', () => { + it( + 'should be possible to use the PageUp key to go to the first listbox option', + suppressConsoleLogs(async () => { + render( + + Trigger + + Option A + Option B + Option C + + + ) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.ArrowUp) + + const options = getListboxOptions() + + // We should be on the last option + assertActiveListboxOption(options[2]) + + // We should be able to go to the first option + await press(Keys.PageUp) + assertActiveListboxOption(options[0]) + }) + ) + + it( + 'should be possible to use the PageUp key to go to the first non disabled listbox option', + suppressConsoleLogs(async () => { + render( + + Trigger + + + Option A + + + Option B + + Option C + Option D + + + ) + + // Open listbox + await click(getListboxButton()) + + // We opened via click, we don't have an active option + assertNoActiveListboxOption() + + // We should not be able to go to the end + await press(Keys.PageUp) + + const options = getListboxOptions() + + // We should be on the first non-disabled option + assertActiveListboxOption(options[2]) + }) + ) + + it( + 'should be possible to use the PageUp key to go to the last listbox option if that is the only non-disabled listbox option', + suppressConsoleLogs(async () => { + render( + + Trigger + + + Option A + + + Option B + + + Option C + + Option D + + + ) + + // Open listbox + await click(getListboxButton()) + + // We opened via click, we don't have an active option + assertNoActiveListboxOption() + + // We should not be able to go to the end + await press(Keys.PageUp) + + const options = getListboxOptions() + assertActiveListboxOption(options[3]) + }) + ) + + it( + 'should have no active listbox option upon PageUp key press, when there are no non-disabled listbox options', + suppressConsoleLogs(async () => { + render( + + Trigger + + + Option A + + + Option B + + + Option C + + + Option D + + + + ) + + // Open listbox + await click(getListboxButton()) + + // We opened via click, we don't have an active option + assertNoActiveListboxOption() + + // We should not be able to go to the end + await press(Keys.PageUp) + + assertNoActiveListboxOption() + }) + ) + }) + + describe('`Any` key aka search', () => { + it( + 'should be possible to type a full word that has a perfect match', + suppressConsoleLogs(async () => { + render( + + Trigger + + alice + bob + charlie + + + ) + + // Open listbox + await click(getListboxButton()) + + const options = getListboxOptions() + + // We should be able to go to the second option + await type(word('bob')) + assertActiveListboxOption(options[1]) + + // We should be able to go to the first option + await type(word('alice')) + assertActiveListboxOption(options[0]) + + // We should be able to go to the last option + await type(word('charlie')) + assertActiveListboxOption(options[2]) + }) + ) + + it( + 'should be possible to type a partial of a word', + suppressConsoleLogs(async () => { + render( + + Trigger + + alice + bob + charlie + + + ) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.ArrowUp) + + const options = getListboxOptions() + + // We should be on the last option + assertActiveListboxOption(options[2]) + + // We should be able to go to the second option + await type(word('bo')) + assertActiveListboxOption(options[1]) + + // We should be able to go to the first option + await type(word('ali')) + assertActiveListboxOption(options[0]) + + // We should be able to go to the last option + await type(word('char')) + assertActiveListboxOption(options[2]) + }) + ) + + it( + 'should be possible to type words with spaces', + suppressConsoleLogs(async () => { + render( + + Trigger + + value a + value b + value c + + + ) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.ArrowUp) + + const options = getListboxOptions() + + // We should be on the last option + assertActiveListboxOption(options[2]) + + // We should be able to go to the second option + await type(word('value b')) + assertActiveListboxOption(options[1]) + + // We should be able to go to the first option + await type(word('value a')) + assertActiveListboxOption(options[0]) + + // We should be able to go to the last option + await type(word('value c')) + assertActiveListboxOption(options[2]) + }) + ) + + it( + 'should not be possible to search for a disabled option', + suppressConsoleLogs(async () => { + render( + + Trigger + + alice + + bob + + charlie + + + ) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.ArrowUp) + + const options = getListboxOptions() + + // We should be on the last option + assertActiveListboxOption(options[2]) + + // We should not be able to go to the disabled option + await type(word('bo')) + + // We should still be on the last option + assertActiveListboxOption(options[2]) + }) + ) + }) +}) + +describe('Mouse interactions', () => { + it( + 'should focus the Listbox.Button when we click the Listbox.Label', + suppressConsoleLogs(async () => { + render( + + Label + Trigger + + Option A + Option B + Option C + + + ) + + // Ensure the button is not focused yet + assertActiveElement(document.body) + + // Focus the label + await click(getListboxLabel()) + + // Ensure that the actual button is focused instead + assertActiveElement(getListboxButton()) + }) + ) + + it( + 'should be possible to open a listbox on click', + suppressConsoleLogs(async () => { + render( + + Trigger + + Option A + Option B + Option C + + + ) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Open listbox + await click(getListboxButton()) + + // Verify it is open + assertListboxButton({ state: ListboxState.Open }) + assertListbox({ + state: ListboxState.Open, + attributes: { id: 'headlessui-listbox-options-2' }, + }) + assertActiveElement(getListbox()) + assertListboxButtonLinkedWithListbox() + + // Verify we have listbox options + const options = getListboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertListboxOption(option)) + }) + ) + + it( + 'should be possible to open a listbox on click, and focus the selected option', + suppressConsoleLogs(async () => { + render( + + Trigger + + Option A + Option B + Option C + + + ) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Open listbox + await click(getListboxButton()) + + // Verify it is open + assertListboxButton({ state: ListboxState.Open }) + assertListbox({ + state: ListboxState.Open, + attributes: { id: 'headlessui-listbox-options-2' }, + }) + assertActiveElement(getListbox()) + assertListboxButtonLinkedWithListbox() + + // Verify we have listbox options + const options = getListboxOptions() + expect(options).toHaveLength(3) + options.forEach((option, i) => assertListboxOption(option, { selected: i === 1 })) + + // Verify that the second listbox option is active (because it is already selected) + assertActiveListboxOption(options[1]) + }) + ) + + it( + 'should be possible to close a listbox on click', + suppressConsoleLogs(async () => { + render( + + Trigger + + Option A + Option B + Option C + + + ) + + // Open listbox + await click(getListboxButton()) + + // Verify it is open + assertListboxButton({ state: ListboxState.Open }) + + // Click to close + await click(getListboxButton()) + + // Verify it is closed + assertListboxButton({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.Closed }) + }) + ) + + it('should focus the listbox when you try to focus the button again (when the listbox is already open)', async () => { + render( + + Trigger + + Option A + Option B + Option C + + + ) + + // Open listbox + await click(getListboxButton()) + + // Verify listbox is focused + assertActiveElement(getListbox()) + + // Try to Re-focus the button + getListboxButton()?.focus() + + // Verify listbox is still focused + assertActiveElement(getListbox()) + }) + + it( + 'should be a no-op when we click outside of a closed listbox', + suppressConsoleLogs(async () => { + render( + + Trigger + + alice + bob + charlie + + + ) + + // Verify that the window is closed + assertListbox({ state: ListboxState.Closed }) + + // Click something that is not related to the listbox + await click(document.body) + + // Should still be closed + assertListbox({ state: ListboxState.Closed }) + }) + ) + + it( + 'should be possible to click outside of the listbox which should close the listbox', + suppressConsoleLogs(async () => { + render( + + Trigger + + alice + bob + charlie + + + ) + + // Open listbox + await click(getListboxButton()) + assertListbox({ state: ListboxState.Open }) + assertActiveElement(getListbox()) + + // Click something that is not related to the listbox + await click(document.body) + + // Should be closed now + assertListbox({ state: ListboxState.Closed }) + }) + ) + + it( + 'should be possible to click outside of the listbox on another listbox button which should close the current listbox and open the new listbox', + suppressConsoleLogs(async () => { + render( +
+ + Trigger + + alice + bob + charlie + + + + + Trigger + + alice + bob + charlie + + +
+ ) + + const [button1, button2] = getListboxButtons() + + // Click the first menu button + await click(button1) + expect(getListboxes()).toHaveLength(1) // Only 1 menu should be visible + + // Ensure the open menu is linked to the first button + assertListboxButtonLinkedWithListbox(button1, getListbox()) + + // Click the second menu button + await click(button2) + + expect(getListboxes()).toHaveLength(1) // Only 1 menu should be visible + + // Ensure the open menu is linked to the second button + assertListboxButtonLinkedWithListbox(button2, getListbox()) + }) + ) + + it( + 'should be possible to click outside of the listbox which should close the listbox (even if we press the listbox button)', + suppressConsoleLogs(async () => { + render( + + Trigger + + alice + bob + charlie + + + ) + + // Open listbox + await click(getListboxButton()) + assertListbox({ state: ListboxState.Open }) + assertActiveElement(getListbox()) + + // Click the listbox button again + await click(getListboxButton()) + + // Should be closed now + assertListbox({ state: ListboxState.Closed }) + }) + ) + + it( + 'should be possible to hover an option and make it active', + suppressConsoleLogs(async () => { + render( + + Trigger + + alice + bob + charlie + + + ) + + // Open listbox + await click(getListboxButton()) + + const options = getListboxOptions() + // We should be able to go to the second option + await mouseMove(options[1]) + assertActiveListboxOption(options[1]) + + // We should be able to go to the first option + await mouseMove(options[0]) + assertActiveListboxOption(options[0]) + + // We should be able to go to the last option + await mouseMove(options[2]) + assertActiveListboxOption(options[2]) + }) + ) + + it( + 'should make a listbox option active when you move the mouse over it', + suppressConsoleLogs(async () => { + render( + + Trigger + + alice + bob + charlie + + + ) + + // Open listbox + await click(getListboxButton()) + + const options = getListboxOptions() + // We should be able to go to the second option + await mouseMove(options[1]) + assertActiveListboxOption(options[1]) + }) + ) + + it( + 'should be a no-op when we move the mouse and the listbox option is already active', + suppressConsoleLogs(async () => { + render( + + Trigger + + alice + bob + charlie + + + ) + + // Open listbox + await click(getListboxButton()) + + const options = getListboxOptions() + + // We should be able to go to the second option + await mouseMove(options[1]) + assertActiveListboxOption(options[1]) + + await mouseMove(options[1]) + + // Nothing should be changed + assertActiveListboxOption(options[1]) + }) + ) + + it( + 'should be a no-op when we move the mouse and the listbox option is disabled', + suppressConsoleLogs(async () => { + render( + + Trigger + + alice + + bob + + charlie + + + ) + + // Open listbox + await click(getListboxButton()) + + const options = getListboxOptions() + + await mouseMove(options[1]) + assertNoActiveListboxOption() + }) + ) + + it( + 'should not be possible to hover an option that is disabled', + suppressConsoleLogs(async () => { + render( + + Trigger + + alice + + bob + + charlie + + + ) + + // Open listbox + await click(getListboxButton()) + + const options = getListboxOptions() + + // Try to hover over option 1, which is disabled + await mouseMove(options[1]) + + // We should not have an active option now + assertNoActiveListboxOption() + }) + ) + + it( + 'should be possible to mouse leave an option and make it inactive', + suppressConsoleLogs(async () => { + render( + + Trigger + + alice + bob + charlie + + + ) + + // Open listbox + await click(getListboxButton()) + + const options = getListboxOptions() + + // We should be able to go to the second option + await mouseMove(options[1]) + assertActiveListboxOption(options[1]) + + await mouseLeave(options[1]) + assertNoActiveListboxOption() + + // We should be able to go to the first option + await mouseMove(options[0]) + assertActiveListboxOption(options[0]) + + await mouseLeave(options[0]) + assertNoActiveListboxOption() + + // We should be able to go to the last option + await mouseMove(options[2]) + assertActiveListboxOption(options[2]) + + await mouseLeave(options[2]) + assertNoActiveListboxOption() + }) + ) + + it( + 'should be possible to mouse leave a disabled option and be a no-op', + suppressConsoleLogs(async () => { + render( + + Trigger + + alice + + bob + + charlie + + + ) + + // Open listbox + await click(getListboxButton()) + + const options = getListboxOptions() + + // Try to hover over option 1, which is disabled + await mouseMove(options[1]) + assertNoActiveListboxOption() + + await mouseLeave(options[1]) + assertNoActiveListboxOption() + }) + ) + + it( + 'should be possible to click a listbox option, which closes the listbox', + suppressConsoleLogs(async () => { + const handleChange = jest.fn() + function Example() { + const [value, setValue] = React.useState(undefined) + + return ( + { + setValue(value) + handleChange(value) + }} + > + Trigger + + alice + bob + charlie + + + ) + } + + render() + + // Open listbox + await click(getListboxButton()) + assertListbox({ state: ListboxState.Open }) + assertActiveElement(getListbox()) + + const options = getListboxOptions() + + // We should be able to click the first option + await click(options[1]) + assertListbox({ state: ListboxState.Closed }) + expect(handleChange).toHaveBeenCalledTimes(1) + expect(handleChange).toHaveBeenCalledWith('bob') + + // Open listbox again + await click(getListboxButton()) + + // Verify the active option is the previously selected one + assertActiveListboxOption(getListboxOptions()[1]) + }) + ) + + it( + 'should be possible to click a disabled listbox option, which is a no-op', + suppressConsoleLogs(async () => { + const handleChange = jest.fn() + function Example() { + const [value, setValue] = React.useState(undefined) + + return ( + { + setValue(value) + handleChange(value) + }} + > + Trigger + + alice + + bob + + charlie + + + ) + } + + render() + + // Open listbox + await click(getListboxButton()) + assertListbox({ state: ListboxState.Open }) + assertActiveElement(getListbox()) + + const options = getListboxOptions() + + // We should be able to click the first option + await click(options[1]) + assertListbox({ state: ListboxState.Open }) + assertActiveElement(getListbox()) + expect(handleChange).toHaveBeenCalledTimes(0) + + // Close the listbox + await click(getListboxButton()) + + // Open listbox again + await click(getListboxButton()) + + // Verify the active option is non existing + assertNoActiveListboxOption() + }) + ) + + it( + 'should be possible focus a listbox option, so that it becomes active', + suppressConsoleLogs(async () => { + render( + + Trigger + + alice + bob + charlie + + + ) + + // Open listbox + await click(getListboxButton()) + assertListbox({ state: ListboxState.Open }) + assertActiveElement(getListbox()) + + const options = getListboxOptions() + + // Verify that nothing is active yet + assertNoActiveListboxOption() + + // We should be able to focus the first option + await focus(options[1]) + assertActiveListboxOption(options[1]) + }) + ) + + it( + 'should not be possible to focus a listbox option which is disabled', + suppressConsoleLogs(async () => { + render( + + Trigger + + alice + + bob + + charlie + + + ) + + // Open listbox + await click(getListboxButton()) + assertListbox({ state: ListboxState.Open }) + assertActiveElement(getListbox()) + + const options = getListboxOptions() + + // We should not be able to focus the first option + await focus(options[1]) + assertNoActiveListboxOption() + }) + ) +}) diff --git a/packages/@headlessui-react/src/components/listbox/listbox.tsx b/packages/@headlessui-react/src/components/listbox/listbox.tsx new file mode 100644 index 0000000..fb8b2cb --- /dev/null +++ b/packages/@headlessui-react/src/components/listbox/listbox.tsx @@ -0,0 +1,642 @@ +import * as React 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 { forwardRefWithAs, render } from '../../utils/render' +import { match } from '../../utils/match' +import { disposables } from '../../utils/disposables' +import { Keys } from '../keyboard' + +enum ListboxStates { + Open, + Closed, +} + +type ListboxOptionDataRef = React.MutableRefObject<{ + textValue?: string + disabled: boolean + value: unknown +}> + +type StateDefinition = { + listboxState: ListboxStates + propsRef: React.MutableRefObject<{ value: unknown; onChange(value: unknown): void }> + labelRef: React.MutableRefObject + buttonRef: React.MutableRefObject + optionsRef: React.MutableRefObject + options: { id: string; dataRef: ListboxOptionDataRef }[] + searchQuery: string + activeOptionIndex: number | null +} + +enum ActionTypes { + OpenListbox, + CloseListbox, + + GoToOption, + Search, + ClearSearch, + + RegisterOption, + UnregisterOption, +} + +enum Focus { + First, + Previous, + Next, + Last, + Specific, + Nothing, +} + +function calculateActiveOptionIndex( + state: StateDefinition, + focus: Focus, + id?: string +): StateDefinition['activeOptionIndex'] { + if (state.options.length <= 0) return null + + const options = state.options + const activeOptionIndex = state.activeOptionIndex ?? -1 + + const nextActiveIndex = match(focus, { + [Focus.First]: () => options.findIndex(option => !option.dataRef.current.disabled), + [Focus.Previous]: () => { + const idx = options + .slice() + .reverse() + .findIndex((option, idx, all) => { + if (activeOptionIndex !== -1 && all.length - idx - 1 >= activeOptionIndex) return false + return !option.dataRef.current.disabled + }) + if (idx === -1) return idx + return options.length - 1 - idx + }, + [Focus.Next]: () => { + return options.findIndex((option, idx) => { + if (idx <= activeOptionIndex) return false + return !option.dataRef.current.disabled + }) + }, + [Focus.Last]: () => { + const idx = options + .slice() + .reverse() + .findIndex(option => !option.dataRef.current.disabled) + if (idx === -1) return idx + return options.length - 1 - idx + }, + [Focus.Specific]: () => options.findIndex(option => option.id === id), + [Focus.Nothing]: () => null, + }) + + if (nextActiveIndex === -1) return state.activeOptionIndex + return nextActiveIndex +} + +type Actions = + | { type: ActionTypes.CloseListbox } + | { type: ActionTypes.OpenListbox } + | { type: ActionTypes.GoToOption; focus: Focus; id?: string } + | { type: ActionTypes.Search; value: string } + | { type: ActionTypes.ClearSearch } + | { type: ActionTypes.RegisterOption; id: string; dataRef: ListboxOptionDataRef } + | { type: ActionTypes.UnregisterOption; id: string } + +const reducers: { + [P in ActionTypes]: ( + state: StateDefinition, + action: Extract + ) => StateDefinition +} = { + [ActionTypes.CloseListbox]: state => ({ ...state, listboxState: ListboxStates.Closed }), + [ActionTypes.OpenListbox]: state => ({ ...state, listboxState: ListboxStates.Open }), + [ActionTypes.GoToOption]: (state, action) => { + const activeOptionIndex = calculateActiveOptionIndex(state, action.focus, action.id) + + if (state.searchQuery === '' && state.activeOptionIndex === activeOptionIndex) { + return state + } + + return { ...state, searchQuery: '', activeOptionIndex } + }, + [ActionTypes.Search]: (state, action) => { + const searchQuery = state.searchQuery + action.value + const match = state.options.findIndex( + option => + !option.dataRef.current.disabled && + option.dataRef.current.textValue?.startsWith(searchQuery) + ) + + if (match === -1 || match === state.activeOptionIndex) { + return { ...state, searchQuery } + } + + return { ...state, searchQuery, activeOptionIndex: match } + }, + [ActionTypes.ClearSearch]: state => ({ ...state, searchQuery: '' }), + [ActionTypes.RegisterOption]: (state, action) => ({ + ...state, + options: [...state.options, { id: action.id, dataRef: action.dataRef }], + }), + [ActionTypes.UnregisterOption]: (state, action) => { + const nextOptions = state.options.slice() + const currentActiveOption = + state.activeOptionIndex !== null ? nextOptions[state.activeOptionIndex] : null + + const 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) + })(), + } + }, +} + +const ListboxContext = React.createContext<[StateDefinition, React.Dispatch] | null>(null) + +function stateReducer(state: StateDefinition, action: Actions) { + return match(action.type, reducers, state, action) +} + +function useListboxContext(component: string) { + const context = React.useContext(ListboxContext) + if (context === null) { + const err = new Error(`<${component} /> is missing a parent <${Listbox.name} /> component.`) + if (Error.captureStackTrace) Error.captureStackTrace(err, useListboxContext) + throw err + } + return context +} + +// --- + +const DEFAULT_LISTBOX_TAG = React.Fragment + +type ListboxRenderPropArg = { open: boolean } + +export function Listbox< + TTag extends React.ElementType = typeof DEFAULT_LISTBOX_TAG, + TType = string +>(props: Props & { value: TType; onChange(value: TType): void }) { + const { value, onChange, ...passThroughProps } = props + const d = useDisposables() + const reducerBag = React.useReducer(stateReducer, { + listboxState: ListboxStates.Closed, + propsRef: { current: { value, onChange } }, + labelRef: React.createRef(), + buttonRef: React.createRef(), + optionsRef: React.createRef(), + options: [], + searchQuery: '', + activeOptionIndex: null, + } as StateDefinition) + const [{ listboxState, propsRef, optionsRef, buttonRef }, dispatch] = reducerBag + + useIsoMorphicEffect(() => { + propsRef.current.value = value + }, [value, propsRef]) + useIsoMorphicEffect(() => { + propsRef.current.onChange = onChange + }, [onChange, propsRef]) + + React.useEffect(() => { + function handler(event: MouseEvent) { + if (listboxState !== ListboxStates.Open) return + if (buttonRef.current?.contains(event.target as HTMLElement)) return + + if (!optionsRef.current?.contains(event.target as HTMLElement)) { + dispatch({ type: ActionTypes.CloseListbox }) + if (!event.defaultPrevented) buttonRef.current?.focus() + } + } + + window.addEventListener('click', handler) + return () => window.removeEventListener('click', handler) + }, [listboxState, optionsRef, buttonRef, d, dispatch]) + + const propsBag = React.useMemo( + () => ({ open: listboxState === ListboxStates.Open }), + [listboxState] + ) + + return ( + + {render(passThroughProps, propsBag, DEFAULT_LISTBOX_TAG)} + + ) +} + +// --- + +type ButtonPropsWeControl = + | 'ref' + | 'id' + | 'type' + | 'aria-haspopup' + | 'aria-controls' + | 'aria-expanded' + | 'aria-labelledby' + | 'onKeyDown' + | 'onFocus' + | 'onBlur' + | 'onPointerUp' + +const DEFAULT_BUTTON_TAG = 'button' + +type ButtonRenderPropArg = { open: boolean; focused: boolean } + +const Button = forwardRefWithAs(function Button< + TTag extends React.ElementType = typeof DEFAULT_BUTTON_TAG +>( + props: Props, + ref: React.Ref +) { + const [state, dispatch] = useListboxContext([Listbox.name, Button.name].join('.')) + const buttonRef = useSyncRefs(state.buttonRef, ref) + const [focused, setFocused] = React.useState(false) + + const id = `headlessui-listbox-button-${useId()}` + const d = useDisposables() + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + switch (event.key) { + // Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-13 + + case Keys.Space: + case Keys.Enter: + case Keys.ArrowDown: + event.preventDefault() + dispatch({ type: ActionTypes.OpenListbox }) + d.nextFrame(() => { + state.optionsRef.current?.focus() + if (!state.propsRef.current.value) + dispatch({ type: ActionTypes.GoToOption, focus: Focus.First }) + }) + break + + case Keys.ArrowUp: + event.preventDefault() + dispatch({ type: ActionTypes.OpenListbox }) + d.nextFrame(() => { + state.optionsRef.current?.focus() + if (!state.propsRef.current.value) + dispatch({ type: ActionTypes.GoToOption, focus: Focus.Last }) + }) + break + } + }, + [dispatch, state, d] + ) + + const handlePointerUp = React.useCallback( + (event: MouseEvent) => { + if (state.listboxState === ListboxStates.Open) { + dispatch({ type: ActionTypes.CloseListbox }) + } else { + event.preventDefault() + dispatch({ type: ActionTypes.OpenListbox }) + d.nextFrame(() => state.optionsRef.current?.focus()) + } + }, + [dispatch, d, state] + ) + + const handleFocus = React.useCallback(() => { + if (state.listboxState === ListboxStates.Open) return state.optionsRef.current?.focus() + setFocused(true) + }, [state, setFocused]) + + const handleBlur = React.useCallback(() => setFocused(false), [setFocused]) + const labelledby = useComputed(() => { + if (!state.labelRef.current) return undefined + return [state.labelRef.current.id, id].join(' ') + }, [state.labelRef.current, id]) + + const propsBag = React.useMemo( + () => ({ open: state.listboxState === ListboxStates.Open, focused }), + [state, focused] + ) + const passthroughProps = props + const propsWeControl = { + ref: buttonRef, + id, + type: 'button', + 'aria-haspopup': true, + 'aria-controls': state.optionsRef.current?.id, + 'aria-expanded': state.listboxState === ListboxStates.Open ? true : undefined, + 'aria-labelledby': labelledby, + onKeyDown: handleKeyDown, + onFocus: handleFocus, + onBlur: handleBlur, + onPointerUp: handlePointerUp, + } + + return render({ ...passthroughProps, ...propsWeControl }, propsBag, DEFAULT_BUTTON_TAG) +}) + +// --- + +type LabelPropsWeControl = 'id' | 'ref' | 'onPointerUp' + +const DEFAULT_LABEL_TAG = 'label' + +type LabelRenderPropArg = { open: boolean } + +function Label( + props: Props +) { + const [state] = useListboxContext([Listbox.name, Label.name].join('.')) + const id = `headlessui-listbox-label-${useId()}` + + const handlePointerUp = React.useCallback(() => state.buttonRef.current?.focus(), [ + state.buttonRef, + ]) + + const propsBag = React.useMemo( + () => ({ open: state.listboxState === ListboxStates.Open }), + [state] + ) + const propsWeControl = { + ref: state.labelRef, + id, + onPointerUp: handlePointerUp, + } + return render({ ...props, ...propsWeControl }, propsBag, DEFAULT_LABEL_TAG) +} + +// --- + +type OptionsPropsWeControl = + | 'aria-activedescendant' + | 'aria-labelledby' + | 'id' + | 'onKeyDown' + | 'ref' + | 'role' + | 'tabIndex' + +const DEFAULT_OPTIONS_TAG = 'ul' + +type OptionsRenderPropArg = { open: boolean } + +type ListboxOptionsProp = Props & { + static?: boolean +} + +const Options = forwardRefWithAs(function Options< + TTag extends React.ElementType = typeof DEFAULT_OPTIONS_TAG +>(props: ListboxOptionsProp, ref: React.Ref) { + const { + enter, + enterFrom, + enterTo, + leave, + leaveFrom, + leaveTo, + static: isStatic = false, + ...passthroughProps + } = props + const [state, dispatch] = useListboxContext([Listbox.name, Options.name].join('.')) + const optionsRef = useSyncRefs(state.optionsRef, ref) + + const id = `headlessui-listbox-options-${useId()}` + const d = useDisposables() + const searchDisposables = useDisposables() + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + searchDisposables.dispose() + + switch (event.key) { + // Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-12 + + // @ts-expect-error Fallthrough is expected here + case Keys.Space: + if (state.searchQuery !== '') { + event.preventDefault() + return dispatch({ type: ActionTypes.Search, value: event.key }) + } + // When in type ahead mode, fallthrough + case Keys.Enter: + event.preventDefault() + dispatch({ type: ActionTypes.CloseListbox }) + if (state.activeOptionIndex !== null) { + const { dataRef } = state.options[state.activeOptionIndex] + state.propsRef.current.onChange(dataRef.current.value) + } + d.nextFrame(() => state.buttonRef.current?.focus()) + break + + case Keys.ArrowDown: + event.preventDefault() + return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Next }) + + case Keys.ArrowUp: + event.preventDefault() + return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Previous }) + + case Keys.Home: + case Keys.PageUp: + event.preventDefault() + return dispatch({ type: ActionTypes.GoToOption, focus: Focus.First }) + + case Keys.End: + case Keys.PageDown: + event.preventDefault() + return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Last }) + + case Keys.Escape: + event.preventDefault() + dispatch({ type: ActionTypes.CloseListbox }) + return d.nextFrame(() => state.buttonRef.current?.focus()) + + case Keys.Tab: + return event.preventDefault() + + default: + if (event.key.length === 1) { + dispatch({ type: ActionTypes.Search, value: event.key }) + searchDisposables.setTimeout(() => dispatch({ type: ActionTypes.ClearSearch }), 350) + } + break + } + }, + [d, dispatch, searchDisposables, state] + ) + + const labelledby = useComputed(() => state.labelRef.current?.id ?? state.buttonRef.current?.id, [ + state.labelRef.current, + state.buttonRef.current, + ]) + + const propsBag = React.useMemo( + () => ({ open: state.listboxState === ListboxStates.Open }), + [state] + ) + const propsWeControl = { + 'aria-activedescendant': + state.activeOptionIndex === null ? undefined : state.options[state.activeOptionIndex]?.id, + 'aria-labelledby': labelledby, + id, + onKeyDown: handleKeyDown, + role: 'listbox', + tabIndex: 0, + } + + if (!isStatic && state.listboxState === ListboxStates.Closed) return null + + return render( + { ...passthroughProps, ...propsWeControl, ...{ ref: optionsRef } }, + propsBag, + DEFAULT_OPTIONS_TAG + ) +}) + +// --- + +type ListboxOptionPropsWeControl = + | 'id' + | 'role' + | 'tabIndex' + | 'aria-disabled' + | 'aria-selected' + | 'onPointerLeave' + | 'onFocus' + +const DEFAULT_OPTION_TAG = 'li' + +type OptionRenderPropArg = { active: boolean; selected: boolean; disabled: boolean } + +function Option( + props: Props & { + disabled?: boolean + value: TType + + // Special treatment, can either be a string or a function that resolves to a string + className?: ((bag: OptionRenderPropArg) => string) | string + } +) { + const { disabled = false, value, className, ...passthroughProps } = props + const [state, dispatch] = useListboxContext([Listbox.name, Option.name].join('.')) + const d = useDisposables() + const id = `headlessui-listbox-option-${useId()}` + const active = + state.activeOptionIndex !== null ? state.options[state.activeOptionIndex].id === id : false + const selected = state.propsRef.current.value === value + + const bag = React.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]) + + const select = React.useCallback(() => state.propsRef.current.onChange(value), [ + state.propsRef, + value, + ]) + + useIsoMorphicEffect(() => { + dispatch({ type: ActionTypes.RegisterOption, id, dataRef: bag }) + return () => dispatch({ type: ActionTypes.UnregisterOption, id }) + }, [bag, id]) + + useIsoMorphicEffect(() => { + if (!selected) return + dispatch({ type: ActionTypes.GoToOption, focus: Focus.Specific, id }) + document.getElementById(id)?.focus?.() + }, []) + + useIsoMorphicEffect(() => { + if (!active) return + const d = disposables() + d.nextFrame(() => document.getElementById(id)?.scrollIntoView?.({ block: 'nearest' })) + return d.dispose + }, [active]) + + const handleClick = React.useCallback( + (event: { preventDefault: Function }) => { + if (disabled) return event.preventDefault() + select() + dispatch({ type: ActionTypes.CloseListbox }) + d.nextFrame(() => state.buttonRef.current?.focus()) + }, + [d, dispatch, state.buttonRef, disabled, select] + ) + + const handleFocus = React.useCallback(() => { + if (disabled) return dispatch({ type: ActionTypes.GoToOption, focus: Focus.Nothing }) + dispatch({ type: ActionTypes.GoToOption, focus: Focus.Specific, id }) + }, [disabled, id, dispatch]) + + const handlePointerMove = React.useCallback(() => { + if (disabled) return + if (active) return + dispatch({ type: ActionTypes.GoToOption, focus: Focus.Specific, id }) + }, [disabled, active, id, dispatch]) + + const handlePointerLeave = React.useCallback(() => { + if (disabled) return + if (!active) return + dispatch({ type: ActionTypes.GoToOption, focus: Focus.Nothing }) + }, [disabled, active, dispatch]) + + const propsBag = React.useMemo(() => ({ active, selected, disabled }), [ + active, + selected, + disabled, + ]) + const propsWeControl = { + id, + role: 'option', + tabIndex: -1, + className: resolvePropValue(className, propsBag), + 'aria-disabled': disabled === true ? true : undefined, + 'aria-selected': selected === true ? true : undefined, + onClick: handleClick, + onFocus: handleFocus, + onPointerMove: handlePointerMove, + onPointerLeave: handlePointerLeave, + } + + return render( + { ...passthroughProps, ...propsWeControl }, + propsBag, + DEFAULT_OPTION_TAG + ) +} + +function resolvePropValue(property: TProperty, bag: TBag) { + if (property === undefined) return undefined + if (typeof property === 'function') return property(bag) + return property +} + +// --- + +Listbox.Button = Button +Listbox.Label = Label +Listbox.Options = Options +Listbox.Option = Option diff --git a/packages/@headlessui-react/src/components/menu/menu.test.tsx b/packages/@headlessui-react/src/components/menu/menu.test.tsx index 6d5c1ef..9ffa5a8 100644 --- a/packages/@headlessui-react/src/components/menu/menu.test.tsx +++ b/packages/@headlessui-react/src/components/menu/menu.test.tsx @@ -4,7 +4,6 @@ import { render } from '@testing-library/react' import { Menu } from './menu' import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs' import { - MenuButtonState, MenuState, assertMenu, assertMenuButton, @@ -13,6 +12,11 @@ import { assertMenuLinkedWithMenuItem, assertActiveElement, assertNoActiveMenuItem, + getMenuButton, + getMenuButtons, + getMenu, + getMenus, + getMenuItems, } from '../../test-utils/accessibility-assertions' import { click, @@ -28,35 +32,6 @@ import { jest.mock('../../hooks/use-id') -function getMenuButton(): HTMLElement | null { - // This is just an assumption for our tests. We assume that we only have 1 button. And if we have - // more, than we assume that it is the first one. - return document.querySelector('[role="button"],button') -} - -function getMenuButtons(): HTMLElement[] { - // This is just an assumption for our tests. We assume that we only have 1 button. And if we have - // more, than we assume that it is the first one. - return Array.from(document.querySelectorAll('[role="button"],button')) -} - -function getMenu(): HTMLElement | null { - // This is just an assumption for our tests. We assume that our menu has this role and that it is - // the first item in the DOM. - return document.querySelector('[role="menu"]') -} - -function getMenus(): HTMLElement[] { - // This is just an assumption for our tests. We assume that our menu has this role and that it is - // the first item in the DOM. - return Array.from(document.querySelectorAll('[role="menu"]')) -} - -function getMenuItems(): HTMLElement[] { - // This is just an assumption for our tests. We assume that all menu items have this role. - return Array.from(document.querySelectorAll('[role="menuitem"]')) -} - beforeAll(() => { jest.spyOn(window, 'requestAnimationFrame').mockImplementation(setImmediate as any) jest.spyOn(window, 'cancelAnimationFrame').mockImplementation(clearImmediate as any) @@ -79,7 +54,7 @@ describe('Safe guards', () => { ) it( - 'should be possible to render a menu without crashing', + 'should be possible to render a Menu without crashing', suppressConsoleLogs(async () => { render( @@ -92,11 +67,11 @@ describe('Safe guards', () => { ) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) }) ) }) @@ -104,7 +79,7 @@ describe('Safe guards', () => { describe('Rendering', () => { describe('Menu', () => { it( - 'should be possilbe to render a Menu using a render prop', + 'should be possible to render a Menu using a render prop', suppressConsoleLogs(async () => { render( @@ -123,24 +98,24 @@ describe('Rendering', () => { ) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) await click(getMenuButton()) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Open, + assertMenuButton({ + state: MenuState.Open, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Open }) + assertMenu({ state: MenuState.Open }) }) ) }) - describe('MenuButton', () => { + describe('Menu.Button', () => { it( 'should be possible to render a Menu.Button using a render prop', suppressConsoleLogs(async () => { @@ -155,21 +130,21 @@ describe('Rendering', () => { ) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, textContent: JSON.stringify({ open: false, focused: false }), }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) await click(getMenuButton()) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Open, + assertMenuButton({ + state: MenuState.Open, attributes: { id: 'headlessui-menu-button-1' }, textContent: JSON.stringify({ open: true, focused: false }), }) - assertMenu(getMenu(), { state: MenuState.Open }) + assertMenu({ state: MenuState.Open }) }) ) @@ -189,26 +164,26 @@ describe('Rendering', () => { ) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, textContent: JSON.stringify({ open: false, focused: false }), }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) await click(getMenuButton()) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Open, + assertMenuButton({ + state: MenuState.Open, attributes: { id: 'headlessui-menu-button-1' }, textContent: JSON.stringify({ open: true, focused: false }), }) - assertMenu(getMenu(), { state: MenuState.Open }) + assertMenu({ state: MenuState.Open }) }) ) }) - describe('MenuItems', () => { + describe('Menu.Items', () => { it( 'should be possible to render Menu.Items using a render prop', suppressConsoleLogs(async () => { @@ -225,26 +200,26 @@ describe('Rendering', () => { ) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) await click(getMenuButton()) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Open, + assertMenuButton({ + state: MenuState.Open, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { + assertMenu({ state: MenuState.Open, textContent: JSON.stringify({ open: true }), }) }) ) - it('should be possible to always render the MenuItems if we provide it a `static` prop', () => { + it('should be possible to always render the Menu.Items if we provide it a `static` prop', () => { render( Trigger @@ -261,9 +236,9 @@ describe('Rendering', () => { }) }) - describe('MenuItem', () => { + describe('Menu.Item', () => { it( - 'should be possible to render a MenuItem using a render prop', + 'should be possible to render a Menu.Item using a render prop', suppressConsoleLogs(async () => { render( @@ -274,19 +249,19 @@ describe('Rendering', () => { ) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) await click(getMenuButton()) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Open, + assertMenuButton({ + state: MenuState.Open, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { + assertMenu({ state: MenuState.Open, textContent: JSON.stringify({ active: false, disabled: false }), }) @@ -316,11 +291,11 @@ describe('Rendering composition', () => { ) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Open menu await click(getMenuButton()) @@ -333,7 +308,7 @@ describe('Rendering composition', () => { expect('' + items[2].classList).toEqual('no-special-treatment') // Double check that nothing is active - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() // Make the first item active await press(Keys.ArrowDown) @@ -344,7 +319,7 @@ describe('Rendering composition', () => { expect('' + items[2].classList).toEqual('no-special-treatment') // Double check that the first item is the active one - assertMenuLinkedWithMenuItem(getMenu(), items[0]) + assertMenuLinkedWithMenuItem(items[0]) // Let's go down, this should go to the third item since the second item is disabled! await press(Keys.ArrowDown) @@ -355,7 +330,7 @@ describe('Rendering composition', () => { expect('' + items[2].classList).toEqual('no-special-treatment') // Double check that the last item is the active one - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) }) ) @@ -373,11 +348,11 @@ describe('Rendering composition', () => { ) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Open menu await click(getMenuButton()) @@ -405,11 +380,11 @@ describe('Keyboard interactions', () => { ) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Focus the button getMenuButton()?.focus() @@ -418,12 +393,12 @@ describe('Keyboard interactions', () => { await press(Keys.Enter) // Verify it is open - assertMenuButton(getMenuButton(), { state: MenuButtonState.Open }) - assertMenu(getMenu(), { + assertMenuButton({ state: MenuState.Open }) + assertMenu({ state: MenuState.Open, attributes: { id: 'headlessui-menu-items-2' }, }) - assertMenuButtonLinkedWithMenu(getMenuButton(), getMenu()) + assertMenuButtonLinkedWithMenu() // Verify we have menu items const items = getMenuItems() @@ -431,7 +406,7 @@ describe('Keyboard interactions', () => { items.forEach(item => assertMenuItem(item)) // Verify that the first menu item is active - assertMenuLinkedWithMenuItem(getMenu(), items[0]) + assertMenuLinkedWithMenuItem(items[0]) }) ) @@ -445,16 +420,16 @@ describe('Keyboard interactions', () => { ) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Focus the button getMenuButton()?.focus() // Open menu await press(Keys.Enter) - assertMenu(getMenu(), { state: MenuState.Open }) + assertMenu({ state: MenuState.Open }) - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() }) ) @@ -474,11 +449,11 @@ describe('Keyboard interactions', () => { ) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Focus the button getMenuButton()?.focus() @@ -489,7 +464,7 @@ describe('Keyboard interactions', () => { const items = getMenuItems() // Verify that the first non-disabled menu item is active - assertMenuLinkedWithMenuItem(getMenu(), items[1]) + assertMenuLinkedWithMenuItem(items[1]) }) ) @@ -511,11 +486,11 @@ describe('Keyboard interactions', () => { ) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Focus the button getMenuButton()?.focus() @@ -526,7 +501,7 @@ describe('Keyboard interactions', () => { const items = getMenuItems() // Verify that the first non-disabled menu item is active - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) }) ) @@ -550,11 +525,11 @@ describe('Keyboard interactions', () => { ) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Focus the button getMenuButton()?.focus() @@ -562,7 +537,7 @@ describe('Keyboard interactions', () => { // Open menu await press(Keys.Enter) - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() }) ) @@ -580,24 +555,24 @@ describe('Keyboard interactions', () => { ) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Open menu await click(getMenuButton()) // Verify it is open - assertMenuButton(getMenuButton(), { state: MenuButtonState.Open }) + assertMenuButton({ state: MenuState.Open }) // Close menu await press(Keys.Enter) // Verify it is closed - assertMenuButton(getMenuButton(), { state: MenuButtonState.Closed }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenuButton({ state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) }) ) @@ -618,17 +593,17 @@ describe('Keyboard interactions', () => { ) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Open menu await click(getMenuButton()) // Verify it is open - assertMenuButton(getMenuButton(), { state: MenuButtonState.Open }) + assertMenuButton({ state: MenuState.Open }) // Activate the first menu item const items = getMenuItems() @@ -638,8 +613,8 @@ describe('Keyboard interactions', () => { await press(Keys.Enter) // Verify it is closed - assertMenuButton(getMenuButton(), { state: MenuButtonState.Closed }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenuButton({ state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Verify the "click" went through on the `a` tag expect(clickHandler).toHaveBeenCalled() @@ -667,17 +642,17 @@ describe('Keyboard interactions', () => { ) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Open menu await click(getMenuButton()) // Verify it is open - assertMenuButton(getMenuButton(), { state: MenuButtonState.Open }) + assertMenuButton({ state: MenuState.Open }) // Activate the second menu item const items = getMenuItems() @@ -687,8 +662,8 @@ describe('Keyboard interactions', () => { await press(Keys.Enter) // Verify it is closed - assertMenuButton(getMenuButton(), { state: MenuButtonState.Closed }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenuButton({ state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Verify the button got "clicked" expect(clickHandler).toHaveBeenCalledTimes(1) @@ -722,11 +697,11 @@ describe('Keyboard interactions', () => { ) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Focus the button getMenuButton()?.focus() @@ -735,18 +710,18 @@ describe('Keyboard interactions', () => { await press(Keys.Space) // Verify it is open - assertMenuButton(getMenuButton(), { state: MenuButtonState.Open }) - assertMenu(getMenu(), { + assertMenuButton({ state: MenuState.Open }) + assertMenu({ state: MenuState.Open, attributes: { id: 'headlessui-menu-items-2' }, }) - assertMenuButtonLinkedWithMenu(getMenuButton(), getMenu()) + assertMenuButtonLinkedWithMenu() // Verify we have menu items const items = getMenuItems() expect(items).toHaveLength(3) items.forEach(item => assertMenuItem(item)) - assertMenuLinkedWithMenuItem(getMenu(), items[0]) + assertMenuLinkedWithMenuItem(items[0]) }) ) @@ -760,16 +735,16 @@ describe('Keyboard interactions', () => { ) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Focus the button getMenuButton()?.focus() // Open menu await press(Keys.Space) - assertMenu(getMenu(), { state: MenuState.Open }) + assertMenu({ state: MenuState.Open }) - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() }) ) @@ -789,11 +764,11 @@ describe('Keyboard interactions', () => { ) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Focus the button getMenuButton()?.focus() @@ -804,7 +779,7 @@ describe('Keyboard interactions', () => { const items = getMenuItems() // Verify that the first non-disabled menu item is active - assertMenuLinkedWithMenuItem(getMenu(), items[1]) + assertMenuLinkedWithMenuItem(items[1]) }) ) @@ -826,11 +801,11 @@ describe('Keyboard interactions', () => { ) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Focus the button getMenuButton()?.focus() @@ -841,7 +816,7 @@ describe('Keyboard interactions', () => { const items = getMenuItems() // Verify that the first non-disabled menu item is active - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) }) ) @@ -865,11 +840,11 @@ describe('Keyboard interactions', () => { ) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Focus the button getMenuButton()?.focus() @@ -877,7 +852,7 @@ describe('Keyboard interactions', () => { // Open menu await press(Keys.Space) - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() }) ) @@ -895,24 +870,24 @@ describe('Keyboard interactions', () => { ) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Open menu await click(getMenuButton()) // Verify it is open - assertMenuButton(getMenuButton(), { state: MenuButtonState.Open }) + assertMenuButton({ state: MenuState.Open }) // Close menu await press(Keys.Space) // Verify it is closed - assertMenuButton(getMenuButton(), { state: MenuButtonState.Closed }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenuButton({ state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) }) ) @@ -933,17 +908,17 @@ describe('Keyboard interactions', () => { ) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Open menu await click(getMenuButton()) // Verify it is open - assertMenuButton(getMenuButton(), { state: MenuButtonState.Open }) + assertMenuButton({ state: MenuState.Open }) // Activate the first menu item const items = getMenuItems() @@ -953,8 +928,8 @@ describe('Keyboard interactions', () => { await press(Keys.Space) // Verify it is closed - assertMenuButton(getMenuButton(), { state: MenuButtonState.Closed }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenuButton({ state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Verify the "click" went through on the `a` tag expect(clickHandler).toHaveBeenCalled() @@ -984,19 +959,19 @@ describe('Keyboard interactions', () => { await press(Keys.Space) // Verify it is open - assertMenuButton(getMenuButton(), { state: MenuButtonState.Open }) - assertMenu(getMenu(), { + assertMenuButton({ state: MenuState.Open }) + assertMenu({ state: MenuState.Open, attributes: { id: 'headlessui-menu-items-2' }, }) - assertMenuButtonLinkedWithMenu(getMenuButton(), getMenu()) + assertMenuButtonLinkedWithMenu() // Close menu await press(Keys.Escape) // Verify it is closed - assertMenuButton(getMenuButton(), { state: MenuButtonState.Closed }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenuButton({ state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) }) ) }) @@ -1016,11 +991,11 @@ describe('Keyboard interactions', () => { ) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Focus the button getMenuButton()?.focus() @@ -1029,25 +1004,25 @@ describe('Keyboard interactions', () => { await press(Keys.Enter) // Verify it is open - assertMenuButton(getMenuButton(), { state: MenuButtonState.Open }) - assertMenu(getMenu(), { + assertMenuButton({ state: MenuState.Open }) + assertMenu({ state: MenuState.Open, attributes: { id: 'headlessui-menu-items-2' }, }) - assertMenuButtonLinkedWithMenu(getMenuButton(), getMenu()) + assertMenuButtonLinkedWithMenu() // Verify we have menu items const items = getMenuItems() expect(items).toHaveLength(3) items.forEach(item => assertMenuItem(item)) - assertMenuLinkedWithMenuItem(getMenu(), items[0]) + assertMenuLinkedWithMenuItem(items[0]) // Try to tab await press(Keys.Tab) // Verify it is still open - assertMenuButton(getMenuButton(), { state: MenuButtonState.Open }) - assertMenu(getMenu(), { state: MenuState.Open }) + assertMenuButton({ state: MenuState.Open }) + assertMenu({ state: MenuState.Open }) }) ) @@ -1065,11 +1040,11 @@ describe('Keyboard interactions', () => { ) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Focus the button getMenuButton()?.focus() @@ -1078,25 +1053,25 @@ describe('Keyboard interactions', () => { await press(Keys.Enter) // Verify it is open - assertMenuButton(getMenuButton(), { state: MenuButtonState.Open }) - assertMenu(getMenu(), { + assertMenuButton({ state: MenuState.Open }) + assertMenu({ state: MenuState.Open, attributes: { id: 'headlessui-menu-items-2' }, }) - assertMenuButtonLinkedWithMenu(getMenuButton(), getMenu()) + assertMenuButtonLinkedWithMenu() // Verify we have menu items const items = getMenuItems() expect(items).toHaveLength(3) items.forEach(item => assertMenuItem(item)) - assertMenuLinkedWithMenuItem(getMenu(), items[0]) + assertMenuLinkedWithMenuItem(items[0]) // Try to Shift+Tab await press(shift(Keys.Tab)) // Verify it is still open - assertMenuButton(getMenuButton(), { state: MenuButtonState.Open }) - assertMenu(getMenu(), { state: MenuState.Open }) + assertMenuButton({ state: MenuState.Open }) + assertMenu({ state: MenuState.Open }) }) ) }) @@ -1116,11 +1091,11 @@ describe('Keyboard interactions', () => { ) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Focus the button getMenuButton()?.focus() @@ -1129,12 +1104,12 @@ describe('Keyboard interactions', () => { await press(Keys.ArrowDown) // Verify it is open - assertMenuButton(getMenuButton(), { state: MenuButtonState.Open }) - assertMenu(getMenu(), { + assertMenuButton({ state: MenuState.Open }) + assertMenu({ state: MenuState.Open, attributes: { id: 'headlessui-menu-items-2' }, }) - assertMenuButtonLinkedWithMenu(getMenuButton(), getMenu()) + assertMenuButtonLinkedWithMenu() // Verify we have menu items const items = getMenuItems() @@ -1142,7 +1117,7 @@ describe('Keyboard interactions', () => { items.forEach(item => assertMenuItem(item)) // Verify that the first menu item is active - assertMenuLinkedWithMenuItem(getMenu(), items[0]) + assertMenuLinkedWithMenuItem(items[0]) }) ) @@ -1156,16 +1131,16 @@ describe('Keyboard interactions', () => { ) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Focus the button getMenuButton()?.focus() // Open menu await press(Keys.ArrowDown) - assertMenu(getMenu(), { state: MenuState.Open }) + assertMenu({ state: MenuState.Open }) - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() }) ) @@ -1183,11 +1158,11 @@ describe('Keyboard interactions', () => { ) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Focus the button getMenuButton()?.focus() @@ -1199,19 +1174,19 @@ describe('Keyboard interactions', () => { const items = getMenuItems() expect(items).toHaveLength(3) items.forEach(item => assertMenuItem(item)) - assertMenuLinkedWithMenuItem(getMenu(), items[0]) + assertMenuLinkedWithMenuItem(items[0]) // We should be able to go down once await press(Keys.ArrowDown) - assertMenuLinkedWithMenuItem(getMenu(), items[1]) + assertMenuLinkedWithMenuItem(items[1]) // We should be able to go down again await press(Keys.ArrowDown) - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) // We should NOT be able to go down again (because last item). Current implementation won't go around. await press(Keys.ArrowDown) - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) }) ) @@ -1231,11 +1206,11 @@ describe('Keyboard interactions', () => { ) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Focus the button getMenuButton()?.focus() @@ -1247,11 +1222,11 @@ describe('Keyboard interactions', () => { const items = getMenuItems() expect(items).toHaveLength(3) items.forEach(item => assertMenuItem(item)) - assertMenuLinkedWithMenuItem(getMenu(), items[1]) + assertMenuLinkedWithMenuItem(items[1]) // We should be able to go down once await press(Keys.ArrowDown) - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) }) ) @@ -1273,11 +1248,11 @@ describe('Keyboard interactions', () => { ) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Focus the button getMenuButton()?.focus() @@ -1289,7 +1264,7 @@ describe('Keyboard interactions', () => { const items = getMenuItems() expect(items).toHaveLength(3) items.forEach(item => assertMenuItem(item)) - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) }) ) }) @@ -1309,11 +1284,11 @@ describe('Keyboard interactions', () => { ) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Focus the button getMenuButton()?.focus() @@ -1322,12 +1297,12 @@ describe('Keyboard interactions', () => { await press(Keys.ArrowUp) // Verify it is open - assertMenuButton(getMenuButton(), { state: MenuButtonState.Open }) - assertMenu(getMenu(), { + assertMenuButton({ state: MenuState.Open }) + assertMenu({ state: MenuState.Open, attributes: { id: 'headlessui-menu-items-2' }, }) - assertMenuButtonLinkedWithMenu(getMenuButton(), getMenu()) + assertMenuButtonLinkedWithMenu() // Verify we have menu items const items = getMenuItems() @@ -1335,7 +1310,7 @@ describe('Keyboard interactions', () => { items.forEach(item => assertMenuItem(item)) // ! ALERT: The LAST item should now be active - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) }) ) @@ -1349,16 +1324,16 @@ describe('Keyboard interactions', () => { ) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Focus the button getMenuButton()?.focus() // Open menu await press(Keys.ArrowUp) - assertMenu(getMenu(), { state: MenuState.Open }) + assertMenu({ state: MenuState.Open }) - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() }) ) @@ -1380,11 +1355,11 @@ describe('Keyboard interactions', () => { ) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Focus the button getMenuButton()?.focus() @@ -1396,7 +1371,7 @@ describe('Keyboard interactions', () => { const items = getMenuItems() expect(items).toHaveLength(3) items.forEach(item => assertMenuItem(item)) - assertMenuLinkedWithMenuItem(getMenu(), items[0]) + assertMenuLinkedWithMenuItem(items[0]) }) ) @@ -1418,11 +1393,11 @@ describe('Keyboard interactions', () => { ) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Focus the button getMenuButton()?.focus() @@ -1434,15 +1409,15 @@ describe('Keyboard interactions', () => { const items = getMenuItems() expect(items).toHaveLength(3) items.forEach(item => assertMenuItem(item)) - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) // We should not be able to go up (because those are disabled) await press(Keys.ArrowUp) - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) // We should not be able to go down (because this is the last item) await press(Keys.ArrowDown) - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) }) ) @@ -1460,11 +1435,11 @@ describe('Keyboard interactions', () => { ) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Focus the button getMenuButton()?.focus() @@ -1473,30 +1448,30 @@ describe('Keyboard interactions', () => { await press(Keys.ArrowUp) // Verify it is open - assertMenuButton(getMenuButton(), { state: MenuButtonState.Open }) - assertMenu(getMenu(), { + assertMenuButton({ state: MenuState.Open }) + assertMenu({ state: MenuState.Open, attributes: { id: 'headlessui-menu-items-2' }, }) - assertMenuButtonLinkedWithMenu(getMenuButton(), getMenu()) + assertMenuButtonLinkedWithMenu() // Verify we have menu items const items = getMenuItems() expect(items).toHaveLength(3) items.forEach(item => assertMenuItem(item)) - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) // We should be able to go down once await press(Keys.ArrowUp) - assertMenuLinkedWithMenuItem(getMenu(), items[1]) + assertMenuLinkedWithMenuItem(items[1]) // We should be able to go down again await press(Keys.ArrowUp) - assertMenuLinkedWithMenuItem(getMenu(), items[0]) + assertMenuLinkedWithMenuItem(items[0]) // We should NOT be able to go up again (because first item). Current implementation won't go around. await press(Keys.ArrowUp) - assertMenuLinkedWithMenuItem(getMenu(), items[0]) + assertMenuLinkedWithMenuItem(items[0]) }) ) }) @@ -1525,11 +1500,11 @@ describe('Keyboard interactions', () => { const items = getMenuItems() // We should be on the first item - assertMenuLinkedWithMenuItem(getMenu(), items[0]) + assertMenuLinkedWithMenuItem(items[0]) // We should be able to go to the last item await press(Keys.End) - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) }) ) @@ -1561,11 +1536,11 @@ describe('Keyboard interactions', () => { const items = getMenuItems() // We should be on the first item - assertMenuLinkedWithMenuItem(getMenu(), items[0]) + assertMenuLinkedWithMenuItem(items[0]) // We should be able to go to the last non-disabled item await press(Keys.End) - assertMenuLinkedWithMenuItem(getMenu(), items[1]) + assertMenuLinkedWithMenuItem(items[1]) }) ) @@ -1594,13 +1569,13 @@ describe('Keyboard interactions', () => { await click(getMenuButton()) // We opened via click, we don't have an active item - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() // We should not be able to go to the end await press(Keys.End) const items = getMenuItems() - assertMenuLinkedWithMenuItem(getMenu(), items[0]) + assertMenuLinkedWithMenuItem(items[0]) }) ) @@ -1631,12 +1606,12 @@ describe('Keyboard interactions', () => { await click(getMenuButton()) // We opened via click, we don't have an active item - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() // We should not be able to go to the end await press(Keys.End) - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() }) ) }) @@ -1665,11 +1640,11 @@ describe('Keyboard interactions', () => { const items = getMenuItems() // We should be on the first item - assertMenuLinkedWithMenuItem(getMenu(), items[0]) + assertMenuLinkedWithMenuItem(items[0]) // We should be able to go to the last item await press(Keys.PageDown) - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) }) ) @@ -1701,11 +1676,11 @@ describe('Keyboard interactions', () => { const items = getMenuItems() // We should be on the first item - assertMenuLinkedWithMenuItem(getMenu(), items[0]) + assertMenuLinkedWithMenuItem(items[0]) // We should be able to go to the last non-disabled item await press(Keys.PageDown) - assertMenuLinkedWithMenuItem(getMenu(), items[1]) + assertMenuLinkedWithMenuItem(items[1]) }) ) @@ -1734,13 +1709,13 @@ describe('Keyboard interactions', () => { await click(getMenuButton()) // We opened via click, we don't have an active item - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() // We should not be able to go to the end await press(Keys.PageDown) const items = getMenuItems() - assertMenuLinkedWithMenuItem(getMenu(), items[0]) + assertMenuLinkedWithMenuItem(items[0]) }) ) @@ -1771,12 +1746,12 @@ describe('Keyboard interactions', () => { await click(getMenuButton()) // We opened via click, we don't have an active item - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() // We should not be able to go to the end await press(Keys.PageDown) - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() }) ) }) @@ -1805,11 +1780,11 @@ describe('Keyboard interactions', () => { const items = getMenuItems() // We should be on the last item - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) // We should be able to go to the first item await press(Keys.Home) - assertMenuLinkedWithMenuItem(getMenu(), items[0]) + assertMenuLinkedWithMenuItem(items[0]) }) ) @@ -1836,7 +1811,7 @@ describe('Keyboard interactions', () => { await click(getMenuButton()) // We opened via click, we don't have an active item - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() // We should not be able to go to the end await press(Keys.Home) @@ -1844,7 +1819,7 @@ describe('Keyboard interactions', () => { const items = getMenuItems() // We should be on the first non-disabled item - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) }) ) @@ -1873,13 +1848,13 @@ describe('Keyboard interactions', () => { await click(getMenuButton()) // We opened via click, we don't have an active item - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() // We should not be able to go to the end await press(Keys.Home) const items = getMenuItems() - assertMenuLinkedWithMenuItem(getMenu(), items[3]) + assertMenuLinkedWithMenuItem(items[3]) }) ) @@ -1910,12 +1885,12 @@ describe('Keyboard interactions', () => { await click(getMenuButton()) // We opened via click, we don't have an active item - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() // We should not be able to go to the end await press(Keys.Home) - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() }) ) }) @@ -1944,11 +1919,11 @@ describe('Keyboard interactions', () => { const items = getMenuItems() // We should be on the last item - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) // We should be able to go to the first item await press(Keys.PageUp) - assertMenuLinkedWithMenuItem(getMenu(), items[0]) + assertMenuLinkedWithMenuItem(items[0]) }) ) @@ -1975,7 +1950,7 @@ describe('Keyboard interactions', () => { await click(getMenuButton()) // We opened via click, we don't have an active item - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() // We should not be able to go to the end await press(Keys.PageUp) @@ -1983,7 +1958,7 @@ describe('Keyboard interactions', () => { const items = getMenuItems() // We should be on the first non-disabled item - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) }) ) @@ -2012,13 +1987,13 @@ describe('Keyboard interactions', () => { await click(getMenuButton()) // We opened via click, we don't have an active item - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() // We should not be able to go to the end await press(Keys.PageUp) const items = getMenuItems() - assertMenuLinkedWithMenuItem(getMenu(), items[3]) + assertMenuLinkedWithMenuItem(items[3]) }) ) @@ -2049,12 +2024,12 @@ describe('Keyboard interactions', () => { await click(getMenuButton()) // We opened via click, we don't have an active item - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() // We should not be able to go to the end await press(Keys.PageUp) - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() }) ) }) @@ -2081,15 +2056,15 @@ describe('Keyboard interactions', () => { // We should be able to go to the second item await type(word('bob')) - assertMenuLinkedWithMenuItem(getMenu(), items[1]) + assertMenuLinkedWithMenuItem(items[1]) // We should be able to go to the first item await type(word('alice')) - assertMenuLinkedWithMenuItem(getMenu(), items[0]) + assertMenuLinkedWithMenuItem(items[0]) // We should be able to go to the last item await type(word('charlie')) - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) }) ) @@ -2116,19 +2091,19 @@ describe('Keyboard interactions', () => { const items = getMenuItems() // We should be on the last item - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) // We should be able to go to the second item await type(word('bo')) - assertMenuLinkedWithMenuItem(getMenu(), items[1]) + assertMenuLinkedWithMenuItem(items[1]) // We should be able to go to the first item await type(word('ali')) - assertMenuLinkedWithMenuItem(getMenu(), items[0]) + assertMenuLinkedWithMenuItem(items[0]) // We should be able to go to the last item await type(word('char')) - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) }) ) @@ -2155,19 +2130,19 @@ describe('Keyboard interactions', () => { const items = getMenuItems() // We should be on the last item - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) // We should be able to go to the second item await type(word('value b')) - assertMenuLinkedWithMenuItem(getMenu(), items[1]) + assertMenuLinkedWithMenuItem(items[1]) // We should be able to go to the first item await type(word('value a')) - assertMenuLinkedWithMenuItem(getMenu(), items[0]) + assertMenuLinkedWithMenuItem(items[0]) // We should be able to go to the last item await type(word('value c')) - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) }) ) @@ -2196,13 +2171,13 @@ describe('Keyboard interactions', () => { const items = getMenuItems() // We should be on the last item - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) // We should not be able to go to the disabled item await type(word('bo')) // We should still be on the last item - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) }) ) }) @@ -2223,22 +2198,22 @@ describe('Mouse interactions', () => { ) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Open menu await click(getMenuButton()) // Verify it is open - assertMenuButton(getMenuButton(), { state: MenuButtonState.Open }) - assertMenu(getMenu(), { + assertMenuButton({ state: MenuState.Open }) + assertMenu({ state: MenuState.Open, attributes: { id: 'headlessui-menu-items-2' }, }) - assertMenuButtonLinkedWithMenu(getMenuButton(), getMenu()) + assertMenuButtonLinkedWithMenu() // Verify we have menu items const items = getMenuItems() @@ -2265,14 +2240,14 @@ describe('Mouse interactions', () => { await click(getMenuButton()) // Verify it is open - assertMenuButton(getMenuButton(), { state: MenuButtonState.Open }) + assertMenuButton({ state: MenuState.Open }) // Click to close await click(getMenuButton()) // Verify it is closed - assertMenuButton(getMenuButton(), { state: MenuButtonState.Closed }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenuButton({ state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) }) ) @@ -2316,13 +2291,13 @@ describe('Mouse interactions', () => { ) // Verify that the window is closed - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Click something that is not related to the menu await click(document.body) // Should still be closed - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) }) ) @@ -2342,13 +2317,13 @@ describe('Mouse interactions', () => { // Open menu await click(getMenuButton()) - assertMenu(getMenu(), { state: MenuState.Open }) + assertMenu({ state: MenuState.Open }) // Click something that is not related to the menu await click(document.body) // Should be closed now - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) }) ) @@ -2368,13 +2343,13 @@ describe('Mouse interactions', () => { // Open menu await click(getMenuButton()) - assertMenu(getMenu(), { state: MenuState.Open }) + assertMenu({ state: MenuState.Open }) // Click the menu button again await click(getMenuButton()) // Should be closed now - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) }) ) @@ -2442,15 +2417,15 @@ describe('Mouse interactions', () => { const items = getMenuItems() // We should be able to go to the second item await mouseMove(items[1]) - assertMenuLinkedWithMenuItem(getMenu(), items[1]) + assertMenuLinkedWithMenuItem(items[1]) // We should be able to go to the first item await mouseMove(items[0]) - assertMenuLinkedWithMenuItem(getMenu(), items[0]) + assertMenuLinkedWithMenuItem(items[0]) // We should be able to go to the last item await mouseMove(items[2]) - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) }) ) @@ -2474,7 +2449,7 @@ describe('Mouse interactions', () => { const items = getMenuItems() // We should be able to go to the second item await mouseMove(items[1]) - assertMenuLinkedWithMenuItem(getMenu(), items[1]) + assertMenuLinkedWithMenuItem(items[1]) }) ) @@ -2499,12 +2474,12 @@ describe('Mouse interactions', () => { // We should be able to go to the second item await mouseMove(items[1]) - assertMenuLinkedWithMenuItem(getMenu(), items[1]) + assertMenuLinkedWithMenuItem(items[1]) await mouseMove(items[1]) // Nothing should be changed - assertMenuLinkedWithMenuItem(getMenu(), items[1]) + assertMenuLinkedWithMenuItem(items[1]) }) ) @@ -2530,7 +2505,7 @@ describe('Mouse interactions', () => { const items = getMenuItems() await mouseMove(items[1]) - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() }) ) @@ -2559,7 +2534,7 @@ describe('Mouse interactions', () => { await mouseMove(items[1]) // We should not have an active item now - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() }) ) @@ -2584,24 +2559,24 @@ describe('Mouse interactions', () => { // We should be able to go to the second item await mouseMove(items[1]) - assertMenuLinkedWithMenuItem(getMenu(), items[1]) + assertMenuLinkedWithMenuItem(items[1]) await mouseLeave(items[1]) - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() // We should be able to go to the first item await mouseMove(items[0]) - assertMenuLinkedWithMenuItem(getMenu(), items[0]) + assertMenuLinkedWithMenuItem(items[0]) await mouseLeave(items[0]) - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() // We should be able to go to the last item await mouseMove(items[2]) - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) await mouseLeave(items[2]) - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() }) ) @@ -2628,10 +2603,10 @@ describe('Mouse interactions', () => { // Try to hover over item 1, which is disabled await mouseMove(items[1]) - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() await mouseLeave(items[1]) - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() }) ) @@ -2654,13 +2629,14 @@ describe('Mouse interactions', () => { // Open menu await click(getMenuButton()) - assertMenu(getMenu(), { state: MenuState.Open }) + assertMenu({ state: MenuState.Open }) const items = getMenuItems() // We should be able to click the first item await click(items[1]) - assertMenu(getMenu(), { state: MenuState.Closed }) + + assertMenu({ state: MenuState.Closed }) expect(clickHandler).toHaveBeenCalled() }) ) @@ -2686,11 +2662,11 @@ describe('Mouse interactions', () => { // Open menu await click(getMenuButton()) - assertMenu(getMenu(), { state: MenuState.Open }) + assertMenu({ state: MenuState.Open }) // We should be able to click the first item await click(getMenuItems()[1]) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Verify the callback has been called expect(clickHandler).toHaveBeenCalledTimes(1) @@ -2700,7 +2676,7 @@ describe('Mouse interactions', () => { // Click the last item, which should close and invoke the handler await click(getMenuItems()[2]) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Verify the callback has been called expect(clickHandler).toHaveBeenCalledTimes(2) @@ -2725,13 +2701,13 @@ describe('Mouse interactions', () => { // Open menu await click(getMenuButton()) - assertMenu(getMenu(), { state: MenuState.Open }) + assertMenu({ state: MenuState.Open }) const items = getMenuItems() // We should be able to click the first item await click(items[1]) - assertMenu(getMenu(), { state: MenuState.Open }) + assertMenu({ state: MenuState.Open }) }) ) @@ -2751,16 +2727,16 @@ describe('Mouse interactions', () => { // Open menu await click(getMenuButton()) - assertMenu(getMenu(), { state: MenuState.Open }) + assertMenu({ state: MenuState.Open }) const items = getMenuItems() // Verify that nothing is active yet - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() // We should be able to focus the first item await focus(items[1]) - assertMenuLinkedWithMenuItem(getMenu(), items[1]) + assertMenuLinkedWithMenuItem(items[1]) }) ) @@ -2782,13 +2758,13 @@ describe('Mouse interactions', () => { // Open menu await click(getMenuButton()) - assertMenu(getMenu(), { state: MenuState.Open }) + assertMenu({ state: MenuState.Open }) const items = getMenuItems() // We should not be able to focus the first item await focus(items[1]) - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() }) ) @@ -2816,7 +2792,7 @@ describe('Mouse interactions', () => { // Open menu await click(getMenuButton()) - assertMenu(getMenu(), { state: MenuState.Open }) + assertMenu({ state: MenuState.Open }) const items = getMenuItems() diff --git a/packages/@headlessui-react/src/components/menu/menu.tsx b/packages/@headlessui-react/src/components/menu/menu.tsx index 772a46c..3dffd34 100644 --- a/packages/@headlessui-react/src/components/menu/menu.tsx +++ b/packages/@headlessui-react/src/components/menu/menu.tsx @@ -4,37 +4,17 @@ import * as React from 'react' import { Props } from '../../types' import { match } from '../../utils/match' import { forwardRefWithAs, render } from '../../utils/render' -import { Transition, TransitionClasses } from '../transitions/transition' import { useDisposables } from '../../hooks/use-disposables' import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect' import { useSyncRefs } from '../../hooks/use-sync-refs' import { useId } from '../../hooks/use-id' +import { Keys } from '../keyboard' enum MenuStates { Open, Closed, } -// TODO: This must already exist somewhere, right? 🤔 -// Ref: https://www.w3.org/TR/uievents-key/#named-key-attribute-values -enum Key { - Space = ' ', - Enter = 'Enter', - Escape = 'Escape', - Backspace = 'Backspace', - - ArrowUp = 'ArrowUp', - ArrowDown = 'ArrowDown', - - Home = 'Home', - End = 'End', - - PageUp = 'PageUp', - PageDown = 'PageDown', - - Tab = 'Tab', -} - type MenuItemDataRef = React.MutableRefObject<{ textValue?: string; disabled: boolean }> type StateDefinition = { @@ -286,9 +266,9 @@ const Button = forwardRefWithAs(function Button< switch (event.key) { // Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-13 - case Key.Space: - case Key.Enter: - case Key.ArrowDown: + case Keys.Space: + case Keys.Enter: + case Keys.ArrowDown: event.preventDefault() dispatch({ type: ActionTypes.OpenMenu }) d.nextFrame(() => { @@ -297,7 +277,7 @@ const Button = forwardRefWithAs(function Button< }) break - case Key.ArrowUp: + case Keys.ArrowUp: event.preventDefault() dispatch({ type: ActionTypes.OpenMenu }) d.nextFrame(() => { @@ -369,20 +349,10 @@ type ItemsRenderPropArg = { open: boolean } const Items = forwardRefWithAs(function Items< TTag extends React.ElementType = typeof DEFAULT_ITEMS_TAG >( - props: Props & - TransitionClasses & { static?: boolean }, + props: Props & { static?: boolean }, ref: React.Ref ) { - const { - enter, - enterFrom, - enterTo, - leave, - leaveFrom, - leaveTo, - static: isStatic = false, - ...passthroughProps - } = props + const { static: isStatic = false, ...passthroughProps } = props const [state, dispatch] = useMenuContext([Menu.name, Items.name].join('.')) const itemsRef = useSyncRefs(state.itemsRef, ref) @@ -397,12 +367,14 @@ const Items = forwardRefWithAs(function Items< switch (event.key) { // Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-12 - // @ts-expect-error Falthrough is expected here - case Key.Space: - if (state.searchQuery !== '') + // @ts-expect-error Fallthrough is expected here + case Keys.Space: + if (state.searchQuery !== '') { + event.preventDefault() return dispatch({ type: ActionTypes.Search, value: event.key }) + } // When in type ahead mode, fallthrough - case Key.Enter: + case Keys.Enter: event.preventDefault() dispatch({ type: ActionTypes.CloseMenu }) if (state.activeItemIndex !== null) { @@ -412,31 +384,31 @@ const Items = forwardRefWithAs(function Items< } break - case Key.ArrowDown: + case Keys.ArrowDown: event.preventDefault() return dispatch({ type: ActionTypes.GoToItem, focus: Focus.NextItem }) - case Key.ArrowUp: + case Keys.ArrowUp: event.preventDefault() return dispatch({ type: ActionTypes.GoToItem, focus: Focus.PreviousItem }) - case Key.Home: - case Key.PageUp: + case Keys.Home: + case Keys.PageUp: event.preventDefault() return dispatch({ type: ActionTypes.GoToItem, focus: Focus.FirstItem }) - case Key.End: - case Key.PageDown: + case Keys.End: + case Keys.PageDown: event.preventDefault() return dispatch({ type: ActionTypes.GoToItem, focus: Focus.LastItem }) - case Key.Escape: + case Keys.Escape: event.preventDefault() dispatch({ type: ActionTypes.CloseMenu }) d.nextFrame(() => state.buttonRef.current?.focus()) break - case Key.Tab: + case Keys.Tab: return event.preventDefault() default: @@ -461,36 +433,12 @@ const Items = forwardRefWithAs(function Items< tabIndex: 0, } - if (isStatic) { - return render( - { ...passthroughProps, ...propsWeControl, ...{ ref: itemsRef } }, - propsBag, - DEFAULT_ITEMS_TAG - ) - } + if (!isStatic && state.menuState === MenuStates.Closed) return null - return ( - - {ref => - render( - { - ...passthroughProps, - ...propsWeControl, - ...{ - ref(elementRef: HTMLDivElement) { - ref.current = elementRef - itemsRef(elementRef) - }, - }, - }, - propsBag, - DEFAULT_ITEMS_TAG - ) - } - + return render( + { ...passthroughProps, ...propsWeControl, ...{ ref: itemsRef } }, + propsBag, + DEFAULT_ITEMS_TAG ) }) @@ -501,7 +449,6 @@ type MenuItemPropsWeControl = | 'role' | 'tabIndex' | 'aria-disabled' - | 'onPointerEnter' | 'onPointerLeave' | 'onFocus' @@ -563,8 +510,9 @@ function Item( const handlePointerLeave = React.useCallback(() => { if (disabled) return + if (!active) return dispatch({ type: ActionTypes.GoToItem, focus: Focus.Nothing }) - }, [disabled, dispatch]) + }, [disabled, active, dispatch]) const propsBag = React.useMemo(() => ({ active, disabled }), [active, disabled]) const propsWeControl = { diff --git a/packages/@headlessui-react/src/components/transitions/transition.tsx b/packages/@headlessui-react/src/components/transitions/transition.tsx index 3bb10e6..1b7453b 100644 --- a/packages/@headlessui-react/src/components/transitions/transition.tsx +++ b/packages/@headlessui-react/src/components/transitions/transition.tsx @@ -8,7 +8,7 @@ import { useIsoMorphicEffect } from '../../hooks/use-iso-morphic-effect' import { match } from '../../utils/match' import { Reason, transition } from './utils/transition' -type ID = number +type ID = ReturnType function useSplitClasses(classes: string = '') { return React.useMemo(() => classes.split(' ').filter(className => className.trim().length > 1), [ diff --git a/packages/@headlessui-react/src/hooks/use-computed.ts b/packages/@headlessui-react/src/hooks/use-computed.ts new file mode 100644 index 0000000..991b2c9 --- /dev/null +++ b/packages/@headlessui-react/src/hooks/use-computed.ts @@ -0,0 +1,12 @@ +import * as React from 'react' +import { useIsoMorphicEffect } from './use-iso-morphic-effect' + +export function useComputed(cb: () => T, dependencies: React.DependencyList) { + const [value, setValue] = React.useState(cb) + const cbRef = React.useRef(cb) + useIsoMorphicEffect(() => { + cbRef.current = cb + }, [cb]) + useIsoMorphicEffect(() => setValue(cbRef.current), [cbRef, setValue, ...dependencies]) + return value +} diff --git a/packages/@headlessui-react/src/hooks/use-id.ts b/packages/@headlessui-react/src/hooks/use-id.ts index b9b26a0..37be593 100644 --- a/packages/@headlessui-react/src/hooks/use-id.ts +++ b/packages/@headlessui-react/src/hooks/use-id.ts @@ -1,11 +1,29 @@ import * as React from 'react' +import { useIsoMorphicEffect } from './use-iso-morphic-effect' +// We used a "simple" approach first which worked for SSR and rehydration on the client. However we +// didn't take care of the Suspense case. To fix this we used the approach the @reach-ui/auto-id +// uses. +// +// Credits: https://github.com/reach/reach-ui/blob/develop/packages/auto-id/src/index.tsx + +let state = { serverHandoffComplete: false } let id = 0 function generateId() { return ++id } export function useId() { - const [id] = React.useState(generateId) - return id + const [id, setId] = React.useState(state.serverHandoffComplete ? generateId : null) + + useIsoMorphicEffect(() => { + if (id === null) setId(generateId()) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + React.useEffect(() => { + if (state.serverHandoffComplete === false) state.serverHandoffComplete = true + }, []) + + return id != null ? '' + id : undefined } diff --git a/packages/@headlessui-react/src/index.test.ts b/packages/@headlessui-react/src/index.test.ts index 01a10a0..a63e8e8 100644 --- a/packages/@headlessui-react/src/index.test.ts +++ b/packages/@headlessui-react/src/index.test.ts @@ -5,5 +5,5 @@ import * as TailwindUI from './index' * the outside world that we didn't want! */ it('should expose the correct components', () => { - expect(Object.keys(TailwindUI)).toEqual(['Transition', 'Menu']) + expect(Object.keys(TailwindUI)).toEqual(['Transition', 'Menu', 'Listbox']) }) diff --git a/packages/@headlessui-react/src/index.ts b/packages/@headlessui-react/src/index.ts index 61f598f..ab42a84 100644 --- a/packages/@headlessui-react/src/index.ts +++ b/packages/@headlessui-react/src/index.ts @@ -1,2 +1,3 @@ export * from './components/transitions/transition' export * from './components/menu/menu' +export * from './components/listbox/listbox' diff --git a/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts b/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts index 14f8a58..6118280 100644 --- a/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts +++ b/packages/@headlessui-react/src/test-utils/accessibility-assertions.ts @@ -1,156 +1,181 @@ -export enum MenuButtonState { - Open, - Closed, +function assertNever(x: never): never { + throw new Error('Unexpected object: ' + x) } +// --- + +export function getMenuButton(): HTMLElement | null { + return document.querySelector('button,[role="button"]') +} + +export function getMenuButtons(): HTMLElement[] { + return Array.from(document.querySelectorAll('button,[role="button"]')) +} + +export function getMenu(): HTMLElement | null { + return document.querySelector('[role="menu"]') +} + +export function getMenus(): HTMLElement[] { + return Array.from(document.querySelectorAll('[role="menu"]')) +} + +export function getMenuItems(): HTMLElement[] { + return Array.from(document.querySelectorAll('[role="menuitem"]')) +} + +// --- + export enum MenuState { Open, Closed, } -type MenuButtonOptions = { attributes?: Record; textContent?: string } & ( - | { state: MenuButtonState.Closed } - | { state: MenuButtonState.Open } -) -export function assertMenuButton(button: HTMLElement | null, options: MenuButtonOptions) { +export function assertMenuButton( + options: { + attributes?: Record + textContent?: string + state: MenuState + }, + button = getMenuButton() +) { try { if (button === null) return expect(button).not.toBe(null) // Ensure menu button have these properties - expect(button.hasAttribute('id')).toBe(true) - expect(button.hasAttribute('aria-haspopup')).toBe(true) + expect(button).toHaveAttribute('id') + expect(button).toHaveAttribute('aria-haspopup') - if (options.state === MenuButtonState.Open) { - expect(button.hasAttribute('aria-controls')).toBe(true) - expect(button.getAttribute('aria-expanded')).toBe('true') - } + switch (options.state) { + case MenuState.Open: + expect(button).toHaveAttribute('aria-controls') + expect(button).toHaveAttribute('aria-expanded', 'true') + break - if (options.state === MenuButtonState.Closed) { - expect(button.getAttribute('aria-controls')).toBeNull() - expect(button.getAttribute('aria-expanded')).toBeNull() + case MenuState.Closed: + expect(button).not.toHaveAttribute('aria-controls') + expect(button).not.toHaveAttribute('aria-expanded') + break + + default: + assertNever(options.state) } if (options.textContent) { - expect(button.textContent?.trim()).toBe(options.textContent.trim()) + expect(button).toHaveTextContent(options.textContent) } // Ensure menu button has the following attributes for (let attributeName in options.attributes) { - expect(button.getAttribute(attributeName)).toEqual(options.attributes[attributeName]) + expect(button).toHaveAttribute(attributeName, options.attributes[attributeName]) } } catch (err) { - if (Error.captureStackTrace) { - Error.captureStackTrace(err, assertMenuButton) - } + Error.captureStackTrace(err, assertMenuButton) throw err } } -export function assertMenuButtonLinkedWithMenu( - button: HTMLElement | null, - menu: HTMLElement | null -) { +export function assertMenuButtonLinkedWithMenu(button = getMenuButton(), menu = getMenu()) { try { if (button === null) return expect(button).not.toBe(null) if (menu === null) return expect(menu).not.toBe(null) // Ensure link between button & menu is correct - expect(button.getAttribute('aria-controls')).toBe(menu.getAttribute('id')) - expect(menu.getAttribute('aria-labelledby')).toBe(button.getAttribute('id')) + expect(button).toHaveAttribute('aria-controls', menu.getAttribute('id')) + expect(menu).toHaveAttribute('aria-labelledby', button.getAttribute('id')) } catch (err) { - if (Error.captureStackTrace) { - Error.captureStackTrace(err, assertMenuButtonLinkedWithMenu) - } + Error.captureStackTrace(err, assertMenuButtonLinkedWithMenu) throw err } } -export function assertMenuLinkedWithMenuItem(menu: HTMLElement | null, item: HTMLElement | null) { +export function assertMenuLinkedWithMenuItem(item: HTMLElement | null, menu = getMenu()) { try { if (menu === null) return expect(menu).not.toBe(null) if (item === null) return expect(item).not.toBe(null) // Ensure link between menu & menu item is correct - expect(menu.getAttribute('aria-activedescendant')).toBe(item.getAttribute('id')) + expect(menu).toHaveAttribute('aria-activedescendant', item.getAttribute('id')) } catch (err) { - if (Error.captureStackTrace) { - Error.captureStackTrace(err, assertMenuLinkedWithMenuItem) - } + Error.captureStackTrace(err, assertMenuLinkedWithMenuItem) throw err } } -export function assertNoActiveMenuItem(menu: HTMLElement | null) { +export function assertNoActiveMenuItem(menu = getMenu()) { try { if (menu === null) return expect(menu).not.toBe(null) // Ensure we don't have an active menu - expect(menu.hasAttribute('aria-activedescendant')).toBe(false) + expect(menu).not.toHaveAttribute('aria-activedescendant') } catch (err) { - if (Error.captureStackTrace) { - Error.captureStackTrace(err, assertNoActiveMenuItem) - } + Error.captureStackTrace(err, assertNoActiveMenuItem) throw err } } -type MenuOptions = { attributes?: Record; textContent?: string } & ( - | { state: MenuState.Closed } - | { state: MenuState.Open } -) -export function assertMenu(menu: HTMLElement | null, options: MenuOptions) { +export function assertMenu( + options: { + attributes?: Record + textContent?: string + state: MenuState + }, + menu = getMenu() +) { try { - if (options.state === MenuState.Open) { - if (menu === null) return expect(menu).not.toBe(null) + switch (options.state) { + case MenuState.Open: + if (menu === null) return expect(menu).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(menu.hasAttribute('aria-labelledby')).toBe(true) + // Check that some attributes exists, doesn't really matter what the values are at this point in + // time, we just require them. + expect(menu).toHaveAttribute('aria-labelledby') - // Check that we have the correct values for certain attributes - expect(menu.getAttribute('role')).toBe('menu') + // Check that we have the correct values for certain attributes + expect(menu).toHaveAttribute('role', 'menu') - // Check that the menu is focused - expect(document.activeElement).toBe(menu) + if (options.textContent) { + expect(menu).toHaveTextContent(options.textContent) + } - if (options.textContent) { - expect(menu.textContent?.trim()).toBe(options.textContent.trim()) - } + // Ensure menu button has the following attributes + for (let attributeName in options.attributes) { + expect(menu).toHaveAttribute(attributeName, options.attributes[attributeName]) + } + break - // Ensure menu button has the following attributes - for (let attributeName in options.attributes) { - expect(menu.getAttribute(attributeName)).toEqual(options.attributes[attributeName]) - } - } + case MenuState.Closed: + expect(menu).toBe(null) + break - if (options.state === MenuState.Closed) { - expect(menu).toBeNull() + default: + assertNever(options.state) } } catch (err) { - if (Error.captureStackTrace) { - Error.captureStackTrace(err, assertMenu) - } + Error.captureStackTrace(err, assertMenu) throw err } } -type MenuItemOptions = { tag?: string; attributes?: Record } -export function assertMenuItem(item: HTMLElement | null, options?: MenuItemOptions) { +export function assertMenuItem( + item: HTMLElement | null, + options?: { tag?: string; attributes?: Record } +) { 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.hasAttribute('id')).toBe(true) + expect(item).toHaveAttribute('id') // Check that we have the correct values for certain attributes - expect(item.getAttribute('role')).toBe('menuitem') - expect(item.getAttribute('tabindex')).toBe('-1') + expect(item).toHaveAttribute('role', 'menuitem') + expect(item).toHaveAttribute('tabindex', '-1') // Ensure menu button has the following attributes if (options) { for (let attributeName in options.attributes) { - expect(item.getAttribute(attributeName)).toEqual(options.attributes[attributeName]) + expect(item).toHaveAttribute(attributeName, options.attributes[attributeName]) } if (options.tag) { @@ -158,21 +183,301 @@ export function assertMenuItem(item: HTMLElement | null, options?: MenuItemOptio } } } catch (err) { - if (Error.captureStackTrace) { - Error.captureStackTrace(err, assertMenuItem) - } + Error.captureStackTrace(err, assertMenuItem) throw err } } +// --- + +export function getListboxLabel(): HTMLElement | null { + return document.querySelector('label,[id^="headlessui-listbox-label"]') +} + +export function getListboxButton(): HTMLElement | null { + return document.querySelector('button,[role="button"]') +} + +export function getListboxButtons(): HTMLElement[] { + return Array.from(document.querySelectorAll('button,[role="button"]')) +} + +export function getListbox(): HTMLElement | null { + return document.querySelector('[role="listbox"]') +} + +export function getListboxes(): HTMLElement[] { + return Array.from(document.querySelectorAll('[role="listbox"]')) +} + +export function getListboxOptions(): HTMLElement[] { + return Array.from(document.querySelectorAll('[role="option"]')) +} + +// --- + +export enum ListboxState { + Open, + Closed, +} + +export function assertListbox( + options: { + attributes?: Record + textContent?: string + state: ListboxState + }, + listbox = getListbox() +) { + try { + switch (options.state) { + case ListboxState.Open: + if (listbox === null) return expect(listbox).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(listbox).toHaveAttribute('aria-labelledby') + + // Check that we have the correct values for certain attributes + expect(listbox).toHaveAttribute('role', 'listbox') + + if (options.textContent) { + expect(listbox).toHaveTextContent(options.textContent) + } + + // Ensure listbox button has the following attributes + for (let attributeName in options.attributes) { + expect(listbox).toHaveAttribute(attributeName, options.attributes[attributeName]) + } + break + + case ListboxState.Closed: + expect(listbox).toBe(null) + break + + default: + assertNever(options.state) + } + } catch (err) { + Error.captureStackTrace(err, assertListbox) + throw err + } +} + +export function assertListboxButton( + options: { + attributes?: Record + textContent?: string + state: ListboxState + }, + button = getListboxButton() +) { + 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 ListboxState.Open: + expect(button).toHaveAttribute('aria-controls') + expect(button).toHaveAttribute('aria-expanded', 'true') + break + + case ListboxState.Closed: + expect(button).not.toHaveAttribute('aria-controls') + expect(button).not.toHaveAttribute('aria-expanded') + 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) { + Error.captureStackTrace(err, assertListboxButton) + throw err + } +} + +export function assertListboxLabel( + options: { + attributes?: Record + tag?: string + textContent?: string + }, + label = getListboxLabel() +) { + 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) { + Error.captureStackTrace(err, assertListboxLabel) + throw err + } +} + +export function assertListboxButtonLinkedWithListbox( + button = getListboxButton(), + listbox = getListbox() +) { + try { + if (button === null) return expect(button).not.toBe(null) + if (listbox === null) return expect(listbox).not.toBe(null) + + // Ensure link between button & listbox is correct + expect(button).toHaveAttribute('aria-controls', listbox.getAttribute('id')) + expect(listbox).toHaveAttribute('aria-labelledby', button.getAttribute('id')) + } catch (err) { + Error.captureStackTrace(err, assertListboxButtonLinkedWithListbox) + throw err + } +} + +export function assertListboxLabelLinkedWithListbox( + label = getListboxLabel(), + listbox = getListbox() +) { + try { + if (label === null) return expect(label).not.toBe(null) + if (listbox === null) return expect(listbox).not.toBe(null) + + expect(listbox).toHaveAttribute('aria-labelledby', label.getAttribute('id')) + } catch (err) { + Error.captureStackTrace(err, assertListboxLabelLinkedWithListbox) + throw err + } +} + +export function assertListboxButtonLinkedWithListboxLabel( + button = getListboxButton(), + label = getListboxLabel() +) { + 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) { + Error.captureStackTrace(err, assertListboxButtonLinkedWithListboxLabel) + throw err + } +} + +export function assertActiveListboxOption(item: HTMLElement | null, listbox = getListbox()) { + try { + if (listbox === null) return expect(listbox).not.toBe(null) + if (item === null) return expect(item).not.toBe(null) + + // Ensure link between listbox & listbox item is correct + expect(listbox).toHaveAttribute('aria-activedescendant', item.getAttribute('id')) + } catch (err) { + Error.captureStackTrace(err, assertActiveListboxOption) + throw err + } +} + +export function assertNoActiveListboxOption(listbox = getListbox()) { + try { + if (listbox === null) return expect(listbox).not.toBe(null) + + // Ensure we don't have an active listbox + expect(listbox).not.toHaveAttribute('aria-activedescendant') + } catch (err) { + Error.captureStackTrace(err, assertNoActiveListboxOption) + throw err + } +} + +export function assertNoSelectedListboxOption(items = getListboxOptions()) { + try { + for (let item of items) expect(item).not.toHaveAttribute('aria-selected') + } catch (err) { + Error.captureStackTrace(err, assertNoSelectedListboxOption) + throw err + } +} + +export function assertListboxOption( + 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') + expect(item).toHaveAttribute('tabindex', '-1') + + // Ensure listbox 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) { + Error.captureStackTrace(err, assertListboxOption) + throw err + } +} + +// --- + export function assertActiveElement(element: HTMLElement | null) { try { if (element === null) return expect(element).not.toBe(null) expect(document.activeElement).toBe(element) } catch (err) { - if (Error.captureStackTrace) { - Error.captureStackTrace(err, assertActiveElement) - } + Error.captureStackTrace(err, assertActiveElement) throw err } } diff --git a/packages/@headlessui-react/src/test-utils/interactions.ts b/packages/@headlessui-react/src/test-utils/interactions.ts index 48db62b..e9a3d04 100644 --- a/packages/@headlessui-react/src/test-utils/interactions.ts +++ b/packages/@headlessui-react/src/test-utils/interactions.ts @@ -1,6 +1,8 @@ import { fireEvent } from '@testing-library/react' import { disposables } from '../utils/disposables' +const d = disposables() + export const Keys: Record> = { Space: { key: ' ' }, Enter: { key: 'Enter' }, @@ -34,7 +36,6 @@ export async function type(events: Partial[]) { if (document.activeElement === null) return expect(document.activeElement).not.toBe(null) const element = document.activeElement - const d = disposables() events.forEach(event => { fireEvent.keyDown(element, event) @@ -60,8 +61,6 @@ export async function click(element: Document | Element | Window | Node | null) try { if (element === null) return expect(element).not.toBe(null) - const d = disposables() - fireEvent.pointerDown(element) fireEvent.mouseDown(element) fireEvent.pointerUp(element) @@ -79,8 +78,6 @@ export async function focus(element: Document | Element | Window | Node | null) try { if (element === null) return expect(element).not.toBe(null) - const d = disposables() - fireEvent.focus(element) await new Promise(d.nextFrame) @@ -92,7 +89,6 @@ export async function focus(element: Document | Element | Window | Node | null) export async function mouseEnter(element: Document | Element | Window | null) { try { if (element === null) return expect(element).not.toBe(null) - const d = disposables() fireEvent.pointerOver(element) fireEvent.pointerEnter(element) @@ -108,7 +104,6 @@ export async function mouseEnter(element: Document | Element | Window | null) { export async function mouseMove(element: Document | Element | Window | null) { try { if (element === null) return expect(element).not.toBe(null) - const d = disposables() fireEvent.pointerMove(element) fireEvent.mouseMove(element) @@ -123,7 +118,6 @@ export async function mouseMove(element: Document | Element | Window | null) { export async function mouseLeave(element: Document | Element | Window | null) { try { if (element === null) return expect(element).not.toBe(null) - const d = disposables() fireEvent.pointerOut(element) fireEvent.pointerLeave(element) 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 99740e7..07f7702 100644 --- a/packages/@headlessui-react/src/test-utils/suppress-console-logs.ts +++ b/packages/@headlessui-react/src/test-utils/suppress-console-logs.ts @@ -4,13 +4,13 @@ type FunctionPropertyNames = { string export function suppressConsoleLogs( - cb: (...args: T) => void, + cb: (...args: T) => unknown, type: FunctionPropertyNames = 'error' ) { return (...args: T) => { const spy = jest.spyOn(global.console, type).mockImplementation(jest.fn()) - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { Promise.resolve(cb(...args)).then(resolve, reject) }).finally(() => spy.mockRestore()) } diff --git a/packages/@headlessui-vue/examples/src/components/listbox/listbox.vue b/packages/@headlessui-vue/examples/src/components/listbox/listbox.vue new file mode 100644 index 0000000..76f3ca8 --- /dev/null +++ b/packages/@headlessui-vue/examples/src/components/listbox/listbox.vue @@ -0,0 +1,124 @@ + + + diff --git a/packages/@headlessui-vue/examples/src/routes.json b/packages/@headlessui-vue/examples/src/routes.json index 8e533da..ee0d884 100644 --- a/packages/@headlessui-vue/examples/src/routes.json +++ b/packages/@headlessui-vue/examples/src/routes.json @@ -28,5 +28,16 @@ "component": "./components/menu/menu-with-transition-and-popper.vue" } ] + }, + { + "name": "Listbox", + "path": "/listbox", + "children": [ + { + "name": "Listbox (basic)", + "path": "/listbox/listbox", + "component": "./components/listbox/listbox.vue" + } + ] } ] diff --git a/packages/@headlessui-vue/jest.config.js b/packages/@headlessui-vue/jest.config.js new file mode 100644 index 0000000..4596442 --- /dev/null +++ b/packages/@headlessui-vue/jest.config.js @@ -0,0 +1,3 @@ +const create = require('../../jest/create-jest-config.js') + +module.exports = create(__dirname, { displayName: ' Vue ' }) diff --git a/packages/@headlessui-vue/package.json b/packages/@headlessui-vue/package.json index ea41bc1..0e9ded4 100644 --- a/packages/@headlessui-vue/package.json +++ b/packages/@headlessui-vue/package.json @@ -31,18 +31,15 @@ "vue": "^3.0.0-rc.13" }, "devDependencies": { - "@popperjs/core": "^2.4.4", - "@tailwindcss/ui": "^0.6.2", - "@testing-library/vue": "^5.0.4", + "@popperjs/core": "^2.5.3", + "@testing-library/vue": "^5.1.0", "@types/debounce": "^1.2.0", - "@types/node": "^14.11.1", + "@types/node": "^14.11.2", "@vue/compiler-sfc": "3.0.0-rc.13", - "@vue/test-utils": "^2.0.0-beta.5", + "@vue/test-utils": "^2.0.0-beta.6", "husky": "^4.3.0", - "tailwindcss": "^1.8.10", - "tsdx": "^0.13.3", "vite": "^1.0.0-rc.4", "vue": "^3.0.0-rc.13", - "vue-router": "^4.0.0-beta.10" + "vue-router": "^4.0.0-beta.12" } } diff --git a/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx b/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx new file mode 100644 index 0000000..0ab7d55 --- /dev/null +++ b/packages/@headlessui-vue/src/components/listbox/listbox.test.tsx @@ -0,0 +1,3322 @@ +import { defineComponent, ref, watchEffect } 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' +import { + assertActiveElement, + assertActiveListboxOption, + assertListbox, + assertListboxButton, + assertListboxButtonLinkedWithListbox, + assertListboxButtonLinkedWithListboxLabel, + assertListboxOption, + assertListboxLabel, + assertListboxLabelLinkedWithListbox, + assertNoActiveListboxOption, + assertNoSelectedListboxOption, + getListbox, + getListboxButton, + getListboxButtons, + getListboxes, + getListboxOptions, + getListboxLabel, + ListboxState, +} from '../../test-utils/accessibility-assertions' +import { + click, + focus, + mouseMove, + mouseLeave, + press, + shift, + type, + word, + Keys, +} from '../../test-utils/interactions' + +jest.mock('../../hooks/use-id') + +function renderTemplate(input: string | Partial[0]>) { + const defaultComponents = { Listbox, ListboxLabel, ListboxButton, ListboxOptions, ListboxOption } + + if (typeof input === 'string') { + return render(defineComponent({ template: input, components: defaultComponents })) + } + + return render( + defineComponent( + Object.assign({}, input, { + components: { ...defaultComponents, ...input.components }, + }) as Parameters[0] + ) + ) +} + +describe('safeguards', () => { + it.each([ + ['ListboxButton', ListboxButton], + ['ListboxLabel', ListboxLabel], + ['ListboxOptions', ListboxOptions], + ['ListboxOption', ListboxOption], + ])( + '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 Listbox without crashing', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + }) + ) +}) + +describe('Rendering', () => { + describe('Listbox', () => { + it( + 'should be possible to render a Listbox using a render prop', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + {({ open }) => ( + <> + Trigger + {open && ( + + Option A + Option B + Option C + + )} + + )} + + `, + setup: () => ({ value: ref(null) }), + }) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + await click(getListboxButton()) + + assertListboxButton({ + state: ListboxState.Open, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Open }) + }) + ) + }) + + describe('ListboxLabel', () => { + it( + 'should be possible to render a ListboxLabel using a render prop', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + {{JSON.stringify(data)}} + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-2' }, + }) + assertListboxLabel({ + attributes: { id: 'headlessui-listbox-label-1' }, + textContent: JSON.stringify({ open: false }), + }) + assertListbox({ state: ListboxState.Closed }) + + await click(getListboxButton()) + + assertListboxLabel({ + attributes: { id: 'headlessui-listbox-label-1' }, + textContent: JSON.stringify({ open: true }), + }) + assertListbox({ state: ListboxState.Open }) + assertListboxLabelLinkedWithListbox() + assertListboxButtonLinkedWithListboxLabel() + }) + ) + + it( + 'should be possible to render a ListboxLabel using a render prop and an `as` prop', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + {{JSON.stringify(data)}} + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertListboxLabel({ + attributes: { id: 'headlessui-listbox-label-1' }, + textContent: JSON.stringify({ open: false }), + tag: 'p', + }) + assertListbox({ state: ListboxState.Closed }) + + await click(getListboxButton()) + assertListboxLabel({ + attributes: { id: 'headlessui-listbox-label-1' }, + textContent: JSON.stringify({ open: true }), + tag: 'p', + }) + assertListbox({ state: ListboxState.Open }) + }) + ) + }) + + describe('ListboxButton', () => { + it( + 'should be possible to render a ListboxButton using a render prop', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + {{JSON.stringify(data)}} + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + textContent: JSON.stringify({ open: false, focused: false }), + }) + assertListbox({ state: ListboxState.Closed }) + + await click(getListboxButton()) + + assertListboxButton({ + state: ListboxState.Open, + attributes: { id: 'headlessui-listbox-button-1' }, + textContent: JSON.stringify({ open: true, focused: false }), + }) + assertListbox({ state: ListboxState.Open }) + }) + ) + + it( + 'should be possible to render a ListboxButton using a render prop and an `as` prop', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + {{JSON.stringify(data)}} + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + textContent: JSON.stringify({ open: false, focused: false }), + }) + assertListbox({ state: ListboxState.Closed }) + + await click(getListboxButton()) + + assertListboxButton({ + state: ListboxState.Open, + attributes: { id: 'headlessui-listbox-button-1' }, + textContent: JSON.stringify({ open: true, focused: false }), + }) + assertListbox({ state: ListboxState.Open }) + }) + ) + + it( + 'should be possible to render a ListboxButton and a ListboxLabel and see them linked together', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Label + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + await new Promise(requestAnimationFrame) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-2' }, + }) + assertListbox({ state: ListboxState.Closed }) + assertListboxButtonLinkedWithListboxLabel() + }) + ) + }) + + describe('ListboxOptions', () => { + it( + 'should be possible to render ListboxOptions using a render prop', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + {{JSON.stringify(data)}} + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + await click(getListboxButton()) + + assertListboxButton({ + state: ListboxState.Open, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ + state: ListboxState.Open, + textContent: JSON.stringify({ open: true }), + }) + assertActiveElement(getListbox()) + }) + ) + + it('should be possible to always render the ListboxOptions if we provide it a `static` prop', () => { + renderTemplate({ + template: ` + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Let's verify that the Listbox is already there + expect(getListbox()).not.toBe(null) + }) + }) + + describe('ListboxOption', () => { + it( + 'should be possible to render a ListboxOption using a render prop', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + {{JSON.stringify(data)}} + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + await click(getListboxButton()) + + assertListboxButton({ + state: ListboxState.Open, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ + state: ListboxState.Open, + textContent: JSON.stringify({ active: false, selected: false, disabled: false }), + }) + }) + ) + }) +}) + +describe('Rendering composition', () => { + it( + 'should be possible to conditionally render classNames (aka className can be a function?!)', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + + Option A + + + Option B + + + Option C + + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Open Listbox + await click(getListboxButton()) + + const options = getListboxOptions() + + // Verify correct classNames + expect('' + options[0].classList).toEqual( + JSON.stringify({ active: false, selected: false, disabled: false }) + ) + expect('' + options[1].classList).toEqual( + JSON.stringify({ active: false, selected: false, disabled: true }) + ) + expect('' + options[2].classList).toEqual('no-special-treatment') + + // Double check that nothing is active + assertNoActiveListboxOption(getListbox()) + + // Make the first option active + await press(Keys.ArrowDown) + + // Verify the classNames + expect('' + options[0].classList).toEqual( + JSON.stringify({ active: true, selected: false, disabled: false }) + ) + expect('' + options[1].classList).toEqual( + JSON.stringify({ active: false, selected: false, disabled: true }) + ) + expect('' + options[2].classList).toEqual('no-special-treatment') + + // Double check that the first option is the active one + assertActiveListboxOption(options[0]) + + // Let's go down, this should go to the third option since the second option is disabled! + await press(Keys.ArrowDown) + + // Verify the classNames + expect('' + options[0].classList).toEqual( + JSON.stringify({ active: false, selected: false, disabled: false }) + ) + expect('' + options[1].classList).toEqual( + JSON.stringify({ active: false, selected: false, disabled: true }) + ) + expect('' + options[2].classList).toEqual('no-special-treatment') + + // Double check that the last option is the active one + assertActiveListboxOption(options[2]) + }) + ) + + it( + 'should be possible to swap the Listbox option with a button for example', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + + Option A + + + Option B + + + Option C + + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Open Listbox + await click(getListboxButton()) + + // Verify options are buttons now + getListboxOptions().forEach(option => assertListboxOption(option, { tag: 'button' })) + }) + ) +}) + +describe('Keyboard interactions', () => { + describe('`Enter` key', () => { + it( + 'should be possible to open the listbox with Enter', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.Enter) + + // Verify it is open + assertListboxButton({ state: ListboxState.Open }) + assertListbox({ + state: ListboxState.Open, + attributes: { id: 'headlessui-listbox-options-2' }, + }) + assertActiveElement(getListbox()) + assertListboxButtonLinkedWithListbox() + + // Verify we have listbox options + const options = getListboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertListboxOption(option, { selected: false })) + + // Verify that the first listbox option is active + assertActiveListboxOption(options[0]) + assertNoSelectedListboxOption() + }) + ) + + it( + 'should be possible to open the listbox with Enter, and focus the selected option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref('b') }), + }) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.Enter) + + // Verify it is open + assertListboxButton({ state: ListboxState.Open }) + assertListbox({ + state: ListboxState.Open, + attributes: { id: 'headlessui-listbox-options-2' }, + }) + assertActiveElement(getListbox()) + assertListboxButtonLinkedWithListbox() + + // Verify we have listbox options + const options = getListboxOptions() + expect(options).toHaveLength(3) + options.forEach((option, i) => assertListboxOption(option, { selected: i === 1 })) + + // Verify that the second listbox option is active (because it is already selected) + assertActiveListboxOption(options[1]) + }) + ) + + it( + 'should have no active listbox option when there are no listbox options at all', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertListbox({ state: ListboxState.Closed }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.Enter) + assertListbox({ state: ListboxState.Open }) + assertActiveElement(getListbox()) + + assertNoActiveListboxOption() + }) + ) + + it( + 'should focus the first non disabled listbox option when opening with Enter', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + + Option A + + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.Enter) + + const options = getListboxOptions() + + // Verify that the first non-disabled listbox option is active + assertActiveListboxOption(options[1]) + }) + ) + + it( + 'should focus the first non disabled listbox option when opening with Enter (jump over multiple disabled ones)', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + + Option A + + + Option B + + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.Enter) + + const options = getListboxOptions() + + // Verify that the first non-disabled listbox option is active + assertActiveListboxOption(options[2]) + }) + ) + + it( + 'should have no active listbox option upon Enter key press, when there are no non-disabled listbox options', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + + Option A + + + Option B + + + Option C + + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.Enter) + + assertNoActiveListboxOption() + }) + ) + + it( + 'should be possible to close the listbox with Enter when there is no active listboxoption', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Open listbox + await click(getListboxButton()) + + // Verify it is open + assertListboxButton({ state: ListboxState.Open }) + + // Close listbox + await press(Keys.Enter) + + // Verify it is closed + assertListboxButton({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.Closed }) + }) + ) + + it( + 'should be possible to close the listbox with Enter and choose the active listbox option', + suppressConsoleLogs(async () => { + const handleChange = jest.fn() + renderTemplate({ + template: ` + + Trigger + + Option A + Option B + Option C + + + `, + setup() { + const value = ref(null) + watchEffect(() => { + if (value.value !== null) handleChange(value.value) + }) + return { value } + }, + }) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Open listbox + await click(getListboxButton()) + + // Verify it is open + assertListboxButton({ state: ListboxState.Open }) + + // Activate the first listbox option + const options = getListboxOptions() + await mouseMove(options[0]) + + // Choose option, and close listbox + await press(Keys.Enter) + + // Verify it is closed + assertListboxButton({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.Closed }) + + // Verify we got the change event + expect(handleChange).toHaveBeenCalledTimes(1) + expect(handleChange).toHaveBeenCalledWith('a') + + // Open listbox again + await click(getListboxButton()) + + // Verify the active option is the previously selected one + assertActiveListboxOption(getListboxOptions()[0]) + }) + ) + }) + + describe('`Space` key', () => { + it( + 'should be possible to open the listbox with Space', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.Space) + + // Verify it is open + assertListboxButton({ state: ListboxState.Open }) + assertListbox({ + state: ListboxState.Open, + attributes: { id: 'headlessui-listbox-options-2' }, + }) + assertActiveElement(getListbox()) + assertListboxButtonLinkedWithListbox() + + // Verify we have listbox options + const options = getListboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertListboxOption(option)) + assertActiveListboxOption(options[0]) + }) + ) + + it( + 'should be possible to open the listbox with Space, and focus the selected option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref('b') }), + }) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.Space) + + // Verify it is open + assertListboxButton({ state: ListboxState.Open }) + assertListbox({ + state: ListboxState.Open, + attributes: { id: 'headlessui-listbox-options-2' }, + }) + assertActiveElement(getListbox()) + assertListboxButtonLinkedWithListbox() + + // Verify we have listbox options + const options = getListboxOptions() + expect(options).toHaveLength(3) + options.forEach((option, i) => assertListboxOption(option, { selected: i === 1 })) + + // Verify that the second listbox option is active (because it is already selected) + assertActiveListboxOption(options[1]) + }) + ) + + it( + 'should have no active listbox option when there are no listbox options at all', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertListbox({ state: ListboxState.Closed }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.Space) + assertListbox({ state: ListboxState.Open }) + assertActiveElement(getListbox()) + + assertNoActiveListboxOption() + }) + ) + + it( + 'should focus the first non disabled listbox option when opening with Space', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + + Option A + + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.Space) + + const options = getListboxOptions() + + // Verify that the first non-disabled listbox option is active + assertActiveListboxOption(options[1]) + }) + ) + + it( + 'should focus the first non disabled listbox option when opening with Space (jump over multiple disabled ones)', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + + Option A + + + Option B + + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.Space) + + const options = getListboxOptions() + + // Verify that the first non-disabled listbox option is active + assertActiveListboxOption(options[2]) + }) + ) + + it( + 'should have no active listbox option upon Space key press, when there are no non-disabled listbox options', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + + Option A + + + Option B + + + Option C + + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.Space) + + assertNoActiveListboxOption() + }) + ) + + it( + 'should be possible to close the listbox with Space and choose the active listbox option', + suppressConsoleLogs(async () => { + const handleChange = jest.fn() + renderTemplate({ + template: ` + + Trigger + + Option A + Option B + Option C + + + `, + setup() { + const value = ref(null) + watchEffect(() => { + if (value.value !== null) handleChange(value.value) + }) + return { value } + }, + }) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Open listbox + await click(getListboxButton()) + + // Verify it is open + assertListboxButton({ state: ListboxState.Open }) + + // Activate the first listbox option + const options = getListboxOptions() + await mouseMove(options[0]) + + // Choose option, and close listbox + await press(Keys.Space) + + // Verify it is closed + assertListboxButton({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.Closed }) + + // Verify we got the change event + expect(handleChange).toHaveBeenCalledTimes(1) + expect(handleChange).toHaveBeenCalledWith('a') + + // Open listbox again + await click(getListboxButton()) + + // Verify the active option is the previously selected one + assertActiveListboxOption(getListboxOptions()[0]) + }) + ) + }) + + describe('`Escape` key', () => { + it( + 'should be possible to close an open listbox with Escape', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.Space) + + // Verify it is open + assertListboxButton({ state: ListboxState.Open }) + assertListbox({ + state: ListboxState.Open, + attributes: { id: 'headlessui-listbox-options-2' }, + }) + assertActiveElement(getListbox()) + assertListboxButtonLinkedWithListbox() + + // Close listbox + await press(Keys.Escape) + + // Verify it is closed + assertListboxButton({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.Closed }) + }) + ) + }) + + describe('`Tab` key', () => { + it( + 'should focus trap when we use Tab', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.Enter) + + // Verify it is open + assertListboxButton({ state: ListboxState.Open }) + assertListbox({ + state: ListboxState.Open, + attributes: { id: 'headlessui-listbox-options-2' }, + }) + assertActiveElement(getListbox()) + assertListboxButtonLinkedWithListbox() + + // Verify we have listbox options + const options = getListboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertListboxOption(option)) + assertActiveListboxOption(options[0]) + + // Try to tab + await press(Keys.Tab) + + // Verify it is still open + assertListboxButton({ state: ListboxState.Open }) + assertListbox({ state: ListboxState.Open }) + assertActiveElement(getListbox()) + }) + ) + + it( + 'should focus trap when we use Shift+Tab', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.Enter) + + // Verify it is open + assertListboxButton({ state: ListboxState.Open }) + assertListbox({ + state: ListboxState.Open, + attributes: { id: 'headlessui-listbox-options-2' }, + }) + assertActiveElement(getListbox()) + assertListboxButtonLinkedWithListbox() + + // Verify we have listbox options + const options = getListboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertListboxOption(option)) + assertActiveListboxOption(options[0]) + + // Try to Shift+Tab + await press(shift(Keys.Tab)) + + // Verify it is still open + assertListboxButton({ state: ListboxState.Open }) + assertListbox({ state: ListboxState.Open }) + assertActiveElement(getListbox()) + }) + ) + }) + + describe('`ArrowDown` key', () => { + it( + 'should be possible to open the listbox with ArrowDown', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.ArrowDown) + + // Verify it is open + assertListboxButton({ state: ListboxState.Open }) + assertListbox({ + state: ListboxState.Open, + attributes: { id: 'headlessui-listbox-options-2' }, + }) + assertActiveElement(getListbox()) + assertListboxButtonLinkedWithListbox() + + // Verify we have listbox options + const options = getListboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertListboxOption(option)) + + // Verify that the first listbox option is active + assertActiveListboxOption(options[0]) + }) + ) + + it( + 'should be possible to open the listbox with ArrowDown, and focus the selected option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref('b') }), + }) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.ArrowDown) + + // Verify it is open + assertListboxButton({ state: ListboxState.Open }) + assertListbox({ + state: ListboxState.Open, + attributes: { id: 'headlessui-listbox-options-2' }, + }) + assertActiveElement(getListbox()) + assertListboxButtonLinkedWithListbox() + + // Verify we have listbox options + const options = getListboxOptions() + expect(options).toHaveLength(3) + options.forEach((option, i) => assertListboxOption(option, { selected: i === 1 })) + + // Verify that the second listbox option is active (because it is already selected) + assertActiveListboxOption(options[1]) + }) + ) + + it( + 'should have no active listbox option when there are no listbox options at all', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertListbox({ state: ListboxState.Closed }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.ArrowDown) + assertListbox({ state: ListboxState.Open }) + assertActiveElement(getListbox()) + + assertNoActiveListboxOption() + }) + ) + + it( + 'should be possible to use ArrowDown to navigate the listbox options', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.Enter) + + // Verify we have listbox options + const options = getListboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertListboxOption(option)) + assertActiveListboxOption(options[0]) + + // We should be able to go down once + await press(Keys.ArrowDown) + assertActiveListboxOption(options[1]) + + // We should be able to go down again + await press(Keys.ArrowDown) + assertActiveListboxOption(options[2]) + + // We should NOT be able to go down again (because last option). Current implementation won't go around. + await press(Keys.ArrowDown) + assertActiveListboxOption(options[2]) + }) + ) + + it( + 'should be possible to use ArrowDown to navigate the listbox options and skip the first disabled one', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + + Option A + + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.Enter) + + // Verify we have listbox options + const options = getListboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertListboxOption(option)) + assertActiveListboxOption(options[1]) + + // We should be able to go down once + await press(Keys.ArrowDown) + assertActiveListboxOption(options[2]) + }) + ) + + it( + 'should be possible to use ArrowDown to navigate the listbox options and jump to the first non-disabled one', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + + Option A + + + Option B + + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.Enter) + + // Verify we have listbox options + const options = getListboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertListboxOption(option)) + assertActiveListboxOption(options[2]) + }) + ) + }) + + describe('`ArrowUp` key', () => { + it( + 'should be possible to open the listbox with ArrowUp and the last option should be active', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.ArrowUp) + + // Verify it is open + assertListboxButton({ state: ListboxState.Open }) + assertListbox({ + state: ListboxState.Open, + attributes: { id: 'headlessui-listbox-options-2' }, + }) + assertActiveElement(getListbox()) + assertListboxButtonLinkedWithListbox() + + // Verify we have listbox options + const options = getListboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertListboxOption(option)) + + // ! ALERT: The LAST option should now be active + assertActiveListboxOption(options[2]) + }) + ) + + it( + 'should be possible to open the listbox with ArrowUp, and focus the selected option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref('b') }), + }) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.ArrowUp) + + // Verify it is open + assertListboxButton({ state: ListboxState.Open }) + assertListbox({ + state: ListboxState.Open, + attributes: { id: 'headlessui-listbox-options-2' }, + }) + assertActiveElement(getListbox()) + assertListboxButtonLinkedWithListbox() + + // Verify we have listbox options + const options = getListboxOptions() + expect(options).toHaveLength(3) + options.forEach((option, i) => assertListboxOption(option, { selected: i === 1 })) + + // Verify that the second listbox option is active (because it is already selected) + assertActiveListboxOption(options[1]) + }) + ) + + it( + 'should have no active listbox option when there are no listbox options at all', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertListbox({ state: ListboxState.Closed }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.ArrowUp) + assertListbox({ state: ListboxState.Open }) + assertActiveElement(getListbox()) + + assertNoActiveListboxOption() + }) + ) + + it( + 'should be possible to use ArrowUp to navigate the listbox options and jump to the first non-disabled one', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + Option A + + Option B + + + Option C + + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.ArrowUp) + + // Verify we have listbox options + const options = getListboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertListboxOption(option)) + assertActiveListboxOption(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: ` + + Trigger + + + Option A + + + Option B + + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.Enter) + + // Verify we have listbox options + const options = getListboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertListboxOption(option)) + assertActiveListboxOption(options[2]) + + // We should not be able to go up (because those are disabled) + await press(Keys.ArrowUp) + assertActiveListboxOption(options[2]) + + // We should not be able to go down (because this is the last option) + await press(Keys.ArrowDown) + assertActiveListboxOption(options[2]) + }) + ) + + it( + 'should be possible to use ArrowUp to navigate the listbox options', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.ArrowUp) + + // Verify it is open + assertListboxButton({ state: ListboxState.Open }) + assertListbox({ + state: ListboxState.Open, + attributes: { id: 'headlessui-listbox-options-2' }, + }) + assertActiveElement(getListbox()) + assertListboxButtonLinkedWithListbox() + + // Verify we have listbox options + const options = getListboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertListboxOption(option)) + assertActiveListboxOption(options[2]) + + // We should be able to go down once + await press(Keys.ArrowUp) + assertActiveListboxOption(options[1]) + + // We should be able to go down again + await press(Keys.ArrowUp) + assertActiveListboxOption(options[0]) + + // We should NOT be able to go up again (because first option). Current implementation won't go around. + await press(Keys.ArrowUp) + assertActiveListboxOption(options[0]) + }) + ) + }) + + describe('`End` key', () => { + it( + 'should be possible to use the End key to go to the last listbox option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.Enter) + + const options = getListboxOptions() + + // We should be on the first option + assertActiveListboxOption(options[0]) + + // We should be able to go to the last option + await press(Keys.End) + assertActiveListboxOption(options[2]) + }) + ) + + it( + 'should be possible to use the End key to go to the last non disabled listbox option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + Option A + Option B + + Option C + + + Option D + + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.Enter) + + const options = getListboxOptions() + + // We should be on the first option + assertActiveListboxOption(options[0]) + + // We should be able to go to the last non-disabled option + await press(Keys.End) + assertActiveListboxOption(options[1]) + }) + ) + + it( + 'should be possible to use the End key to go to the first listbox option if that is the only non-disabled listbox option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + Option A + + Option B + + + Option C + + + Option D + + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open listbox + await click(getListboxButton()) + + // We opened via click, we don't have an active option + assertNoActiveListboxOption() + + // We should not be able to go to the end + await press(Keys.End) + + const options = getListboxOptions() + assertActiveListboxOption(options[0]) + }) + ) + + it( + 'should have no active listbox option upon End key press, when there are no non-disabled listbox options', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + + Option A + + + Option B + + + Option C + + + Option D + + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open listbox + await click(getListboxButton()) + + // We opened via click, we don't have an active option + assertNoActiveListboxOption() + + // We should not be able to go to the end + await press(Keys.End) + + assertNoActiveListboxOption() + }) + ) + }) + + describe('`PageDown` key', () => { + it( + 'should be possible to use the PageDown key to go to the last listbox option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.Enter) + + const options = getListboxOptions() + + // We should be on the first option + assertActiveListboxOption(options[0]) + + // We should be able to go to the last option + await press(Keys.PageDown) + assertActiveListboxOption(options[2]) + }) + ) + + it( + 'should be possible to use the PageDown key to go to the last non disabled listbox option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + Option A + Option B + + Option C + + + Option D + + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.Enter) + + const options = getListboxOptions() + + // We should be on the first option + assertActiveListboxOption(options[0]) + + // We should be able to go to the last non-disabled option + await press(Keys.PageDown) + assertActiveListboxOption(options[1]) + }) + ) + + it( + 'should be possible to use the PageDown key to go to the first listbox option if that is the only non-disabled listbox option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + Option A + + Option B + + + Option C + + + Option D + + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open listbox + await click(getListboxButton()) + + // We opened via click, we don't have an active option + assertNoActiveListboxOption() + + // We should not be able to go to the end + await press(Keys.PageDown) + + const options = getListboxOptions() + assertActiveListboxOption(options[0]) + }) + ) + + it( + 'should have no active listbox option upon PageDown key press, when there are no non-disabled listbox options', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + + Option A + + + Option B + + + Option C + + + Option D + + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open listbox + await click(getListboxButton()) + + // We opened via click, we don't have an active option + assertNoActiveListboxOption() + + // We should not be able to go to the end + await press(Keys.PageDown) + + assertNoActiveListboxOption() + }) + ) + }) + + describe('`Home` key', () => { + it( + 'should be possible to use the Home key to go to the first listbox option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.ArrowUp) + + const options = getListboxOptions() + + // We should be on the last option + assertActiveListboxOption(options[2]) + + // We should be able to go to the first option + await press(Keys.Home) + assertActiveListboxOption(options[0]) + }) + ) + + it( + 'should be possible to use the Home key to go to the first non disabled listbox option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + + Option A + + + Option B + + Option C + Option D + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open listbox + await click(getListboxButton()) + + // We opened via click, we don't have an active option + assertNoActiveListboxOption() + + // We should not be able to go to the end + await press(Keys.Home) + + const options = getListboxOptions() + + // We should be on the first non-disabled option + assertActiveListboxOption(options[2]) + }) + ) + + it( + 'should be possible to use the Home key to go to the last listbox option if that is the only non-disabled listbox option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + + Option A + + + Option B + + + Option C + + Option D + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open listbox + await click(getListboxButton()) + + // We opened via click, we don't have an active option + assertNoActiveListboxOption() + + // We should not be able to go to the end + await press(Keys.Home) + + const options = getListboxOptions() + assertActiveListboxOption(options[3]) + }) + ) + + it( + 'should have no active listbox option upon Home key press, when there are no non-disabled listbox options', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + + Option A + + + Option B + + + Option C + + + Option D + + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open listbox + await click(getListboxButton()) + + // We opened via click, we don't have an active option + assertNoActiveListboxOption() + + // We should not be able to go to the end + await press(Keys.Home) + + assertNoActiveListboxOption() + }) + ) + }) + + describe('`PageUp` key', () => { + it( + 'should be possible to use the PageUp key to go to the first listbox option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.ArrowUp) + + const options = getListboxOptions() + + // We should be on the last option + assertActiveListboxOption(options[2]) + + // We should be able to go to the first option + await press(Keys.PageUp) + assertActiveListboxOption(options[0]) + }) + ) + + it( + 'should be possible to use the PageUp key to go to the first non disabled listbox option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + + Option A + + + Option B + + Option C + Option D + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open listbox + await click(getListboxButton()) + + // We opened via click, we don't have an active option + assertNoActiveListboxOption() + + // We should not be able to go to the end + await press(Keys.PageUp) + + const options = getListboxOptions() + + // We should be on the first non-disabled option + assertActiveListboxOption(options[2]) + }) + ) + + it( + 'should be possible to use the PageUp key to go to the last listbox option if that is the only non-disabled listbox option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + + Option A + + + Option B + + + Option C + + Option D + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open listbox + await click(getListboxButton()) + + // We opened via click, we don't have an active option + assertNoActiveListboxOption() + + // We should not be able to go to the end + await press(Keys.PageUp) + + const options = getListboxOptions() + assertActiveListboxOption(options[3]) + }) + ) + + it( + 'should have no active listbox option upon PageUp key press, when there are no non-disabled listbox options', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + + Option A + + + Option B + + + Option C + + + Option D + + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open listbox + await click(getListboxButton()) + + // We opened via click, we don't have an active option + assertNoActiveListboxOption() + + // We should not be able to go to the end + await press(Keys.PageUp) + + assertNoActiveListboxOption() + }) + ) + }) + + describe('`Any` key aka search', () => { + it( + 'should be possible to type a full word that has a perfect match', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + alice + bob + charlie + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open listbox + await click(getListboxButton()) + + const options = getListboxOptions() + + // We should be able to go to the second option + await type(word('bob')) + assertActiveListboxOption(options[1]) + + // We should be able to go to the first option + await type(word('alice')) + assertActiveListboxOption(options[0]) + + // We should be able to go to the last option + await type(word('charlie')) + assertActiveListboxOption(options[2]) + }) + ) + + it( + 'should be possible to type a partial of a word', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + alice + bob + charlie + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.ArrowUp) + + const options = getListboxOptions() + + // We should be on the last option + assertActiveListboxOption(options[2]) + + // We should be able to go to the second option + await type(word('bo')) + assertActiveListboxOption(options[1]) + + // We should be able to go to the first option + await type(word('ali')) + assertActiveListboxOption(options[0]) + + // We should be able to go to the last option + await type(word('char')) + assertActiveListboxOption(options[2]) + }) + ) + + it( + 'should be possible to type words with spaces', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + value a + value b + value c + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.ArrowUp) + + const options = getListboxOptions() + + // We should be on the last option + assertActiveListboxOption(options[2]) + + // We should be able to go to the second option + await type(word('value b')) + assertActiveListboxOption(options[1]) + + // We should be able to go to the first option + await type(word('value a')) + assertActiveListboxOption(options[0]) + + // We should be able to go to the last option + await type(word('value c')) + assertActiveListboxOption(options[2]) + }) + ) + + it( + 'should not be possible to search for a disabled option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + alice + + bob + + charlie + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Focus the button + getListboxButton()?.focus() + + // Open listbox + await press(Keys.ArrowUp) + + const options = getListboxOptions() + + // We should be on the last option + assertActiveListboxOption(options[2]) + + // We should not be able to go to the disabled option + await type(word('bo')) + + // We should still be on the last option + assertActiveListboxOption(options[2]) + }) + ) + }) +}) + +describe('Mouse interactions', () => { + it( + 'should focus the ListboxButton when we click the ListboxLabel', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + 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(getListboxLabel()) + + // Ensure that the actual button is focused instead + assertActiveElement(getListboxButton()) + }) + ) + + it( + 'should be possible to open a listbox on click', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Open listbox + await click(getListboxButton()) + + // Verify it is open + assertListboxButton({ state: ListboxState.Open }) + assertListbox({ + state: ListboxState.Open, + attributes: { id: 'headlessui-listbox-options-2' }, + }) + assertActiveElement(getListbox()) + assertListboxButtonLinkedWithListbox() + + // Verify we have listbox options + const options = getListboxOptions() + expect(options).toHaveLength(3) + options.forEach(option => assertListboxOption(option)) + }) + ) + + it( + 'should be possible to open a listbox on click, and focus the selected option', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref('b') }), + }) + + assertListboxButton({ + state: ListboxState.Closed, + attributes: { id: 'headlessui-listbox-button-1' }, + }) + assertListbox({ state: ListboxState.Closed }) + + // Open listbox + await click(getListboxButton()) + + // Verify it is open + assertListboxButton({ state: ListboxState.Open }) + assertListbox({ + state: ListboxState.Open, + attributes: { id: 'headlessui-listbox-options-2' }, + }) + assertActiveElement(getListbox()) + assertListboxButtonLinkedWithListbox() + + // Verify we have listbox options + const options = getListboxOptions() + expect(options).toHaveLength(3) + options.forEach((option, i) => assertListboxOption(option, { selected: i === 1 })) + + // Verify that the second listbox option is active (because it is already selected) + assertActiveListboxOption(options[1]) + }) + ) + + it( + 'should be possible to close a listbox on click', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open listbox + await click(getListboxButton()) + + // Verify it is open + assertListboxButton({ state: ListboxState.Open }) + + // Click to close + await click(getListboxButton()) + + // Verify it is closed + assertListboxButton({ state: ListboxState.Closed }) + assertListbox({ state: ListboxState.Closed }) + }) + ) + + it('should focus the listbox when you try to focus the button again (when the listbox is already open)', async () => { + renderTemplate({ + template: ` + + Trigger + + Option A + Option B + Option C + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open listbox + await click(getListboxButton()) + + // Verify listbox is focused + assertActiveElement(getListbox()) + + // Try to Re-focus the button + getListboxButton()?.focus() + + // Verify listbox is still focused + assertActiveElement(getListbox()) + }) + + it( + 'should be a no-op when we click outside of a closed listbox', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + alice + bob + charlie + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Verify that the window is closed + assertListbox({ state: ListboxState.Closed }) + + // Click something that is not related to the listbox + await click(document.body) + + // Should still be closed + assertListbox({ state: ListboxState.Closed }) + }) + ) + + it( + 'should be possible to click outside of the listbox which should close the listbox', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + alice + bob + charlie + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open listbox + await click(getListboxButton()) + assertListbox({ state: ListboxState.Open }) + assertActiveElement(getListbox()) + + // Click something that is not related to the listbox + await click(document.body) + + // Should be closed now + assertListbox({ state: ListboxState.Closed }) + }) + ) + + it( + 'should be possible to click outside of the listbox on another listbox button which should close the current listbox and open the new listbox', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` +
+ + Trigger + + alice + bob + charlie + + + + + Trigger + + alice + bob + charlie + + +
+ `, + setup: () => ({ value: ref(null) }), + }) + + const [button1, button2] = getListboxButtons() + + // Click the first menu button + await click(button1) + expect(getListboxes()).toHaveLength(1) // Only 1 menu should be visible + + // Ensure the open menu is linked to the first button + assertListboxButtonLinkedWithListbox(button1, getListbox()) + + // Click the second menu button + await click(button2) + + expect(getListboxes()).toHaveLength(1) // Only 1 menu should be visible + + // Ensure the open menu is linked to the second button + assertListboxButtonLinkedWithListbox(button2, getListbox()) + }) + ) + + it( + 'should be possible to click outside of the listbox which should close the listbox (even if we press the listbox button)', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + alice + bob + charlie + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open listbox + await click(getListboxButton()) + assertListbox({ state: ListboxState.Open }) + assertActiveElement(getListbox()) + + // Click the listbox button again + await click(getListboxButton()) + + // Should be closed now + assertListbox({ state: ListboxState.Closed }) + }) + ) + + it( + 'should be possible to hover an option and make it active', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + alice + bob + charlie + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open listbox + await click(getListboxButton()) + + const options = getListboxOptions() + // We should be able to go to the second option + await mouseMove(options[1]) + assertActiveListboxOption(options[1]) + + // We should be able to go to the first option + await mouseMove(options[0]) + assertActiveListboxOption(options[0]) + + // We should be able to go to the last option + await mouseMove(options[2]) + assertActiveListboxOption(options[2]) + }) + ) + + it( + 'should make a listbox option active when you move the mouse over it', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + alice + bob + charlie + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open listbox + await click(getListboxButton()) + + const options = getListboxOptions() + // We should be able to go to the second option + await mouseMove(options[1]) + assertActiveListboxOption(options[1]) + }) + ) + + it( + 'should be a no-op when we move the mouse and the listbox option is already active', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + alice + bob + charlie + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open listbox + await click(getListboxButton()) + + const options = getListboxOptions() + + // We should be able to go to the second option + await mouseMove(options[1]) + assertActiveListboxOption(options[1]) + + await mouseMove(options[1]) + + // Nothing should be changed + assertActiveListboxOption(options[1]) + }) + ) + + it( + 'should be a no-op when we move the mouse and the listbox option is disabled', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + alice + + bob + + charlie + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open listbox + await click(getListboxButton()) + + const options = getListboxOptions() + + await mouseMove(options[1]) + assertNoActiveListboxOption() + }) + ) + + it( + 'should not be possible to hover an option that is disabled', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + alice + + bob + + charlie + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open listbox + await click(getListboxButton()) + + const options = getListboxOptions() + + // Try to hover over option 1, which is disabled + await mouseMove(options[1]) + + // We should not have an active option now + assertNoActiveListboxOption() + }) + ) + + it( + 'should be possible to mouse leave an option and make it inactive', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + alice + bob + charlie + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open listbox + await click(getListboxButton()) + + const options = getListboxOptions() + + // We should be able to go to the second option + await mouseMove(options[1]) + assertActiveListboxOption(options[1]) + + await mouseLeave(options[1]) + assertNoActiveListboxOption() + + // We should be able to go to the first option + await mouseMove(options[0]) + assertActiveListboxOption(options[0]) + + await mouseLeave(options[0]) + assertNoActiveListboxOption() + + // We should be able to go to the last option + await mouseMove(options[2]) + assertActiveListboxOption(options[2]) + + await mouseLeave(options[2]) + assertNoActiveListboxOption() + }) + ) + + it( + 'should be possible to mouse leave a disabled option and be a no-op', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + alice + + bob + + charlie + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open listbox + await click(getListboxButton()) + + const options = getListboxOptions() + + // Try to hover over option 1, which is disabled + await mouseMove(options[1]) + assertNoActiveListboxOption() + + await mouseLeave(options[1]) + assertNoActiveListboxOption() + }) + ) + + it( + 'should be possible to click a listbox option, which closes the listbox', + suppressConsoleLogs(async () => { + const handleChange = jest.fn() + renderTemplate({ + template: ` + + Trigger + + alice + bob + charlie + + + `, + setup() { + const value = ref(null) + watchEffect(() => { + if (value.value !== null) handleChange(value.value) + }) + return { value } + }, + }) + + // Open listbox + await click(getListboxButton()) + assertListbox({ state: ListboxState.Open }) + assertActiveElement(getListbox()) + + const options = getListboxOptions() + + // We should be able to click the first option + await click(options[1]) + assertListbox({ state: ListboxState.Closed }) + expect(handleChange).toHaveBeenCalledTimes(1) + expect(handleChange).toHaveBeenCalledWith('bob') + + // Open listbox again + await click(getListboxButton()) + + // Verify the active option is the previously selected one + assertActiveListboxOption(getListboxOptions()[1]) + }) + ) + + it( + 'should be possible to click a disabled listbox option, which is a no-op', + suppressConsoleLogs(async () => { + const handleChange = jest.fn() + renderTemplate({ + template: ` + + Trigger + + alice + + bob + + charlie + + + `, + setup() { + const value = ref(null) + watchEffect(() => { + if (value.value !== null) handleChange(value.value) + }) + return { value } + }, + }) + + // Open listbox + await click(getListboxButton()) + assertListbox({ state: ListboxState.Open }) + assertActiveElement(getListbox()) + + const options = getListboxOptions() + + // We should be able to click the first option + await click(options[1]) + assertListbox({ state: ListboxState.Open }) + assertActiveElement(getListbox()) + expect(handleChange).toHaveBeenCalledTimes(0) + + // Close the listbox + await click(getListboxButton()) + + // Open listbox again + await click(getListboxButton()) + + // Verify the active option is non existing + assertNoActiveListboxOption() + }) + ) + + it( + 'should be possible focus a listbox option, so that it becomes active', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + alice + bob + charlie + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open listbox + await click(getListboxButton()) + assertListbox({ state: ListboxState.Open }) + assertActiveElement(getListbox()) + + const options = getListboxOptions() + + // Verify that nothing is active yet + assertNoActiveListboxOption() + + // We should be able to focus the first option + await focus(options[1]) + assertActiveListboxOption(options[1]) + }) + ) + + it( + 'should not be possible to focus a listbox option which is disabled', + suppressConsoleLogs(async () => { + renderTemplate({ + template: ` + + Trigger + + alice + + bob + + charlie + + + `, + setup: () => ({ value: ref(null) }), + }) + + // Open listbox + await click(getListboxButton()) + assertListbox({ state: ListboxState.Open }) + assertActiveElement(getListbox()) + + const options = getListboxOptions() + + // We should not be able to focus the first option + await focus(options[1]) + assertNoActiveListboxOption() + }) + ) +}) diff --git a/packages/@headlessui-vue/src/components/listbox/listbox.ts b/packages/@headlessui-vue/src/components/listbox/listbox.ts new file mode 100644 index 0000000..349eaa8 --- /dev/null +++ b/packages/@headlessui-vue/src/components/listbox/listbox.ts @@ -0,0 +1,558 @@ +import { + defineComponent, + ref, + provide, + inject, + onMounted, + onUnmounted, + computed, + nextTick, + InjectionKey, + Ref, + ComputedRef, + watchEffect, +} from 'vue' +import { match } from '../../utils/match' +import { render } from '../../utils/render' +import { useId } from '../../hooks/use-id' +import { Keys } from '../../keyboard' + +enum ListboxStates { + Open, + Closed, +} + +enum Focus { + First, + Previous, + Next, + Last, + Specific, + Nothing, +} + +type ListboxOptionDataRef = Ref<{ textValue: string; disabled: boolean; value: unknown }> +type StateDefinition = { + // State + listboxState: Ref + value: ComputedRef + labelRef: Ref + buttonRef: Ref + optionsRef: Ref + options: Ref<{ id: string; dataRef: ListboxOptionDataRef }[]> + searchQuery: Ref + activeOptionIndex: Ref + + // State mutators + closeListbox(): void + openListbox(): void + goToOption(focus: Focus, id?: string): void + search(value: string): void + clearSearch(): void + registerOption(id: string, dataRef: ListboxOptionDataRef): void + unregisterOption(id: string): void + select(value: unknown): void +} + +const ListboxContext = Symbol('ListboxContext') as InjectionKey + +function useListboxContext(component: string) { + const context = inject(ListboxContext) + + if (context === undefined) { + const err = new Error(`<${component} /> is missing a parent component.`) + if (Error.captureStackTrace) Error.captureStackTrace(err, useListboxContext) + throw err + } + + return context +} + +// --- + +export const Listbox = defineComponent({ + name: 'Listbox', + props: { + as: { type: [Object, String], default: 'template' }, + modelValue: { type: [Object, String], default: null }, + }, + setup(props, { slots, attrs, emit }) { + const { modelValue, ...passThroughProps } = props + const listboxState = ref(ListboxStates.Closed) + const labelRef = ref(null) + const buttonRef = ref(null) + const optionsRef = ref(null) + const options = ref([]) + const searchQuery = ref('') + const activeOptionIndex = ref(null) + + const value = computed(() => props.modelValue) + + function calculateActiveOptionIndex(focus: Focus, id?: string) { + if (options.value.length <= 0) return null + + const currentActiveOptionIndex = activeOptionIndex.value ?? -1 + + const nextActiveIndex = match(focus, { + [Focus.First]: () => options.value.findIndex(option => !option.dataRef.disabled), + [Focus.Previous]: () => { + const idx = options.value + .slice() + .reverse() + .findIndex((option, idx, all) => { + if ( + currentActiveOptionIndex !== -1 && + all.length - idx - 1 >= currentActiveOptionIndex + ) + return false + return !option.dataRef.disabled + }) + if (idx === -1) return idx + return options.value.length - 1 - idx + }, + [Focus.Next]: () => { + return options.value.findIndex((option, idx) => { + if (idx <= currentActiveOptionIndex) return false + return !option.dataRef.disabled + }) + }, + [Focus.Last]: () => { + const idx = options.value + .slice() + .reverse() + .findIndex(option => !option.dataRef.disabled) + if (idx === -1) return idx + return options.value.length - 1 - idx + }, + [Focus.Specific]: () => options.value.findIndex(option => option.id === id), + [Focus.Nothing]: () => null, + }) + + if (nextActiveIndex === -1) return activeOptionIndex.value + return nextActiveIndex + } + + const api = { + listboxState, + value, + labelRef, + buttonRef, + optionsRef, + options, + searchQuery, + activeOptionIndex, + closeListbox: () => (listboxState.value = ListboxStates.Closed), + openListbox: () => (listboxState.value = ListboxStates.Open), + goToOption(focus: Focus, id?: string) { + const nextActiveOptionIndex = calculateActiveOptionIndex(focus, id) + if (searchQuery.value === '' && activeOptionIndex.value === nextActiveOptionIndex) return + searchQuery.value = '' + activeOptionIndex.value = nextActiveOptionIndex + }, + search(value: string) { + searchQuery.value += value + + const match = options.value.findIndex( + option => + !option.dataRef.disabled && option.dataRef.textValue.startsWith(searchQuery.value) + ) + + if (match === -1 || match === activeOptionIndex.value) { + return + } + + activeOptionIndex.value = match + }, + clearSearch() { + searchQuery.value = '' + }, + registerOption(id: string, dataRef: ListboxOptionDataRef) { + // @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.push({ id, dataRef }) + }, + unregisterOption(id: string) { + const nextOptions = options.value.slice() + const currentActiveOption = + activeOptionIndex.value !== null ? nextOptions[activeOptionIndex.value] : null + const 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) + })() + }, + select(value: unknown) { + emit('update:modelValue', value) + }, + } + + onMounted(() => { + function handler(event: MouseEvent) { + if (listboxState.value !== ListboxStates.Open) return + if (buttonRef.value?.contains(event.target as HTMLElement)) return + + if (!optionsRef.value?.contains(event.target as HTMLElement)) { + api.closeListbox() + if (!event.defaultPrevented) buttonRef.value?.focus() + } + } + + window.addEventListener('click', handler) + onUnmounted(() => window.removeEventListener('click', handler)) + }) + + // @ts-expect-error Types of property 'dataRef' are incompatible. + provide(ListboxContext, api) + + return () => { + const slot = { open: listboxState.value === ListboxStates.Open } + return render({ props: passThroughProps, slot, slots, attrs }) + } + }, +}) + +// --- + +export const ListboxLabel = defineComponent({ + name: 'ListboxLabel', + props: { as: { type: [Object, String], default: 'label' } }, + render() { + const api = useListboxContext('ListboxLabel') + + const slot = { open: api.listboxState.value === ListboxStates.Open } + const propsWeControl = { + id: this.id, + ref: 'el', + onPointerUp: this.handlePointerUp, + } + + return render({ + props: { ...this.$props, ...propsWeControl }, + slot, + attrs: this.$attrs, + slots: this.$slots, + }) + }, + setup() { + const api = useListboxContext('ListboxLabel') + const id = `headlessui-listbox-label-${useId()}` + + return { + id, + el: api.labelRef, + handlePointerUp() { + api.buttonRef.value?.focus() + }, + } + }, +}) + +// --- + +export const ListboxButton = defineComponent({ + name: 'ListboxButton', + props: { as: { type: [Object, String], default: 'button' } }, + render() { + const api = useListboxContext('ListboxButton') + + const slot = { open: api.listboxState.value === ListboxStates.Open, focused: this.focused } + const propsWeControl = { + ref: 'el', + id: this.id, + type: 'button', + 'aria-haspopup': true, + 'aria-controls': api.optionsRef.value?.id, + 'aria-expanded': api.listboxState.value === ListboxStates.Open ? true : undefined, + 'aria-labelledby': api.labelRef.value + ? [api.labelRef.value.id, this.id].join(' ') + : undefined, + onKeyDown: this.handleKeyDown, + onFocus: this.handleFocus, + onBlur: this.handleBlur, + onPointerUp: this.handlePointerUp, + } + + return render({ + props: { ...this.$props, ...propsWeControl }, + slot, + attrs: this.$attrs, + slots: this.$slots, + }) + }, + setup() { + const api = useListboxContext('ListboxButton') + const id = `headlessui-listbox-button-${useId()}` + const focused = ref(false) + + function handleKeyDown(event: KeyboardEvent) { + switch (event.key) { + // Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-13 + + case Keys.Space: + case Keys.Enter: + case Keys.ArrowDown: + event.preventDefault() + api.openListbox() + nextTick(() => { + api.optionsRef.value?.focus() + if (!api.value.value) api.goToOption(Focus.First) + }) + break + + case Keys.ArrowUp: + event.preventDefault() + api.openListbox() + nextTick(() => { + api.optionsRef.value?.focus() + if (!api.value.value) api.goToOption(Focus.Last) + }) + break + } + } + + function handlePointerUp(event: MouseEvent) { + if (api.listboxState.value === ListboxStates.Open) { + api.closeListbox() + } else { + event.preventDefault() + api.openListbox() + nextTick(() => api.optionsRef.value?.focus()) + } + } + + function handleFocus() { + if (api.listboxState.value === ListboxStates.Open) return api.optionsRef.value?.focus() + focused.value = true + } + + function handleBlur() { + focused.value = false + } + + return { + id, + el: api.buttonRef, + focused, + handleKeyDown, + handlePointerUp, + handleFocus, + handleBlur, + } + }, +}) + +// --- + +export const ListboxOptions = defineComponent({ + name: 'ListboxOptions', + props: { + as: { type: [Object, String], default: 'ul' }, + static: { type: Boolean, default: false }, + }, + render() { + const api = useListboxContext('ListboxOptions') + + // `static` is a reserved keyword, therefore aliasing it... + const { static: isStatic, ...passThroughProps } = this.$props + + if (!isStatic && api.listboxState.value === ListboxStates.Closed) return null + + const slot = { open: api.listboxState.value === ListboxStates.Open } + const propsWeControl = { + 'aria-activedescendant': + api.activeOptionIndex.value === null + ? undefined + : api.options.value[api.activeOptionIndex.value]?.id, + 'aria-labelledby': api.labelRef.value?.id ?? api.buttonRef.value?.id, + id: this.id, + onKeyDown: this.handleKeyDown, + role: 'listbox', + tabIndex: 0, + ref: 'el', + } + + return render({ + props: { ...passThroughProps, ...propsWeControl }, + slot, + attrs: this.$attrs, + slots: this.$slots, + }) + }, + setup() { + const api = useListboxContext('ListboxOptions') + const id = `headlessui-listbox-options-${useId()}` + const searchDebounce = ref | null>(null) + + function handleKeyDown(event: KeyboardEvent) { + if (searchDebounce.value) clearTimeout(searchDebounce.value) + + switch (event.key) { + // Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-12 + + // @ts-expect-error Fallthrough is expected here + case Keys.Space: + if (api.searchQuery.value !== '') { + event.preventDefault() + return api.search(event.key) + } + // When in type ahead mode, fallthrough + case Keys.Enter: + event.preventDefault() + api.closeListbox() + if (api.activeOptionIndex.value !== null) { + const { dataRef } = api.options.value[api.activeOptionIndex.value] + api.select(dataRef.value) + nextTick(() => api.buttonRef.value?.focus()) + } + break + + case Keys.ArrowDown: + event.preventDefault() + return api.goToOption(Focus.Next) + + case Keys.ArrowUp: + event.preventDefault() + return api.goToOption(Focus.Previous) + + case Keys.Home: + case Keys.PageUp: + event.preventDefault() + return api.goToOption(Focus.First) + + case Keys.End: + case Keys.PageDown: + event.preventDefault() + return api.goToOption(Focus.Last) + + case Keys.Escape: + event.preventDefault() + api.closeListbox() + nextTick(() => api.buttonRef.value?.focus()) + break + + case Keys.Tab: + return event.preventDefault() + + default: + if (event.key.length === 1) { + api.search(event.key) + searchDebounce.value = setTimeout(() => api.clearSearch(), 350) + } + break + } + } + + return { + id, + el: api.optionsRef, + handleKeyDown, + } + }, +}) + +export const ListboxOption = defineComponent({ + name: 'ListboxOption', + props: { + as: { type: [Object, String], default: 'li' }, + value: { type: [Object, String], default: null }, + disabled: { type: Boolean, default: false }, + class: { type: [String, Function], required: false }, + className: { type: [String, Function], required: false }, + }, + setup(props, { slots, attrs }) { + const api = useListboxContext('ListboxOption') + const id = `headlessui-listbox-option-${useId()}` + const { disabled, class: defaultClass, className = defaultClass, value } = props + + const active = computed(() => { + return api.activeOptionIndex.value !== null + ? api.options.value[api.activeOptionIndex.value].id === id + : false + }) + + const selected = computed(() => api.value.value === value) + + const dataRef = ref({ disabled, value, textValue: '' }) + onMounted(() => { + const textValue = document + .getElementById(id) + ?.textContent?.toLowerCase() + .trim() + if (textValue !== undefined) dataRef.value.textValue = textValue + }) + + onMounted(() => api.registerOption(id, dataRef)) + onUnmounted(() => api.unregisterOption(id)) + + onMounted(() => { + if (!selected.value) return + api.goToOption(Focus.Specific, id) + document.getElementById(id)?.focus?.() + }) + + watchEffect(() => { + if (!active.value) return + nextTick(() => document.getElementById(id)?.scrollIntoView?.({ block: 'nearest' })) + }) + + function handleClick(event: MouseEvent) { + if (disabled) return event.preventDefault() + api.select(value) + api.closeListbox() + nextTick(() => api.buttonRef.value?.focus()) + } + + function handleFocus() { + if (disabled) return api.goToOption(Focus.Nothing) + api.goToOption(Focus.Specific, id) + } + + function handlePointerMove() { + if (disabled) return + if (active.value) return + api.goToOption(Focus.Specific, id) + } + + function handlePointerLeave() { + if (disabled) return + if (!active.value) return + api.goToOption(Focus.Nothing) + } + + return () => { + const slot = { active: active.value, selected: selected.value, disabled } + const propsWeControl = { + id, + role: 'option', + tabIndex: -1, + class: resolvePropValue(className, slot), + 'aria-disabled': disabled === true ? true : undefined, + 'aria-selected': selected.value === true ? selected.value : undefined, + onClick: handleClick, + onFocus: handleFocus, + onPointerMove: handlePointerMove, + onPointerLeave: handlePointerLeave, + } + + return render({ + props: { ...props, ...propsWeControl }, + slot, + attrs, + slots, + }) + } + }, +}) + +// --- + +function resolvePropValue(property: TProperty, bag: TBag) { + if (property === undefined) return undefined + if (typeof property === 'function') return property(bag) + return property +} diff --git a/packages/@headlessui-vue/src/components/menu/menu.test.tsx b/packages/@headlessui-vue/src/components/menu/menu.test.tsx index f6a1f50..cd34c26 100644 --- a/packages/@headlessui-vue/src/components/menu/menu.test.tsx +++ b/packages/@headlessui-vue/src/components/menu/menu.test.tsx @@ -3,7 +3,6 @@ import { render } from '../../test-utils/vue-testing-library' import { Menu, MenuButton, MenuItems, MenuItem } from './menu' import { suppressConsoleLogs } from '../../test-utils/suppress-console-logs' import { - MenuButtonState, MenuState, assertMenu, assertMenuButton, @@ -12,6 +11,11 @@ import { assertMenuLinkedWithMenuItem, assertActiveElement, assertNoActiveMenuItem, + getMenuButton, + getMenu, + getMenuItems, + getMenuButtons, + getMenus, } from '../../test-utils/accessibility-assertions' import { click, @@ -43,35 +47,6 @@ function renderTemplate(input: string | Partial { it.each([ ['MenuButton', MenuButton], @@ -98,11 +73,11 @@ describe('Safe guards', () => { `) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) }) }) @@ -120,21 +95,21 @@ describe('Rendering', () => { `) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, textContent: 'Trigger hidden', }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) await click(getMenuButton()) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Open, + assertMenuButton({ + state: MenuState.Open, attributes: { id: 'headlessui-menu-button-1' }, textContent: 'Trigger visible', }) - assertMenu(getMenu(), { state: MenuState.Open }) + assertMenu({ state: MenuState.Open }) }) it('should be possible to render a Menu using a template `as` prop', async () => { @@ -151,19 +126,19 @@ describe('Rendering', () => { `) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) await click(getMenuButton()) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Open, + assertMenuButton({ + state: MenuState.Open, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Open }) + assertMenu({ state: MenuState.Open }) }) it( @@ -202,21 +177,21 @@ describe('Rendering', () => { `) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, textContent: 'Trigger hidden', }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) await click(getMenuButton()) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Open, + assertMenuButton({ + state: MenuState.Open, attributes: { id: 'headlessui-menu-button-1' }, textContent: 'Trigger visible', }) - assertMenu(getMenu(), { state: MenuState.Open }) + assertMenu({ state: MenuState.Open }) }) it('should be possible to render a MenuButton using a template `as` prop', async () => { @@ -233,19 +208,19 @@ describe('Rendering', () => { `) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1', 'data-open': 'false' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) await click(getMenuButton()) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Open, + assertMenuButton({ + state: MenuState.Open, attributes: { id: 'headlessui-menu-button-1', 'data-open': 'true' }, }) - assertMenu(getMenu(), { state: MenuState.Open }) + assertMenu({ state: MenuState.Open }) }) it( @@ -286,19 +261,19 @@ describe('Rendering', () => { `) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) await click(getMenuButton()) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Open, + assertMenuButton({ + state: MenuState.Open, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Open }) + assertMenu({ state: MenuState.Open }) expect(getMenu()?.firstChild?.textContent).toBe('visible') }) @@ -316,19 +291,19 @@ describe('Rendering', () => { `) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) await click(getMenuButton()) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Open, + assertMenuButton({ + state: MenuState.Open, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Open, attributes: { 'data-open': 'true' } }) + assertMenu({ state: MenuState.Open, attributes: { 'data-open': 'true' } }) }) it('should yell when we render MenuItems using a template `as` prop that contains multiple children', async () => { @@ -398,19 +373,19 @@ describe('Rendering', () => { `) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) await click(getMenuButton()) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Open, + assertMenuButton({ + state: MenuState.Open, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Open }) + assertMenu({ state: MenuState.Open }) expect(getMenuItems()[0]?.textContent).toBe( `Item A - ${JSON.stringify({ active: false, disabled: false })}` ) @@ -434,21 +409,21 @@ describe('Rendering', () => { `) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) getMenuButton()?.focus() await press(Keys.Enter) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Open, + assertMenuButton({ + state: MenuState.Open, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Open }) + assertMenu({ state: MenuState.Open }) assertMenuItem(getMenuItems()[0], { tag: 'a', attributes: { 'data-active': 'true', 'data-disabled': 'false' }, @@ -516,11 +491,11 @@ describe('Rendering composition', () => { `) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Open menu await click(getMenuButton()) @@ -533,7 +508,7 @@ describe('Rendering composition', () => { expect('' + items[2].classList).toEqual('no-special-treatment') // Double check that nothing is active - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() // Make the first item active await press(Keys.ArrowDown) @@ -544,7 +519,7 @@ describe('Rendering composition', () => { expect('' + items[2].classList).toEqual('no-special-treatment') // Double check that the first item is the active one - assertMenuLinkedWithMenuItem(getMenu(), items[0]) + assertMenuLinkedWithMenuItem(items[0]) // Let's go down, this should go to the third item since the second item is disabled! await press(Keys.ArrowDown) @@ -555,7 +530,7 @@ describe('Rendering composition', () => { expect('' + items[2].classList).toEqual('no-special-treatment') // Double check that the last item is the active one - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) }) it( @@ -581,11 +556,11 @@ describe('Rendering composition', () => { setup: () => ({ MyButton }), }) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Open menu await click(getMenuButton()) @@ -613,11 +588,11 @@ describe('Keyboard interactions', () => { `) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Focus the button getMenuButton()?.focus() @@ -626,12 +601,12 @@ describe('Keyboard interactions', () => { await press(Keys.Enter) // Verify it is open - assertMenuButton(getMenuButton(), { state: MenuButtonState.Open }) - assertMenu(getMenu(), { + assertMenuButton({ state: MenuState.Open }) + assertMenu({ state: MenuState.Open, attributes: { id: 'headlessui-menu-items-2' }, }) - assertMenuButtonLinkedWithMenu(getMenuButton(), getMenu()) + assertMenuButtonLinkedWithMenu() // Verify we have menu items const items = getMenuItems() @@ -639,7 +614,7 @@ describe('Keyboard interactions', () => { items.forEach(item => assertMenuItem(item)) // Verify that the first menu item is active - assertMenuLinkedWithMenuItem(getMenu(), items[0]) + assertMenuLinkedWithMenuItem(items[0]) }) it('should have no active menu item when there are no menu items at all', async () => { @@ -650,16 +625,16 @@ describe('Keyboard interactions', () => { `) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Focus the button getMenuButton()?.focus() // Open menu await press(Keys.Enter) - assertMenu(getMenu(), { state: MenuState.Open }) + assertMenu({ state: MenuState.Open }) - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() }) it('should focus the first non disabled menu item when opening with Enter', async () => { @@ -674,11 +649,11 @@ describe('Keyboard interactions', () => { `) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Focus the button getMenuButton()?.focus() @@ -689,7 +664,7 @@ describe('Keyboard interactions', () => { const items = getMenuItems() // Verify that the first non-disabled menu item is active - assertMenuLinkedWithMenuItem(getMenu(), items[1]) + assertMenuLinkedWithMenuItem(items[1]) }) it('should focus the first non disabled menu item when opening with Enter (jump over multiple disabled ones)', async () => { @@ -704,11 +679,11 @@ describe('Keyboard interactions', () => { `) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Focus the button getMenuButton()?.focus() @@ -719,7 +694,7 @@ describe('Keyboard interactions', () => { const items = getMenuItems() // Verify that the first non-disabled menu item is active - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) }) it('should have no active menu item upon Enter key press, when there are no non-disabled menu items', async () => { @@ -734,11 +709,11 @@ describe('Keyboard interactions', () => { `) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Focus the button getMenuButton()?.focus() @@ -746,7 +721,7 @@ describe('Keyboard interactions', () => { // Open menu await press(Keys.Enter) - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() }) it('should be possible to close the menu with Enter when there is no active menuitem', async () => { @@ -761,24 +736,24 @@ describe('Keyboard interactions', () => { `) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Open menu await click(getMenuButton()) // Verify it is open - assertMenuButton(getMenuButton(), { state: MenuButtonState.Open }) + assertMenuButton({ state: MenuState.Open }) // Close menu await press(Keys.Enter) // Verify it is closed - assertMenuButton(getMenuButton(), { state: MenuButtonState.Closed }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenuButton({ state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) }) it('should be possible to close the menu with Enter and invoke the active menu item', async () => { @@ -797,17 +772,17 @@ describe('Keyboard interactions', () => { setup: () => ({ clickHandler }), }) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Open menu await click(getMenuButton()) // Verify it is open - assertMenuButton(getMenuButton(), { state: MenuButtonState.Open }) + assertMenuButton({ state: MenuState.Open }) // Activate the first menu item const items = getMenuItems() @@ -817,8 +792,8 @@ describe('Keyboard interactions', () => { await press(Keys.Enter) // Verify it is closed - assertMenuButton(getMenuButton(), { state: MenuButtonState.Closed }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenuButton({ state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Verify the "click" went through on the `a` tag expect(clickHandler).toHaveBeenCalled() @@ -846,17 +821,17 @@ describe('Keyboard interactions', () => { setup: () => ({ clickHandler }), }) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Open menu await click(getMenuButton()) // Verify it is open - assertMenuButton(getMenuButton(), { state: MenuButtonState.Open }) + assertMenuButton({ state: MenuState.Open }) // Activate the second menu item const items = getMenuItems() @@ -866,8 +841,8 @@ describe('Keyboard interactions', () => { await press(Keys.Enter) // Verify it is closed - assertMenuButton(getMenuButton(), { state: MenuButtonState.Closed }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenuButton({ state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Verify the button got "clicked" expect(clickHandler).toHaveBeenCalledTimes(1) @@ -898,11 +873,11 @@ describe('Keyboard interactions', () => { `) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Focus the button getMenuButton()?.focus() @@ -911,18 +886,18 @@ describe('Keyboard interactions', () => { await press(Keys.Space) // Verify it is open - assertMenuButton(getMenuButton(), { state: MenuButtonState.Open }) - assertMenu(getMenu(), { + assertMenuButton({ state: MenuState.Open }) + assertMenu({ state: MenuState.Open, attributes: { id: 'headlessui-menu-items-2' }, }) - assertMenuButtonLinkedWithMenu(getMenuButton(), getMenu()) + assertMenuButtonLinkedWithMenu() // Verify we have menu items const items = getMenuItems() expect(items).toHaveLength(3) items.forEach(item => assertMenuItem(item)) - assertMenuLinkedWithMenuItem(getMenu(), items[0]) + assertMenuLinkedWithMenuItem(items[0]) }) it('should have no active menu item when there are no menu items at all', async () => { @@ -933,16 +908,16 @@ describe('Keyboard interactions', () => { `) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Focus the button getMenuButton()?.focus() // Open menu await press(Keys.Space) - assertMenu(getMenu(), { state: MenuState.Open }) + assertMenu({ state: MenuState.Open }) - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() }) it('should focus the first non disabled menu item when opening with Space', async () => { @@ -957,11 +932,11 @@ describe('Keyboard interactions', () => { `) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Focus the button getMenuButton()?.focus() @@ -972,7 +947,7 @@ describe('Keyboard interactions', () => { const items = getMenuItems() // Verify that the first non-disabled menu item is active - assertMenuLinkedWithMenuItem(getMenu(), items[1]) + assertMenuLinkedWithMenuItem(items[1]) }) it('should focus the first non disabled menu item when opening with Space (jump over multiple disabled ones)', async () => { @@ -987,11 +962,11 @@ describe('Keyboard interactions', () => { `) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Focus the button getMenuButton()?.focus() @@ -1002,7 +977,7 @@ describe('Keyboard interactions', () => { const items = getMenuItems() // Verify that the first non-disabled menu item is active - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) }) it('should have no active menu item upon Space key press, when there are no non-disabled menu items', async () => { @@ -1017,11 +992,11 @@ describe('Keyboard interactions', () => { `) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Focus the button getMenuButton()?.focus() @@ -1029,7 +1004,7 @@ describe('Keyboard interactions', () => { // Open menu await press(Keys.Space) - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() }) it( @@ -1046,24 +1021,24 @@ describe('Keyboard interactions', () => { `) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Open menu await click(getMenuButton()) // Verify it is open - assertMenuButton(getMenuButton(), { state: MenuButtonState.Open }) + assertMenuButton({ state: MenuState.Open }) // Close menu await press(Keys.Space) // Verify it is closed - assertMenuButton(getMenuButton(), { state: MenuButtonState.Closed }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenuButton({ state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) }) ) @@ -1085,17 +1060,17 @@ describe('Keyboard interactions', () => { setup: () => ({ clickHandler }), }) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Open menu await click(getMenuButton()) // Verify it is open - assertMenuButton(getMenuButton(), { state: MenuButtonState.Open }) + assertMenuButton({ state: MenuState.Open }) // Activate the first menu item const items = getMenuItems() @@ -1105,8 +1080,8 @@ describe('Keyboard interactions', () => { await press(Keys.Space) // Verify it is closed - assertMenuButton(getMenuButton(), { state: MenuButtonState.Closed }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenuButton({ state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Verify the "click" went through on the `a` tag expect(clickHandler).toHaveBeenCalled() @@ -1134,19 +1109,19 @@ describe('Keyboard interactions', () => { await press(Keys.Space) // Verify it is open - assertMenuButton(getMenuButton(), { state: MenuButtonState.Open }) - assertMenu(getMenu(), { + assertMenuButton({ state: MenuState.Open }) + assertMenu({ state: MenuState.Open, attributes: { id: 'headlessui-menu-items-2' }, }) - assertMenuButtonLinkedWithMenu(getMenuButton(), getMenu()) + assertMenuButtonLinkedWithMenu() // Close menu await press(Keys.Escape) // Verify it is closed - assertMenuButton(getMenuButton(), { state: MenuButtonState.Closed }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenuButton({ state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) }) }) @@ -1163,11 +1138,11 @@ describe('Keyboard interactions', () => { `) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Focus the button getMenuButton()?.focus() @@ -1176,25 +1151,25 @@ describe('Keyboard interactions', () => { await press(Keys.Enter) // Verify it is open - assertMenuButton(getMenuButton(), { state: MenuButtonState.Open }) - assertMenu(getMenu(), { + assertMenuButton({ state: MenuState.Open }) + assertMenu({ state: MenuState.Open, attributes: { id: 'headlessui-menu-items-2' }, }) - assertMenuButtonLinkedWithMenu(getMenuButton(), getMenu()) + assertMenuButtonLinkedWithMenu() // Verify we have menu items const items = getMenuItems() expect(items).toHaveLength(3) items.forEach(item => assertMenuItem(item)) - assertMenuLinkedWithMenuItem(getMenu(), items[0]) + assertMenuLinkedWithMenuItem(items[0]) // Try to tab await press(Keys.Tab) // Verify it is still open - assertMenuButton(getMenuButton(), { state: MenuButtonState.Open }) - assertMenu(getMenu(), { state: MenuState.Open }) + assertMenuButton({ state: MenuState.Open }) + assertMenu({ state: MenuState.Open }) }) it('should focus trap when we use Shift+Tab', async () => { @@ -1209,11 +1184,11 @@ describe('Keyboard interactions', () => { `) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Focus the button getMenuButton()?.focus() @@ -1222,25 +1197,25 @@ describe('Keyboard interactions', () => { await press(Keys.Enter) // Verify it is open - assertMenuButton(getMenuButton(), { state: MenuButtonState.Open }) - assertMenu(getMenu(), { + assertMenuButton({ state: MenuState.Open }) + assertMenu({ state: MenuState.Open, attributes: { id: 'headlessui-menu-items-2' }, }) - assertMenuButtonLinkedWithMenu(getMenuButton(), getMenu()) + assertMenuButtonLinkedWithMenu() // Verify we have menu items const items = getMenuItems() expect(items).toHaveLength(3) items.forEach(item => assertMenuItem(item)) - assertMenuLinkedWithMenuItem(getMenu(), items[0]) + assertMenuLinkedWithMenuItem(items[0]) // Try to Shift+Tab await press(shift(Keys.Tab)) // Verify it is still open - assertMenuButton(getMenuButton(), { state: MenuButtonState.Open }) - assertMenu(getMenu(), { state: MenuState.Open }) + assertMenuButton({ state: MenuState.Open }) + assertMenu({ state: MenuState.Open }) }) }) @@ -1257,11 +1232,11 @@ describe('Keyboard interactions', () => { `) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Focus the button getMenuButton()?.focus() @@ -1270,12 +1245,12 @@ describe('Keyboard interactions', () => { await press(Keys.ArrowDown) // Verify it is open - assertMenuButton(getMenuButton(), { state: MenuButtonState.Open }) - assertMenu(getMenu(), { + assertMenuButton({ state: MenuState.Open }) + assertMenu({ state: MenuState.Open, attributes: { id: 'headlessui-menu-items-2' }, }) - assertMenuButtonLinkedWithMenu(getMenuButton(), getMenu()) + assertMenuButtonLinkedWithMenu() // Verify we have menu items const items = getMenuItems() @@ -1283,7 +1258,7 @@ describe('Keyboard interactions', () => { items.forEach(item => assertMenuItem(item)) // Verify that the first menu item is active - assertMenuLinkedWithMenuItem(getMenu(), items[0]) + assertMenuLinkedWithMenuItem(items[0]) }) it('should have no active menu item when there are no menu items at all', async () => { @@ -1294,16 +1269,16 @@ describe('Keyboard interactions', () => { `) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Focus the button getMenuButton()?.focus() // Open menu await press(Keys.ArrowDown) - assertMenu(getMenu(), { state: MenuState.Open }) + assertMenu({ state: MenuState.Open }) - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() }) it('should be possible to use ArrowDown to navigate the menu items', async () => { @@ -1318,11 +1293,11 @@ describe('Keyboard interactions', () => { `) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Focus the button getMenuButton()?.focus() @@ -1334,19 +1309,19 @@ describe('Keyboard interactions', () => { const items = getMenuItems() expect(items).toHaveLength(3) items.forEach(item => assertMenuItem(item)) - assertMenuLinkedWithMenuItem(getMenu(), items[0]) + assertMenuLinkedWithMenuItem(items[0]) // We should be able to go down once await press(Keys.ArrowDown) - assertMenuLinkedWithMenuItem(getMenu(), items[1]) + assertMenuLinkedWithMenuItem(items[1]) // We should be able to go down again await press(Keys.ArrowDown) - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) // We should NOT be able to go down again (because last item). Current implementation won't go around. await press(Keys.ArrowDown) - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) }) it('should be possible to use ArrowDown to navigate the menu items and skip the first disabled one', async () => { @@ -1361,11 +1336,11 @@ describe('Keyboard interactions', () => { `) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Focus the button getMenuButton()?.focus() @@ -1377,11 +1352,11 @@ describe('Keyboard interactions', () => { const items = getMenuItems() expect(items).toHaveLength(3) items.forEach(item => assertMenuItem(item)) - assertMenuLinkedWithMenuItem(getMenu(), items[1]) + assertMenuLinkedWithMenuItem(items[1]) // We should be able to go down once await press(Keys.ArrowDown) - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) }) it('should be possible to use ArrowDown to navigate the menu items and jump to the first non-disabled one', async () => { @@ -1396,11 +1371,11 @@ describe('Keyboard interactions', () => { `) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Focus the button getMenuButton()?.focus() @@ -1412,7 +1387,7 @@ describe('Keyboard interactions', () => { const items = getMenuItems() expect(items).toHaveLength(3) items.forEach(item => assertMenuItem(item)) - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) }) }) @@ -1429,11 +1404,11 @@ describe('Keyboard interactions', () => { `) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Focus the button getMenuButton()?.focus() @@ -1442,12 +1417,12 @@ describe('Keyboard interactions', () => { await press(Keys.ArrowUp) // Verify it is open - assertMenuButton(getMenuButton(), { state: MenuButtonState.Open }) - assertMenu(getMenu(), { + assertMenuButton({ state: MenuState.Open }) + assertMenu({ state: MenuState.Open, attributes: { id: 'headlessui-menu-items-2' }, }) - assertMenuButtonLinkedWithMenu(getMenuButton(), getMenu()) + assertMenuButtonLinkedWithMenu() // Verify we have menu items const items = getMenuItems() @@ -1455,7 +1430,7 @@ describe('Keyboard interactions', () => { items.forEach(item => assertMenuItem(item)) // ! ALERT: The LAST item should now be active - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) }) it('should have no active menu item when there are no menu items at all', async () => { @@ -1466,16 +1441,16 @@ describe('Keyboard interactions', () => { `) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Focus the button getMenuButton()?.focus() // Open menu await press(Keys.ArrowUp) - assertMenu(getMenu(), { state: MenuState.Open }) + assertMenu({ state: MenuState.Open }) - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() }) it('should be possible to use ArrowUp to navigate the menu items and jump to the first non-disabled one', async () => { @@ -1490,11 +1465,11 @@ describe('Keyboard interactions', () => { `) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Focus the button getMenuButton()?.focus() @@ -1506,7 +1481,7 @@ describe('Keyboard interactions', () => { const items = getMenuItems() expect(items).toHaveLength(3) items.forEach(item => assertMenuItem(item)) - assertMenuLinkedWithMenuItem(getMenu(), items[0]) + assertMenuLinkedWithMenuItem(items[0]) }) it('should not be possible to navigate up or down if there is only a single non-disabled item', async () => { @@ -1521,11 +1496,11 @@ describe('Keyboard interactions', () => { `) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Focus the button getMenuButton()?.focus() @@ -1537,15 +1512,15 @@ describe('Keyboard interactions', () => { const items = getMenuItems() expect(items).toHaveLength(3) items.forEach(item => assertMenuItem(item)) - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) // We should not be able to go up (because those are disabled) await press(Keys.ArrowUp) - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) // We should not be able to go down (because this is the last item) await press(Keys.ArrowDown) - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) }) it('should be possible to use ArrowUp to navigate the menu items', async () => { @@ -1560,11 +1535,11 @@ describe('Keyboard interactions', () => { `) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Focus the button getMenuButton()?.focus() @@ -1573,30 +1548,30 @@ describe('Keyboard interactions', () => { await press(Keys.ArrowUp) // Verify it is open - assertMenuButton(getMenuButton(), { state: MenuButtonState.Open }) - assertMenu(getMenu(), { + assertMenuButton({ state: MenuState.Open }) + assertMenu({ state: MenuState.Open, attributes: { id: 'headlessui-menu-items-2' }, }) - assertMenuButtonLinkedWithMenu(getMenuButton(), getMenu()) + assertMenuButtonLinkedWithMenu() // Verify we have menu items const items = getMenuItems() expect(items).toHaveLength(3) items.forEach(item => assertMenuItem(item)) - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) // We should be able to go down once await press(Keys.ArrowUp) - assertMenuLinkedWithMenuItem(getMenu(), items[1]) + assertMenuLinkedWithMenuItem(items[1]) // We should be able to go down again await press(Keys.ArrowUp) - assertMenuLinkedWithMenuItem(getMenu(), items[0]) + assertMenuLinkedWithMenuItem(items[0]) // We should NOT be able to go up again (because first item). Current implementation won't go around. await press(Keys.ArrowUp) - assertMenuLinkedWithMenuItem(getMenu(), items[0]) + assertMenuLinkedWithMenuItem(items[0]) }) }) @@ -1622,11 +1597,11 @@ describe('Keyboard interactions', () => { const items = getMenuItems() // We should be on the first item - assertMenuLinkedWithMenuItem(getMenu(), items[0]) + assertMenuLinkedWithMenuItem(items[0]) // We should be able to go to the last item await press(Keys.End) - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) }) it('should be possible to use the End key to go to the last non disabled menu item', async () => { @@ -1651,11 +1626,11 @@ describe('Keyboard interactions', () => { const items = getMenuItems() // We should be on the first item - assertMenuLinkedWithMenuItem(getMenu(), items[0]) + assertMenuLinkedWithMenuItem(items[0]) // We should be able to go to the last non-disabled item await press(Keys.End) - assertMenuLinkedWithMenuItem(getMenu(), items[1]) + assertMenuLinkedWithMenuItem(items[1]) }) it('should be possible to use the End key to go to the first menu item if that is the only non-disabled menu item', async () => { @@ -1675,13 +1650,13 @@ describe('Keyboard interactions', () => { await click(getMenuButton()) // We opened via click, we don't have an active item - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() // We should not be able to go to the end await press(Keys.End) const items = getMenuItems() - assertMenuLinkedWithMenuItem(getMenu(), items[0]) + assertMenuLinkedWithMenuItem(items[0]) }) it('should have no active menu item upon End key press, when there are no non-disabled menu items', async () => { @@ -1701,12 +1676,12 @@ describe('Keyboard interactions', () => { await click(getMenuButton()) // We opened via click, we don't have an active item - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() // We should not be able to go to the end await press(Keys.End) - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() }) }) @@ -1732,11 +1707,11 @@ describe('Keyboard interactions', () => { const items = getMenuItems() // We should be on the first item - assertMenuLinkedWithMenuItem(getMenu(), items[0]) + assertMenuLinkedWithMenuItem(items[0]) // We should be able to go to the last item await press(Keys.PageDown) - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) }) it('should be possible to use the PageDown key to go to the last non disabled menu item', async () => { @@ -1761,11 +1736,11 @@ describe('Keyboard interactions', () => { const items = getMenuItems() // We should be on the first item - assertMenuLinkedWithMenuItem(getMenu(), items[0]) + assertMenuLinkedWithMenuItem(items[0]) // We should be able to go to the last non-disabled item await press(Keys.PageDown) - assertMenuLinkedWithMenuItem(getMenu(), items[1]) + assertMenuLinkedWithMenuItem(items[1]) }) it('should be possible to use the PageDown key to go to the first menu item if that is the only non-disabled menu item', async () => { @@ -1785,13 +1760,13 @@ describe('Keyboard interactions', () => { await click(getMenuButton()) // We opened via click, we don't have an active item - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() // We should not be able to go to the end await press(Keys.PageDown) const items = getMenuItems() - assertMenuLinkedWithMenuItem(getMenu(), items[0]) + assertMenuLinkedWithMenuItem(items[0]) }) it('should have no active menu item upon PageDown key press, when there are no non-disabled menu items', async () => { @@ -1811,12 +1786,12 @@ describe('Keyboard interactions', () => { await click(getMenuButton()) // We opened via click, we don't have an active item - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() // We should not be able to go to the end await press(Keys.PageDown) - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() }) }) @@ -1842,11 +1817,11 @@ describe('Keyboard interactions', () => { const items = getMenuItems() // We should be on the last item - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) // We should be able to go to the first item await press(Keys.Home) - assertMenuLinkedWithMenuItem(getMenu(), items[0]) + assertMenuLinkedWithMenuItem(items[0]) }) it('should be possible to use the Home key to go to the first non disabled menu item', async () => { @@ -1866,7 +1841,7 @@ describe('Keyboard interactions', () => { await click(getMenuButton()) // We opened via click, we don't have an active item - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() // We should not be able to go to the end await press(Keys.Home) @@ -1874,7 +1849,7 @@ describe('Keyboard interactions', () => { const items = getMenuItems() // We should be on the first non-disabled item - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) }) it('should be possible to use the Home key to go to the last menu item if that is the only non-disabled menu item', async () => { @@ -1894,13 +1869,13 @@ describe('Keyboard interactions', () => { await click(getMenuButton()) // We opened via click, we don't have an active item - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() // We should not be able to go to the end await press(Keys.Home) const items = getMenuItems() - assertMenuLinkedWithMenuItem(getMenu(), items[3]) + assertMenuLinkedWithMenuItem(items[3]) }) it('should have no active menu item upon Home key press, when there are no non-disabled menu items', async () => { @@ -1920,12 +1895,12 @@ describe('Keyboard interactions', () => { await click(getMenuButton()) // We opened via click, we don't have an active item - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() // We should not be able to go to the end await press(Keys.Home) - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() }) }) @@ -1951,11 +1926,11 @@ describe('Keyboard interactions', () => { const items = getMenuItems() // We should be on the last item - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) // We should be able to go to the first item await press(Keys.PageUp) - assertMenuLinkedWithMenuItem(getMenu(), items[0]) + assertMenuLinkedWithMenuItem(items[0]) }) it('should be possible to use the PageUp key to go to the first non disabled menu item', async () => { @@ -1975,7 +1950,7 @@ describe('Keyboard interactions', () => { await click(getMenuButton()) // We opened via click, we don't have an active item - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() // We should not be able to go to the end await press(Keys.PageUp) @@ -1983,7 +1958,7 @@ describe('Keyboard interactions', () => { const items = getMenuItems() // We should be on the first non-disabled item - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) }) it('should be possible to use the PageUp key to go to the last menu item if that is the only non-disabled menu item', async () => { @@ -2003,13 +1978,13 @@ describe('Keyboard interactions', () => { await click(getMenuButton()) // We opened via click, we don't have an active item - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() // We should not be able to go to the end await press(Keys.PageUp) const items = getMenuItems() - assertMenuLinkedWithMenuItem(getMenu(), items[3]) + assertMenuLinkedWithMenuItem(items[3]) }) it('should have no active menu item upon PageUp key press, when there are no non-disabled menu items', async () => { @@ -2029,12 +2004,12 @@ describe('Keyboard interactions', () => { await click(getMenuButton()) // We opened via click, we don't have an active item - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() // We should not be able to go to the end await press(Keys.PageUp) - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() }) }) @@ -2058,15 +2033,15 @@ describe('Keyboard interactions', () => { // We should be able to go to the second item await type(word('bob')) - assertMenuLinkedWithMenuItem(getMenu(), items[1]) + assertMenuLinkedWithMenuItem(items[1]) // We should be able to go to the first item await type(word('alice')) - assertMenuLinkedWithMenuItem(getMenu(), items[0]) + assertMenuLinkedWithMenuItem(items[0]) // We should be able to go to the last item await type(word('charlie')) - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) }) it('should be possible to type a partial of a word', async () => { @@ -2090,19 +2065,19 @@ describe('Keyboard interactions', () => { const items = getMenuItems() // We should be on the last item - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) // We should be able to go to the second item await type(word('bo')) - assertMenuLinkedWithMenuItem(getMenu(), items[1]) + assertMenuLinkedWithMenuItem(items[1]) // We should be able to go to the first item await type(word('ali')) - assertMenuLinkedWithMenuItem(getMenu(), items[0]) + assertMenuLinkedWithMenuItem(items[0]) // We should be able to go to the last item await type(word('char')) - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) }) it( @@ -2128,19 +2103,19 @@ describe('Keyboard interactions', () => { const items = getMenuItems() // We should be on the last item - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) // We should be able to go to the second item await type(word('value b')) - assertMenuLinkedWithMenuItem(getMenu(), items[1]) + assertMenuLinkedWithMenuItem(items[1]) // We should be able to go to the first item await type(word('value a')) - assertMenuLinkedWithMenuItem(getMenu(), items[0]) + assertMenuLinkedWithMenuItem(items[0]) // We should be able to go to the last item await type(word('value c')) - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) }) ) @@ -2165,13 +2140,13 @@ describe('Keyboard interactions', () => { const items = getMenuItems() // We should be on the last item - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) // We should not be able to go to the disabled item await type(word('bo')) // We should still be on the last item - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) }) }) }) @@ -2189,22 +2164,22 @@ describe('Mouse interactions', () => { `) - assertMenuButton(getMenuButton(), { - state: MenuButtonState.Closed, + assertMenuButton({ + state: MenuState.Closed, attributes: { id: 'headlessui-menu-button-1' }, }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Open menu await click(getMenuButton()) // Verify it is open - assertMenuButton(getMenuButton(), { state: MenuButtonState.Open }) - assertMenu(getMenu(), { + assertMenuButton({ state: MenuState.Open }) + assertMenu({ state: MenuState.Open, attributes: { id: 'headlessui-menu-items-2' }, }) - assertMenuButtonLinkedWithMenu(getMenuButton(), getMenu()) + assertMenuButtonLinkedWithMenu() // Verify we have menu items const items = getMenuItems() @@ -2228,14 +2203,14 @@ describe('Mouse interactions', () => { await click(getMenuButton()) // Verify it is open - assertMenuButton(getMenuButton(), { state: MenuButtonState.Open }) + assertMenuButton({ state: MenuState.Open }) // Click to close await click(getMenuButton()) // Verify it is closed - assertMenuButton(getMenuButton(), { state: MenuButtonState.Closed }) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenuButton({ state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) }) it('should focus the menu when you try to focus the button again (when the menu is already open)', async () => { @@ -2276,13 +2251,13 @@ describe('Mouse interactions', () => { `) // Verify that the window is closed - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Click something that is not related to the menu await click(document.body) // Should still be closed - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) }) it('should be possible to click outside of the menu which should close the menu', async () => { @@ -2299,13 +2274,13 @@ describe('Mouse interactions', () => { // Open menu await click(getMenuButton()) - assertMenu(getMenu(), { state: MenuState.Open }) + assertMenu({ state: MenuState.Open }) // Click something that is not related to the menu await click(document.body) // Should be closed now - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) }) it('should be possible to click outside of the menu which should close the menu (even if we press the menu button)', async () => { @@ -2322,13 +2297,13 @@ describe('Mouse interactions', () => { // Open menu await click(getMenuButton()) - assertMenu(getMenu(), { state: MenuState.Open }) + assertMenu({ state: MenuState.Open }) // Click the menu button again await click(getMenuButton()) // Should be closed now - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) }) it( @@ -2393,15 +2368,15 @@ describe('Mouse interactions', () => { const items = getMenuItems() // We should be able to go to the second item await mouseMove(items[1]) - assertMenuLinkedWithMenuItem(getMenu(), items[1]) + assertMenuLinkedWithMenuItem(items[1]) // We should be able to go to the first item await mouseMove(items[0]) - assertMenuLinkedWithMenuItem(getMenu(), items[0]) + assertMenuLinkedWithMenuItem(items[0]) // We should be able to go to the last item await mouseMove(items[2]) - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) }) it('should make a menu item active when you move the mouse over it', async () => { @@ -2422,7 +2397,7 @@ describe('Mouse interactions', () => { const items = getMenuItems() // We should be able to go to the second item await mouseMove(items[1]) - assertMenuLinkedWithMenuItem(getMenu(), items[1]) + assertMenuLinkedWithMenuItem(items[1]) }) it('should be a no-op when we move the mouse and the menu item is already active', async () => { @@ -2444,12 +2419,12 @@ describe('Mouse interactions', () => { // We should be able to go to the second item await mouseMove(items[1]) - assertMenuLinkedWithMenuItem(getMenu(), items[1]) + assertMenuLinkedWithMenuItem(items[1]) await mouseMove(items[1]) // Nothing should be changed - assertMenuLinkedWithMenuItem(getMenu(), items[1]) + assertMenuLinkedWithMenuItem(items[1]) }) it('should be a no-op when we move the mouse and the menu item is disabled', async () => { @@ -2470,7 +2445,7 @@ describe('Mouse interactions', () => { const items = getMenuItems() await mouseMove(items[1]) - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() }) it('should not be possible to hover an item that is disabled', async () => { @@ -2494,7 +2469,7 @@ describe('Mouse interactions', () => { await mouseMove(items[1]) // We should not have an active item now - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() }) it('should be possible to mouse leave an item and make it inactive', async () => { @@ -2516,24 +2491,24 @@ describe('Mouse interactions', () => { // We should be able to go to the second item await mouseMove(items[1]) - assertMenuLinkedWithMenuItem(getMenu(), items[1]) + assertMenuLinkedWithMenuItem(items[1]) await mouseLeave(items[1]) - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() // We should be able to go to the first item await mouseMove(items[0]) - assertMenuLinkedWithMenuItem(getMenu(), items[0]) + assertMenuLinkedWithMenuItem(items[0]) await mouseLeave(items[0]) - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() // We should be able to go to the last item await mouseMove(items[2]) - assertMenuLinkedWithMenuItem(getMenu(), items[2]) + assertMenuLinkedWithMenuItem(items[2]) await mouseLeave(items[2]) - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() }) it('should be possible to mouse leave a disabled item and be a no-op', async () => { @@ -2555,10 +2530,10 @@ describe('Mouse interactions', () => { // Try to hover over item 1, which is disabled await mouseMove(items[1]) - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() await mouseLeave(items[1]) - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() }) it('should be possible to click a menu item, which closes the menu', async () => { @@ -2579,13 +2554,14 @@ describe('Mouse interactions', () => { // Open menu await click(getMenuButton()) - assertMenu(getMenu(), { state: MenuState.Open }) + assertMenu({ state: MenuState.Open }) const items = getMenuItems() // We should be able to click the first item await click(items[1]) - assertMenu(getMenu(), { state: MenuState.Closed }) + + assertMenu({ state: MenuState.Closed }) expect(clickHandler).toHaveBeenCalled() }) @@ -2609,11 +2585,11 @@ describe('Mouse interactions', () => { // Open menu await click(getMenuButton()) - assertMenu(getMenu(), { state: MenuState.Open }) + assertMenu({ state: MenuState.Open }) // We should be able to click the first item await click(getMenuItems()[1]) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Verify the callback has been called expect(clickHandler).toHaveBeenCalledTimes(1) @@ -2623,7 +2599,7 @@ describe('Mouse interactions', () => { // Click the last item, which should close and invoke the handler await click(getMenuItems()[2]) - assertMenu(getMenu(), { state: MenuState.Closed }) + assertMenu({ state: MenuState.Closed }) // Verify the callback has been called expect(clickHandler).toHaveBeenCalledTimes(2) @@ -2643,13 +2619,13 @@ describe('Mouse interactions', () => { // Open menu await click(getMenuButton()) - assertMenu(getMenu(), { state: MenuState.Open }) + assertMenu({ state: MenuState.Open }) const items = getMenuItems() // We should be able to click the first item await click(items[1]) - assertMenu(getMenu(), { state: MenuState.Open }) + assertMenu({ state: MenuState.Open }) }) it('should be possible focus a menu item, so that it becomes active', async () => { @@ -2666,16 +2642,16 @@ describe('Mouse interactions', () => { // Open menu await click(getMenuButton()) - assertMenu(getMenu(), { state: MenuState.Open }) + assertMenu({ state: MenuState.Open }) const items = getMenuItems() // Verify that nothing is active yet - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() // We should be able to focus the first item await focus(items[1]) - assertMenuLinkedWithMenuItem(getMenu(), items[1]) + assertMenuLinkedWithMenuItem(items[1]) }) it('should not be possible to focus a menu item which is disabled', async () => { @@ -2692,13 +2668,13 @@ describe('Mouse interactions', () => { // Open menu await click(getMenuButton()) - assertMenu(getMenu(), { state: MenuState.Open }) + assertMenu({ state: MenuState.Open }) const items = getMenuItems() // We should not be able to focus the first item await focus(items[1]) - assertNoActiveMenuItem(getMenu()) + assertNoActiveMenuItem() }) it('should not be possible to activate a disabled item', async () => { @@ -2724,7 +2700,7 @@ describe('Mouse interactions', () => { // Open menu await click(getMenuButton()) - assertMenu(getMenu(), { state: MenuState.Open }) + assertMenu({ state: MenuState.Open }) const items = getMenuItems() diff --git a/packages/@headlessui-vue/src/components/menu/menu.ts b/packages/@headlessui-vue/src/components/menu/menu.ts index 3337bbe..64c75cc 100644 --- a/packages/@headlessui-vue/src/components/menu/menu.ts +++ b/packages/@headlessui-vue/src/components/menu/menu.ts @@ -13,32 +13,13 @@ import { import { match } from '../../utils/match' import { render } from '../../utils/render' import { useId } from '../../hooks/use-id' +import { Keys } from '../../keyboard' enum MenuStates { Open, Closed, } -// TODO: This must already exist somewhere, right? 🤔 -// Ref: https://www.w3.org/TR/uievents-key/#named-key-attribute-values -enum Key { - Space = ' ', - Enter = 'Enter', - Escape = 'Escape', - Backspace = 'Backspace', - - ArrowUp = 'ArrowUp', - ArrowDown = 'ArrowDown', - - Home = 'Home', - End = 'End', - - PageUp = 'PageUp', - PageDown = 'PageDown', - - Tab = 'Tab', -} - enum Focus { FirstItem, PreviousItem, @@ -244,9 +225,9 @@ export const MenuButton = defineComponent({ switch (event.key) { // Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-13 - case Key.Space: - case Key.Enter: - case Key.ArrowDown: + case Keys.Space: + case Keys.Enter: + case Keys.ArrowDown: event.preventDefault() api.openMenu() nextTick(() => { @@ -255,7 +236,7 @@ export const MenuButton = defineComponent({ }) break - case Key.ArrowUp: + case Keys.ArrowUp: event.preventDefault() api.openMenu() nextTick(() => { @@ -335,11 +316,14 @@ export const MenuItems = defineComponent({ switch (event.key) { // Ref: https://www.w3.org/TR/wai-aria-practices-1.2/#keyboard-interaction-12 - // @ts-expect-error Falthrough is expected here - case Key.Space: - if (api.searchQuery.value !== '') return api.search(event.key) + // @ts-expect-error Fallthrough is expected here + case Keys.Space: + if (api.searchQuery.value !== '') { + event.preventDefault() + return api.search(event.key) + } // When in type ahead mode, fallthrough - case Key.Enter: + case Keys.Enter: event.preventDefault() api.closeMenu() if (api.activeItemIndex.value !== null) { @@ -349,31 +333,31 @@ export const MenuItems = defineComponent({ } break - case Key.ArrowDown: + case Keys.ArrowDown: event.preventDefault() return api.goToItem(Focus.NextItem) - case Key.ArrowUp: + case Keys.ArrowUp: event.preventDefault() return api.goToItem(Focus.PreviousItem) - case Key.Home: - case Key.PageUp: + case Keys.Home: + case Keys.PageUp: event.preventDefault() return api.goToItem(Focus.FirstItem) - case Key.End: - case Key.PageDown: + case Keys.End: + case Keys.PageDown: event.preventDefault() return api.goToItem(Focus.LastItem) - case Key.Escape: + case Keys.Escape: event.preventDefault() api.closeMenu() nextTick(() => api.buttonRef.value?.focus()) break - case Key.Tab: + case Keys.Tab: return event.preventDefault() default: @@ -442,6 +426,7 @@ export const MenuItem = defineComponent({ function handlePointerLeave() { if (disabled) return + if (!active.value) return api.goToItem(Focus.Nothing) } diff --git a/packages/@headlessui-vue/src/index.test.ts b/packages/@headlessui-vue/src/index.test.ts index e29b5d8..8f4654b 100644 --- a/packages/@headlessui-vue/src/index.test.ts +++ b/packages/@headlessui-vue/src/index.test.ts @@ -11,5 +11,12 @@ it('should expose the correct components', () => { 'MenuButton', 'MenuItems', 'MenuItem', + + // Listbox + 'Listbox', + 'ListboxLabel', + 'ListboxButton', + 'ListboxOptions', + 'ListboxOption', ]) }) diff --git a/packages/@headlessui-vue/src/index.ts b/packages/@headlessui-vue/src/index.ts index 7b9c88b..ed920ca 100644 --- a/packages/@headlessui-vue/src/index.ts +++ b/packages/@headlessui-vue/src/index.ts @@ -1 +1,2 @@ export * from './components/menu/menu' +export * from './components/listbox/listbox' diff --git a/packages/@headlessui-vue/src/keyboard.ts b/packages/@headlessui-vue/src/keyboard.ts new file mode 100644 index 0000000..41d6fee --- /dev/null +++ b/packages/@headlessui-vue/src/keyboard.ts @@ -0,0 +1,19 @@ +// TODO: This must already exist somewhere, right? 🤔 +// Ref: https://www.w3.org/TR/uievents-key/#named-key-attribute-values +export enum Keys { + Space = ' ', + Enter = 'Enter', + Escape = 'Escape', + Backspace = 'Backspace', + + ArrowUp = 'ArrowUp', + ArrowDown = 'ArrowDown', + + Home = 'Home', + End = 'End', + + PageUp = 'PageUp', + PageDown = 'PageDown', + + Tab = 'Tab', +} diff --git a/packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts b/packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts index 3394dec..6118280 100644 --- a/packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts +++ b/packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts @@ -1,158 +1,483 @@ -export enum MenuButtonState { - Open, - Closed, +function assertNever(x: never): never { + throw new Error('Unexpected object: ' + x) } +// --- + +export function getMenuButton(): HTMLElement | null { + return document.querySelector('button,[role="button"]') +} + +export function getMenuButtons(): HTMLElement[] { + return Array.from(document.querySelectorAll('button,[role="button"]')) +} + +export function getMenu(): HTMLElement | null { + return document.querySelector('[role="menu"]') +} + +export function getMenus(): HTMLElement[] { + return Array.from(document.querySelectorAll('[role="menu"]')) +} + +export function getMenuItems(): HTMLElement[] { + return Array.from(document.querySelectorAll('[role="menuitem"]')) +} + +// --- + export enum MenuState { Open, Closed, } -type MenuButtonOptions = { attributes?: Record; textContent?: string } & ( - | { state: MenuButtonState.Closed } - | { state: MenuButtonState.Open } -) -export function assertMenuButton(button: HTMLElement | null, options: MenuButtonOptions) { +export function assertMenuButton( + options: { + attributes?: Record + textContent?: string + state: MenuState + }, + button = getMenuButton() +) { try { if (button === null) return expect(button).not.toBe(null) // Ensure menu button have these properties - expect(button.hasAttribute('id')).toBe(true) - expect(button.hasAttribute('aria-haspopup')).toBe(true) + expect(button).toHaveAttribute('id') + expect(button).toHaveAttribute('aria-haspopup') - if (options.state === MenuButtonState.Open) { - expect(button.hasAttribute('aria-controls')).toBe(true) - expect(button.getAttribute('aria-expanded')).toBe('true') - } + switch (options.state) { + case MenuState.Open: + expect(button).toHaveAttribute('aria-controls') + expect(button).toHaveAttribute('aria-expanded', 'true') + break - if (options.state === MenuButtonState.Closed) { - expect(button.getAttribute('aria-controls')).toBeNull() - expect(button.getAttribute('aria-expanded')).toBeNull() + case MenuState.Closed: + expect(button).not.toHaveAttribute('aria-controls') + expect(button).not.toHaveAttribute('aria-expanded') + break + + default: + assertNever(options.state) } if (options.textContent) { - expect(button.textContent?.trim()).toBe(options.textContent.trim()) + expect(button).toHaveTextContent(options.textContent) } // Ensure menu button has the following attributes for (let attributeName in options.attributes) { - expect(button.getAttribute(attributeName)).toEqual(options.attributes[attributeName]) + expect(button).toHaveAttribute(attributeName, options.attributes[attributeName]) } } catch (err) { - if (Error.captureStackTrace) Error.captureStackTrace(err, assertMenuButton) + Error.captureStackTrace(err, assertMenuButton) throw err } } -export function assertMenuButtonLinkedWithMenu( - button: HTMLElement | null, - menu: HTMLElement | null -) { +export function assertMenuButtonLinkedWithMenu(button = getMenuButton(), menu = getMenu()) { try { if (button === null) return expect(button).not.toBe(null) if (menu === null) return expect(menu).not.toBe(null) // Ensure link between button & menu is correct - expect(button.getAttribute('aria-controls')).toBe(menu.getAttribute('id')) - expect(menu.getAttribute('aria-labelledby')).toBe(button.getAttribute('id')) + expect(button).toHaveAttribute('aria-controls', menu.getAttribute('id')) + expect(menu).toHaveAttribute('aria-labelledby', button.getAttribute('id')) } catch (err) { - if (Error.captureStackTrace) Error.captureStackTrace(err, assertMenuButtonLinkedWithMenu) + Error.captureStackTrace(err, assertMenuButtonLinkedWithMenu) throw err } } -export function assertMenuLinkedWithMenuItem(menu: HTMLElement | null, item: HTMLElement | null) { +export function assertMenuLinkedWithMenuItem(item: HTMLElement | null, menu = getMenu()) { try { if (menu === null) return expect(menu).not.toBe(null) if (item === null) return expect(item).not.toBe(null) // Ensure link between menu & menu item is correct - expect(menu.getAttribute('aria-activedescendant')).toBe(item.getAttribute('id')) + expect(menu).toHaveAttribute('aria-activedescendant', item.getAttribute('id')) } catch (err) { - if (Error.captureStackTrace) Error.captureStackTrace(err, assertMenuLinkedWithMenuItem) + Error.captureStackTrace(err, assertMenuLinkedWithMenuItem) throw err } } -export function assertNoActiveMenuItem(menu: HTMLElement | null) { +export function assertNoActiveMenuItem(menu = getMenu()) { try { if (menu === null) return expect(menu).not.toBe(null) // Ensure we don't have an active menu - expect(menu.hasAttribute('aria-activedescendant')).toBe(false) + expect(menu).not.toHaveAttribute('aria-activedescendant') } catch (err) { - if (Error.captureStackTrace) Error.captureStackTrace(err, assertNoActiveMenuItem) + Error.captureStackTrace(err, assertNoActiveMenuItem) throw err } } -type MenuOptions = { attributes?: Record } & ( - | { state: MenuState.Closed } - | { state: MenuState.Open } -) -export function assertMenu(menu: HTMLElement | null, options: MenuOptions) { +export function assertMenu( + options: { + attributes?: Record + textContent?: string + state: MenuState + }, + menu = getMenu() +) { try { - if (options.state === MenuState.Open) { - if (menu === null) return expect(menu).not.toBe(null) + switch (options.state) { + case MenuState.Open: + if (menu === null) return expect(menu).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(menu.hasAttribute('aria-labelledby')).toBe(true) + // Check that some attributes exists, doesn't really matter what the values are at this point in + // time, we just require them. + expect(menu).toHaveAttribute('aria-labelledby') - // Check that we have the correct values for certain attributes - expect(menu.getAttribute('role')).toBe('menu') + // Check that we have the correct values for certain attributes + expect(menu).toHaveAttribute('role', 'menu') - // Check that the menu is focused - expect(document.activeElement).toBe(menu) + if (options.textContent) { + expect(menu).toHaveTextContent(options.textContent) + } - // Ensure menu button has the following attributes - for (let attributeName in options.attributes) { - expect(menu.getAttribute(attributeName)).toEqual(options.attributes[attributeName]) - } - } + // Ensure menu button has the following attributes + for (let attributeName in options.attributes) { + expect(menu).toHaveAttribute(attributeName, options.attributes[attributeName]) + } + break - if (options.state === MenuState.Closed) { - expect(menu).toBeNull() + case MenuState.Closed: + expect(menu).toBe(null) + break + + default: + assertNever(options.state) } } catch (err) { - if (Error.captureStackTrace) Error.captureStackTrace(err, assertMenu) + Error.captureStackTrace(err, assertMenu) throw err } } -type MenuItemOptions = { tag: string; attributes?: Record } -export function assertMenuItem(item: HTMLElement | null, options?: MenuItemOptions) { +export function assertMenuItem( + item: HTMLElement | null, + options?: { tag?: string; attributes?: Record } +) { 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.hasAttribute('id')).toBe(true) + expect(item).toHaveAttribute('id') // Check that we have the correct values for certain attributes - expect(item.getAttribute('role')).toBe('menuitem') - expect(item.getAttribute('tabindex')).toBe('-1') + expect(item).toHaveAttribute('role', 'menuitem') + expect(item).toHaveAttribute('tabindex', '-1') - if (options?.tag) { - expect(item.tagName.toLowerCase()).toBe(options.tag) - } + // Ensure menu button has the following attributes + if (options) { + for (let attributeName in options.attributes) { + expect(item).toHaveAttribute(attributeName, options.attributes[attributeName]) + } - // Ensure menu item has the following attributes - for (let attributeName in options?.attributes) { - expect(item.getAttribute(attributeName)).toEqual(options?.attributes[attributeName]) + if (options.tag) { + expect(item.tagName.toLowerCase()).toBe(options.tag) + } } } catch (err) { - if (Error.captureStackTrace) Error.captureStackTrace(err, assertMenuItem) + Error.captureStackTrace(err, assertMenuItem) throw err } } +// --- + +export function getListboxLabel(): HTMLElement | null { + return document.querySelector('label,[id^="headlessui-listbox-label"]') +} + +export function getListboxButton(): HTMLElement | null { + return document.querySelector('button,[role="button"]') +} + +export function getListboxButtons(): HTMLElement[] { + return Array.from(document.querySelectorAll('button,[role="button"]')) +} + +export function getListbox(): HTMLElement | null { + return document.querySelector('[role="listbox"]') +} + +export function getListboxes(): HTMLElement[] { + return Array.from(document.querySelectorAll('[role="listbox"]')) +} + +export function getListboxOptions(): HTMLElement[] { + return Array.from(document.querySelectorAll('[role="option"]')) +} + +// --- + +export enum ListboxState { + Open, + Closed, +} + +export function assertListbox( + options: { + attributes?: Record + textContent?: string + state: ListboxState + }, + listbox = getListbox() +) { + try { + switch (options.state) { + case ListboxState.Open: + if (listbox === null) return expect(listbox).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(listbox).toHaveAttribute('aria-labelledby') + + // Check that we have the correct values for certain attributes + expect(listbox).toHaveAttribute('role', 'listbox') + + if (options.textContent) { + expect(listbox).toHaveTextContent(options.textContent) + } + + // Ensure listbox button has the following attributes + for (let attributeName in options.attributes) { + expect(listbox).toHaveAttribute(attributeName, options.attributes[attributeName]) + } + break + + case ListboxState.Closed: + expect(listbox).toBe(null) + break + + default: + assertNever(options.state) + } + } catch (err) { + Error.captureStackTrace(err, assertListbox) + throw err + } +} + +export function assertListboxButton( + options: { + attributes?: Record + textContent?: string + state: ListboxState + }, + button = getListboxButton() +) { + 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 ListboxState.Open: + expect(button).toHaveAttribute('aria-controls') + expect(button).toHaveAttribute('aria-expanded', 'true') + break + + case ListboxState.Closed: + expect(button).not.toHaveAttribute('aria-controls') + expect(button).not.toHaveAttribute('aria-expanded') + 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) { + Error.captureStackTrace(err, assertListboxButton) + throw err + } +} + +export function assertListboxLabel( + options: { + attributes?: Record + tag?: string + textContent?: string + }, + label = getListboxLabel() +) { + 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) { + Error.captureStackTrace(err, assertListboxLabel) + throw err + } +} + +export function assertListboxButtonLinkedWithListbox( + button = getListboxButton(), + listbox = getListbox() +) { + try { + if (button === null) return expect(button).not.toBe(null) + if (listbox === null) return expect(listbox).not.toBe(null) + + // Ensure link between button & listbox is correct + expect(button).toHaveAttribute('aria-controls', listbox.getAttribute('id')) + expect(listbox).toHaveAttribute('aria-labelledby', button.getAttribute('id')) + } catch (err) { + Error.captureStackTrace(err, assertListboxButtonLinkedWithListbox) + throw err + } +} + +export function assertListboxLabelLinkedWithListbox( + label = getListboxLabel(), + listbox = getListbox() +) { + try { + if (label === null) return expect(label).not.toBe(null) + if (listbox === null) return expect(listbox).not.toBe(null) + + expect(listbox).toHaveAttribute('aria-labelledby', label.getAttribute('id')) + } catch (err) { + Error.captureStackTrace(err, assertListboxLabelLinkedWithListbox) + throw err + } +} + +export function assertListboxButtonLinkedWithListboxLabel( + button = getListboxButton(), + label = getListboxLabel() +) { + 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) { + Error.captureStackTrace(err, assertListboxButtonLinkedWithListboxLabel) + throw err + } +} + +export function assertActiveListboxOption(item: HTMLElement | null, listbox = getListbox()) { + try { + if (listbox === null) return expect(listbox).not.toBe(null) + if (item === null) return expect(item).not.toBe(null) + + // Ensure link between listbox & listbox item is correct + expect(listbox).toHaveAttribute('aria-activedescendant', item.getAttribute('id')) + } catch (err) { + Error.captureStackTrace(err, assertActiveListboxOption) + throw err + } +} + +export function assertNoActiveListboxOption(listbox = getListbox()) { + try { + if (listbox === null) return expect(listbox).not.toBe(null) + + // Ensure we don't have an active listbox + expect(listbox).not.toHaveAttribute('aria-activedescendant') + } catch (err) { + Error.captureStackTrace(err, assertNoActiveListboxOption) + throw err + } +} + +export function assertNoSelectedListboxOption(items = getListboxOptions()) { + try { + for (let item of items) expect(item).not.toHaveAttribute('aria-selected') + } catch (err) { + Error.captureStackTrace(err, assertNoSelectedListboxOption) + throw err + } +} + +export function assertListboxOption( + 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') + expect(item).toHaveAttribute('tabindex', '-1') + + // Ensure listbox 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) { + Error.captureStackTrace(err, assertListboxOption) + throw err + } +} + +// --- + export function assertActiveElement(element: HTMLElement | null) { try { if (element === null) return expect(element).not.toBe(null) expect(document.activeElement).toBe(element) } catch (err) { - if (Error.captureStackTrace) Error.captureStackTrace(err, assertActiveElement) + Error.captureStackTrace(err, assertActiveElement) throw err } } diff --git a/scripts/test.sh b/scripts/test.sh index 5ba0d36..2032db2 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -1,23 +1,11 @@ #!/bin/bash set -e -ROOT_DIR="$(git rev-parse --show-toplevel)/" -TARGET_DIR="$(pwd)" -RELATIVE_TARGET_DIR="${TARGET_DIR/$ROOT_DIR/}" - -# INFO: This script is always run from the root of the repository. If we execute this script from a -# package then the filters (in this case a path to $RELATIVE_TARGET_DIR) will be applied. - -pushd $ROOT_DIR > /dev/null - node="yarn node" -tsdxArgs=() - -# Add script name -tsdxArgs+=("test") +jestArgs=() # Add default arguments -tsdxArgs+=("--passWithNoTests" $RELATIVE_TARGET_DIR) +jestArgs+=("--passWithNoTests") # Add arguments based on environment variables if [ -n "$CI" ]; then @@ -26,9 +14,7 @@ if [ -n "$CI" ]; then fi # Passthrough arguments and flags -tsdxArgs+=($@) +jestArgs+=($@) # Execute -$node "$(yarn bin tsdx)" "${tsdxArgs[@]}" - -popd > /dev/null +$node "$(yarn bin jest)" "${jestArgs[@]}" diff --git a/yarn.lock b/yarn.lock index af41dcc..0c7b2b0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1010,7 +1010,7 @@ core-js-pure "^3.0.0" regenerator-runtime "^0.13.4" -"@babel/runtime@7.11.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.10.3", "@babel/runtime@^7.11.2", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.6": +"@babel/runtime@7.11.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.10.3", "@babel/runtime@^7.11.2", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2": version "7.11.2" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.11.2.tgz#f549c13c754cc40b87644b9fa9f09a6a95fe0736" integrity sha512-TeWkU52so0mPtDcaCTxNBI/IHiz0pZgr8VEFqXFtZWpYD08ZB6FaSwVAS8MKRQAP3bYKiVjwysOJgMFY28o6Tw== @@ -1479,10 +1479,10 @@ "@nodelib/fs.scandir" "2.1.3" fastq "^1.6.0" -"@popperjs/core@^2.4.4": - version "2.4.4" - resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.4.4.tgz#11d5db19bd178936ec89cd84519c4de439574398" - integrity sha512-1oO6+dN5kdIA3sKPZhRGJTfGVP4SWV6KqlMOwry4J3HfyD68sl/3KmG7DeYUzvN+RbhXDnv/D8vNNB8168tAMg== +"@popperjs/core@^2.5.3": + version "2.5.3" + resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.5.3.tgz#4982b0b66b7a4cf949b86f5d25a8cf757d3cfd9d" + integrity sha512-RFwCobxsvZ6j7twS7dHIZQZituMIDJJNHS/qY6iuthVebxS3zhRY+jaC2roEKiAYaVuTcGmX6Luc6YBcf6zJVg== "@rollup/plugin-commonjs@^11.0.0": version "11.1.0" @@ -1609,7 +1609,34 @@ dom-accessibility-api "^0.5.1" pretty-format "^26.4.2" -"@testing-library/react@^11.0.2": +"@testing-library/dom@^7.24.3": + version "7.24.3" + resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-7.24.3.tgz#dae3071463cf28dc7755b43d9cf2202e34cbb85d" + integrity sha512-6eW9fUhEbR423FZvoHRwbWm9RUUByLWGayYFNVvqTnQLYvsNpBS4uEuKH9aqr3trhxFwGVneJUonehL3B1sHJw== + dependencies: + "@babel/code-frame" "^7.10.4" + "@babel/runtime" "^7.10.3" + "@types/aria-query" "^4.2.0" + aria-query "^4.2.2" + chalk "^4.1.0" + dom-accessibility-api "^0.5.1" + pretty-format "^26.4.2" + +"@testing-library/jest-dom@^5.11.4": + version "5.11.4" + resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-5.11.4.tgz#f325c600db352afb92995c2576022b35621ddc99" + integrity sha512-6RRn3epuweBODDIv3dAlWjOEHQLpGJHB2i912VS3JQtsD22+ENInhdDNl4ZZQiViLlIfFinkSET/J736ytV9sw== + dependencies: + "@babel/runtime" "^7.9.2" + "@types/testing-library__jest-dom" "^5.9.1" + aria-query "^4.2.2" + chalk "^3.0.0" + css "^3.0.0" + css.escape "^1.5.1" + lodash "^4.17.15" + redent "^3.0.0" + +"@testing-library/react@^11.0.4": version "11.0.4" resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-11.0.4.tgz#c84082bfe1593d8fcd475d46baee024452f31dee" integrity sha512-U0fZO2zxm7M0CB5h1+lh31lbAwMSmDMEMGpMT3BUPJwIjDEKYWOV4dx7lb3x2Ue0Pyt77gmz/VropuJnSz/Iew== @@ -1617,15 +1644,15 @@ "@babel/runtime" "^7.11.2" "@testing-library/dom" "^7.24.2" -"@testing-library/vue@^5.0.4": - version "5.0.4" - resolved "https://registry.yarnpkg.com/@testing-library/vue/-/vue-5.0.4.tgz#25d1de4a8c7a18ad28a4c6fa458f56f93cb190b9" - integrity sha512-09Ahx9DJB3sWsgZN8iSATDKgN5DFQAZr+lUJ+wQpkK3mfe2AyAwR2f9UDaCczvAjsoagHDaYrIyqyFb5bSJ1sA== +"@testing-library/vue@^5.1.0": + version "5.1.0" + resolved "https://registry.yarnpkg.com/@testing-library/vue/-/vue-5.1.0.tgz#3d0eb3d1861661c44f2bc20f6a1b5d8bd8b7500c" + integrity sha512-RuV63Ywys7rhF+UpdSKpFrcQfyiGj9ecxAL76HCOCGbtlXdyqUbGegZ+vZpC22scTCSxmr42l0g0gJEyp00W+g== dependencies: - "@babel/runtime" "^7.9.6" - "@testing-library/dom" "^7.5.7" - "@types/testing-library__vue" "^2.0.1" - "@vue/test-utils" "^1.0.3" + "@babel/runtime" "^7.11.2" + "@testing-library/dom" "^7.24.3" + "@types/testing-library__vue" "^5.0.0" + "@vue/test-utils" "^1.1.0" "@types/accepts@*": version "1.3.5" @@ -1790,6 +1817,14 @@ dependencies: "@types/istanbul-lib-report" "*" +"@types/jest@*": + version "26.0.14" + resolved "https://registry.yarnpkg.com/@types/jest/-/jest-26.0.14.tgz#078695f8f65cb55c5a98450d65083b2b73e5a3f3" + integrity sha512-Hz5q8Vu0D288x3iWXePSn53W7hAjP0H7EQ6QvDO9c7t46mR0lNOLlfuwQ+JkVxuhygHzlzPX+0jKdA3ZgSh+Vg== + dependencies: + jest-diff "^25.2.1" + pretty-format "^25.2.1" + "@types/jest@^24.0.15": version "24.9.1" resolved "https://registry.yarnpkg.com/@types/jest/-/jest-24.9.1.tgz#02baf9573c78f1b9974a5f36778b366aa77bd534" @@ -1843,11 +1878,16 @@ resolved "https://registry.yarnpkg.com/@types/mime/-/mime-2.0.3.tgz#c893b73721db73699943bfc3653b1deb7faa4a3a" integrity sha512-Jus9s4CDbqwocc5pOAnh8ShfrnMcPHuJYzVcSUU7lrh8Ni5HuIqX3oilL86p3dlTrk0LzHRCgA/GQ7uNCw6l2Q== -"@types/node@*", "@types/node@^14.10.1", "@types/node@^14.11.1": +"@types/node@*": version "14.11.1" resolved "https://registry.yarnpkg.com/@types/node/-/node-14.11.1.tgz#56af902ad157e763f9ba63d671c39cda3193c835" integrity sha512-oTQgnd0hblfLsJ6BvJzzSL+Inogp3lq9fGgqRkMB/ziKMgEUaFl801OncOzUmalfzt14N0oPHMK47ipl+wbTIw== +"@types/node@^14.11.2": + version "14.11.2" + resolved "https://registry.yarnpkg.com/@types/node/-/node-14.11.2.tgz#2de1ed6670439387da1c9f549a2ade2b0a799256" + integrity sha512-jiE3QIxJ8JLNcb1Ps6rDbysDhN4xa8DJJvuC9prr6w+1tIh+QAbYyNF3tyiZNLDBIuBCf4KEcV2UvQm/V60xfA== + "@types/normalize-package-data@^2.4.0": version "2.4.0" resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz#e486d0d97396d79beedd0a6e33f4534ff6b4973e" @@ -1920,13 +1960,20 @@ resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e" integrity sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw== -"@types/testing-library__vue@^2.0.1": - version "2.0.3" - resolved "https://registry.yarnpkg.com/@types/testing-library__vue/-/testing-library__vue-2.0.3.tgz#5559a0af03de44d751c9352ce8cdf254716a26ee" - integrity sha512-TPkSj+kwQVpUh4FOhcztMUBd1C12CQBOOaOyKuKtloBRkNcBqZJ+t/O6hwX6wdyGFLLMhosMH9VsZk/VF4eerQ== +"@types/testing-library__jest-dom@^5.9.1": + version "5.9.2" + resolved "https://registry.yarnpkg.com/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.9.2.tgz#59e4771a1cf87d51e89a5cc8195cd3b647cba322" + integrity sha512-K7nUSpH/5i8i0NagTJ+uFUDRueDlnMNhJtMjMwTGPPSqyImbWC/hgKPDCKt6Phu2iMJg2kWqlax+Ucj2DKMwpA== + dependencies: + "@types/jest" "*" + +"@types/testing-library__vue@^5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@types/testing-library__vue/-/testing-library__vue-5.0.0.tgz#b16de69eb2769f6c9bb85c66a1dab53ca56ee1fd" + integrity sha512-R94Vv9UfDoW/Vvb+ZBsy9evZO7utmFhEoe4F7+Xo8G/DpO5TZM/BdL07zkF3sjqWSyRhbHYHwXIrPwJAXQZGHg== dependencies: "@testing-library/dom" "^7.5.7" - "@vue/test-utils" "^1.0.0-beta.29" + "@vue/test-utils" "^1.0.3" pretty-format "^25.5.0" vue "^2.6.10" vue-router "^3.0" @@ -2126,7 +2173,7 @@ resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.0.0-rc.13.tgz#2ec450daa7eae752da38da41a5b1c84056b10b7e" integrity sha512-8zEVHmffW1P8Wlt8P63N+zKJrmzL6y0P2P6biWdl4CI9E5QVKlbOEYl7i+tU/dpa6oLj6nEzBxUCwA7UHvcPkw== -"@vue/test-utils@^1.0.0-beta.29", "@vue/test-utils@^1.0.3": +"@vue/test-utils@^1.0.3", "@vue/test-utils@^1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@vue/test-utils/-/test-utils-1.1.0.tgz#76305e73a786c921ede1352849614e26c7113f94" integrity sha512-M+3jtVqNYIrvzO5gaxogre5a5+96h0hN/dXw+5Lj0t+dp6fAhYcUjpLrC9j9cEEkl2Rcuh/gKYRUmR5N4vcqPw== @@ -2135,10 +2182,10 @@ lodash "^4.17.15" pretty "^2.0.0" -"@vue/test-utils@^2.0.0-beta.5": - version "2.0.0-beta.5" - resolved "https://registry.yarnpkg.com/@vue/test-utils/-/test-utils-2.0.0-beta.5.tgz#c5980e3e6d22a1811483577bcfbcc2c4493c4a73" - integrity sha512-ohWcS277p/3KHK5di6UskDZK8hsaZ7hzsJiMl1f0jI+boeaq53MqwA9c8VaHsJrmJEOjNH0Y3QDzyU7LTXpKNQ== +"@vue/test-utils@^2.0.0-beta.6": + version "2.0.0-beta.6" + resolved "https://registry.yarnpkg.com/@vue/test-utils/-/test-utils-2.0.0-beta.6.tgz#2f7a653b0025cd4236968269c5972e807fa1fb2c" + integrity sha512-nBj5HHoTD+2xg0OQ93p/Hil5SkFUcNJ5BA2RUnHlOH6a4PVskgMK8dOLyVcZ1ZJif7knjt7yQVJ6K6YwIzeR1A== "@webassemblyjs/ast@1.9.0": version "1.9.0" @@ -2444,7 +2491,7 @@ ansi-escapes@^3.0.0: resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-3.2.0.tgz#8780b98ff9dbf5638152d1f1fe5c1d7b4442976b" integrity sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ== -ansi-escapes@^4.2.1: +ansi-escapes@^4.2.1, ansi-escapes@^4.3.0: version "4.3.1" resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.1.tgz#a5c47cc43181f1f38ffd7076837700d395522a61" integrity sha512-JWF7ocqNrp8u9oqpgV+wH5ftbt+cfvv+PTjOvKLT3AdYly/LmORARfEVT1iyjwN+4MqE5UmVKoAdIBqeoCHgLA== @@ -2650,6 +2697,11 @@ astral-regex@^1.0.0: resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-1.0.0.tgz#6c8c3fb827dd43ee3918f27b82782ab7658a6fd9" integrity sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg== +astral-regex@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31" + integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ== + async-each@^1.0.1: version "1.0.3" resolved "https://registry.yarnpkg.com/async-each/-/async-each-1.0.3.tgz#b727dbf87d7651602f06f4d4ac387f47d91b0cbf" @@ -3320,11 +3372,16 @@ camelcase@^6.0.0: resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.0.0.tgz#5259f7c30e35e278f1bdc2a4d91230b37cad981e" integrity sha512-8KMDF1Vz2gzOq54ONPJS65IvTUaB1cHJ2DMM7MbPmLZljDH1qpzzLsWdiN9pHh6qvkRVDTi/07+eNGch/oLU4w== -caniuse-lite@^1.0.30001093, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001113, caniuse-lite@^1.0.30001131: +caniuse-lite@^1.0.30001093, caniuse-lite@^1.0.30001113, caniuse-lite@^1.0.30001131: version "1.0.30001131" resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001131.tgz#afad8a28fc2b7a0d3ae9407e71085a0ead905d54" integrity sha512-4QYi6Mal4MMfQMSqGIRPGbKIbZygeN83QsWq1ixpUwvtfgAZot5BrCKzGygvZaV+CnELdTwD0S4cqUNozq7/Cw== +caniuse-lite@^1.0.30001109: + version "1.0.30001140" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001140.tgz#30dae27599f6ede2603a0962c82e468bca894232" + integrity sha512-xFtvBtfGrpjTOxTpjP5F2LmN04/ZGfYV8EQzUIC/RmKpdrmzJrjqlJ4ho7sGuAMPko2/Jl08h7x9uObCfBFaAA== + capture-exit@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/capture-exit/-/capture-exit-2.0.0.tgz#fb953bfaebeb781f62898239dabb426d08a509a4" @@ -3501,6 +3558,14 @@ cli-spinners@^2.0.0, cli-spinners@^2.2.0: resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-2.4.0.tgz#c6256db216b878cfba4720e719cec7cf72685d7f" integrity sha512-sJAofoarcm76ZGpuooaO0eDy8saEy+YoZBLjC4h8srt4jeBnkYeOgqxgsJQTpyt2LjI5PTfLJHSL+41Yu4fEJA== +cli-truncate@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/cli-truncate/-/cli-truncate-2.1.0.tgz#c39e28bf05edcde5be3b98992a22deed5a2b93c7" + integrity sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg== + dependencies: + slice-ansi "^3.0.0" + string-width "^4.2.0" + cli-width@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-3.0.0.tgz#a2f48437a2caa9a22436e794bf071ec9e61cedf6" @@ -3627,6 +3692,11 @@ commander@^5.0.0: resolved "https://registry.yarnpkg.com/commander/-/commander-5.1.0.tgz#46abbd1652f8e059bddaef99bbdcb2ad9cf179ae" integrity sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg== +commander@^6.0.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-6.1.0.tgz#f8d722b78103141006b66f4c7ba1e97315ba75bc" + integrity sha512-wl7PNrYWd2y5mp1OK/LhTlv8Ff4kQJQRXXAvF+uU/TPNiVJUxZLRYGj/B0y/lPGAVcSbJqH2Za/cvHmrPMC8mA== + commondir@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" @@ -3911,7 +3981,7 @@ css-unit-converter@^1.1.1: resolved "https://registry.yarnpkg.com/css-unit-converter/-/css-unit-converter-1.1.2.tgz#4c77f5a1954e6dbff60695ecb214e3270436ab21" integrity sha512-IiJwMC8rdZE0+xiEZHeru6YoONC4rfPMqGm2W85jMIbkFvv5nFTwJVFHam2eFrN6txmoUYFAFXiv8ICVeTO0MA== -css.escape@^1.5.0: +css.escape@^1.5.0, css.escape@^1.5.1: version "1.5.1" resolved "https://registry.yarnpkg.com/css.escape/-/css.escape-1.5.1.tgz#42e27d4fa04ae32f931a4b4d4191fa9cddee97cb" integrity sha1-QuJ9T6BK4y+TGktNQZH6nN3ul8s= @@ -3926,6 +3996,15 @@ css@^2.0.0: source-map-resolve "^0.5.2" urix "^0.1.0" +css@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/css/-/css-3.0.0.tgz#4447a4d58fdd03367c516ca9f64ae365cee4aa5d" + integrity sha512-DG9pFfwOrzc+hawpmqX/dHYHJG+Bsdb0klhyi1sDneOgGOXy9wQIC8hzyVp1e4NRYDBdxcylvywPkkXCHAzTyQ== + dependencies: + inherits "^2.0.4" + source-map "^0.6.1" + source-map-resolve "^0.6.0" + cssesc@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee" @@ -4089,6 +4168,11 @@ decode-uri-component@^0.2.0: resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" integrity sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU= +dedent@^0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/dedent/-/dedent-0.7.0.tgz#2495ddbaf6eb874abb0e1be9df22d2e5a544326c" + integrity sha1-JJXduvbrh0q7Dhvp3yLS5aVEMmw= + deep-equal@~1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5" @@ -4212,6 +4296,11 @@ diff-sequences@^24.9.0: resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-24.9.0.tgz#5715d6244e2aa65f48bba0bc972db0b0b11e95b5" integrity sha512-Dj6Wk3tWyTE+Fo1rW8v0Xhwk80um6yFYKbuAxc9c3EZxIHFDYwbi34Uk42u1CdnIiVorvt4RmlSDjIPyzGC2ew== +diff-sequences@^25.2.6: + version "25.2.6" + resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-25.2.6.tgz#5f467c00edd35352b7bca46d7927d60e687a76dd" + integrity sha512-Hq8o7+6GaZeoFjtpgvRBUknSXNeJiCx7V9Fr94ZMljNiCr9n9L8H8aJqgWOQiDDGdyn29fRNcDdRVJ5fdyihfg== + diff-sequences@^26.3.0: version "26.3.0" resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-26.3.0.tgz#62a59b1b29ab7fd27cef2a33ae52abe73042d0a2" @@ -4457,7 +4546,7 @@ enhanced-resolve@^4.3.0: memory-fs "^0.5.0" tapable "^1.0.0" -enquirer@^2.3.4: +enquirer@^2.3.4, enquirer@^2.3.6: version "2.3.6" resolved "https://registry.yarnpkg.com/enquirer/-/enquirer-2.3.6.tgz#2a7fe5dd634a1e4125a975ec994ff5456dc3734d" integrity sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg== @@ -4895,7 +4984,7 @@ execa@^1.0.0: signal-exit "^3.0.0" strip-eof "^1.0.0" -execa@^4.0.0, execa@^4.0.1: +execa@^4.0.0, execa@^4.0.1, execa@^4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/execa/-/execa-4.0.3.tgz#0a34dabbad6d66100bd6f2c576c8669403f317f2" integrity sha512-WFDXGHckXPWZX19t1kCsXzOpqX9LWYNqn4C+HqZlk/V0imTkzJZqf87ZBhvpHaftERYknpk0fjSylnXVlVgI0A== @@ -5063,7 +5152,7 @@ figgy-pudding@^3.5.1: resolved "https://registry.yarnpkg.com/figgy-pudding/-/figgy-pudding-3.5.2.tgz#b4eee8148abb01dcf1d1ac34367d59e12fa61d6e" integrity sha512-0btnI/H8f2pavGMN8w40mlSKOfTK2SVJmBfBeVIj3kNw0swwgzyRq0d5TJVOwodFmtvpPeWPN/MCcfuWF0Ezbw== -figures@^3.0.0: +figures@^3.0.0, figures@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/figures/-/figures-3.2.0.tgz#625c18bd293c604dc4a8ddb2febf0c88341746af" integrity sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg== @@ -5199,14 +5288,14 @@ fragment-cache@^0.2.1: dependencies: map-cache "^0.2.2" -framer-motion@^2.6.13: - version "2.6.15" - resolved "https://registry.yarnpkg.com/framer-motion/-/framer-motion-2.6.15.tgz#08e8aae96f199ed1a40a8414458b455101301d6d" - integrity sha512-S9q1adpF0ZEoKQfI3SC3V4EAWnxa8JmHh+8H7fNgBWmiGOR9+BtglEhDcRx8/ylnb+H+uqM50RryonsnGV6U1A== +framer-motion@^2.7.6: + version "2.7.6" + resolved "https://registry.yarnpkg.com/framer-motion/-/framer-motion-2.7.6.tgz#ab88b432576f25542c15932e1daf1b3a7d130176" + integrity sha512-FhoU46MHqD0deJ5GRr9I8wKGTVftVtW+upT1uiqhmxWE0zzmcv4sAgdYAUTpMG9nZJf4FeFuItNxLElmcA/Clw== dependencies: framesync "^4.1.0" hey-listen "^1.0.8" - popmotion "9.0.0-rc.14" + popmotion "9.0.0-rc.19" style-value-types "^3.1.9" tslib "^1.10.0" optionalDependencies: @@ -5313,6 +5402,11 @@ get-caller-file@^2.0.1: resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== +get-own-enumerable-property-symbols@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz#b5fde77f22cbe35f390b4e089922c50bce6ef664" + integrity sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g== + get-package-type@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a" @@ -6058,6 +6152,11 @@ is-number@^7.0.0: resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== +is-obj@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-1.0.1.tgz#3e4729ac1f5fde025cd7d83a896dab9f4f67db0f" + integrity sha1-PkcprB9f3gJc19g6iW2rn09n2w8= + is-plain-object@^2.0.3, is-plain-object@^2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677" @@ -6084,6 +6183,11 @@ is-regex@^1.1.0, is-regex@^1.1.1: dependencies: has-symbols "^1.0.1" +is-regexp@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-regexp/-/is-regexp-1.0.0.tgz#fd2d883545c46bac5a633e7b9a09e87fa2cb5069" + integrity sha1-/S2INUXEa6xaYz57mgnof6LLUGk= + is-stream@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" @@ -6371,6 +6475,16 @@ jest-diff@^24.3.0, jest-diff@^24.9.0: jest-get-type "^24.9.0" pretty-format "^24.9.0" +jest-diff@^25.2.1: + version "25.5.0" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-25.5.0.tgz#1dd26ed64f96667c068cef026b677dfa01afcfa9" + integrity sha512-z1kygetuPiREYdNIumRpAHY6RXiGmp70YHptjdaxTWGmA085W3iCnXNx0DhflK3vwrKmrRWyY1wUpkPMVxMK7A== + dependencies: + chalk "^3.0.0" + diff-sequences "^25.2.6" + jest-get-type "^25.2.6" + pretty-format "^25.5.0" + jest-diff@^26.1.0, jest-diff@^26.4.2: version "26.4.2" resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-26.4.2.tgz#a1b7b303bcc534aabdb3bd4a7caf594ac059f5aa" @@ -6470,6 +6584,11 @@ jest-get-type@^24.9.0: resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-24.9.0.tgz#1684a0c8a50f2e4901b6644ae861f579eed2ef0e" integrity sha512-lUseMzAley4LhIcpSP9Jf+fTrQ4a1yHQwLNeeVa2cEmbCGeoZAtYPOIv8JaxLD/sUpKxetKGP+gsHl8f8TSj8Q== +jest-get-type@^25.2.6: + version "25.2.6" + resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-25.2.6.tgz#0b0a32fab8908b44d508be81681487dbabb8d877" + integrity sha512-DxjtyzOHjObRM+sM1knti6or+eOgcGU4xVSb2HNP1TqO4ahsT+rqZg+nyqHWJSvWgKC5cG3QjGFBqxLghiF/Ig== + jest-get-type@^26.3.0: version "26.3.0" resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-26.3.0.tgz#e97dc3c3f53c2b406ca7afaed4493b1d099199e0" @@ -7346,6 +7465,41 @@ lines-and-columns@^1.1.6: resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00" integrity sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA= +lint-staged@^10.4.0: + version "10.4.0" + resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-10.4.0.tgz#d18628f737328e0bbbf87d183f4020930e9a984e" + integrity sha512-uaiX4U5yERUSiIEQc329vhCTDDwUcSvKdRLsNomkYLRzijk3v8V9GWm2Nz0RMVB87VcuzLvtgy6OsjoH++QHIg== + dependencies: + chalk "^4.1.0" + cli-truncate "^2.1.0" + commander "^6.0.0" + cosmiconfig "^7.0.0" + debug "^4.1.1" + dedent "^0.7.0" + enquirer "^2.3.6" + execa "^4.0.3" + listr2 "^2.6.0" + log-symbols "^4.0.0" + micromatch "^4.0.2" + normalize-path "^3.0.0" + please-upgrade-node "^3.2.0" + string-argv "0.3.1" + stringify-object "^3.3.0" + +listr2@^2.6.0: + version "2.6.2" + resolved "https://registry.yarnpkg.com/listr2/-/listr2-2.6.2.tgz#4912eb01e1e2dd72ec37f3895a56bf2622d6f36a" + integrity sha512-6x6pKEMs8DSIpA/tixiYY2m/GcbgMplMVmhQAaLFxEtNSKLeWTGjtmU57xvv6QCm2XcqzyNXL/cTSVf4IChCRA== + dependencies: + chalk "^4.1.0" + cli-truncate "^2.1.0" + figures "^3.2.0" + indent-string "^4.0.0" + log-update "^4.0.0" + p-map "^4.0.0" + rxjs "^6.6.2" + through "^2.3.8" + load-json-file@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-2.0.0.tgz#7947e42149af80d696cbf797bcaabcfe1fe29ca8" @@ -7465,6 +7619,13 @@ log-symbols@^3.0.0: dependencies: chalk "^2.4.2" +log-symbols@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.0.0.tgz#69b3cc46d20f448eccdb75ea1fa733d9e821c920" + integrity sha512-FN8JBzLx6CzeMrB0tg6pqlGU1wCrXW+ZXGH481kfsBqer0hToTIiHdjH4Mq8xJUbvATujKCvaREGWpGUionraA== + dependencies: + chalk "^4.0.0" + log-update@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/log-update/-/log-update-2.3.0.tgz#88328fd7d1ce7938b29283746f0b1bc126b24708" @@ -7474,6 +7635,16 @@ log-update@^2.3.0: cli-cursor "^2.0.0" wrap-ansi "^3.0.1" +log-update@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/log-update/-/log-update-4.0.0.tgz#589ecd352471f2a1c0c570287543a64dfd20e0a1" + integrity sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg== + dependencies: + ansi-escapes "^4.3.0" + cli-cursor "^3.1.0" + slice-ansi "^4.0.0" + wrap-ansi "^6.2.0" + loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" @@ -7658,6 +7829,11 @@ mimic-fn@^2.1.0: resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== +min-indent@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869" + integrity sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg== + mini-svg-data-uri@^1.0.3: version "1.2.3" resolved "https://registry.yarnpkg.com/mini-svg-data-uri/-/mini-svg-data-uri-1.2.3.tgz#e16baa92ad55ddaa1c2c135759129f41910bc39f" @@ -8345,6 +8521,13 @@ p-map@^3.0.0: dependencies: aggregate-error "^3.0.0" +p-map@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/p-map/-/p-map-4.0.0.tgz#bb2f95a5eda2ec168ec9274e06a747c3e2904d2b" + integrity sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ== + dependencies: + aggregate-error "^3.0.0" + p-reduce@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/p-reduce/-/p-reduce-1.0.0.tgz#18c2b0dd936a4690a529f8231f58a0fdb6a47dfa" @@ -8612,10 +8795,10 @@ pnp-webpack-plugin@1.6.4: dependencies: ts-pnp "^1.1.6" -popmotion@9.0.0-rc.14: - version "9.0.0-rc.14" - resolved "https://registry.yarnpkg.com/popmotion/-/popmotion-9.0.0-rc.14.tgz#e57351b7b85a3e42b7a16affbbd440138797c11f" - integrity sha512-zdMw1OSKjFBH+KKpZx7P+cGSUb3QCqg5QD12f6llucUeEFT+SDZYxvTY09JI23ZcJyzxgKFT1anbLq0eZ9bj3g== +popmotion@9.0.0-rc.19: + version "9.0.0-rc.19" + resolved "https://registry.yarnpkg.com/popmotion/-/popmotion-9.0.0-rc.19.tgz#24b28d9d4481536da699af77841d04bc5351f1d5" + integrity sha512-rmXYVzkFPHZAqTgnlrif2Wiojv2qOra0IFb22Md/Uogqi7ZLPi7EoVbZzwQxoSpbeixWx8+yr4LamAXqsFj4OQ== dependencies: framesync "^4.1.0" hey-listen "^1.0.8" @@ -8779,7 +8962,7 @@ postcss@^6.0.9: source-map "^0.6.1" supports-color "^5.4.0" -postcss@^7.0.0, postcss@^7.0.1, postcss@^7.0.11, postcss@^7.0.14, postcss@^7.0.18, postcss@^7.0.26, postcss@^7.0.27, postcss@^7.0.28, postcss@^7.0.32, postcss@^7.0.5, postcss@^7.0.6: +postcss@^7.0.0, postcss@^7.0.1, postcss@^7.0.14, postcss@^7.0.26, postcss@^7.0.27, postcss@^7.0.28, postcss@^7.0.32, postcss@^7.0.5, postcss@^7.0.6: version "7.0.34" resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.34.tgz#f2baf57c36010df7de4009940f21532c16d65c20" integrity sha512-H/7V2VeNScX9KE83GDrDZNiGT1m2H+UTnlinIzhjlLX9hfMUn1mHNnGeX81a1c8JSBdBvqk7c2ZOG6ZPn5itGw== @@ -8788,6 +8971,15 @@ postcss@^7.0.0, postcss@^7.0.1, postcss@^7.0.11, postcss@^7.0.14, postcss@^7.0.1 source-map "^0.6.1" supports-color "^6.1.0" +postcss@^7.0.11, postcss@^7.0.18: + version "7.0.35" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.35.tgz#d2be00b998f7f211d8a276974079f2e92b970e24" + integrity sha512-3QT8bBJeX/S5zKTTjTCIjRF3If4avAT6kqxcASlTWEtAFCb9NH0OUxNDfgZSWdP5fJnBYCMEWkIFfWeugjzYMg== + dependencies: + chalk "^2.4.2" + source-map "^0.6.1" + supports-color "^6.1.0" + prelude-ls@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" @@ -8815,7 +9007,7 @@ pretty-format@^24.9.0: ansi-styles "^3.2.0" react-is "^16.8.4" -pretty-format@^25.5.0: +pretty-format@^25.2.1, pretty-format@^25.5.0: version "25.5.0" resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-25.5.0.tgz#7873c1d774f682c34b8d48b6743a2bf2ac55791a" integrity sha512-kbo/kq2LQ/A/is0PQwsEHM7Ca6//bGPPvU6UnsdDRSKTWxT/ru/xb88v4BJf6a69H+uTytOEsTusT9ksd/1iWQ== @@ -9156,6 +9348,14 @@ rechoir@^0.6.2: dependencies: resolve "^1.1.6" +redent@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/redent/-/redent-3.0.0.tgz#e557b7998316bb53c9f1f56fa626352c6963059f" + integrity sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg== + dependencies: + indent-string "^4.0.0" + strip-indent "^3.0.0" + reduce-css-calc@^2.1.6: version "2.1.7" resolved "https://registry.yarnpkg.com/reduce-css-calc/-/reduce-css-calc-2.1.7.tgz#1ace2e02c286d78abcd01fd92bfe8097ab0602c2" @@ -9572,7 +9772,7 @@ run-queue@^1.0.0, run-queue@^1.0.3: dependencies: aproba "^1.1.1" -rxjs@^6.6.0: +rxjs@^6.6.0, rxjs@^6.6.2: version "6.6.3" resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.3.tgz#8ca84635c4daa900c0d3967a6ee7ac60271ee552" integrity sha512-trsQc+xYYXZ3urjOiJOuCOa5N3jAZ3eiSpQB5hIT8zGlL2QfnHLJ2r7GMkBGuIausdJN1OneaI6gQlsqNHHmZQ== @@ -9871,6 +10071,24 @@ slice-ansi@^2.1.0: astral-regex "^1.0.0" is-fullwidth-code-point "^2.0.0" +slice-ansi@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-3.0.0.tgz#31ddc10930a1b7e0b67b08c96c2f49b77a789787" + integrity sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ== + dependencies: + ansi-styles "^4.0.0" + astral-regex "^2.0.0" + is-fullwidth-code-point "^3.0.0" + +slice-ansi@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-4.0.0.tgz#500e8dd0fd55b05815086255b3195adf2a45fe6b" + integrity sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ== + dependencies: + ansi-styles "^4.0.0" + astral-regex "^2.0.0" + is-fullwidth-code-point "^3.0.0" + snapdragon-node@^2.0.1: version "2.1.1" resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b" @@ -9926,6 +10144,14 @@ source-map-resolve@^0.5.0, source-map-resolve@^0.5.2: source-map-url "^0.4.0" urix "^0.1.0" +source-map-resolve@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/source-map-resolve/-/source-map-resolve-0.6.0.tgz#3d9df87e236b53f16d01e58150fc7711138e5ed2" + integrity sha512-KXBr9d/fO/bWo97NXsPIAW1bFSBOuCnjbNTBMO7N59hsv5i9yzRDfcYwwt0l04+VqnKC+EwzvJZIP/qkuMgR/w== + dependencies: + atob "^2.1.2" + decode-uri-component "^0.2.0" + source-map-support@^0.5.6, source-map-support@~0.5.12: version "0.5.19" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.19.tgz#a98b62f86dcaf4f67399648c085291ab9e8fed61" @@ -10111,6 +10337,11 @@ stream-shift@^1.0.0: resolved "https://registry.yarnpkg.com/stream-shift/-/stream-shift-1.0.1.tgz#d7088281559ab2778424279b0877da3c392d5a3d" integrity sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ== +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, string-hash@^1.1.1: version "1.1.3" resolved "https://registry.yarnpkg.com/string-hash/-/string-hash-1.1.3.tgz#e8aafc0ac1855b4666929ed7dd1275df5d6c811b" @@ -10208,6 +10439,15 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" +stringify-object@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/stringify-object/-/stringify-object-3.3.0.tgz#703065aefca19300d3ce88af4f5b3956d7556629" + integrity sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw== + dependencies: + get-own-enumerable-property-symbols "^3.0.0" + is-obj "^1.0.1" + is-regexp "^1.0.0" + strip-ansi@6.0.0, strip-ansi@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.0.tgz#0b1571dd7669ccd4f3e06e14ef1eed26225ae532" @@ -10256,6 +10496,13 @@ strip-final-newline@^2.0.0: resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad" integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA== +strip-indent@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/strip-indent/-/strip-indent-3.0.0.tgz#c32e1cee940b6b3432c771bc2c54bcce73cd3001" + integrity sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ== + dependencies: + min-indent "^1.0.0" + strip-json-comments@^3.0.1: version "3.1.1" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" @@ -10471,7 +10718,7 @@ through2@^2.0.0: readable-stream "~2.3.6" xtend "~4.0.1" -through@^2.3.6: +through@^2.3.6, through@^2.3.8: version "2.3.8" resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" integrity sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU= @@ -11065,10 +11312,10 @@ vue-router@^3.0: resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-3.4.3.tgz#fa93768616ee338aa174f160ac965167fa572ffa" integrity sha512-BADg1mjGWX18Dpmy6bOGzGNnk7B/ZA0RxuA6qedY/YJwirMfKXIDzcccmHbQI0A6k5PzMdMloc0ElHfyOoX35A== -vue-router@^4.0.0-beta.10: - version "4.0.0-beta.10" - resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-4.0.0-beta.10.tgz#45f9e6fee6fcc7094b696e90c2c9f76c3aaf40ed" - integrity sha512-y3YxV8rO9e4mgFqdyskytRMLzwbxR65ZaAW59xZL+T3M3kHX5p+/XB6j7K5cVm/EgZFOLRb+Zht3ShVaEonn/A== +vue-router@^4.0.0-beta.12: + version "4.0.0-beta.12" + resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-4.0.0-beta.12.tgz#873d1bbd16882ab2ae35973e3e691412104f3914" + integrity sha512-prbqAs2hSlKGt3U/Iyq8G62q/oprwmEd//a6x5M1uqP1aZxwjq0s27ZG8hfUSOOPB7SYg4NOydwy6zi/b3S2Ww== vue@^2.6.10: version "2.6.12"