├── .gitignore ├── src ├── index.js ├── util.js ├── popover.js ├── dialog.js └── menu.js ├── test ├── util.spec.js ├── popover.html ├── dialog.spec.js ├── dialog.html ├── popover.spec.js ├── menu.html └── menu.spec.js ├── CHANGELOG.md ├── LICENCE ├── package.json ├── playwright.config.js └── README.markdown /.gitignore: -------------------------------------------------------------------------------- 1 | package-lock.json 2 | node_modules/ 3 | /test-results/ 4 | /playwright-report/ 5 | /playwright/.cache/ 6 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { default as Menu } from './menu.js' 2 | export { default as Dialog } from './dialog.js' 3 | export { default as Popover } from './popover.js' 4 | -------------------------------------------------------------------------------- /test/util.spec.js: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test' 2 | import { keyboardFocusableElements, uid } from '../src//util.js' 3 | 4 | test('uid', () => { 5 | const a = uid() 6 | const b = uid() 7 | expect(a).not.toEqual(b) 8 | }) 9 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | export function keyboardFocusableElements(element) { 2 | return [ 3 | ...element.querySelectorAll( 4 | 'a[href], button, input:not([type="hidden"]), select, textarea, summary, [tabindex]:not([tabindex="-1"])' 5 | ) 6 | ].filter(el => 7 | !el.hasAttribute('disabled') && !el.getAttribute('aria-hidden') 8 | ) 9 | } 10 | 11 | export function uid() { 12 | return String( 13 | Date.now().toString(32) + Math.random().toString(16) 14 | ).replace(/\./g, '') 15 | } 16 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | 4 | ## Unreleased 5 | 6 | * Dialog: enable toggling of classes on `` element. 7 | * Dialog: allow for no backdrop. 8 | 9 | 10 | ## v0.0.4 (22 May 2023) 11 | 12 | * Add Popper component. 13 | 14 | 15 | ## v0.0.3 (16 May 2023) 16 | 17 | * Add Dialog (Modal) component. 18 | * Complete Menu (Dropdown) component. 19 | 20 | 21 | ## v0.0.2 (24 April 2023) 22 | 23 | * Add basic Menu (Dropdown) component. 24 | 25 | 26 | ## v0.0.1 (24 April 2023) 27 | 28 | * Set up repo structure. 29 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Andrew Stewart 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "headlessui-stimulus", 3 | "version": "0.0.4", 4 | "description": "Stimulus components for Headless UI", 5 | "author": "Andrew Stewart ", 6 | "type": "module", 7 | "main": "src/index.js", 8 | "source": "src/index.js", 9 | "exports": "./src/index.js", 10 | "license": "MIT", 11 | "keywords": [ 12 | "stimulus", 13 | "stimulusjs", 14 | "headlessui", 15 | "components" 16 | ], 17 | "repository": { 18 | "type": "git", 19 | "url": "https://github.com/airblade/headlessui-stimulus.git" 20 | }, 21 | "bugs": { 22 | "url": "https://github.com/airblade/headlessui-stimulus/issues" 23 | }, 24 | "homepage": "https://github.com/airblade/headlessui-stimulus", 25 | "dependencies": { 26 | "el-transition": "0.0.7" 27 | }, 28 | "peerDependencies": { 29 | "@hotwired/stimulus": "^3.0.0" 30 | }, 31 | "devDependencies": { 32 | "@hotwired/stimulus": "^3.0.0", 33 | "@playwright/test": "^1.32.3", 34 | "http-server": "^14.1.1" 35 | }, 36 | "scripts": { 37 | "start": "http-server -p 8000 --cors", 38 | "test": "npx playwright test", 39 | "release": "git tag -a -m 'Version $npm_package_version' v$npm_package_version && git push && git push --tags && npm login && npm publish && echo 'Remember to update version on demo page'" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /playwright.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { defineConfig, devices } from '@playwright/test'; 3 | 4 | /** 5 | * Read environment variables from file. 6 | * https://github.com/motdotla/dotenv 7 | */ 8 | // require('dotenv').config(); 9 | 10 | /** 11 | * @see https://playwright.dev/docs/test-configuration 12 | */ 13 | // const config = { 14 | export default defineConfig({ 15 | testDir: './test', 16 | /* Run tests in files in parallel */ 17 | fullyParallel: true, 18 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 19 | forbidOnly: false, 20 | /* Retry on CI only */ 21 | retries: 0, 22 | /* Opt out of parallel tests on CI. */ 23 | workers: undefined, 24 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 25 | reporter: 'html', 26 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 27 | use: { 28 | /* Base URL to use in actions like `await page.goto('/')`. */ 29 | baseURL: 'http://127.0.0.1:8000/test/', 30 | 31 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 32 | trace: 'on-first-retry', 33 | }, 34 | 35 | /* Configure projects for major browsers */ 36 | projects: [ 37 | { 38 | name: 'chromium', 39 | use: { ...devices['Desktop Chrome'] }, 40 | }, 41 | 42 | { 43 | name: 'firefox', 44 | use: { ...devices['Desktop Firefox'] }, 45 | }, 46 | 47 | { 48 | name: 'webkit', 49 | use: { ...devices['Desktop Safari'] }, 50 | }, 51 | 52 | /* Test against mobile viewports. */ 53 | // { 54 | // name: 'Mobile Chrome', 55 | // use: { ...devices['Pixel 5'] }, 56 | // }, 57 | // { 58 | // name: 'Mobile Safari', 59 | // use: { ...devices['iPhone 12'] }, 60 | // }, 61 | 62 | /* Test against branded browsers. */ 63 | // { 64 | // name: 'Microsoft Edge', 65 | // use: { ...devices['Desktop Edge'], channel: 'msedge' }, 66 | // }, 67 | // { 68 | // name: 'Google Chrome', 69 | // use: { ..devices['Desktop Chrome'], channel: 'chrome' }, 70 | // }, 71 | ], 72 | 73 | /* Run your local dev server before starting the tests */ 74 | webServer: { 75 | command: 'npm run start', 76 | url: 'http://127.0.0.1:8000/test/', 77 | reuseExistingServer: true, 78 | }, 79 | }); 80 | -------------------------------------------------------------------------------- /test/popover.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 17 | 18 | 19 | 20 | 28 | 29 | 30 | 31 |
32 |
37 | 45 | 50 | 61 |
62 | 63 |
64 | 69 |
70 |
71 | 72 | 73 | -------------------------------------------------------------------------------- /test/dialog.spec.js: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test' 2 | 3 | 4 | test.beforeEach(async ({ page }, testInfo) => { 5 | await page.goto('dialog.html') 6 | }) 7 | 8 | 9 | test('dialog is opened initially', async ({ page }) => { 10 | await expect(page.locator('[data-controller="dialog"]')).toHaveAttribute('aria-modal', 'true') 11 | await expect(page.locator('[data-controller="dialog"]')).toHaveAttribute('role', 'dialog') 12 | 13 | let id = await page.locator('[data-dialog-target="title"]').getAttribute('id') 14 | await expect(page.locator('[data-controller="dialog"]')).toHaveAttribute('aria-labelledby', id) 15 | 16 | id = await page.locator('[data-dialog-target="description"]').getAttribute('id') 17 | await expect(page.locator('[data-controller="dialog"]')).toHaveAttribute('aria-describedby', id) 18 | 19 | await expect(page.locator('[data-dialog-target="backdrop"]')).toBeVisible() 20 | await expect(page.locator('[data-dialog-target="panel"]')).toBeVisible() 21 | }) 22 | 23 | 24 | test('mouse click outside open dialog closes dialog', async ({ page }) => { 25 | await page.locator('#canvas').click({force: true, position: {x: 0, y: 0}}) 26 | await expect(page.locator('[data-dialog-target="backdrop"]')).not.toBeVisible() 27 | await expect(page.locator('[data-dialog-target="panel"]')).not.toBeVisible() 28 | }) 29 | 30 | 31 | test('escape with open dialog closes dialog', async ({ page }) => { 32 | await page.keyboard.press('Escape') 33 | await expect(page.locator('[data-dialog-target="backdrop"]')).not.toBeVisible() 34 | await expect(page.locator('[data-dialog-target="panel"]')).not.toBeVisible() 35 | }) 36 | 37 | 38 | test('intial focus with open dialog', async ({ page }) => { 39 | await expect(page.locator('[data-dialog-target="initialFocus"]')).toBeFocused() 40 | }) 41 | 42 | 43 | test('tab when dialog is open focuses next item', async ({ page }) => { 44 | await page.keyboard.press('Tab') 45 | await expect(page.getByRole('link', {name: 'payment'})).toBeFocused() 46 | }) 47 | 48 | 49 | test('shift+tab when dialog is open focuses previous item', async ({ page }) => { 50 | await page.keyboard.press('Shift+Tab') 51 | await expect(page.getByRole('link', {name: 'your order'})).toBeFocused() 52 | }) 53 | 54 | 55 | test('toggles classes on html element', async ({ page }) => { 56 | await expect(page.locator('html')).toHaveClass('overflow-hidden') 57 | await page.keyboard.press('Escape') 58 | await expect(page.locator('html')).not.toHaveClass('overflow-hidden') 59 | }) 60 | -------------------------------------------------------------------------------- /src/popover.js: -------------------------------------------------------------------------------- 1 | import { Controller } from '@hotwired/stimulus' 2 | import { enter, leave } from 'el-transition' 3 | import { keyboardFocusableElements, uid } from './util.js' 4 | 5 | // TODO popover group - https://github.com/tailwindlabs/headlessui/discussions/2503 6 | export default class extends Controller { 7 | 8 | static targets = ['button', 'panel', 'overlay'] 9 | static values = { 10 | open: Boolean, 11 | focusPanel: Boolean, 12 | focusOnClose: String, 13 | unmount: Boolean 14 | } 15 | 16 | initialize() { 17 | this.boundCloseOnClickOutsideElement = this.closeOnClickOutsideElement.bind(this) 18 | } 19 | 20 | connect() { 21 | this.connected = true 22 | this.setAriaAttributes() 23 | } 24 | 25 | disconnect() { 26 | window.removeEventListener('click', this.boundCloseOnClickOutsideElement) 27 | } 28 | 29 | closeOnClickOutsideElement(event) { 30 | if (!this.element.contains(event.target)) this.close() 31 | } 32 | 33 | openValueChanged(value, prev) { 34 | if (value) { 35 | this.doOpen() 36 | } 37 | else { 38 | this.doClose() 39 | } 40 | } 41 | 42 | toggle() { 43 | this.openValue = !this.openValue 44 | } 45 | 46 | open(event) { 47 | this.openValue = true 48 | } 49 | 50 | close() { 51 | this.openValue = false 52 | } 53 | 54 | // Override this to use a third party, e.g. Popper.js. 55 | async showPanel() { 56 | await enter(this.panelTarget) 57 | } 58 | 59 | keydownPanel(event) { 60 | if (event.key.toLowerCase() !== 'tab') return 61 | 62 | window.requestAnimationFrame(() => { 63 | if (!this.panelTarget.contains(document.activeElement)) { 64 | this.tabbing = true 65 | this.close() 66 | } 67 | }) 68 | } 69 | 70 | async doOpen() { 71 | this.element.dataset.headlessuiState = 'open' 72 | this.buttonTarget.dataset.headlessuiState = 'open' 73 | this.panelTarget.dataset.headlessuiState = 'open' 74 | 75 | this.buttonTarget.setAttribute('aria-expanded', 'true') 76 | 77 | if (this.hasOverlayTarget) enter(this.overlayTarget) 78 | 79 | await this.showPanel() 80 | if (this.focusPanelValue) this.focusPanelFirstElement() 81 | 82 | window.addEventListener('click', this.boundCloseOnClickOutsideElement) 83 | } 84 | 85 | doClose() { 86 | delete this.element.dataset.headlessuiState 87 | delete this.buttonTarget.dataset.headlessuiState 88 | delete this.panelTarget.dataset.headlessuiState 89 | 90 | this.buttonTarget.setAttribute('aria-expanded', 'false') 91 | 92 | window.removeEventListener('click', this.boundCloseOnClickOutsideElement) 93 | 94 | if (this.hasOverlayTarget) leave(this.overlayTarget) 95 | 96 | leave(this.panelTarget).then(() => { 97 | if (this.unmountValue) this.panelTarget.remove() 98 | }) 99 | 100 | if (!this.connected) return 101 | 102 | this.setFocusOnClose() 103 | } 104 | 105 | focusPanelFirstElement() { 106 | keyboardFocusableElements(this.panelTarget)[0]?.focus() 107 | } 108 | 109 | setFocusOnClose() { 110 | if (this.tabbing) { 111 | this.tabbing = false 112 | return 113 | } 114 | 115 | if (this.focusOnCloseValue === '') { 116 | this.buttonTarget.focus() 117 | } 118 | else { 119 | document.querySelector(this.focusOnCloseValue)?.focus() 120 | } 121 | } 122 | 123 | setAriaAttributes() { 124 | let id = this.panelTarget.id 125 | if (id == '') { 126 | id = uid() 127 | this.panelTarget.id = id 128 | } 129 | this.buttonTarget.setAttribute('aria-controls', id) 130 | } 131 | 132 | } 133 | -------------------------------------------------------------------------------- /src/dialog.js: -------------------------------------------------------------------------------- 1 | import { Controller } from '@hotwired/stimulus' 2 | import { enter, leave } from 'el-transition' 3 | import { keyboardFocusableElements, uid } from './util.js' 4 | 5 | export default class extends Controller { 6 | 7 | static targets = ['panel', 'backdrop', 'title', 'description', 'initialFocus'] 8 | static classes = ['htmlOpen'] 9 | static values = { open: Boolean, unmount: Boolean } 10 | 11 | // Bind the outside-click handler in initialize() rather than connect() 12 | // because the openValueChanged() value callback, which uses the handler, 13 | // runs between initialize() and connect(). 14 | initialize() { 15 | this.boundCloseOnClickOutsideElement = this.closeOnClickOutsideElement.bind(this) 16 | } 17 | 18 | connect() { 19 | this.setAriaAttributes() 20 | } 21 | 22 | closeOnClickOutsideElement(event) { 23 | if (!this.panelTarget.contains(event.target)) this.close() 24 | } 25 | 26 | openValueChanged(value) { 27 | if (value) { 28 | this.doOpen() 29 | } 30 | else { 31 | this.doClose() 32 | } 33 | } 34 | 35 | open(event) { 36 | this.openValue = true 37 | event.stopPropagation() 38 | } 39 | 40 | close() { 41 | this.openValue = false 42 | } 43 | 44 | keydown(event) { 45 | switch(event.key.toLowerCase()) { 46 | case 'escape': 47 | this.close() 48 | break 49 | case 'tab': 50 | event.preventDefault() 51 | event.shiftKey ? this.up(event) : this.down(event) 52 | break 53 | } 54 | } 55 | 56 | // private 57 | 58 | setAriaAttributes() { 59 | this.element.setAttribute('aria-modal', true) 60 | this.element.setAttribute('role', 'dialog') 61 | 62 | if (this.hasTitleTarget) { 63 | let id = this.titleTarget.id 64 | if (id == '') { 65 | id = uid() 66 | this.titleTarget.id = id 67 | } 68 | this.element.setAttribute('aria-labelledby', id) 69 | } 70 | 71 | if (this.hasDescriptionTarget) { 72 | let id = this.descriptionTarget.id 73 | if (id == '') { 74 | id = uid() 75 | this.descriptionTarget.id = id 76 | } 77 | this.element.setAttribute('aria-describedby', id) 78 | } 79 | } 80 | 81 | doOpen(event) { 82 | this.element.dataset.headlessuiState = 'open' 83 | this.panelTarget.dataset.headlessuiState = 'open' 84 | 85 | if (this.hasHtmlOpenClass) document.documentElement.classList.add(...this.htmlOpenClasses) 86 | if (this.hasBackdropTarget) enter(this.backdropTarget) 87 | enter(this.panelTarget) 88 | 89 | this.initialFocusElement()?.focus() 90 | window.addEventListener('click', this.boundCloseOnClickOutsideElement) 91 | } 92 | 93 | doClose() { 94 | delete this.element.dataset.headlessuiState 95 | delete this.panelTarget.dataset.headlessuiState 96 | 97 | window.removeEventListener('click', this.boundCloseOnClickOutsideElement) 98 | 99 | if (this.hasHtmlOpenClass) document.documentElement.classList.remove(...this.htmlOpenClasses) 100 | if (this.hasBackdropTarget) leave(this.backdropTarget) 101 | leave(this.panelTarget).then(() => { 102 | if (this.unmountValue) this.element.remove() 103 | }) 104 | } 105 | 106 | up() { 107 | const i = this.focusablePanelElements().indexOf(document.activeElement) 108 | const j = (i - 1 + this.focusablePanelElements().length) % 109 | this.focusablePanelElements().length 110 | this.focusablePanelElements()[j].focus() 111 | } 112 | 113 | down() { 114 | const i = this.focusablePanelElements().indexOf(document.activeElement) 115 | const j = (i + 1) % this.focusablePanelElements().length 116 | this.focusablePanelElements()[j].focus() 117 | } 118 | 119 | initialFocusElement() { 120 | return this.hasInitialFocusTarget 121 | ? this.initialFocusTarget 122 | : this.focusablePanelElements()[0] 123 | } 124 | 125 | focusablePanelElements() { 126 | // Or we could introduce `data-dialog-target="focusable"`. 127 | return keyboardFocusableElements(this.panelTarget) 128 | } 129 | 130 | } 131 | -------------------------------------------------------------------------------- /test/dialog.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 17 | 18 | 19 | 20 | 27 | 28 | 29 | 30 |
31 | 32 |
38 | 39 | 46 | 47 | 58 | 59 | 104 |
105 | 106 |
107 | 108 | 109 | -------------------------------------------------------------------------------- /test/popover.spec.js: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test' 2 | 3 | 4 | test.beforeEach(async ({ page }, testInfo) => { 5 | await page.goto('popover.html') 6 | }) 7 | 8 | 9 | test('popover button is not focused initially', async ({ page }) => { 10 | await expect(page.locator('[data-popover-target="button"]')).not.toBeFocused() 11 | }) 12 | 13 | 14 | test('popover is closed initially', async ({ page }) => { 15 | await expect(page.locator('[data-popover-target="button"]')).toHaveAttribute('aria-expanded', 'false') 16 | await expect(page.locator('[data-popover-target="panel"]')).not.toBeVisible() 17 | }) 18 | 19 | 20 | test('popover button controls panel', async ({ page }) => { 21 | const id = await page.locator('[data-popover-target="panel"]').getAttribute('id') 22 | await expect(page.locator('[data-popover-target="button"]')).toHaveAttribute('aria-controls', id) 23 | }) 24 | 25 | 26 | test('state of open popover', async ({ page }) => { 27 | await page.locator('[data-popover-target="button"]').click() 28 | await expect(page.locator('[data-controller="popover"]')).toHaveAttribute('data-popover-open-value', 'true') 29 | await expect(page.locator('[data-controller="popover"]')).toHaveAttribute('data-headlessui-state', 'open') 30 | await expect(page.locator('[data-popover-target="button"]')).toHaveAttribute('data-headlessui-state', 'open') 31 | await expect(page.locator('[data-popover-target="button"]')).toHaveAttribute('aria-expanded', 'true') 32 | await expect(page.locator('[data-popover-target="panel"]')).toHaveAttribute('data-headlessui-state', 'open') 33 | await expect(page.locator('[data-popover-target="panel"]')).toBeVisible() 34 | await expect(page.locator('a:has-text("Some link")')).not.toBeFocused() 35 | }) 36 | 37 | 38 | test('state of opened then closed popover', async ({ page }) => { 39 | await page.locator('[data-popover-target="button"]').click() 40 | await page.locator('[data-popover-target="overlay"]').click() 41 | await expect(page.locator('[data-controller="popover"]')).toHaveAttribute('data-popover-open-value', 'false') 42 | await expect(page.locator('[data-controller="popover"]')).not.toHaveAttribute('data-headlessui-state', 'open') 43 | await expect(page.locator('[data-popover-target="button"]')).not.toHaveAttribute('data-headlessui-state', 'open') 44 | await expect(page.locator('[data-popover-target="button"]')).not.toHaveAttribute('aria-expanded', 'true') 45 | await expect(page.locator('[data-popover-target="panel"]')).not.toHaveAttribute('data-headlessui-state', 'open') 46 | await expect(page.locator('[data-popover-target="panel"]')).not.toBeVisible() 47 | await expect(page.locator('a:has-text("Some link")')).not.toBeFocused() 48 | await expect(page.locator('[data-popover-target="button"]')).toBeFocused() 49 | }) 50 | 51 | 52 | test('mouse click button opens popover', async ({ page }) => { 53 | await page.locator('[data-popover-target="button"]').click() 54 | await expect(page.locator('[data-controller="popover"]')).toHaveAttribute('data-popover-open-value', 'true') 55 | }) 56 | 57 | 58 | test.skip('mouse click button with open menu closes menu', async ({ page }) => { 59 | // the overlay intercepts pointer events so we cannot reach the button 60 | }) 61 | 62 | 63 | test('mouse click overlay closes menu', async ({ page }) => { 64 | await page.locator('[data-popover-target="button"]').click() 65 | await page.locator('[data-popover-target="overlay"]').click() 66 | await expect(page.locator('[data-controller="popover"]')).toHaveAttribute('data-popover-open-value', 'false') 67 | }) 68 | 69 | 70 | test('enter on button opens popover', async ({ page }) => { 71 | await page.locator('[data-popover-target="button"]').focus() 72 | await page.keyboard.press('Enter') 73 | await expect(page.locator('[data-controller="popover"]')).toHaveAttribute('data-popover-open-value', 'true') 74 | }) 75 | 76 | 77 | test('space on button opens popover', async ({ page }) => { 78 | await page.locator('[data-popover-target="button"]').focus() 79 | await page.keyboard.press('Space') 80 | await expect(page.locator('[data-controller="popover"]')).toHaveAttribute('data-popover-open-value', 'true') 81 | }) 82 | 83 | 84 | test('escape with open popover closes popover', async ({ page }) => { 85 | await page.locator('[data-popover-target="button"]').focus() 86 | await page.keyboard.press('Escape') 87 | await expect(page.locator('[data-controller="popover"]')).toHaveAttribute('data-popover-open-value', 'false') 88 | await expect(page.locator('[data-popover-target="button"]')).toBeFocused() 89 | }) 90 | 91 | 92 | test('tab when popover is open focuses first item in panel', async ({ page, browserName }) => { 93 | test.skip(browserName === 'webkit', 'Fails for React and Vue too') 94 | 95 | await page.locator('[data-popover-target="button"]').focus() 96 | await page.keyboard.press('Enter') 97 | await page.keyboard.press('Tab') 98 | await expect(page.locator('a:has-text("Some link")')).toBeFocused() 99 | }) 100 | 101 | 102 | test('shift+tab when popover is open focuses previous item', async ({ page, browserName }) => { 103 | test.skip(browserName === 'webkit', 'Fails for React and Vue too') 104 | 105 | await page.locator('[data-popover-target="button"]').focus() 106 | await page.keyboard.press('Enter') 107 | await page.keyboard.press('Tab') 108 | await page.keyboard.press('Tab') 109 | await expect(page.locator('a:has-text("Another link")')).toBeFocused() 110 | await page.keyboard.press('Shift+Tab') 111 | await expect(page.locator('a:has-text("Some link")')).toBeFocused() 112 | }) 113 | 114 | 115 | test('tabbing out of popover closes it', async ({ page, browserName }) => { 116 | test.skip(browserName === 'webkit', 'Fails for React and Vue too') 117 | 118 | await page.locator('[data-popover-target="button"]').focus() 119 | await page.keyboard.press('Enter') 120 | await page.keyboard.press('Tab') 121 | await page.keyboard.press('Tab') 122 | await page.keyboard.press('Tab') 123 | await expect(page.locator('select')).toBeFocused() 124 | await expect(page.locator('[data-controller="popover"]')).toHaveAttribute('data-popover-open-value', 'false') 125 | }) 126 | -------------------------------------------------------------------------------- /src/menu.js: -------------------------------------------------------------------------------- 1 | import { Controller } from '@hotwired/stimulus' 2 | import { enter, leave } from 'el-transition' 3 | 4 | export default class extends Controller { 5 | 6 | static targets = ['button', 'menuItems', 'menuItem'] 7 | static values = {index: Number} 8 | static classes = ['active', 'inactive'] 9 | 10 | initialize() { 11 | this.boundCloseOnClickOutsideElement = this.closeOnClickOutsideElement.bind(this) 12 | } 13 | 14 | disconnect() { 15 | window.removeEventListener('click', this.boundCloseOnClickOutsideElement) 16 | } 17 | 18 | closeOnClickOutsideElement(event) { 19 | if (!this.element.contains(event.target)) this.close() 20 | } 21 | 22 | indexValueChanged(value) { 23 | this.menuItemTargets.forEach((el, i) => { 24 | if (i == value) { 25 | el.dataset.headlessuiState = 'active' 26 | if (this.hasActiveClass) el.classList.add(...this.activeClasses) 27 | if (this.hasInactiveClass) el.classList.remove(...this.inactiveClasses) 28 | } 29 | else { 30 | el.dataset.headlessuiState = '' 31 | if (this.hasActiveClass) el.classList.remove(...this.activeClasses) 32 | if (this.hasInactiveClass) el.classList.add(...this.inactiveClasses) 33 | } 34 | }) 35 | 36 | this.menuItemTargets[value].focus() 37 | } 38 | 39 | toggle(event) { 40 | if (this.isOpen()) { 41 | this.close() 42 | } 43 | else { 44 | this.open(event) 45 | this.first() 46 | } 47 | } 48 | 49 | open(event) { 50 | this.element.setAttribute('open', '') 51 | this.buttonTarget.setAttribute('aria-expanded', 'true') 52 | this.menuItemsTarget.dataset.headlessuiState = 'open' 53 | 54 | enter(this.menuItemsTarget) 55 | 56 | window.addEventListener('click', this.boundCloseOnClickOutsideElement) 57 | } 58 | 59 | close() { 60 | window.removeEventListener('click', this.boundCloseOnClickOutsideElement) 61 | 62 | this.element.removeAttribute('open') 63 | this.buttonTarget.setAttribute('aria-expanded', 'false') 64 | delete this.menuItemsTarget.dataset.headlessuiState 65 | 66 | leave(this.menuItemsTarget).then(() => { 67 | this.buttonTarget.focus() 68 | }) 69 | } 70 | 71 | down(event, wrap) { 72 | if (this.isOpen()) { 73 | this.indexValue = this.indexOf(this.activeMenuItemSucc(wrap)) 74 | } 75 | else { 76 | this.open(event) 77 | this.first() 78 | } 79 | } 80 | 81 | up(event, wrap) { 82 | if (this.isOpen()) { 83 | this.indexValue = this.indexOf(this.activeMenuItemPrev(wrap)) 84 | } 85 | else { 86 | this.open(event) 87 | this.last() 88 | } 89 | } 90 | 91 | first() { 92 | if (!this.isOpen()) return 93 | this.indexValue = this.indexOf(this.activeMenuItems()[0]) 94 | } 95 | 96 | last() { 97 | if (!this.isOpen()) return 98 | this.indexValue = this.indexOf( 99 | this.activeMenuItems()[this.activeMenuItems().length - 1] 100 | ) 101 | } 102 | 103 | clickItem(target) { 104 | target.click() 105 | this.close() 106 | } 107 | 108 | // letter - lowercase 109 | focusMatchingItem(letter) { 110 | const i = this 111 | .menuItemTargets 112 | .findIndex(el => el.textContent.trim()[0].toLowerCase() === letter) 113 | if (i != -1) this.indexValue = i 114 | } 115 | 116 | keydownButton(event) { 117 | switch (event.key.toLowerCase()) { 118 | case 'arrowup': 119 | this.up(event, false) 120 | break 121 | case 'arrowdown': 122 | this.down(event, false) 123 | break 124 | 125 | } 126 | } 127 | 128 | keydownItems(event) { 129 | const key = event.key.toLowerCase() 130 | switch (key) { 131 | case 'escape': 132 | this.close() 133 | break 134 | case 'arrowup': 135 | this.up(event, false) 136 | break 137 | case 'arrowdown': 138 | this.down(event, false) 139 | break 140 | case 'tab': 141 | event.preventDefault() 142 | event.shiftKey ? this.up(event, true) : this.down(event, true) 143 | break 144 | case 'home': 145 | this.first() 146 | break 147 | case 'end': 148 | this.last() 149 | break 150 | case ' ': 151 | case 'enter': 152 | this.clickItem(event.target) 153 | break 154 | case key.length == 1 && /[a-z]/.test(key) && key: 155 | this.focusMatchingItem(key) 156 | break 157 | } 158 | } 159 | 160 | activate(event) { 161 | if (event.target.hasAttribute('disabled')) return 162 | if (!this.menuItemTargets.includes(event.target)) return 163 | this.indexValue = this.indexOf(event.target) 164 | } 165 | 166 | indexOf(menuItem) { 167 | return this.menuItemTargets.indexOf(menuItem) 168 | } 169 | 170 | activeIndexOf(menuItem) { 171 | return this.activeMenuItems().indexOf(menuItem) 172 | } 173 | 174 | activeIndex() { 175 | return this.activeIndexOf(this.activeMenuItem()) 176 | } 177 | 178 | activeMenuItem() { 179 | return this.menuItemTargets[this.indexValue] 180 | } 181 | 182 | activeMenuItemSucc(wrap) { 183 | if (wrap) { 184 | return this.activeMenuItems()[ 185 | (this.activeIndex() + 1) % this.activeMenuItems().length 186 | ] 187 | } 188 | else { 189 | return this.activeMenuItems()[this.activeIndex() + 1] || 190 | this.activeMenuItems()[this.activeIndex()] 191 | } 192 | } 193 | 194 | activeMenuItemPrev(wrap) { 195 | if (wrap) { 196 | return this.activeMenuItems()[ 197 | (this.activeIndex() - 1 + this.activeMenuItems().length) % 198 | this.activeMenuItems().length 199 | ] 200 | } 201 | else { 202 | if (this.activeIndex() == 0) { 203 | return this.activeMenuItems()[0] 204 | } 205 | else { 206 | return this.activeMenuItems()[this.activeIndex() - 1] 207 | } 208 | } 209 | } 210 | 211 | activeMenuItems() { 212 | return this.menuItemTargets.filter(el => !el.hasAttribute('disabled')) 213 | } 214 | 215 | isOpen() { 216 | return this.element.hasAttribute('open') 217 | } 218 | 219 | } 220 | -------------------------------------------------------------------------------- /test/menu.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 17 | 18 | 19 | 20 | 27 | 28 | 29 | 30 |
31 | 146 |
147 |

This is outside the menu.

148 | 149 | 150 | -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | # Headless UI - Stimulus 2 | 3 | Please see the [demo page](https://airblade.github.io/headlessui-stimulus) for some examples. 4 | 5 | Status: implementing the components as I need them. Just started and a long way to go :) 6 | 7 | This is a set of [Stimulus](https://stimulus.hotwired.dev) controllers for [Headless UI's components](https://headlessui.com). 8 | 9 | - [x] [Menu (Dropdown)](#menu-dropdown) 10 | - [ ] Listbox (Select) 11 | - [ ] Combobox (Autocomplete) 12 | - [ ] Switch (Toggle) 13 | - [ ] Disclosure 14 | - [x] [Dialog (Modal)](#dialog-modal) 15 | - [x] [Popover](#popover) 16 | - [ ] Radio Group 17 | - [ ] Tabs 18 | - [x] [Transitions](#transitions) 19 | 20 | They all come with keyboard navigation and focus management, and automatically manage relevant ARIA attributes. 21 | 22 | 23 | ## Installation 24 | 25 | ``` 26 | bin/importmap pin headlessui-stimulus 27 | ``` 28 | 29 | 30 | ## Usage 31 | 32 | Register the components with your Stimulus application. For example, to register the Menu (Dropdown) component: 33 | 34 | ```diff 35 | import { Application } from '@hotwired/stimulus' 36 | + import { Menu } from 'headlessui-stimulus' 37 | 38 | const application = Application.start() 39 | + application.register('menu', Menu) 40 | ``` 41 | 42 | Note: you must have a `hidden` CSS class for elements to show and hide: 43 | 44 | ```css 45 | .hidden { 46 | display: none; 47 | } 48 | ``` 49 | 50 | If you use Tailwind CSS's [@headlessui/tailwindcss](https://github.com/tailwindlabs/headlessui/tree/main/packages/%40headlessui-tailwindcss) plugin, you can use modifiers like `ui-open:*` and `ui-active:*` to style these components. 51 | 52 | If you don't use the plugin, you can use the `data-headlessui-state` attributes directly to conditionally apply different styles. 53 | 54 | 55 | ## Menu (Dropdown) 56 | 57 | See [Headless UI: Menu](https://headlessui.com/react/menu). 58 | 59 | Use the following markup (the classes and ARIA attributes are omitted for clarity). 60 | 61 | ```html 62 |
63 | 70 | 88 |
89 | ``` 90 | 91 | Optionally you can specify classes for the active and inactive menu items like this: 92 | 93 | ```html 94 |
99 | ``` 100 | 101 | 102 | ## Dialog (Modal) 103 | 104 | See [Headless UI: Dialog](https://headlessui.com/react/dialog). 105 | 106 | Use the following markup (the classes and ARIA attributes are omitted for clarity). 107 | 108 | ```html 109 |
110 | 111 |
112 | 113 |
114 |

An important notice

115 |

Blah blah blah.

116 | ... 117 | 118 | ... 119 |
120 |
121 | ``` 122 | 123 | To open the dialog: 124 | 125 | - either call `dialog#open` on the controller; 126 | - or set `data-dialog-open-value="true"` on the controller's element. 127 | 128 | To close the dialog: 129 | 130 | - either call `dialog#close` on the controller; 131 | - or set `data-dialog-open-value="false"` on the controller's element; 132 | - or click outside the panel; 133 | - or press Escape. 134 | 135 | If your dialog has a title and a description, use `data-dialog-target="title"` and `data-dialog-target="description"` to provide the most accessible experience. This will link your title and description to the controller element via the `aria-labelledby` and `aria-describedby` attributes. 136 | 137 | When the dialog opens, the panel's first focusable element by DOM order receives focus. To specify that a different element should receive focus initially, give it the data attribute `data-dialog-target="initialFocus"`. 138 | 139 | To toggle class(es) on the `` element when the dialog opens/closes, use `data-dialog-html-open-class="..."` where the value is a space-separated list of classes. 140 | 141 | You can configure your dialog with the following attributes. Declare them on the controller as `data-dialog-[name]-value`. 142 | 143 | | Name | Default | Description | 144 | |--|--|--| 145 | | open | `false` | Whether the dialog is open (`true`) or closed (`false`). | 146 | | unmount | `false` | On closing the dialog, whether to remove it from the DOM (`true`) or hide it (`false`). | 147 | 148 | You can specify transitions on the backdrop and the panel. 149 | 150 | 151 | ## Popover 152 | 153 | See [Headless UI: Popover](https://headlessui.com/react/popover). 154 | 155 | Use the following markup (the classes and ARIA attributes are omitted for clarity). 156 | 157 | ```html 158 |
159 | 166 | 167 | 168 |
169 | 170 |
171 | ... 172 |
173 |
174 | ``` 175 | 176 | To open the popover: 177 | 178 | - either call `popover#open` on the controller; 179 | - or set `data-popover-open-value="true"` on the controller's element. 180 | 181 | To close the popover: 182 | 183 | - either call `popover#close` on the controller; 184 | - or set `data-popover-open-value="false"` on the controller's element. 185 | - or Tab out of the panel; 186 | - or click outside the panel; 187 | - or press Escape. 188 | 189 | When the popover opens, the panel does not receive focus until you Tab into it. If you would prefer the first focusable element in the panel to receive focus when the panel opens, set the `data-popover-focus-panel="true"` data attribute on the controller's element. 190 | 191 | When the popover closes (unless you Tab out), focus returns to the button target. If you want another element to receive focus, set the `data-popover-focus-on-close-value="..."`. The value should be a CSS selector. 192 | 193 | You can configure your popover with the following attributes. Declare them on the controller as `data-popover-[name]-value`. 194 | 195 | | Name | Default | Description | 196 | |--|--|--| 197 | | open | `false` | Whether the popover is open (`true`) or closed (`false`). | 198 | | focus-panel | `false` | On opening the popover, whether to focus the panel's first focusable element. | 199 | | focus-on-close | "" | On closing the popover (except by using Tab), the element to focus, expressed as a CSS selector. `""` focuses the button target. | 200 | | unmount | `false` | On closing the popover, whether to remove it from the DOM (`true`) or hide it (`false`). | 201 | 202 | You can specify transitions on the overlay and the panel. 203 | 204 | Popover groups are not supported yet (because [I'm not sure how they are supposed to behave](https://github.com/tailwindlabs/headlessui/discussions/2503).) 205 | 206 | 207 | ## Transitions 208 | 209 | Transitions are supported by each component. Specify the transitions you want with these data attributes: 210 | 211 | ```html 212 | data-transition-enter="..." 213 | data-transition-enter-start="..." 214 | data-transition-enter-end="..." 215 | data-transition-leave="..." 216 | data-transition-leave-start="..." 217 | data-transition-leave-end="..." 218 | ``` 219 | 220 | If you are using Tailwind UI components, you can pretty much copy and paste the the transitions specified in the code comments. 221 | 222 | For example, a sidebar component might include this comment in its source code: 223 | 224 | ```html 225 | 235 | ``` 236 | 237 | Here are the corresponding data attributes you would use: 238 | 239 | ```html 240 | data-transition-enter="transition-opacity ease-linear duration-300" 241 | data-transition-enter-start="opacity-0" 242 | data-transition-enter-end="opacity-100" 243 | data-transition-leave="transition-opacity ease-linear duration-300" 244 | data-transition-leave-start="opacity-100" 245 | data-transition-leave-end="opacity-0" 246 | ``` 247 | 248 | 249 | ## Intellectual Property 250 | 251 | This package is copyright Andrew Stewart. 252 | 253 | This package is available as open source under the terms of the MIT licence. 254 | -------------------------------------------------------------------------------- /test/menu.spec.js: -------------------------------------------------------------------------------- 1 | import { test, expect } from '@playwright/test' 2 | 3 | 4 | test.beforeEach(async ({ page }, testInfo) => { 5 | await page.goto('menu.html') 6 | }) 7 | 8 | 9 | test('menu is closed initially', async ({ page }) => { 10 | await expect(page.locator('[data-menu-target="button"]')).toHaveAttribute('aria-expanded', 'false') 11 | await expect(page.locator('[data-menu-target="menuItems"]')).not.toBeVisible() 12 | }) 13 | 14 | 15 | test('mouse click button opens menu', async ({ page }) => { 16 | await page.locator('[data-menu-target="button"]').click() 17 | await expect(page.locator('[data-menu-target="button"]')).toHaveAttribute('aria-expanded', 'true') 18 | await expect(page.locator('[data-menu-target="menuItems"]')).toBeVisible() 19 | await expect(page.locator('[data-menu-target="menuItem"]').locator('nth=0')).toBeFocused() 20 | }) 21 | 22 | 23 | test('mouse click button with open menu closes menu', async ({ page }) => { 24 | await page.locator('[data-menu-target="button"]').click() 25 | await page.locator('[data-menu-target="button"]').click() 26 | await expect(page.locator('[data-menu-target="button"]')).toHaveAttribute('aria-expanded', 'false') 27 | await expect(page.locator('[data-menu-target="button"]')).toBeFocused() 28 | await expect(page.locator('[data-menu-target="menuItems"]')).not.toBeVisible() 29 | }) 30 | 31 | 32 | test('mouse click outside open menu closes menu', async ({ page }) => { 33 | await page.locator('[data-menu-target="button"]').click() 34 | await page.getByText('This is outside the menu.').click() 35 | await expect(page.locator('[data-menu-target="button"]')).toHaveAttribute('aria-expanded', 'false') 36 | await expect(page.locator('[data-menu-target="button"]')).toBeFocused() 37 | await expect(page.locator('[data-menu-target="menuItems"]')).not.toBeVisible() 38 | }) 39 | 40 | 41 | test('hover over menu item activates it', async ({ page }) => { 42 | await page.locator('[data-menu-target="button"]').click() 43 | await page.getBy 44 | 45 | await page.locator('[data-menu-target="menuItem"]').filter({hasText: 'Move'}).hover() 46 | await expect(page.locator('[data-menu-target="menuItem"]').filter({hasText: 'Move'})).toBeFocused() 47 | }) 48 | 49 | 50 | test('enter on button opens menu', async ({ page }) => { 51 | await page.locator('[data-menu-target="button"]').focus() 52 | await page.keyboard.press('Enter') 53 | await expect(page.locator('[data-menu-target="button"]')).toHaveAttribute('aria-expanded', 'true') 54 | await expect(page.locator('[data-menu-target="menuItems"]')).toBeVisible() 55 | await expect(page.locator('[data-menu-target="menuItem"]').locator('nth=0')).toBeFocused() 56 | }) 57 | 58 | 59 | test('space on button opens menu', async ({ page }) => { 60 | await page.locator('[data-menu-target="button"]').focus() 61 | await page.keyboard.press('Space') 62 | await expect(page.locator('[data-menu-target="button"]')).toHaveAttribute('aria-expanded', 'true') 63 | await expect(page.locator('[data-menu-target="menuItems"]')).toBeVisible() 64 | await expect(page.locator('[data-menu-target="menuItem"]').locator('nth=0')).toBeFocused() 65 | }) 66 | 67 | 68 | test('down arrow on button opens menu and focuses first item', async ({ page }) => { 69 | await page.locator('[data-menu-target="button"]').focus() 70 | await page.keyboard.press('ArrowDown') 71 | await expect(page.locator('[data-menu-target="button"]')).toHaveAttribute('aria-expanded', 'true') 72 | await expect(page.locator('[data-menu-target="menuItems"]')).toBeVisible() 73 | await expect(page.locator('[data-menu-target="menuItem"]').locator('nth=0')).toBeFocused() 74 | }) 75 | 76 | 77 | test('up arrow on button opens menu and focuses last item', async ({ page }) => { 78 | await page.locator('[data-menu-target="button"]').focus() 79 | await page.keyboard.press('ArrowUp') 80 | await expect(page.locator('[data-menu-target="button"]')).toHaveAttribute('aria-expanded', 'true') 81 | await expect(page.locator('[data-menu-target="menuItems"]')).toBeVisible() 82 | await expect(page.locator('[data-menu-target="menuItem"]').locator('nth=-1')).toBeFocused() 83 | }) 84 | 85 | 86 | test('escape with open menu closes menu', async ({ page }) => { 87 | await page.locator('[data-menu-target="button"]').click() 88 | await page.keyboard.press('Escape') 89 | await expect(page.locator('[data-menu-target="button"]')).toHaveAttribute('aria-expanded', 'false') 90 | await expect(page.locator('[data-menu-target="button"]')).toBeFocused() 91 | await expect(page.locator('[data-menu-target="menuItems"]')).not.toBeVisible() 92 | }) 93 | 94 | 95 | test('down arrow when menu is open focuses next item', async ({ page }) => { 96 | await page.locator('[data-menu-target="button"]').click() 97 | await page.keyboard.press('ArrowDown') 98 | await expect(page.locator('[data-menu-target="menuItem"]').locator('nth=1')).toBeFocused() 99 | }) 100 | 101 | 102 | test('up arrow when menu is open focuses previous item', async ({ page }) => { 103 | await page.locator('[data-menu-target="button"]').click() 104 | await page.keyboard.press('End') 105 | await page.keyboard.press('ArrowUp') 106 | await expect(page.locator('[data-menu-target="menuItem"]').locator('nth=-2')).toBeFocused() 107 | }) 108 | 109 | 110 | test('tab when menu is open focuses next item', async ({ page }) => { 111 | await page.locator('[data-menu-target="button"]').click() 112 | await page.keyboard.press('Tab') 113 | await expect(page.locator('[data-menu-target="menuItem"]').locator('nth=1')).toBeFocused() 114 | }) 115 | 116 | 117 | test('shift+tab when menu is open focuses previous item', async ({ page }) => { 118 | await page.locator('[data-menu-target="button"]').click() 119 | await page.keyboard.press('End') 120 | await page.keyboard.press('Shift+Tab') 121 | await expect(page.locator('[data-menu-target="menuItem"]').locator('nth=-2')).toBeFocused() 122 | }) 123 | 124 | 125 | test('down arrow when menu is open skips disabled items', async ({ page }) => { 126 | await page.locator('[data-menu-target="button"]').click() 127 | await page.keyboard.press('ArrowDown') 128 | await page.keyboard.press('ArrowDown') 129 | await expect(page.locator('[data-menu-target="menuItem"]').locator('nth=3')).toBeFocused() 130 | }) 131 | 132 | 133 | test('up arrow when menu is open skips disabled items', async ({ page }) => { 134 | await page.locator('[data-menu-target="button"]').click() 135 | await page.keyboard.press('End') 136 | await page.keyboard.press('ArrowUp') 137 | await page.keyboard.press('ArrowUp') 138 | await expect(page.locator('[data-menu-target="menuItem"]').locator('nth=1')).toBeFocused() 139 | }) 140 | 141 | 142 | test('down arrow when menu is open does not wrap', async ({ page }) => { 143 | await page.locator('[data-menu-target="button"]').click() 144 | await page.keyboard.press('ArrowDown') 145 | await page.keyboard.press('ArrowDown') 146 | await page.keyboard.press('ArrowDown') 147 | await expect(page.locator('[data-menu-target="menuItem"]').locator('nth=4')).toBeFocused() 148 | await page.keyboard.press('ArrowDown') 149 | await expect(page.locator('[data-menu-target="menuItem"]').locator('nth=4')).toBeFocused() 150 | }) 151 | 152 | 153 | test('up arrow when menu is open does not wrap', async ({ page }) => { 154 | await page.locator('[data-menu-target="button"]').click() 155 | await page.keyboard.press('ArrowUp') 156 | await page.keyboard.press('ArrowUp') 157 | await page.keyboard.press('ArrowUp') 158 | await expect(page.locator('[data-menu-target="menuItem"]').locator('nth=0')).toBeFocused() 159 | await page.keyboard.press('ArrowUp') 160 | await expect(page.locator('[data-menu-target="menuItem"]').locator('nth=0')).toBeFocused() 161 | }) 162 | 163 | 164 | test('home when menu is open focuses first item', async ({ page }) => { 165 | await page.locator('[data-menu-target="button"]').click() 166 | await page.keyboard.press('Home') 167 | await expect(page.locator('[data-menu-target="menuItem"]').locator('nth=0')).toBeFocused() 168 | }) 169 | 170 | 171 | test('end when menu is open focuses last item', async ({ page }) => { 172 | await page.locator('[data-menu-target="button"]').click() 173 | await page.keyboard.press('End') 174 | await expect(page.locator('[data-menu-target="menuItem"]').locator('nth=-1')).toBeFocused() 175 | }) 176 | 177 | 178 | test('enter when menu is open activates current item', async ({ page }) => { 179 | await page.locator('[data-menu-target="button"]').click() 180 | await page.keyboard.press('Enter') 181 | await expect(page).toHaveURL(/#edit/) 182 | }) 183 | 184 | 185 | test.skip('space when menu is open activates current item', async ({ page }) => { 186 | await page.locator('[data-menu-target="button"]').click() 187 | await page.keyboard.press('Space') 188 | await expect(page).toHaveURL(/#edit/) 189 | }) 190 | 191 | 192 | test('letter when menu is open focuses first item matching letter', async ({ page }) => { 193 | await page.locator('[data-menu-target="button"]').click() 194 | await page.keyboard.type('m') 195 | await expect(page.locator('[data-menu-target="menuItem"]').filter({hasText: 'Move'})).toBeFocused() 196 | }) 197 | 198 | 199 | test('focus is trapped when menu is open', async ({ page }) => { 200 | await page.locator('[data-menu-target="button"]').click() 201 | await page.keyboard.press('Tab') 202 | await page.keyboard.press('Tab') 203 | await page.keyboard.press('Tab') 204 | await page.keyboard.press('Tab') 205 | // tab wraps around 206 | await expect(page.locator('[data-menu-target="menuItem"]').locator('nth=0')).toBeFocused() 207 | }) 208 | --------------------------------------------------------------------------------