From 58ff88698bd4fc9b61887291510bb0bf90fda3ce Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Fri, 2 Oct 2020 11:05:41 +0200 Subject: [PATCH] feat: add Listbox component (#3) * make jest monorepo aware * add @testing-library/jest-dom for custom matchers This way we can use expect(element).toHaveAttribute(key, value?) * abstract keys enum * change type to unknown, because we don't know the return value * update use-id hook, make it suspense aware Thanks Reach UI! * hoist the disposables collection * add accessbility assertions for listbox Also made it consistent for the Menu component and simplified some of the assertions * add use-computed hook This allows us re-render when hooks change, but also return a value. So this is a combination of useEffect and a useState value. * add Listbox component * bump dependencies * add listbox example * add lint-staged This way we will only lint the files that have been staged and ready to be committed instead of the whole codebase * add missing prevent defaults * improve tests to verify that we can actually update the value of the listbox * scroll the active listbox item into view * small optimization, only focus "Nothing" on pointer leave when we are the active item We used to always go to "Nothing" on pointer leave. And while this code doesn't get called often, it *gets* called if you are using your arrow keys and the mouse pointer is still over the list. * bump dependencies Also moved the tailwind dependencies to the root * fix typo * drop the default Transition inside the Menu and Listbox components * update examples to reflect drop of default Transition wrapper * rename Listbox.{Items,Item} to Listbox.{Options,Option} Also rename all instances of `item` to `option` in tests and comments and what have you... * fix typo * drop disabled prop, use aria-disabled only --- jest.config.js | 14 +- jest/create-jest-config.js | 19 + jest/custom-matchers.ts | 36 +- package.json | 13 +- packages/@headlessui-react/jest.config.js | 3 + packages/@headlessui-react/package.json | 6 +- .../listbox/listbox-with-pure-tailwind.tsx | 115 + .../menu/menu-with-transition-and-popper.tsx | 121 +- .../pages/menu/menu-with-transition.tsx | 105 +- .../src/components/keyboard.ts | 19 + .../src/components/listbox/listbox.test.tsx | 3101 +++++++++++++++ .../src/components/listbox/listbox.tsx | 642 ++++ .../src/components/menu/menu.test.tsx | 618 ++- .../src/components/menu/menu.tsx | 108 +- .../src/components/transitions/transition.tsx | 2 +- .../src/hooks/use-computed.ts | 12 + .../@headlessui-react/src/hooks/use-id.ts | 22 +- packages/@headlessui-react/src/index.test.ts | 2 +- packages/@headlessui-react/src/index.ts | 1 + .../test-utils/accessibility-assertions.ts | 465 ++- .../src/test-utils/interactions.ts | 10 +- .../src/test-utils/suppress-console-logs.ts | 4 +- .../src/components/listbox/listbox.vue | 124 + .../@headlessui-vue/examples/src/routes.json | 11 + packages/@headlessui-vue/jest.config.js | 3 + packages/@headlessui-vue/package.json | 13 +- .../src/components/listbox/listbox.test.tsx | 3322 +++++++++++++++++ .../src/components/listbox/listbox.ts | 558 +++ .../src/components/menu/menu.test.tsx | 640 ++-- .../src/components/menu/menu.ts | 57 +- packages/@headlessui-vue/src/index.test.ts | 7 + packages/@headlessui-vue/src/index.ts | 1 + packages/@headlessui-vue/src/keyboard.ts | 19 + .../test-utils/accessibility-assertions.ts | 461 ++- scripts/test.sh | 22 +- yarn.lock | 343 +- 36 files changed, 9873 insertions(+), 1146 deletions(-) create mode 100644 jest/create-jest-config.js create mode 100644 packages/@headlessui-react/jest.config.js create mode 100644 packages/@headlessui-react/pages/listbox/listbox-with-pure-tailwind.tsx create mode 100644 packages/@headlessui-react/src/components/keyboard.ts create mode 100644 packages/@headlessui-react/src/components/listbox/listbox.test.tsx create mode 100644 packages/@headlessui-react/src/components/listbox/listbox.tsx create mode 100644 packages/@headlessui-react/src/hooks/use-computed.ts create mode 100644 packages/@headlessui-vue/examples/src/components/listbox/listbox.vue create mode 100644 packages/@headlessui-vue/jest.config.js create mode 100644 packages/@headlessui-vue/src/components/listbox/listbox.test.tsx create mode 100644 packages/@headlessui-vue/src/components/listbox/listbox.ts create mode 100644 packages/@headlessui-vue/src/keyboard.ts 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"