diff --git a/README.md b/README.md index a8a6046..decc3ec 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,26 @@ If the `preload` attribute is present, the server fetch will begin on mouse hover over the `
` button, so the content may be loaded by the time the menu is opened. +### Focus management via `` + +```html +
+ Robots + + + + +
+``` + +The `input` attribute changes the keyboard navigation behavior of the menu. While navigating menu items with arrow keys, the input will retain focus. + +Focus state can be styled with `[aria-selected="true"]`. + ## Browser support Browsers without native [custom element support][support] require a [polyfill][]. diff --git a/examples/index.html b/examples/index.html index 48e7ec3..f06de5a 100644 --- a/examples/index.html +++ b/examples/index.html @@ -4,6 +4,11 @@ Codestin Search App +

Menu with plain buttons.

Best robot: Unknown @@ -33,6 +44,7 @@
+

Menu with radio menu items.

Best robot: Unknown @@ -42,6 +54,7 @@
+

Menu with checkbox menu items.

Favorite robots @@ -51,17 +64,22 @@
+

Menu with navigation from input support.

+
- Favorite robots - - - - + Best robot: Unknown + + +
diff --git a/index.js b/index.js index 9a550ca..ee618b8 100644 --- a/index.js +++ b/index.js @@ -25,8 +25,14 @@ class DetailsMenuElement extends HTMLElement { this.setAttribute('src', value) } + get input(): ?HTMLInputElement { + const inputId = this.getAttribute('input') + const input = inputId && document.getElementById(inputId) + return input instanceof HTMLInputElement ? input : null + } + connectedCallback() { - if (!this.hasAttribute('role')) this.setAttribute('role', 'menu') + if (!this.hasAttribute('role') && !this.hasAttribute('input')) this.setAttribute('role', 'menu') const details = this.parentElement if (!details) return @@ -37,6 +43,12 @@ class DetailsMenuElement extends HTMLElement { if (!summary.hasAttribute('role')) summary.setAttribute('role', 'button') } + if (this.input) { + this.input.addEventListener('blur', () => { + clearFocus(this) + }) + } + details.addEventListener('click', shouldCommit) details.addEventListener('change', shouldCommit) details.addEventListener('keydown', keydown) @@ -143,18 +155,29 @@ function autofocus(details: Element): boolean { // Focus first item unless an item is already focused. function focusFirstItem(details: Element) { - const selected = document.activeElement - if (selected && isMenuItem(selected) && details.contains(selected)) return + const selected = getFocusedMenuItem(details) + if (selected) return const target = sibling(details, true) - if (target) target.focus() + if (target) focus(target) +} + +function getFocusedMenuItem(details: Element): ?HTMLElement { + const menu = details.querySelector('details-menu') + if (!(menu instanceof DetailsMenuElement)) return + let selected = document.activeElement + if (selected && menu.input && selected === menu.input) { + const id = menu.input.getAttribute('aria-activedescendant') + selected = id ? document.getElementById(id) : selected + } + return selected && details.contains(selected) && isMenuItem(selected) ? selected : null } function sibling(details: Element, next: boolean): ?HTMLElement { const options = Array.from( details.querySelectorAll('[role^="menuitem"]:not([hidden]):not([disabled]):not([aria-disabled="true"])') ) - const selected = document.activeElement + const selected = getFocusedMenuItem(details) const index = options.indexOf(selected) const found = next ? options[index + 1] : options[index - 1] const def = next ? options[0] : options[options.length - 1] @@ -241,7 +264,7 @@ function keydown(event: KeyboardEvent) { details.setAttribute('open', '') } const target = sibling(details, true) - if (target) target.focus() + if (target) focus(target) event.preventDefault() } break @@ -251,7 +274,7 @@ function keydown(event: KeyboardEvent) { details.setAttribute('open', '') } const target = sibling(details, false) - if (target) target.focus() + if (target) focus(target) event.preventDefault() } break @@ -259,7 +282,7 @@ function keydown(event: KeyboardEvent) { { if (ctrlBindings && event.ctrlKey) { const target = sibling(details, true) - if (target) target.focus() + if (target) focus(target) event.preventDefault() } } @@ -268,7 +291,7 @@ function keydown(event: KeyboardEvent) { { if (ctrlBindings && event.ctrlKey) { const target = sibling(details, false) - if (target) target.focus() + if (target) focus(target) event.preventDefault() } } @@ -276,8 +299,8 @@ function keydown(event: KeyboardEvent) { case ' ': case 'Enter': { - const selected = document.activeElement - if (selected && isMenuItem(selected) && selected.closest('details') === details) { + const selected = getFocusedMenuItem(details) + if (selected) { event.preventDefault() event.stopPropagation() selected.click() @@ -287,6 +310,28 @@ function keydown(event: KeyboardEvent) { } } +function focus(target) { + const menu = target.closest('details-menu') + if (!(menu instanceof DetailsMenuElement)) return + clearFocus(menu) + const input = menu.input + + if (input && document.activeElement === input) { + if (!target.id) target.id = `rand-${(Math.random() * 1000).toFixed(0)}` + target.setAttribute('aria-selected', 'true') + input.setAttribute('aria-activedescendant', target.id) + } else { + target.focus() + } +} + +function clearFocus(menu) { + if (menu.input) menu.input.removeAttribute('aria-activedescendant') + for (const el of menu.querySelectorAll('[role^="menuitem"][aria-selected="true"]')) { + el.removeAttribute('aria-selected') + } +} + function isMenuItem(el: Element): boolean { const role = el.getAttribute('role') return role === 'menuitem' || role === 'menuitemcheckbox' || role === 'menuitemradio' diff --git a/index.js.flow b/index.js.flow index 9c1c1e6..0c275f3 100644 --- a/index.js.flow +++ b/index.js.flow @@ -5,6 +5,7 @@ declare class DetailsMenuElement extends HTMLElement { set preload(value: boolean): void; get src(): string; set src(url: string): void; + get input(): ?HTMLInputElement; } declare module '@github/details-menu-element' { diff --git a/test/test.js b/test/test.js index bd4d4ab..c48b047 100644 --- a/test/test.js +++ b/test/test.js @@ -650,4 +650,48 @@ describe('details-menu element', function() { assert.isFalse(dialogClosed) }) }) + + describe('support input based navigation the menu', function() { + beforeEach(function() { + const container = document.createElement('div') + container.innerHTML = ` +
+ Click + + + + + + +
+ ` + document.body.append(container) + }) + + afterEach(function() { + document.body.innerHTML = '' + }) + + it('navigate from input', function() { + const details = document.querySelector('details') + const menu = details.querySelector('details-menu') + const input = details.querySelector('input') + const items = menu.querySelectorAll('[role="menuitem"]') + + assert.notOk(menu.hasAttribute('role'), 'details-menu should not have role attribute when input is set') + + input.focus() + input.dispatchEvent(new KeyboardEvent('keydown', {key: 'ArrowDown', bubbles: true})) + + assert.equal(input, document.activeElement, 'focus stays on input') + assert.equal(input.getAttribute('aria-activedescendant'), items[0].id, 'activedescendant is set') + assert.equal(items[0].getAttribute('aria-selected'), 'true') + + items[1].focus() + + assert.notOk(input.hasAttribute('aria-activedescendant'), 'activedescendant is removed') + assert.notOk(items[0].hasAttribute('aria-selected')) + assert.notOk(items[1].hasAttribute('aria-selected')) + }) + }) })