diff --git a/packages/@headlessui-vue/.eslintrc.js b/packages/@headlessui-vue/.eslintrc.js new file mode 100644 index 0000000..48cefd8 --- /dev/null +++ b/packages/@headlessui-vue/.eslintrc.js @@ -0,0 +1,6 @@ +module.exports = { + rules: { + 'react-hooks/rules-of-hooks': 'off', + 'react-hooks/exhaustive-deps': 'off', + }, +} diff --git a/packages/@headlessui-vue/README.md b/packages/@headlessui-vue/README.md new file mode 100644 index 0000000..0dff04c --- /dev/null +++ b/packages/@headlessui-vue/README.md @@ -0,0 +1,408 @@ +

+ @headlessui/vue +

+ +

+ A set of completely unstyled, fully accessible UI components for Vue 3, designed to integrate + beautifully with Tailwind CSS. +

+ +

+ Total Downloads + Latest Release + License +

+ +## Installation + +Please note that **this library only supports Vue 3**. + +```sh +# npm +npm install @headlessui/vue + +# Yarn +yarn add @headlessui/vue +``` + +## Components + +_This project is still in early development. New components will be added regularly over the coming months._ + +- [Menu Button](#menu-button-dropdown) + +### Roadmap + +This project is still in early development, but the plan is to build out all of the primitives we need to provide interactive Vue examples of all of the components included in [Tailwind UI](https://tailwindui.com), the commercial component directory that helps us fund the development of our open-source work like [Tailwind CSS](https://tailwindcss.com). + +This includes things like: + +- Listboxes +- Toggles +- Modals +- Tabs +- Slide-overs +- Mobile menus +- Accordions + +...and more in the future. + +We'll be continuing to develop new components on an on-going basis, with a goal of reaching a pretty fleshed out v1.0 by the end of the year. + +## Menu Button (Dropdown) + +[View complete demo on CodeSandbox](https://codesandbox.io/s/flamboyant-glade-b2jb4?file=/src/App.vue) + +The `Menu` component and related child components are used to quickly build custom dropdown components that are fully accessible out of the box, including correct ARIA attribute management and robust keyboard navigation support. + +- [Basic example](#basic-example) +- [Styling](#styling) +- [Transitions](#transitions) +- [Component API](#component-api) + +### Basic example + +Menu Buttons are built using the `Menu`, `MenuButton`, `MenuItems`, and `MenuItem` components. + +The `MenuButton` will automatically open/close the `MenuItems` when clicked, and when the menu is open, the list of items receives focus and is automatically navigable via the keyboard. + +```vue + + + +``` + +### Styling the active item + +This is a headless component so there are no styles included by default. Instead, the components expose useful information via [scoped slots](https://v3.vuejs.org/guide/component-slots.html#scoped-slots) that you can use to apply the styles you'd like to apply yourself. + +To style the active `MenuItem` you can read the `active` slot prop, which tells you whether or not that menu item is the item that is currently focused via the mouse or keyboard. + +You can use this state to conditionally apply whatever active/focus styles you like, for instance a blue background like is typical in most operating systems. + +```vue + +``` + +### Showing/hiding the menu + +By default, your `MenuItems` instance will be shown/hidden automatically based on the internal `open` state tracked within the `Menu` component itself. + +```vue + +``` + +If you'd rather handle this yourself (perhaps because you need to add an extra wrapper element for one reason or another), you can add a `static` prop to the `MenuItems` instance to tell it to always render, and inspect the `open` slot prop provided by the `Menu` to control which element is shown/hidden yourself. + +```vue + +``` + +### Disabling an item + +Use the `disabled` prop to disable a `MenuItem`. This will make it unselectable via keyboard navigation, and it will be skipped when pressing the up/down arrows. + +```vue + +``` + +### Transitions + +To animate the opening/closing of the menu panel, use Vue's built-in `transition` component. All you need to do is wrap your `MenuItems` instance in a `` element and the transition will be applied automatically. + +```vue + +``` + +### Rendering additional content + +The `Menu` component is not limited to rendering only its related subcomponents. You can render anything you like within a menu, which gives you complete control over exactly what you are building. + +For example, if you'd like to add a little header section to the menu with some extra information in it, just render an extra `div` with your content in it. + +```vue + +``` + +Note that only `MenuItem` instances will be navigable via the keyboard. + +### Rendering a different element for a component + +By default, the `Menu` and its subcomponents each render a default element that is sensible for that component. + +For example, `MenuButton` renders a `button` by default, and `MenuItems` renders a `div`. `Menu` and `MenuItem` interestingly _do not render an extra element_, and instead render their children directly by default. + +This is easy to change using the `as` prop, which exists on every component. + +```vue + +``` + +To tell an element to render its children directly with no wrapper element, use `as="template"`. + +```vue + +``` + +### Component API + +#### Menu + +```vue + + More options + + + + + +``` + +##### Props + +| Prop | Type | Default | Description | +| ---- | ------------------- | --------------------------------- | ---------------------------------------------------------- | +| `as` | String \| Component | `template` _(no wrapper element_) | The element or component the `MenuItems` should render as. | + +##### Slot props + +| Prop | Type | Description | +| ------ | ------- | -------------------------------- | +| `open` | Boolean | Whether or not the menu is open. | + +#### MenuButton + +```vue + + More options + + +``` + +##### Props + +| Prop | Type | Default | Description | +| ---- | ------------------- | -------- | ---------------------------------------------------------- | +| `as` | String \| Component | `button` | The element or component the `MenuItems` should render as. | + +##### Slot props + +| Prop | Type | Description | +| ------ | ------- | -------------------------------- | +| `open` | Boolean | Whether or not the menu is open. | + +#### MenuItems + +```vue + + + + +``` + +##### Props + +| Prop | Type | Default | Description | +| -------- | ------------------- | ------- | --------------------------------------------------------------------------- | +| `as` | String \| Component | `div` | The element or component the `MenuItems` should render as. | +| `static` | Boolean | `false` | Whether the element should ignore the internally managed open/closed state. | + +##### Slot props + +| Prop | Type | Description | +| ------ | ------- | -------------------------------- | +| `open` | Boolean | Whether or not the menu is open. | + +#### MenuItem + +```vue + + + Settings + + +``` + +##### Props + +| Prop | Type | Default | Description | +| ---------- | ------------------- | --------------------------------- | ------------------------------------------------------------------------------------- | +| `as` | String \| Component | `template` _(no wrapper element)_ | The element or component the `MenuItem` should render as. | +| `disabled` | Boolean | `false` | Whether or not the item should be disabled for keyboard navigation and ARIA purposes. | + +##### Slot props + +| Prop | Type | Description | +| ---------- | ------- | ---------------------------------------------------------------------------------- | +| `active` | Boolean | Whether or not the item is the active/focused item in the list. | +| `disabled` | Boolean | Whether or not the item is the disabled for keyboard navigation and ARIA purposes. | diff --git a/packages/@headlessui-vue/examples/.gitignore b/packages/@headlessui-vue/examples/.gitignore new file mode 100644 index 0000000..e42754a --- /dev/null +++ b/packages/@headlessui-vue/examples/.gitignore @@ -0,0 +1,4 @@ +node_modules +.DS_Store +dist +*.local \ No newline at end of file diff --git a/packages/@headlessui-vue/examples/index.html b/packages/@headlessui-vue/examples/index.html new file mode 100644 index 0000000..39fdaab --- /dev/null +++ b/packages/@headlessui-vue/examples/index.html @@ -0,0 +1,14 @@ + + + + + + + Headless UI - Playground + + + +
+ + + diff --git a/packages/@headlessui-vue/examples/public/favicon.ico b/packages/@headlessui-vue/examples/public/favicon.ico new file mode 100644 index 0000000..df36fcf Binary files /dev/null and b/packages/@headlessui-vue/examples/public/favicon.ico differ diff --git a/packages/@headlessui-vue/examples/src/App.vue b/packages/@headlessui-vue/examples/src/App.vue new file mode 100644 index 0000000..d95c331 --- /dev/null +++ b/packages/@headlessui-vue/examples/src/App.vue @@ -0,0 +1,35 @@ + + + diff --git a/packages/@headlessui-vue/examples/src/KeyCaster.vue b/packages/@headlessui-vue/examples/src/KeyCaster.vue new file mode 100644 index 0000000..1f23b04 --- /dev/null +++ b/packages/@headlessui-vue/examples/src/KeyCaster.vue @@ -0,0 +1,74 @@ + + + diff --git a/packages/@headlessui-vue/examples/src/components/menu-with-popper.vue b/packages/@headlessui-vue/examples/src/components/menu-with-popper.vue new file mode 100644 index 0000000..6192580 --- /dev/null +++ b/packages/@headlessui-vue/examples/src/components/menu-with-popper.vue @@ -0,0 +1,113 @@ + + + diff --git a/packages/@headlessui-vue/examples/src/components/menu-with-tailwind.vue b/packages/@headlessui-vue/examples/src/components/menu-with-tailwind.vue new file mode 100644 index 0000000..862f458 --- /dev/null +++ b/packages/@headlessui-vue/examples/src/components/menu-with-tailwind.vue @@ -0,0 +1,93 @@ + + + diff --git a/packages/@headlessui-vue/examples/src/main.js b/packages/@headlessui-vue/examples/src/main.js new file mode 100644 index 0000000..07a0076 --- /dev/null +++ b/packages/@headlessui-vue/examples/src/main.js @@ -0,0 +1,6 @@ +import { createApp } from 'vue' +import App from './App.vue' + +import 'tailwindcss/tailwind.css' + +createApp(App).mount('#app') diff --git a/packages/@headlessui-vue/package.json b/packages/@headlessui-vue/package.json new file mode 100644 index 0000000..ceac0c1 --- /dev/null +++ b/packages/@headlessui-vue/package.json @@ -0,0 +1,42 @@ +{ + "name": "@headlessui/vue", + "version": "0.0.0", + "main": "dist/index.js", + "typings": "dist/index.d.ts", + "module": "dist/headlessui.esm.js", + "license": "MIT", + "files": [ + "dist" + ], + "engines": { + "node": ">=10" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/tailwindlabs/headlessui.git", + "directory": "packages/@headlessui-vue" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "playground": "vite", + "build": "../../scripts/build.sh", + "test": "../../scripts/test.sh", + "lint": "../../scripts/lint.sh" + }, + "devDependencies": { + "@popperjs/core": "^2.4.4", + "@tailwindcss/ui": "^0.6.0", + "@testing-library/vue": "^5.0.4", + "@types/debounce": "^1.2.0", + "@types/node": "^14.6.4", + "@vue/compiler-sfc": "^3.0.0-rc.10", + "@vue/test-utils": "^2.0.0-beta.4", + "husky": "^4.3.0", + "tailwindcss": "^1.8.8", + "tsdx": "^0.13.3", + "vite": "^1.0.0-rc.4", + "vue": "^3.0.0-rc.10" + } +} diff --git a/packages/@headlessui-vue/postcss.config.js b/packages/@headlessui-vue/postcss.config.js new file mode 100644 index 0000000..8db70a9 --- /dev/null +++ b/packages/@headlessui-vue/postcss.config.js @@ -0,0 +1 @@ +module.exports = require('../../postcss.config.js') diff --git a/packages/@headlessui-vue/src/components/menu/menu.test.tsx b/packages/@headlessui-vue/src/components/menu/menu.test.tsx new file mode 100644 index 0000000..3e50d75 --- /dev/null +++ b/packages/@headlessui-vue/src/components/menu/menu.test.tsx @@ -0,0 +1,2492 @@ +import { defineComponent, h } from 'vue' +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, + assertMenuButtonLinkedWithMenu, + assertMenuItem, + assertMenuLinkedWithMenuItem, + assertActiveElement, + assertNoActiveMenuItem, +} from '../../test-utils/accessibility-assertions' +import { + click, + focus, + hover, + mouseMove, + press, + shift, + type, + unHover, + Keys, + word, +} from '../../test-utils/interactions' + +function renderTemplate(input: string | Partial[0]>) { + const defaultComponents = { Menu, MenuButton, MenuItems, MenuItem } + + if (typeof input === 'string') { + return render(defineComponent({ template: input, components: defaultComponents })) + } + + return render( + defineComponent( + Object.assign({}, input, { + components: { ...defaultComponents, ...input.components }, + }) as Parameters[0] + ) + ) +} + +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('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 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"]')) +} + +describe('Safe guards', () => { + it.each([ + ['MenuButton', MenuButton], + ['MenuItems', MenuItems], + ['MenuItem', MenuItem], + ])( + '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 Menu without crashing', () => { + renderTemplate(` + + Trigger + + Item A + Item B + Item C + + + `) + + assertMenuButton(getMenuButton(), { + state: MenuButtonState.Closed, + attributes: { id: 'headlessui-menu-button-1' }, + }) + assertMenu(getMenu(), { state: MenuState.Closed }) + }) +}) + +describe('Rendering', () => { + describe('Menu', () => { + it('should be possible to render a Menu using a default render prop', async () => { + renderTemplate(` + + Trigger {{ open ? "visible" : "hidden" }} + + Item A + Item B + Item C + + + `) + + assertMenuButton(getMenuButton(), { + state: MenuButtonState.Closed, + attributes: { id: 'headlessui-menu-button-1' }, + textContent: 'Trigger hidden', + }) + assertMenu(getMenu(), { state: MenuState.Closed }) + + await click(getMenuButton()) + + assertMenuButton(getMenuButton(), { + state: MenuButtonState.Open, + attributes: { id: 'headlessui-menu-button-1' }, + textContent: 'Trigger visible', + }) + assertMenu(getMenu(), { state: MenuState.Open }) + }) + + it('should be possible to render a Menu using a template `as` prop', async () => { + renderTemplate(` + +
+ Trigger + + Item A + Item B + Item C + +
+
+ `) + + assertMenuButton(getMenuButton(), { + state: MenuButtonState.Closed, + attributes: { id: 'headlessui-menu-button-1' }, + }) + assertMenu(getMenu(), { state: MenuState.Closed }) + + await click(getMenuButton()) + + assertMenuButton(getMenuButton(), { + state: MenuButtonState.Open, + attributes: { id: 'headlessui-menu-button-1' }, + }) + assertMenu(getMenu(), { state: MenuState.Open }) + }) + + it( + 'should yell when we render a Menu using a template `as` prop (default) that contains multiple children (if we passthrough props)', + suppressConsoleLogs(() => { + expect(() => + renderTemplate(` + + Trigger + + Item A + Item B + Item C + + + `) + ).toThrowErrorMatchingInlineSnapshot( + `"You should only render 1 child or use the \`as=\\"...\\"\` prop"` + ) + }) + ) + }) + + describe('MenuButton', () => { + it('should be possible to render a MenuButton using a default render prop', async () => { + renderTemplate(` + + + Trigger {{ open ? "visible" : "hidden" }} + + + Item A + Item B + Item C + + + `) + + assertMenuButton(getMenuButton(), { + state: MenuButtonState.Closed, + attributes: { id: 'headlessui-menu-button-1' }, + textContent: 'Trigger hidden', + }) + assertMenu(getMenu(), { state: MenuState.Closed }) + + await click(getMenuButton()) + + assertMenuButton(getMenuButton(), { + state: MenuButtonState.Open, + attributes: { id: 'headlessui-menu-button-1' }, + textContent: 'Trigger visible', + }) + assertMenu(getMenu(), { state: MenuState.Open }) + }) + + it('should be possible to render a MenuButton using a template `as` prop', async () => { + renderTemplate(` + + + + + + Item A + Item B + Item C + + + `) + + assertMenuButton(getMenuButton(), { + state: MenuButtonState.Closed, + attributes: { id: 'headlessui-menu-button-1', 'data-open': 'false' }, + }) + assertMenu(getMenu(), { state: MenuState.Closed }) + + await click(getMenuButton()) + + assertMenuButton(getMenuButton(), { + state: MenuButtonState.Open, + attributes: { id: 'headlessui-menu-button-1', 'data-open': 'true' }, + }) + assertMenu(getMenu(), { state: MenuState.Open }) + }) + + it( + 'should yell when we render a MenuButton using a template `as` prop that contains multiple children', + suppressConsoleLogs(() => { + expect(() => + renderTemplate(` + + + Trigger + + + + Item A + Item B + Item C + + + `) + ).toThrowErrorMatchingInlineSnapshot( + `"You should only render 1 child or use the \`as=\\"...\\"\` prop"` + ) + }) + ) + }) + + describe('MenuItems', () => { + it('should be possible to render MenuItems using a default render prop', async () => { + renderTemplate(` + + Trigger + + {{ open ? "visible" : "hidden" }} + Item A + Item B + Item C + + + `) + + assertMenuButton(getMenuButton(), { + state: MenuButtonState.Closed, + attributes: { id: 'headlessui-menu-button-1' }, + }) + assertMenu(getMenu(), { state: MenuState.Closed }) + + await click(getMenuButton()) + + assertMenuButton(getMenuButton(), { + state: MenuButtonState.Open, + attributes: { id: 'headlessui-menu-button-1' }, + }) + assertMenu(getMenu(), { state: MenuState.Open }) + expect(getMenu()?.firstChild?.textContent).toBe('visible') + }) + + it('should be possible to render MenuItems using a template `as` prop', async () => { + renderTemplate(` + + Trigger + +
+ Item A + Item B + Item C +
+
+
+ `) + + assertMenuButton(getMenuButton(), { + state: MenuButtonState.Closed, + attributes: { id: 'headlessui-menu-button-1' }, + }) + assertMenu(getMenu(), { state: MenuState.Closed }) + + await click(getMenuButton()) + + assertMenuButton(getMenuButton(), { + state: MenuButtonState.Open, + attributes: { id: 'headlessui-menu-button-1' }, + }) + assertMenu(getMenu(), { state: MenuState.Open, attributes: { 'data-open': 'true' } }) + }) + + it('should yell when we render MenuItems using a template `as` prop that contains multiple children', async () => { + const state = { + resolve(_value: Error | PromiseLike) {}, + done(error: unknown) { + state.resolve(error as Error) + return true + }, + promise: new Promise(() => {}), + } + + state.promise = new Promise(resolve => { + state.resolve = resolve + }) + + renderTemplate({ + template: ` + + Trigger + + Item A + Item B + Item C + + + `, + errorCaptured: state.done, + }) + + await click(getMenuButton()) + const error = await state.promise + expect(error.message).toMatchInlineSnapshot( + `"You should only render 1 child or use the \`as=\\"...\\"\` prop"` + ) + }) + + it('should be possible to always render the MenuItems if we provide it a `static` prop', () => { + renderTemplate(` + + Trigger + + Item A + Item B + Item C + + + `) + + // Let's verify that the Menu is already there + expect(getMenu()).not.toBe(null) + }) + }) + + describe('MenuItem', () => { + it('should be possible to render MenuItem using a default render prop', async () => { + renderTemplate(` + + Trigger + + + Item A - {{ JSON.stringify({ active, disabled }) }} + + Item B + Item C + + + `) + + assertMenuButton(getMenuButton(), { + state: MenuButtonState.Closed, + attributes: { id: 'headlessui-menu-button-1' }, + }) + assertMenu(getMenu(), { state: MenuState.Closed }) + + await click(getMenuButton()) + + assertMenuButton(getMenuButton(), { + state: MenuButtonState.Open, + attributes: { id: 'headlessui-menu-button-1' }, + }) + assertMenu(getMenu(), { state: MenuState.Open }) + expect(getMenuItems()[0]?.textContent).toBe( + `Item A - ${JSON.stringify({ active: false, disabled: false })}` + ) + }) + + it('should be possible to render a MenuItem using a template `as` prop', async () => { + renderTemplate(` + + Trigger + + + Item A + + + Item B + + + Item C + + + + `) + + assertMenuButton(getMenuButton(), { + state: MenuButtonState.Closed, + attributes: { id: 'headlessui-menu-button-1' }, + }) + assertMenu(getMenu(), { state: MenuState.Closed }) + + getMenuButton()?.focus() + + await press(Keys.Enter) + + assertMenuButton(getMenuButton(), { + state: MenuButtonState.Open, + attributes: { id: 'headlessui-menu-button-1' }, + }) + assertMenu(getMenu(), { state: MenuState.Open }) + assertMenuItem(getMenuItems()[0], { + tag: 'a', + attributes: { 'data-active': 'true', 'data-disabled': 'false' }, + }) + assertMenuItem(getMenuItems()[1], { + tag: 'a', + attributes: { 'data-active': 'false', 'data-disabled': 'false' }, + }) + assertMenuItem(getMenuItems()[2], { + tag: 'a', + attributes: { 'data-active': 'false', 'data-disabled': 'true' }, + }) + }) + + it('should yell when we render a MenuItem using a template `as` prop that contains multiple children', async () => { + const state = { + resolve(_value: Error | PromiseLike) {}, + done(error: unknown) { + state.resolve(error as Error) + return true + }, + promise: new Promise(() => {}), + } + + state.promise = new Promise(resolve => { + state.resolve = resolve + }) + + renderTemplate({ + template: ` + + Trigger + + + Item A + + + Item B + Item C + + + `, + errorCaptured: state.done, + }) + + await click(getMenuButton()) + const error = await state.promise + expect(error.message).toMatchInlineSnapshot( + `"You should only render 1 child or use the \`as=\\"...\\"\` prop"` + ) + }) + }) +}) + +describe('Rendering composition', () => { + it('should be possible to conditionally render classNames (aka className can be a function?!)', async () => { + renderTemplate(` + + Trigger + + Item A + Item B + Item C + + + `) + + assertMenuButton(getMenuButton(), { + state: MenuButtonState.Closed, + attributes: { id: 'headlessui-menu-button-1' }, + }) + assertMenu(getMenu(), { state: MenuState.Closed }) + + // Open menu + await click(getMenuButton()) + + const items = getMenuItems() + + // Verify correct classNames + expect('' + items[0].classList).toEqual(JSON.stringify({ active: false, disabled: false })) + expect('' + items[1].classList).toEqual(JSON.stringify({ active: false, disabled: true })) + expect('' + items[2].classList).toEqual('no-special-treatment') + + // Double check that nothing is active + assertNoActiveMenuItem(getMenu()) + + // Make the first item active + await press(Keys.ArrowDown) + + // Verify the classNames + expect('' + items[0].classList).toEqual(JSON.stringify({ active: true, disabled: false })) + expect('' + items[1].classList).toEqual(JSON.stringify({ active: false, disabled: true })) + expect('' + items[2].classList).toEqual('no-special-treatment') + + // Double check that the first item is the active one + assertMenuLinkedWithMenuItem(getMenu(), items[0]) + + // Let's go down, this should go to the third item since the second item is disabled! + await press(Keys.ArrowDown) + + // Verify the classNames + expect('' + items[0].classList).toEqual(JSON.stringify({ active: false, disabled: false })) + expect('' + items[1].classList).toEqual(JSON.stringify({ active: false, disabled: true })) + expect('' + items[2].classList).toEqual('no-special-treatment') + + // Double check that the last item is the active one + assertMenuLinkedWithMenuItem(getMenu(), items[2]) + }) + + it( + 'should be possible to swap the menu item with a button for example', + suppressConsoleLogs(async () => { + const MyButton = defineComponent({ + setup(props) { + return () => h('button', { 'data-my-custom-button': true, ...props }) + }, + }) + + renderTemplate({ + template: ` + + Trigger + + Item A + Item B + Item C + + + `, + setup: () => ({ MyButton }), + }) + + assertMenuButton(getMenuButton(), { + state: MenuButtonState.Closed, + attributes: { id: 'headlessui-menu-button-1' }, + }) + assertMenu(getMenu(), { state: MenuState.Closed }) + + // Open menu + await click(getMenuButton()) + + // Verify items are buttons now + const items = getMenuItems() + items.forEach(item => + assertMenuItem(item, { tag: 'button', attributes: { 'data-my-custom-button': 'true' } }) + ) + }) + ) +}) + +describe('Keyboard interactions', () => { + describe('`Enter` key', () => { + it('should be possible to open the menu with Enter', async () => { + renderTemplate(` + + Trigger + + Item A + Item B + Item C + + + `) + + assertMenuButton(getMenuButton(), { + state: MenuButtonState.Closed, + attributes: { id: 'headlessui-menu-button-1' }, + }) + assertMenu(getMenu(), { state: MenuState.Closed }) + + // Focus the button + getMenuButton()?.focus() + + // Open menu + await press(Keys.Enter) + + // Verify it is open + assertMenuButton(getMenuButton(), { state: MenuButtonState.Open }) + assertMenu(getMenu(), { + state: MenuState.Open, + attributes: { id: 'headlessui-menu-items-2' }, + }) + assertMenuButtonLinkedWithMenu(getMenuButton(), getMenu()) + + // Verify we have menu items + const items = getMenuItems() + expect(items).toHaveLength(3) + items.forEach(item => assertMenuItem(item)) + + // Verify that the first menu item is active + assertMenuLinkedWithMenuItem(getMenu(), items[0]) + }) + + it('should have no active menu item when there are no menu items at all', async () => { + renderTemplate(` + + Trigger + + + `) + + assertMenu(getMenu(), { state: MenuState.Closed }) + + // Focus the button + getMenuButton()?.focus() + + // Open menu + await press(Keys.Enter) + assertMenu(getMenu(), { state: MenuState.Open }) + + assertNoActiveMenuItem(getMenu()) + }) + + it('should focus the first non disabled menu item when opening with Enter', async () => { + renderTemplate(` + + Trigger + + Item A + Item B + Item C + + + `) + + assertMenuButton(getMenuButton(), { + state: MenuButtonState.Closed, + attributes: { id: 'headlessui-menu-button-1' }, + }) + assertMenu(getMenu(), { state: MenuState.Closed }) + + // Focus the button + getMenuButton()?.focus() + + // Open menu + await press(Keys.Enter) + + const items = getMenuItems() + + // Verify that the first non-disabled menu item is active + assertMenuLinkedWithMenuItem(getMenu(), items[1]) + }) + + it('should focus the first non disabled menu item when opening with Enter (jump over multiple disabled ones)', async () => { + renderTemplate(` + + Trigger + + Item A + Item B + Item C + + + `) + + assertMenuButton(getMenuButton(), { + state: MenuButtonState.Closed, + attributes: { id: 'headlessui-menu-button-1' }, + }) + assertMenu(getMenu(), { state: MenuState.Closed }) + + // Focus the button + getMenuButton()?.focus() + + // Open menu + await press(Keys.Enter) + + const items = getMenuItems() + + // Verify that the first non-disabled menu item is active + assertMenuLinkedWithMenuItem(getMenu(), items[2]) + }) + + it('should have no active menu item upon Enter key press, when there are no non-disabled menu items', async () => { + renderTemplate(` + + Trigger + + Item A + Item B + Item C + + + `) + + assertMenuButton(getMenuButton(), { + state: MenuButtonState.Closed, + attributes: { id: 'headlessui-menu-button-1' }, + }) + assertMenu(getMenu(), { state: MenuState.Closed }) + + // Focus the button + getMenuButton()?.focus() + + // Open menu + await press(Keys.Enter) + + assertNoActiveMenuItem(getMenu()) + }) + + it('should be possible to close the menu with Enter when there is no active menuitem', async () => { + renderTemplate(` + + Trigger + + Item A + Item B + Item C + + + `) + + assertMenuButton(getMenuButton(), { + state: MenuButtonState.Closed, + attributes: { id: 'headlessui-menu-button-1' }, + }) + assertMenu(getMenu(), { state: MenuState.Closed }) + + // Open menu + await click(getMenuButton()) + + // Verify it is open + assertMenuButton(getMenuButton(), { state: MenuButtonState.Open }) + + // Close menu + await press(Keys.Enter) + + // Verify it is closed + assertMenuButton(getMenuButton(), { state: MenuButtonState.Closed }) + assertMenu(getMenu(), { state: MenuState.Closed }) + }) + + it('should be possible to close the menu with Enter and invoke the active menu item', async () => { + renderTemplate(` + + Trigger + + Item A + Item B + Item C + + + `) + + assertMenuButton(getMenuButton(), { + state: MenuButtonState.Closed, + attributes: { id: 'headlessui-menu-button-1' }, + }) + assertMenu(getMenu(), { state: MenuState.Closed }) + + // Open menu + await click(getMenuButton()) + + // Verify it is open + assertMenuButton(getMenuButton(), { state: MenuButtonState.Open }) + + // Activate the first menu item + const items = getMenuItems() + await hover(items[0]) + + // Close menu, and invoke the item + await press(Keys.Enter) + + // Verify it is closed + assertMenuButton(getMenuButton(), { state: MenuButtonState.Closed }) + assertMenu(getMenu(), { state: MenuState.Closed }) + }) + }) + + it('should be possible to use a button as a menu item and invoke it upon Enter', async () => { + const clickHandler = jest.fn() + + renderTemplate({ + template: ` + + Trigger + + Item A + + Item B + + Item C + + + `, + setup: () => ({ clickHandler }), + }) + + assertMenuButton(getMenuButton(), { + state: MenuButtonState.Closed, + attributes: { id: 'headlessui-menu-button-1' }, + }) + assertMenu(getMenu(), { state: MenuState.Closed }) + + // Open menu + await click(getMenuButton()) + + // Verify it is open + assertMenuButton(getMenuButton(), { state: MenuButtonState.Open }) + + // Activate the second menu item + const items = getMenuItems() + await hover(items[1]) + + // Close menu, and invoke the item + await press(Keys.Enter) + + // Verify it is closed + assertMenuButton(getMenuButton(), { state: MenuButtonState.Closed }) + assertMenu(getMenu(), { state: MenuState.Closed }) + + // Verify the button got "clicked" + expect(clickHandler).toHaveBeenCalledTimes(1) + }) + + describe('`Space` key', () => { + it('should be possible to open the menu with Space', async () => { + renderTemplate(` + + Trigger + + Item A + Item B + Item C + + + `) + + assertMenuButton(getMenuButton(), { + state: MenuButtonState.Closed, + attributes: { id: 'headlessui-menu-button-1' }, + }) + assertMenu(getMenu(), { state: MenuState.Closed }) + + // Focus the button + getMenuButton()?.focus() + + // Open menu + await press(Keys.Space) + + // Verify it is open + assertMenuButton(getMenuButton(), { state: MenuButtonState.Open }) + assertMenu(getMenu(), { + state: MenuState.Open, + attributes: { id: 'headlessui-menu-items-2' }, + }) + assertMenuButtonLinkedWithMenu(getMenuButton(), getMenu()) + + // Verify we have menu items + const items = getMenuItems() + expect(items).toHaveLength(3) + items.forEach(item => assertMenuItem(item)) + assertMenuLinkedWithMenuItem(getMenu(), items[0]) + }) + + it('should have no active menu item when there are no menu items at all', async () => { + renderTemplate(` + + Trigger + + + `) + + assertMenu(getMenu(), { state: MenuState.Closed }) + + // Focus the button + getMenuButton()?.focus() + + // Open menu + await press(Keys.Space) + assertMenu(getMenu(), { state: MenuState.Open }) + + assertNoActiveMenuItem(getMenu()) + }) + + it('should focus the first non disabled menu item when opening with Space', async () => { + renderTemplate(` + + Trigger + + Item A + Item B + Item C + + + `) + + assertMenuButton(getMenuButton(), { + state: MenuButtonState.Closed, + attributes: { id: 'headlessui-menu-button-1' }, + }) + assertMenu(getMenu(), { state: MenuState.Closed }) + + // Focus the button + getMenuButton()?.focus() + + // Open menu + await press(Keys.Space) + + const items = getMenuItems() + + // Verify that the first non-disabled menu item is active + assertMenuLinkedWithMenuItem(getMenu(), items[1]) + }) + + it('should focus the first non disabled menu item when opening with Space (jump over multiple disabled ones)', async () => { + renderTemplate(` + + Trigger + + Item A + Item B + Item C + + + `) + + assertMenuButton(getMenuButton(), { + state: MenuButtonState.Closed, + attributes: { id: 'headlessui-menu-button-1' }, + }) + assertMenu(getMenu(), { state: MenuState.Closed }) + + // Focus the button + getMenuButton()?.focus() + + // Open menu + await press(Keys.Space) + + const items = getMenuItems() + + // Verify that the first non-disabled menu item is active + assertMenuLinkedWithMenuItem(getMenu(), items[2]) + }) + + it('should have no active menu item upon Space key press, when there are no non-disabled menu items', async () => { + renderTemplate(` + + Trigger + + Item A + Item B + Item C + + + `) + + assertMenuButton(getMenuButton(), { + state: MenuButtonState.Closed, + attributes: { id: 'headlessui-menu-button-1' }, + }) + assertMenu(getMenu(), { state: MenuState.Closed }) + + // Focus the button + getMenuButton()?.focus() + + // Open menu + await press(Keys.Space) + + assertNoActiveMenuItem(getMenu()) + }) + }) + + describe('`Escape` key', () => { + it('should be possible to close an open menu with Escape', async () => { + renderTemplate(` + + Trigger + + Item A + Item B + Item C + + + `) + + // Focus the button + getMenuButton()?.focus() + + // Open menu + await press(Keys.Space) + + // Verify it is open + assertMenuButton(getMenuButton(), { state: MenuButtonState.Open }) + assertMenu(getMenu(), { + state: MenuState.Open, + attributes: { id: 'headlessui-menu-items-2' }, + }) + assertMenuButtonLinkedWithMenu(getMenuButton(), getMenu()) + + // Close menu + await press(Keys.Escape) + + // Verify it is closed + assertMenuButton(getMenuButton(), { state: MenuButtonState.Closed }) + assertMenu(getMenu(), { state: MenuState.Closed }) + }) + }) + + describe('`Tab` key', () => { + it('should focus trap when we use Tab', async () => { + renderTemplate(` + + Trigger + + Item A + Item B + Item C + + + `) + + assertMenuButton(getMenuButton(), { + state: MenuButtonState.Closed, + attributes: { id: 'headlessui-menu-button-1' }, + }) + assertMenu(getMenu(), { state: MenuState.Closed }) + + // Focus the button + getMenuButton()?.focus() + + // Open menu + await press(Keys.Enter) + + // Verify it is open + assertMenuButton(getMenuButton(), { state: MenuButtonState.Open }) + assertMenu(getMenu(), { + state: MenuState.Open, + attributes: { id: 'headlessui-menu-items-2' }, + }) + assertMenuButtonLinkedWithMenu(getMenuButton(), getMenu()) + + // Verify we have menu items + const items = getMenuItems() + expect(items).toHaveLength(3) + items.forEach(item => assertMenuItem(item)) + assertMenuLinkedWithMenuItem(getMenu(), items[0]) + + // Try to tab + await press(Keys.Tab) + + // Verify it is still open + assertMenuButton(getMenuButton(), { state: MenuButtonState.Open }) + assertMenu(getMenu(), { state: MenuState.Open }) + }) + + it('should focus trap when we use Shift+Tab', async () => { + renderTemplate(` + + Trigger + + Item A + Item B + Item C + + + `) + + assertMenuButton(getMenuButton(), { + state: MenuButtonState.Closed, + attributes: { id: 'headlessui-menu-button-1' }, + }) + assertMenu(getMenu(), { state: MenuState.Closed }) + + // Focus the button + getMenuButton()?.focus() + + // Open menu + await press(Keys.Enter) + + // Verify it is open + assertMenuButton(getMenuButton(), { state: MenuButtonState.Open }) + assertMenu(getMenu(), { + state: MenuState.Open, + attributes: { id: 'headlessui-menu-items-2' }, + }) + assertMenuButtonLinkedWithMenu(getMenuButton(), getMenu()) + + // Verify we have menu items + const items = getMenuItems() + expect(items).toHaveLength(3) + items.forEach(item => assertMenuItem(item)) + assertMenuLinkedWithMenuItem(getMenu(), 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 }) + }) + }) + + describe('`ArrowDown` key', () => { + it('should be possible to open the menu with ArrowDown', async () => { + renderTemplate(` + + Trigger + + Item A + Item B + Item C + + + `) + + assertMenuButton(getMenuButton(), { + state: MenuButtonState.Closed, + attributes: { id: 'headlessui-menu-button-1' }, + }) + assertMenu(getMenu(), { state: MenuState.Closed }) + + // Focus the button + getMenuButton()?.focus() + + // Open menu + await press(Keys.ArrowDown) + + // Verify it is open + assertMenuButton(getMenuButton(), { state: MenuButtonState.Open }) + assertMenu(getMenu(), { + state: MenuState.Open, + attributes: { id: 'headlessui-menu-items-2' }, + }) + assertMenuButtonLinkedWithMenu(getMenuButton(), getMenu()) + + // Verify we have menu items + const items = getMenuItems() + expect(items).toHaveLength(3) + items.forEach(item => assertMenuItem(item)) + + // Verify that the first menu item is active + assertMenuLinkedWithMenuItem(getMenu(), items[0]) + }) + + it('should have no active menu item when there are no menu items at all', async () => { + renderTemplate(` + + Trigger + + + `) + + assertMenu(getMenu(), { state: MenuState.Closed }) + + // Focus the button + getMenuButton()?.focus() + + // Open menu + await press(Keys.ArrowDown) + assertMenu(getMenu(), { state: MenuState.Open }) + + assertNoActiveMenuItem(getMenu()) + }) + + it('should be possible to use ArrowDown to navigate the menu items', async () => { + renderTemplate(` + + Trigger + + Item A + Item B + Item C + + + `) + + assertMenuButton(getMenuButton(), { + state: MenuButtonState.Closed, + attributes: { id: 'headlessui-menu-button-1' }, + }) + assertMenu(getMenu(), { state: MenuState.Closed }) + + // Focus the button + getMenuButton()?.focus() + + // Open menu + await press(Keys.Enter) + + // Verify we have menu items + const items = getMenuItems() + expect(items).toHaveLength(3) + items.forEach(item => assertMenuItem(item)) + assertMenuLinkedWithMenuItem(getMenu(), items[0]) + + // We should be able to go down once + await press(Keys.ArrowDown) + assertMenuLinkedWithMenuItem(getMenu(), items[1]) + + // We should be able to go down again + await press(Keys.ArrowDown) + assertMenuLinkedWithMenuItem(getMenu(), 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]) + }) + + it('should be possible to use ArrowDown to navigate the menu items and skip the first disabled one', async () => { + renderTemplate(` + + Trigger + + Item A + Item B + Item C + + + `) + + assertMenuButton(getMenuButton(), { + state: MenuButtonState.Closed, + attributes: { id: 'headlessui-menu-button-1' }, + }) + assertMenu(getMenu(), { state: MenuState.Closed }) + + // Focus the button + getMenuButton()?.focus() + + // Open menu + await press(Keys.Enter) + + // Verify we have menu items + const items = getMenuItems() + expect(items).toHaveLength(3) + items.forEach(item => assertMenuItem(item)) + assertMenuLinkedWithMenuItem(getMenu(), items[1]) + + // We should be able to go down once + await press(Keys.ArrowDown) + assertMenuLinkedWithMenuItem(getMenu(), items[2]) + }) + + it('should be possible to use ArrowDown to navigate the menu items and jump to the first non-disabled one', async () => { + renderTemplate(` + + Trigger + + Item A + Item B + Item C + + + `) + + assertMenuButton(getMenuButton(), { + state: MenuButtonState.Closed, + attributes: { id: 'headlessui-menu-button-1' }, + }) + assertMenu(getMenu(), { state: MenuState.Closed }) + + // Focus the button + getMenuButton()?.focus() + + // Open menu + await press(Keys.Enter) + + // Verify we have menu items + const items = getMenuItems() + expect(items).toHaveLength(3) + items.forEach(item => assertMenuItem(item)) + assertMenuLinkedWithMenuItem(getMenu(), items[2]) + }) + }) + + describe('`ArrowUp` key', () => { + it('should be possible to open the menu with ArrowUp and the last item should be active', async () => { + renderTemplate(` + + Trigger + + Item A + Item B + Item C + + + `) + + assertMenuButton(getMenuButton(), { + state: MenuButtonState.Closed, + attributes: { id: 'headlessui-menu-button-1' }, + }) + assertMenu(getMenu(), { state: MenuState.Closed }) + + // Focus the button + getMenuButton()?.focus() + + // Open menu + await press(Keys.ArrowUp) + + // Verify it is open + assertMenuButton(getMenuButton(), { state: MenuButtonState.Open }) + assertMenu(getMenu(), { + state: MenuState.Open, + attributes: { id: 'headlessui-menu-items-2' }, + }) + assertMenuButtonLinkedWithMenu(getMenuButton(), getMenu()) + + // Verify we have menu items + const items = getMenuItems() + expect(items).toHaveLength(3) + items.forEach(item => assertMenuItem(item)) + + // ! ALERT: The LAST item should now be active + assertMenuLinkedWithMenuItem(getMenu(), items[2]) + }) + + it('should have no active menu item when there are no menu items at all', async () => { + renderTemplate(` + + Trigger + + + `) + + assertMenu(getMenu(), { state: MenuState.Closed }) + + // Focus the button + getMenuButton()?.focus() + + // Open menu + await press(Keys.ArrowUp) + assertMenu(getMenu(), { state: MenuState.Open }) + + assertNoActiveMenuItem(getMenu()) + }) + + it('should be possible to use ArrowUp to navigate the menu items and jump to the first non-disabled one', async () => { + renderTemplate(` + + Trigger + + Item A + Item B + Item C + + + `) + + assertMenuButton(getMenuButton(), { + state: MenuButtonState.Closed, + attributes: { id: 'headlessui-menu-button-1' }, + }) + assertMenu(getMenu(), { state: MenuState.Closed }) + + // Focus the button + getMenuButton()?.focus() + + // Open menu + await press(Keys.ArrowUp) + + // Verify we have menu items + const items = getMenuItems() + expect(items).toHaveLength(3) + items.forEach(item => assertMenuItem(item)) + assertMenuLinkedWithMenuItem(getMenu(), items[0]) + }) + + it('should not be possible to navigate up or down if there is only a single non-disabled item', async () => { + renderTemplate(` + + Trigger + + Item A + Item B + Item C + + + `) + + assertMenuButton(getMenuButton(), { + state: MenuButtonState.Closed, + attributes: { id: 'headlessui-menu-button-1' }, + }) + assertMenu(getMenu(), { state: MenuState.Closed }) + + // Focus the button + getMenuButton()?.focus() + + // Open menu + await press(Keys.Enter) + + // Verify we have menu items + const items = getMenuItems() + expect(items).toHaveLength(3) + items.forEach(item => assertMenuItem(item)) + assertMenuLinkedWithMenuItem(getMenu(), items[2]) + + // We should not be able to go up (because those are disabled) + await press(Keys.ArrowUp) + assertMenuLinkedWithMenuItem(getMenu(), items[2]) + + // We should not be able to go down (because this is the last item) + await press(Keys.ArrowDown) + assertMenuLinkedWithMenuItem(getMenu(), items[2]) + }) + + it('should be possible to use ArrowUp to navigate the menu items', async () => { + renderTemplate(` + + Trigger + + Item A + Item B + Item C + + + `) + + assertMenuButton(getMenuButton(), { + state: MenuButtonState.Closed, + attributes: { id: 'headlessui-menu-button-1' }, + }) + assertMenu(getMenu(), { state: MenuState.Closed }) + + // Focus the button + getMenuButton()?.focus() + + // Open menu + await press(Keys.ArrowUp) + + // Verify it is open + assertMenuButton(getMenuButton(), { state: MenuButtonState.Open }) + assertMenu(getMenu(), { + state: MenuState.Open, + attributes: { id: 'headlessui-menu-items-2' }, + }) + assertMenuButtonLinkedWithMenu(getMenuButton(), getMenu()) + + // Verify we have menu items + const items = getMenuItems() + expect(items).toHaveLength(3) + items.forEach(item => assertMenuItem(item)) + assertMenuLinkedWithMenuItem(getMenu(), items[2]) + + // We should be able to go down once + await press(Keys.ArrowUp) + assertMenuLinkedWithMenuItem(getMenu(), items[1]) + + // We should be able to go down again + await press(Keys.ArrowUp) + assertMenuLinkedWithMenuItem(getMenu(), 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]) + }) + }) + + describe('`End` key', () => { + it('should be possible to use the End key to go to the last menu item', async () => { + renderTemplate(` + + Trigger + + Item A + Item B + Item C + + + `) + + // Focus the button + getMenuButton()?.focus() + + // Open menu + await press(Keys.Enter) + + const items = getMenuItems() + + // We should be on the first item + assertMenuLinkedWithMenuItem(getMenu(), items[0]) + + // We should be able to go to the last item + await press(Keys.End) + assertMenuLinkedWithMenuItem(getMenu(), items[2]) + }) + + it('should be possible to use the End key to go to the last non disabled menu item', async () => { + renderTemplate(` + + Trigger + + Item A + Item B + Item C + Item D + + + `) + + // Focus the button + getMenuButton()?.focus() + + // Open menu + await press(Keys.Enter) + + const items = getMenuItems() + + // We should be on the first item + assertMenuLinkedWithMenuItem(getMenu(), items[0]) + + // We should be able to go to the last non-disabled item + await press(Keys.End) + assertMenuLinkedWithMenuItem(getMenu(), 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 () => { + renderTemplate(` + + Trigger + + Item A + Item B + Item C + Item D + + + `) + + // Open menu + await click(getMenuButton()) + + // We opened via click, we don't have an active item + assertNoActiveMenuItem(getMenu()) + + // We should not be able to go to the end + await press(Keys.End) + + const items = getMenuItems() + assertMenuLinkedWithMenuItem(getMenu(), items[0]) + }) + + it('should have no active menu item upon End key press, when there are no non-disabled menu items', async () => { + renderTemplate(` + + Trigger + + Item A + Item B + Item C + Item D + + + `) + + // Open menu + await click(getMenuButton()) + + // We opened via click, we don't have an active item + assertNoActiveMenuItem(getMenu()) + + // We should not be able to go to the end + await press(Keys.End) + + assertNoActiveMenuItem(getMenu()) + }) + }) + + describe('`PageDown` key', () => { + it('should be possible to use the PageDown key to go to the last menu item', async () => { + renderTemplate(` + + Trigger + + Item A + Item B + Item C + + + `) + + // Focus the button + getMenuButton()?.focus() + + // Open menu + await press(Keys.Enter) + + const items = getMenuItems() + + // We should be on the first item + assertMenuLinkedWithMenuItem(getMenu(), items[0]) + + // We should be able to go to the last item + await press(Keys.PageDown) + assertMenuLinkedWithMenuItem(getMenu(), items[2]) + }) + + it('should be possible to use the PageDown key to go to the last non disabled menu item', async () => { + renderTemplate(` + + Trigger + + Item A + Item B + Item C + Item D + + + `) + + // Focus the button + getMenuButton()?.focus() + + // Open menu + await press(Keys.Enter) + + const items = getMenuItems() + + // We should be on the first item + assertMenuLinkedWithMenuItem(getMenu(), items[0]) + + // We should be able to go to the last non-disabled item + await press(Keys.PageDown) + assertMenuLinkedWithMenuItem(getMenu(), 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 () => { + renderTemplate(` + + Trigger + + Item A + Item B + Item C + Item D + + + `) + + // Open menu + await click(getMenuButton()) + + // We opened via click, we don't have an active item + assertNoActiveMenuItem(getMenu()) + + // We should not be able to go to the end + await press(Keys.PageDown) + + const items = getMenuItems() + assertMenuLinkedWithMenuItem(getMenu(), items[0]) + }) + + it('should have no active menu item upon PageDown key press, when there are no non-disabled menu items', async () => { + renderTemplate(` + + Trigger + + Item A + Item B + Item C + Item D + + + `) + + // Open menu + await click(getMenuButton()) + + // We opened via click, we don't have an active item + assertNoActiveMenuItem(getMenu()) + + // We should not be able to go to the end + await press(Keys.PageDown) + + assertNoActiveMenuItem(getMenu()) + }) + }) + + describe('`Home` key', () => { + it('should be possible to use the Home key to go to the first menu item', async () => { + renderTemplate(` + + Trigger + + Item A + Item B + Item C + + + `) + + // Focus the button + getMenuButton()?.focus() + + // Open menu + await press(Keys.ArrowUp) + + const items = getMenuItems() + + // We should be on the last item + assertMenuLinkedWithMenuItem(getMenu(), items[2]) + + // We should be able to go to the first item + await press(Keys.Home) + assertMenuLinkedWithMenuItem(getMenu(), items[0]) + }) + + it('should be possible to use the Home key to go to the first non disabled menu item', async () => { + renderTemplate(` + + Trigger + + Item A + Item B + Item C + Item D + + + `) + + // Open menu + await click(getMenuButton()) + + // We opened via click, we don't have an active item + assertNoActiveMenuItem(getMenu()) + + // We should not be able to go to the end + await press(Keys.Home) + + const items = getMenuItems() + + // We should be on the first non-disabled item + assertMenuLinkedWithMenuItem(getMenu(), 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 () => { + renderTemplate(` + + Trigger + + Item A + Item B + Item C + Item D + + + `) + + // Open menu + await click(getMenuButton()) + + // We opened via click, we don't have an active item + assertNoActiveMenuItem(getMenu()) + + // We should not be able to go to the end + await press(Keys.Home) + + const items = getMenuItems() + assertMenuLinkedWithMenuItem(getMenu(), items[3]) + }) + + it('should have no active menu item upon Home key press, when there are no non-disabled menu items', async () => { + renderTemplate(` + + Trigger + + Item A + Item B + Item C + Item D + + + `) + + // Open menu + await click(getMenuButton()) + + // We opened via click, we don't have an active item + assertNoActiveMenuItem(getMenu()) + + // We should not be able to go to the end + await press(Keys.Home) + + assertNoActiveMenuItem(getMenu()) + }) + }) + + describe('`PageUp` key', () => { + it('should be possible to use the PageUp key to go to the first menu item', async () => { + renderTemplate(` + + Trigger + + Item A + Item B + Item C + + + `) + + // Focus the button + getMenuButton()?.focus() + + // Open menu + await press(Keys.ArrowUp) + + const items = getMenuItems() + + // We should be on the last item + assertMenuLinkedWithMenuItem(getMenu(), items[2]) + + // We should be able to go to the first item + await press(Keys.PageUp) + assertMenuLinkedWithMenuItem(getMenu(), items[0]) + }) + + it('should be possible to use the PageUp key to go to the first non disabled menu item', async () => { + renderTemplate(` + + Trigger + + Item A + Item B + Item C + Item D + + + `) + + // Open menu + await click(getMenuButton()) + + // We opened via click, we don't have an active item + assertNoActiveMenuItem(getMenu()) + + // We should not be able to go to the end + await press(Keys.PageUp) + + const items = getMenuItems() + + // We should be on the first non-disabled item + assertMenuLinkedWithMenuItem(getMenu(), 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 () => { + renderTemplate(` + + Trigger + + Item A + Item B + Item C + Item D + + + `) + + // Open menu + await click(getMenuButton()) + + // We opened via click, we don't have an active item + assertNoActiveMenuItem(getMenu()) + + // We should not be able to go to the end + await press(Keys.PageUp) + + const items = getMenuItems() + assertMenuLinkedWithMenuItem(getMenu(), items[3]) + }) + + it('should have no active menu item upon PageUp key press, when there are no non-disabled menu items', async () => { + renderTemplate(` + + Trigger + + Item A + Item B + Item C + Item D + + + `) + + // Open menu + await click(getMenuButton()) + + // We opened via click, we don't have an active item + assertNoActiveMenuItem(getMenu()) + + // We should not be able to go to the end + await press(Keys.PageUp) + + assertNoActiveMenuItem(getMenu()) + }) + }) + + describe('`Any` key aka search', () => { + it('should be possible to type a full word that has a perfect match', async () => { + renderTemplate(` + + Trigger + + alice + bob + charlie + + + `) + + // Open menu + await click(getMenuButton()) + + const items = getMenuItems() + + // We should be able to go to the second item + await type(word('bob')) + assertMenuLinkedWithMenuItem(getMenu(), items[1]) + + // We should be able to go to the first item + await type(word('alice')) + assertMenuLinkedWithMenuItem(getMenu(), items[0]) + + // We should be able to go to the last item + await type(word('charlie')) + assertMenuLinkedWithMenuItem(getMenu(), items[2]) + }) + + it('should be possible to type a partial of a word', async () => { + renderTemplate(` + + Trigger + + alice + bob + charlie + + + `) + + // Focus the button + getMenuButton()?.focus() + + // Open menu + await press(Keys.ArrowUp) + + const items = getMenuItems() + + // We should be on the last item + assertMenuLinkedWithMenuItem(getMenu(), items[2]) + + // We should be able to go to the second item + await type(word('bo')) + assertMenuLinkedWithMenuItem(getMenu(), items[1]) + + // We should be able to go to the first item + await type(word('ali')) + assertMenuLinkedWithMenuItem(getMenu(), items[0]) + + // We should be able to go to the last item + await type(word('char')) + assertMenuLinkedWithMenuItem(getMenu(), items[2]) + }) + + it('should not be possible to search for a disabled item', async () => { + renderTemplate(` + + Trigger + + alice + bob + charlie + + + `) + + // Focus the button + getMenuButton()?.focus() + + // Open menu + await press(Keys.ArrowUp) + + const items = getMenuItems() + + // We should be on the last item + assertMenuLinkedWithMenuItem(getMenu(), 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]) + }) + }) +}) + +describe('Mouse interactions', () => { + it('should be possible to open a menu on click', async () => { + renderTemplate(` + + Trigger + + Item A + Item B + Item C + + + `) + + assertMenuButton(getMenuButton(), { + state: MenuButtonState.Closed, + attributes: { id: 'headlessui-menu-button-1' }, + }) + assertMenu(getMenu(), { state: MenuState.Closed }) + + // Open menu + await click(getMenuButton()) + + // Verify it is open + assertMenuButton(getMenuButton(), { state: MenuButtonState.Open }) + assertMenu(getMenu(), { + state: MenuState.Open, + attributes: { id: 'headlessui-menu-items-2' }, + }) + assertMenuButtonLinkedWithMenu(getMenuButton(), getMenu()) + + // Verify we have menu items + const items = getMenuItems() + expect(items).toHaveLength(3) + items.forEach(item => assertMenuItem(item)) + }) + + it('should be possible to close a menu on click', async () => { + renderTemplate(` + + Trigger + + Item A + Item B + Item C + + + `) + + // Open menu + await click(getMenuButton()) + + // Verify it is open + assertMenuButton(getMenuButton(), { state: MenuButtonState.Open }) + + // Click to close + await click(getMenuButton()) + + // Verify it is closed + assertMenuButton(getMenuButton(), { state: MenuButtonState.Closed }) + assertMenu(getMenu(), { state: MenuState.Closed }) + }) + + it('should focus the menu when you try to focus the button again (when the menu is already open)', async () => { + renderTemplate(` + + Trigger + + Item A + Item B + Item C + + + `) + + // Open menu + await click(getMenuButton()) + + // Verify menu is focused + assertActiveElement(getMenu()) + + // Try to Re-focus the button + getMenuButton()?.focus() + + // Verify menu is still focused + assertActiveElement(getMenu()) + }) + + it('should be a no-op when we click outside of a closed menu', async () => { + renderTemplate(` + + Trigger + + alice + bob + charlie + + + `) + + // Verify that the window is closed + assertMenu(getMenu(), { 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 }) + }) + + it('should be possible to click outside of the menu which should close the menu', async () => { + renderTemplate(` + + Trigger + + alice + bob + charlie + + + `) + + // Open menu + await click(getMenuButton()) + assertMenu(getMenu(), { 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 }) + }) + + it('should be possible to click outside of the menu which should close the menu (even if we press the menu button)', async () => { + renderTemplate(` + + Trigger + + alice + bob + charlie + + + `) + + // Open menu + await click(getMenuButton()) + assertMenu(getMenu(), { state: MenuState.Open }) + + // Click the menu button again + await click(getMenuButton()) + + // Should be closed now + assertMenu(getMenu(), { state: MenuState.Closed }) + }) + + it('should be possible to hover an item and make it active', async () => { + renderTemplate(` + + Trigger + + alice + bob + charlie + + + `) + + // Open menu + await click(getMenuButton()) + + const items = getMenuItems() + // We should be able to go to the second item + await hover(items[1]) + assertMenuLinkedWithMenuItem(getMenu(), items[1]) + + // We should be able to go to the first item + await hover(items[0]) + assertMenuLinkedWithMenuItem(getMenu(), items[0]) + + // We should be able to go to the last item + await hover(items[2]) + assertMenuLinkedWithMenuItem(getMenu(), items[2]) + }) + + it('should make a menu item active when you move the mouse over it', async () => { + renderTemplate(` + + Trigger + + alice + bob + charlie + + + `) + + // Open menu + await click(getMenuButton()) + + const items = getMenuItems() + // We should be able to go to the second item + await mouseMove(items[1]) + assertMenuLinkedWithMenuItem(getMenu(), items[1]) + }) + + it('should be a no-op when we move the mouse and the menu item is already active', async () => { + renderTemplate(` + + Trigger + + alice + bob + charlie + + + `) + + // Open menu + await click(getMenuButton()) + + const items = getMenuItems() + + // We should be able to go to the second item + await mouseMove(items[1]) + assertMenuLinkedWithMenuItem(getMenu(), items[1]) + + await mouseMove(items[1]) + + // Nothing should be changed + assertMenuLinkedWithMenuItem(getMenu(), items[1]) + }) + + it('should be a no-op when we move the mouse and the menu item is disabled', async () => { + renderTemplate(` + + Trigger + + alice + bob + charlie + + + `) + + // Open menu + await click(getMenuButton()) + + const items = getMenuItems() + + await mouseMove(items[1]) + assertNoActiveMenuItem(getMenu()) + }) + + it('should not be possible to hover an item that is disabled', async () => { + renderTemplate(` + + Trigger + + alice + bob + charlie + + + `) + + // Open menu + await click(getMenuButton()) + + const items = getMenuItems() + + // Try to hover over item 1, which is disabled + await hover(items[1]) + + // We should not have an active item now + assertNoActiveMenuItem(getMenu()) + }) + + it('should be possible to mouse leave an item and make it inactive', async () => { + renderTemplate(` + + Trigger + + alice + bob + charlie + + + `) + + // Open menu + await click(getMenuButton()) + + const items = getMenuItems() + + // We should be able to go to the second item + await hover(items[1]) + assertMenuLinkedWithMenuItem(getMenu(), items[1]) + + await unHover(items[1]) + assertNoActiveMenuItem(getMenu()) + + // We should be able to go to the first item + await hover(items[0]) + assertMenuLinkedWithMenuItem(getMenu(), items[0]) + + await unHover(items[0]) + assertNoActiveMenuItem(getMenu()) + + // We should be able to go to the last item + await hover(items[2]) + assertMenuLinkedWithMenuItem(getMenu(), items[2]) + + await unHover(items[2]) + assertNoActiveMenuItem(getMenu()) + }) + + it('should be possible to mouse leave a disabled item and be a no-op', async () => { + renderTemplate(` + + Trigger + + alice + bob + charlie + + + `) + + // Open menu + await click(getMenuButton()) + + const items = getMenuItems() + + // Try to hover over item 1, which is disabled + await hover(items[1]) + assertNoActiveMenuItem(getMenu()) + + await unHover(items[1]) + assertNoActiveMenuItem(getMenu()) + }) + + it('should be possible to click a menu item, which closes the menu', async () => { + renderTemplate(` + + Trigger + + alice + bob + charlie + + + `) + + // Open menu + await click(getMenuButton()) + assertMenu(getMenu(), { state: MenuState.Open }) + + const items = getMenuItems() + + // We should be able to click the first item + await click(items[1]) + assertMenu(getMenu(), { state: MenuState.Closed }) + }) + + it('should be possible to click a disabled menu item, which is a no-op', async () => { + renderTemplate(` + + Trigger + + alice + bob + charlie + + + `) + + // Open menu + await click(getMenuButton()) + assertMenu(getMenu(), { state: MenuState.Open }) + + const items = getMenuItems() + + // We should be able to click the first item + await click(items[1]) + assertMenu(getMenu(), { state: MenuState.Open }) + }) + + it('should be possible focus a menu item, so that it becomes active', async () => { + renderTemplate(` + + Trigger + + alice + bob + charlie + + + `) + + // Open menu + await click(getMenuButton()) + assertMenu(getMenu(), { state: MenuState.Open }) + + const items = getMenuItems() + + // Verify that nothing is active yet + assertNoActiveMenuItem(getMenu()) + + // We should be able to focus the first item + await focus(items[1]) + assertMenuLinkedWithMenuItem(getMenu(), items[1]) + }) + + it('should not be possible to focus a menu item which is disabled', async () => { + renderTemplate(` + + Trigger + + alice + bob + charlie + + + `) + + // Open menu + await click(getMenuButton()) + assertMenu(getMenu(), { state: MenuState.Open }) + + const items = getMenuItems() + + // We should not be able to focus the first item + await focus(items[1]) + assertNoActiveMenuItem(getMenu()) + }) + + it('should not be possible to activate a disabled item', async () => { + const clickHandler = jest.fn() + + renderTemplate({ + template: ` + + Trigger + + alice + + bob + + charlie + + + `, + setup: () => ({ clickHandler }), + }) + + // Open menu + await click(getMenuButton()) + assertMenu(getMenu(), { state: MenuState.Open }) + + const items = getMenuItems() + + await focus(items[0]) + await focus(items[1]) + await press(Keys.Enter) + + expect(clickHandler).not.toHaveBeenCalled() + }) +}) diff --git a/packages/@headlessui-vue/src/components/menu/menu.ts b/packages/@headlessui-vue/src/components/menu/menu.ts new file mode 100644 index 0000000..e8aaafe --- /dev/null +++ b/packages/@headlessui-vue/src/components/menu/menu.ts @@ -0,0 +1,491 @@ +import { + defineComponent, + ref, + provide, + inject, + onMounted, + onUnmounted, + computed, + nextTick, + InjectionKey, + Ref, +} from 'vue' +import { match } from '../../utils/match' +import { render } from '../../utils/render' +import { useId } from '../../hooks/use-id' + +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, + NextItem, + LastItem, + SpecificItem, + Nothing, +} + +type MenuItemDataRef = Ref<{ textValue: string; disabled: boolean }> +type StateDefinition = { + // State + menuState: Ref + buttonRef: Ref + itemsRef: Ref + items: Ref<{ id: string; dataRef: MenuItemDataRef }[]> + searchQuery: Ref + activeItemIndex: Ref + + // State mutators + toggleMenu(): void + closeMenu(): void + openMenu(): void + goToItem(focus: Focus, id?: string): void + search(value: string): void + clearSearch(): void + registerItem(id: string, dataRef: MenuItemDataRef): void + unregisterItem(id: string): void +} + +const MenuContext = Symbol('MenuContext') as InjectionKey + +function useMenuContext(component: string) { + const context = inject(MenuContext) + + if (context === undefined) { + const err = new Error(`<${component} /> is missing a parent component.`) + if (Error.captureStackTrace) Error.captureStackTrace(err, useMenuContext) + throw err + } + + return context +} + +export const Menu = defineComponent({ + props: { as: { type: [Object, String], default: 'template' } }, + setup(props, { slots, attrs }) { + const menuState = ref(MenuStates.Closed) + const buttonRef = ref(null) + const itemsRef = ref(null) + const items = ref([]) + const searchQuery = ref('') + const activeItemIndex = ref(null) + + function calculateActiveItemIndex(focus: Focus, id?: string) { + if (items.value.length <= 0) return null + + const currentActiveItemIndex = activeItemIndex.value ?? -1 + + const nextActiveIndex = match(focus, { + [Focus.FirstItem]: () => items.value.findIndex(item => !item.dataRef.disabled), + [Focus.PreviousItem]: () => { + const idx = items.value + .slice() + .reverse() + .findIndex((item, idx, all) => { + if (currentActiveItemIndex !== -1 && all.length - idx - 1 >= currentActiveItemIndex) + return false + return !item.dataRef.disabled + }) + if (idx === -1) return idx + return items.value.length - 1 - idx + }, + [Focus.NextItem]: () => { + return items.value.findIndex((item, idx) => { + if (idx <= currentActiveItemIndex) return false + return !item.dataRef.disabled + }) + }, + [Focus.LastItem]: () => { + const idx = items.value + .slice() + .reverse() + .findIndex(item => !item.dataRef.disabled) + if (idx === -1) return idx + return items.value.length - 1 - idx + }, + [Focus.SpecificItem]: () => items.value.findIndex(item => item.id === id), + [Focus.Nothing]: () => null, + }) + + if (nextActiveIndex === -1) return activeItemIndex.value + return nextActiveIndex + } + + const api = { + menuState, + buttonRef, + itemsRef, + items, + searchQuery, + activeItemIndex, + toggleMenu() { + menuState.value = match(menuState.value, { + [MenuStates.Closed]: MenuStates.Open, + [MenuStates.Open]: MenuStates.Closed, + }) + }, + closeMenu: () => (menuState.value = MenuStates.Closed), + openMenu: () => (menuState.value = MenuStates.Open), + goToItem(focus: Focus, id?: string) { + const nextActiveItemIndex = calculateActiveItemIndex(focus, id) + if (searchQuery.value === '' && activeItemIndex.value === nextActiveItemIndex) return + searchQuery.value = '' + activeItemIndex.value = nextActiveItemIndex + }, + search(value: string) { + searchQuery.value += value + + const match = items.value.findIndex( + item => item.dataRef.textValue.startsWith(searchQuery.value) && !item.dataRef.disabled + ) + + if (match === -1 || match === activeItemIndex.value) { + return + } + + activeItemIndex.value = match + }, + clearSearch() { + searchQuery.value = '' + }, + registerItem(id: string, dataRef: MenuItemDataRef) { + // @ts-expect-error The expected type comes from property 'dataRef' which is declared here on type '{ id: string; dataRef: { textValue: string; disabled: boolean; }; }' + items.value.push({ id, dataRef }) + }, + unregisterItem(id: string) { + const nextItems = items.value.slice() + const currentActiveItem = + activeItemIndex.value !== null ? nextItems[activeItemIndex.value] : null + const idx = nextItems.findIndex(a => a.id === id) + if (idx !== -1) nextItems.splice(idx, 1) + items.value = nextItems + activeItemIndex.value = (() => { + if (idx === activeItemIndex.value) return null + if (currentActiveItem === null) return null + + // If we removed the item before the actual active index, then it would be out of sync. To + // fix this, we will find the correct (new) index position. + return nextItems.indexOf(currentActiveItem) + })() + }, + } + + onMounted(() => { + function handler(event: PointerEvent) { + if (event.defaultPrevented) return + if (menuState.value !== MenuStates.Open) return + + if (!itemsRef.value?.contains(event.target as HTMLElement)) { + api.closeMenu() + nextTick(() => buttonRef.value?.focus()) + } + } + + window.addEventListener('pointerdown', handler) + onUnmounted(() => window.removeEventListener('pointerdown', handler)) + }) + + // @ts-expect-error Types of property 'dataRef' are incompatible. + provide(MenuContext, api) + + return () => { + const slot = { open: menuState.value === MenuStates.Open } + return render({ props, slot, slots, attrs }) + } + }, +}) + +export const MenuButton = defineComponent({ + props: { as: { type: [Object, String], default: 'button' } }, + render() { + const api = useMenuContext('MenuButton') + + const slot = { open: api.menuState.value === MenuStates.Open } + const propsWeControl = { + ref: 'el', + id: this.id, + type: 'button', + 'aria-haspopup': true, + 'aria-controls': api.itemsRef.value?.id, + 'aria-expanded': api.menuState.value === MenuStates.Open ? true : undefined, + onKeyDown: this.handleKeyDown, + onFocus: this.handleFocus, + onPointerUp: this.handlePointerUp, + onPointerDown: this.handlePointerDown, + } + + return render({ + props: { ...this.$props, ...propsWeControl }, + slot, + attrs: this.$attrs, + slots: this.$slots, + }) + }, + setup() { + const api = useMenuContext('MenuButton') + const id = `headlessui-menu-button-${useId()}` + + function handleKeyDown(event: KeyboardEvent) { + 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: + event.preventDefault() + api.openMenu() + nextTick(() => { + api.itemsRef.value?.focus() + api.goToItem(Focus.FirstItem) + }) + break + + case Key.ArrowUp: + event.preventDefault() + api.openMenu() + nextTick(() => { + api.itemsRef.value?.focus() + api.goToItem(Focus.LastItem) + }) + break + } + } + + function handlePointerDown(event: PointerEvent) { + // We have a `pointerdown` event listener in the menu for the 'outside click', so we just want + // to prevent going there if we happen to click this button. + event.preventDefault() + } + + function handlePointerUp() { + api.toggleMenu() + nextTick(() => api.itemsRef.value?.focus()) + } + + function handleFocus() { + if (api.menuState.value === MenuStates.Open) api.itemsRef.value?.focus() + } + + return { + id, + el: api.buttonRef, + handleKeyDown, + handlePointerDown, + handlePointerUp, + handleFocus, + } + }, +}) + +export const MenuItems = defineComponent({ + props: { + as: { type: [Object, String], default: 'div' }, + static: { type: Boolean, default: false }, + }, + render() { + const api = useMenuContext('MenuItems') + + // `static` is a reserved keyword, therefore aliasing it... + const { static: isStatic, ...passThroughProps } = this.$props + + if (!isStatic && api.menuState.value === MenuStates.Closed) return null + + const slot = { open: api.menuState.value === MenuStates.Open } + const propsWeControl = { + 'aria-activedescendant': + api.activeItemIndex.value === null + ? undefined + : api.items.value[api.activeItemIndex.value]?.id, + 'aria-labelledby': api.buttonRef.value?.id, + id: this.id, + onKeyDown: this.handleKeyDown, + role: 'menu', + tabIndex: 0, + ref: 'el', + } + + return render({ + props: { ...passThroughProps, ...propsWeControl }, + slot, + attrs: this.$attrs, + slots: this.$slots, + }) + }, + setup() { + const api = useMenuContext('MenuItems') + const id = `headlessui-menu-items-${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 + + case Key.Enter: + api.closeMenu() + if (api.activeItemIndex.value !== null) { + const { id } = api.items.value[api.activeItemIndex.value] + document.getElementById(id)?.click() + nextTick(() => api.buttonRef.value?.focus()) + } + break + + case Key.ArrowDown: + return api.goToItem(Focus.NextItem) + + case Key.ArrowUp: + return api.goToItem(Focus.PreviousItem) + + case Key.Home: + case Key.PageUp: + return api.goToItem(Focus.FirstItem) + + case Key.End: + case Key.PageDown: + return api.goToItem(Focus.LastItem) + + case Key.Escape: + api.closeMenu() + nextTick(() => api.buttonRef.value?.focus()) + break + + case Key.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.itemsRef, + handleKeyDown, + } + }, +}) + +export const MenuItem = defineComponent({ + props: { + as: { type: [Object, String], default: 'template' }, + disabled: { type: Boolean, default: false }, + class: { type: [String, Function], required: false }, + className: { type: [String, Function], required: false }, + onClick: { type: Function, required: false }, + }, + setup(props, { slots, attrs }) { + const api = useMenuContext('MenuItem') + const id = `headlessui-menu-item-${useId()}` + const { disabled, class: defaultClass, className = defaultClass } = props + + const active = computed(() => { + return api.activeItemIndex.value !== null + ? api.items.value[api.activeItemIndex.value].id === id + : false + }) + + const dataRef = ref({ disabled: disabled, textValue: '' }) + onMounted(() => { + const textValue = document + .getElementById(id) + ?.textContent?.toLowerCase() + .trim() + if (textValue !== undefined) dataRef.value.textValue = textValue + }) + + onMounted(() => api.registerItem(id, dataRef)) + onUnmounted(() => api.unregisterItem(id)) + + function handlePointerEnter() { + if (disabled) return + api.goToItem(Focus.SpecificItem, id) + } + + function handleFocus() { + if (disabled) return api.goToItem(Focus.Nothing) + api.goToItem(Focus.SpecificItem, id) + } + + function handlePointerLeave() { + if (disabled) return + api.goToItem(Focus.Nothing) + } + + function handleMouseMove() { + if (disabled) return + if (active.value) return + api.goToItem(Focus.SpecificItem, id) + } + + function handlePointerUp(event: PointerEvent) { + if (disabled) return + event.preventDefault() + api.closeMenu() + nextTick(() => api.buttonRef.value?.focus()) + } + + function handleClick(event: MouseEvent) { + if (disabled) return event.preventDefault() + if (props.onClick) return props.onClick(event) + } + + return () => { + const slot = { active: active.value, disabled } + const propsWeControl = { + id, + role: 'menuitem', + tabIndex: -1, + class: resolvePropValue(className, slot), + disabled: disabled === true ? disabled : undefined, + 'aria-disabled': disabled === true ? disabled : undefined, + onClick: handleClick, + onFocus: handleFocus, + onMouseMove: handleMouseMove, + onPointerEnter: handlePointerEnter, + onPointerLeave: handlePointerLeave, + onPointerUp: handlePointerUp, + } + + 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/hooks/use-id.ts b/packages/@headlessui-vue/src/hooks/use-id.ts new file mode 100644 index 0000000..a48331c --- /dev/null +++ b/packages/@headlessui-vue/src/hooks/use-id.ts @@ -0,0 +1,14 @@ +if (process.env.JEST_WORKER_ID !== undefined) { + beforeEach(() => { + id = 0 + }) +} + +let id = 0 +function generateId() { + return ++id +} + +export function useId() { + return generateId() +} diff --git a/packages/@headlessui-vue/src/index.test.ts b/packages/@headlessui-vue/src/index.test.ts new file mode 100644 index 0000000..e29b5d8 --- /dev/null +++ b/packages/@headlessui-vue/src/index.test.ts @@ -0,0 +1,15 @@ +import * as HeadlessUI from './index' + +/** + * Looks a bit of a silly test, however this ensures that we don't accidentally expose something to + * the outside world that we didn't want! + */ +it('should expose the correct components', () => { + expect(Object.keys(HeadlessUI)).toEqual([ + // Menu + 'Menu', + 'MenuButton', + 'MenuItems', + 'MenuItem', + ]) +}) diff --git a/packages/@headlessui-vue/src/index.ts b/packages/@headlessui-vue/src/index.ts new file mode 100644 index 0000000..7b9c88b --- /dev/null +++ b/packages/@headlessui-vue/src/index.ts @@ -0,0 +1 @@ +export * from './components/menu/menu' diff --git a/packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts b/packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts new file mode 100644 index 0000000..3394dec --- /dev/null +++ b/packages/@headlessui-vue/src/test-utils/accessibility-assertions.ts @@ -0,0 +1,158 @@ +export enum MenuButtonState { + Open, + Closed, +} + +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) { + 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) + + if (options.state === MenuButtonState.Open) { + expect(button.hasAttribute('aria-controls')).toBe(true) + expect(button.getAttribute('aria-expanded')).toBe('true') + } + + if (options.state === MenuButtonState.Closed) { + expect(button.getAttribute('aria-controls')).toBeNull() + expect(button.getAttribute('aria-expanded')).toBeNull() + } + + if (options.textContent) { + expect(button.textContent?.trim()).toBe(options.textContent.trim()) + } + + // Ensure menu button has the following attributes + for (let attributeName in options.attributes) { + expect(button.getAttribute(attributeName)).toEqual(options.attributes[attributeName]) + } + } catch (err) { + if (Error.captureStackTrace) Error.captureStackTrace(err, assertMenuButton) + throw err + } +} + +export function assertMenuButtonLinkedWithMenu( + button: HTMLElement | null, + menu: HTMLElement | null +) { + 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')) + } catch (err) { + if (Error.captureStackTrace) Error.captureStackTrace(err, assertMenuButtonLinkedWithMenu) + throw err + } +} + +export function assertMenuLinkedWithMenuItem(menu: HTMLElement | null, item: HTMLElement | null) { + 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')) + } catch (err) { + if (Error.captureStackTrace) Error.captureStackTrace(err, assertMenuLinkedWithMenuItem) + throw err + } +} + +export function assertNoActiveMenuItem(menu: HTMLElement | null) { + 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) + } catch (err) { + if (Error.captureStackTrace) Error.captureStackTrace(err, assertNoActiveMenuItem) + throw err + } +} + +type MenuOptions = { attributes?: Record } & ( + | { state: MenuState.Closed } + | { state: MenuState.Open } +) +export function assertMenu(menu: HTMLElement | null, options: MenuOptions) { + try { + if (options.state === 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 we have the correct values for certain attributes + expect(menu.getAttribute('role')).toBe('menu') + + // Check that the menu is focused + expect(document.activeElement).toBe(menu) + + // Ensure menu button has the following attributes + for (let attributeName in options.attributes) { + expect(menu.getAttribute(attributeName)).toEqual(options.attributes[attributeName]) + } + } + + if (options.state === MenuState.Closed) { + expect(menu).toBeNull() + } + } catch (err) { + if (Error.captureStackTrace) Error.captureStackTrace(err, assertMenu) + throw err + } +} + +type MenuItemOptions = { tag: string; attributes?: Record } +export function assertMenuItem(item: HTMLElement | null, options?: MenuItemOptions) { + 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) + + // Check that we have the correct values for certain attributes + expect(item.getAttribute('role')).toBe('menuitem') + expect(item.getAttribute('tabindex')).toBe('-1') + + if (options?.tag) { + expect(item.tagName.toLowerCase()).toBe(options.tag) + } + + // Ensure menu item has the following attributes + for (let attributeName in options?.attributes) { + expect(item.getAttribute(attributeName)).toEqual(options?.attributes[attributeName]) + } + } catch (err) { + if (Error.captureStackTrace) Error.captureStackTrace(err, assertMenuItem) + 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) + throw err + } +} diff --git a/packages/@headlessui-vue/src/test-utils/interactions.ts b/packages/@headlessui-vue/src/test-utils/interactions.ts new file mode 100644 index 0000000..f46042e --- /dev/null +++ b/packages/@headlessui-vue/src/test-utils/interactions.ts @@ -0,0 +1,130 @@ +import { nextTick } from 'vue' +import { fireEvent } from '@testing-library/dom' + +export const Keys: Record> = { + Space: { key: ' ' }, + Enter: { key: 'Enter' }, + Escape: { key: 'Escape' }, + Backspace: { key: 'Backspace' }, + + ArrowUp: { key: 'ArrowUp' }, + ArrowDown: { key: 'ArrowDown' }, + + Home: { key: 'Home' }, + End: { key: 'End' }, + + PageUp: { key: 'PageUp' }, + PageDown: { key: 'PageDown' }, + + Tab: { key: 'Tab' }, +} + +export function shift(event: Partial) { + return { ...event, shiftKey: true } +} + +export function word(input: string): Partial[] { + return input.split('').map(key => ({ key })) +} + +export async function type(events: Partial[]) { + jest.useFakeTimers() + + try { + if (document.activeElement === null) return expect(document.activeElement).not.toBe(null) + + const element = document.activeElement + + events.forEach(event => { + fireEvent.keyDown(element, event) + }) + + // We don't want to actually wait in our tests, so let's advance + jest.runAllTimers() + + await new Promise(nextTick) + } catch (err) { + if (Error.captureStackTrace) Error.captureStackTrace(err, type) + throw err + } finally { + jest.useRealTimers() + } +} + +export async function press(event: Partial) { + return type([event]) +} + +export async function click(element: Document | Element | Window | null) { + try { + if (element === null) return expect(element).not.toBe(null) + + fireEvent.pointerDown(element) + fireEvent.mouseDown(element) + fireEvent.pointerUp(element) + fireEvent.mouseUp(element) + fireEvent.click(element) + + await new Promise(nextTick) + } catch (err) { + if (Error.captureStackTrace) Error.captureStackTrace(err, click) + throw err + } +} + +export async function focus(element: Document | Element | Window | null) { + try { + if (element === null) return expect(element).not.toBe(null) + + fireEvent.focus(element) + + await new Promise(nextTick) + } catch (err) { + if (Error.captureStackTrace) Error.captureStackTrace(err, focus) + throw err + } +} + +export async function mouseMove(element: Document | Element | Window | null) { + try { + if (element === null) return expect(element).not.toBe(null) + + fireEvent.mouseMove(element) + + await new Promise(nextTick) + } catch (err) { + if (Error.captureStackTrace) Error.captureStackTrace(err, mouseMove) + throw err + } +} + +export async function hover(element: Document | Element | Window | null) { + try { + if (element === null) return expect(element).not.toBe(null) + + fireEvent.pointerOver(element) + fireEvent.pointerEnter(element) + fireEvent.mouseOver(element) + + await new Promise(nextTick) + } catch (err) { + if (Error.captureStackTrace) Error.captureStackTrace(err, hover) + throw err + } +} + +export async function unHover(element: Document | Element | Window | null) { + try { + if (element === null) return expect(element).not.toBe(null) + + fireEvent.pointerOut(element) + fireEvent.pointerLeave(element) + fireEvent.mouseOut(element) + fireEvent.mouseLeave(element) + + await new Promise(nextTick) + } catch (err) { + if (Error.captureStackTrace) Error.captureStackTrace(err, unHover) + throw err + } +} diff --git a/packages/@headlessui-vue/src/test-utils/suppress-console-logs.ts b/packages/@headlessui-vue/src/test-utils/suppress-console-logs.ts new file mode 100644 index 0000000..0d4e1c3 --- /dev/null +++ b/packages/@headlessui-vue/src/test-utils/suppress-console-logs.ts @@ -0,0 +1,17 @@ +type FunctionPropertyNames = { + [K in keyof T]: T[K] extends (...args: any[]) => any ? K : never +}[keyof T] & + string + +export function suppressConsoleLogs( + cb: (...args: T) => void, + type: FunctionPropertyNames = 'warn' +) { + return (...args: T) => { + const spy = jest.spyOn(global.console, type).mockImplementation(jest.fn()) + + return new Promise((resolve, reject) => { + Promise.resolve(cb(...args)).then(resolve, reject) + }).finally(() => spy.mockRestore()) + } +} diff --git a/packages/@headlessui-vue/src/test-utils/vue-testing-library.ts b/packages/@headlessui-vue/src/test-utils/vue-testing-library.ts new file mode 100644 index 0000000..c1f8cf7 --- /dev/null +++ b/packages/@headlessui-vue/src/test-utils/vue-testing-library.ts @@ -0,0 +1,53 @@ +import { mount } from '@vue/test-utils' +import { logDOM, fireEvent } from '@testing-library/dom' + +const mountedWrappers = new Set() + +export function render( + TestComponent: any, + options?: Omit[1], 'attachTo'> +) { + const div = document.createElement('div') + const baseElement = document.body + const container = baseElement.appendChild(div) + + const attachTo = document.createElement('div') + container.appendChild(attachTo) + + const wrapper = mount(TestComponent, { + ...options, + attachTo, + }) + + mountedWrappers.add(wrapper) + container.appendChild(wrapper.element) + + return { + debug() { + logDOM(div) + }, + } +} + +function cleanup() { + mountedWrappers.forEach(cleanupAtWrapper) +} + +function cleanupAtWrapper(wrapper) { + if (wrapper.element.parentNode && wrapper.element.parentNode.parentNode === document.body) { + document.body.removeChild(wrapper.element.parentNode) + } + + try { + wrapper.unmount() + } catch { + } finally { + mountedWrappers.delete(wrapper) + } +} + +if (typeof afterEach === 'function') { + afterEach(() => cleanup()) +} + +export { fireEvent } diff --git a/packages/@headlessui-vue/src/utils/match.ts b/packages/@headlessui-vue/src/utils/match.ts new file mode 100644 index 0000000..a54b225 --- /dev/null +++ b/packages/@headlessui-vue/src/utils/match.ts @@ -0,0 +1,24 @@ +export function match( + value: TValue, + lookup: Record TReturnValue)>, + ...args: any[] +): TReturnValue { + if (value in lookup) { + const returnValue = lookup[value] + return typeof returnValue === 'function' ? returnValue(...args) : returnValue + } + + const error = new Error( + `Tried to handle "${value}" but there is no handler defined. Only defined handlers are: ${Object.keys( + lookup + ) + .map(key => `"${key}"`) + .join(', ')}.` + ) + + if (Error.captureStackTrace) { + Error.captureStackTrace(error, match) + } + + throw error +} diff --git a/packages/@headlessui-vue/src/utils/render.ts b/packages/@headlessui-vue/src/utils/render.ts new file mode 100644 index 0000000..c80e225 --- /dev/null +++ b/packages/@headlessui-vue/src/utils/render.ts @@ -0,0 +1,32 @@ +import { h, cloneVNode, Slots } from 'vue' + +export function render({ + props, + attrs, + slots, + slot, +}: { + props: Record + slot: Record + attrs: Record + slots: Slots +}) { + const { as, ...passThroughProps } = props + + const children = slots.default?.(slot) + + if (as === 'template') { + if (Object.keys(passThroughProps).length > 0 || 'class' in attrs) { + const [firstChild, ...other] = children ?? [] + + if (other.length > 0) + throw new Error('You should only render 1 child or use the `as="..."` prop') + + return cloneVNode(firstChild, passThroughProps as Record) + } + + return children + } + + return h(as, passThroughProps, children) +} diff --git a/packages/@headlessui-vue/tailwind.config.js b/packages/@headlessui-vue/tailwind.config.js new file mode 100644 index 0000000..6bb435f --- /dev/null +++ b/packages/@headlessui-vue/tailwind.config.js @@ -0,0 +1 @@ +module.exports = require('../../tailwind.config.js') diff --git a/packages/@headlessui-vue/tsconfig.json b/packages/@headlessui-vue/tsconfig.json new file mode 100644 index 0000000..276f318 --- /dev/null +++ b/packages/@headlessui-vue/tsconfig.json @@ -0,0 +1,32 @@ +{ + "include": ["src", "types"], + "compilerOptions": { + "module": "esnext", + "lib": ["dom", "esnext"], + "importHelpers": true, + "declaration": true, + "sourceMap": true, + "rootDir": "./src", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "moduleResolution": "node", + "baseUrl": "./", + "paths": { + "@headlessui/vue": ["src"], + "*": ["src/*", "node_modules/*"] + }, + "types": ["@types/node", "vue", "@types/jest"], + "esModuleInterop": true, + "target": "es5", + "allowJs": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "resolveJsonModule": true, + "isolatedModules": true + }, + "exclude": ["node_modules", "dist"] +} diff --git a/packages/@headlessui-vue/tsconfig.tsdx.json b/packages/@headlessui-vue/tsconfig.tsdx.json new file mode 100644 index 0000000..fc8520e --- /dev/null +++ b/packages/@headlessui-vue/tsconfig.tsdx.json @@ -0,0 +1,3 @@ +{ + "extends": "./tsconfig.json" +} diff --git a/packages/@headlessui-vue/vite.config.js b/packages/@headlessui-vue/vite.config.js new file mode 100644 index 0000000..d414f6d --- /dev/null +++ b/packages/@headlessui-vue/vite.config.js @@ -0,0 +1,21 @@ +const TailwindUIPlugin = ({ + root, // project root directory, absolute path + app, // Koa app instance + server, // raw http server instance + watcher, // chokidar file watcher instance + resolver, // chokidar file watcher instance +}) => { + app.use(async (ctx, next) => { + if (ctx.path === '/') ctx.path = '/examples' + if (ctx.path.endsWith('@headlessui/vue')) { + ctx.type = 'ts' + ctx.path = '/src/index.ts' + } + + await next() + }) +} + +module.exports = { + configureServer: [TailwindUIPlugin], +}