diff --git a/README.md b/README.md index a8a6046..3670cc6 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..6e08771 100644 --- a/examples/index.html +++ b/examples/index.html @@ -21,9 +21,16 @@ text-align: left; padding: 0; } + [role^="menuitem"][aria-selected="true"] { + background: lightblue; + } + [role^="menuitem"]:hover { + background: lightblue; + } +

Menu with plain buttons.

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

Menu with radio menu items.

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

Menu with checkbox menu items.

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

Menu with navigation from input support.

+
- Favorite robots - - - - + Filter robots + + +
diff --git a/index.js b/index.js index 07c996b..18c1aa3 100644 --- a/index.js +++ b/index.js @@ -26,30 +26,45 @@ class DetailsMenuElement extends HTMLElement { } connectedCallback() { - if (!this.hasAttribute('role')) this.setAttribute('role', 'menu') + if (!this.hasAttribute('role')) { + this.setAttribute('role', 'menu') + } + + if (!this.id) { + this.id = menuId() + } const details = this.parentElement if (!details) return const summary = details.querySelector('summary') - if (summary) { - summary.setAttribute('aria-haspopup', 'menu') - if (!summary.hasAttribute('role')) summary.setAttribute('role', 'button') + if (!summary) return + + summary.setAttribute('aria-haspopup', 'menu') + if (!summary.hasAttribute('role')) { + summary.setAttribute('role', 'button') } + const inputId = this.getAttribute('input') + const input = inputId ? document.getElementById(inputId) : null + + const control = input || summary + const subscriptions = [ fromEvent(details, 'click', e => shouldCommit(details, this, e)), fromEvent(details, 'change', e => shouldCommit(details, this, e)), fromEvent(details, 'keydown', e => keydown(details, this, e)), + fromEvent(details, 'toggle', () => clearFocus(details, this)), + fromEvent(details, 'toggle', () => identifyItems(this), {once: true}), fromEvent(details, 'toggle', () => loadFragment(details, this), {once: true}), fromEvent(details, 'toggle', () => closeCurrentMenu(details)), this.preload ? fromEvent(details, 'mouseover', () => loadFragment(details, this), {once: true}) : NullSubscription, - ...focusOnOpen(details) + ...focusOnOpen(details, this) ] - states.set(this, {subscriptions, loaded: false}) + states.set(this, {control, subscriptions, loaded: false}) } disconnectedCallback() { @@ -93,19 +108,20 @@ function loadFragment(details: Element, menu: DetailsMenuElement) { const loader = menu.querySelector('include-fragment') if (loader && !loader.hasAttribute('src')) { + loader.addEventListener('loadend', () => identifyItems(menu)) loader.addEventListener('loadend', () => autofocus(details)) loader.setAttribute('src', src) } } -function focusOnOpen(details: Element): Array { +function focusOnOpen(details: Element, menu: DetailsMenuElement): Array { let isMouse = false const onmousedown = () => (isMouse = true) const onkeydown = () => (isMouse = false) const ontoggle = () => { if (!details.hasAttribute('open')) return if (autofocus(details)) return - if (!isMouse) focusFirstItem(details) + if (!isMouse) focusFirstItem(details, menu) } return [ @@ -138,19 +154,28 @@ function autofocus(details: Element): boolean { } // Focus first item unless an item is already focused. -function focusFirstItem(details: Element) { - const selected = document.activeElement +function focusFirstItem(details: Element, menu: DetailsMenuElement) { + const selected = activeItem(menu) if (selected && isMenuItem(selected) && details.contains(selected)) return - const target = sibling(details, true) - if (target) target.focus() + const target = sibling(menu, true) + if (target) focus(menu, target) } -function sibling(details: Element, next: boolean): ?HTMLElement { +function activeItem(menu: DetailsMenuElement): ?HTMLElement { + const state = states.get(menu) + const control = state ? state.control : null + if (!control) return + + const id = control.getAttribute('aria-activedescendant') + return id ? document.getElementById(id) : null +} + +function sibling(menu: DetailsMenuElement, next: boolean): ?HTMLElement { const options = Array.from( - details.querySelectorAll('[role^="menuitem"]:not([hidden]):not([disabled]):not([aria-disabled="true"])') + menu.querySelectorAll('[role^="menuitem"]:not([hidden]):not([disabled]):not([aria-disabled="true"])') ) - const selected = document.activeElement + const selected = activeItem(menu) const index = options.indexOf(selected) const found = next ? options[index + 1] : options[index - 1] const def = next ? options[0] : options[options.length - 1] @@ -170,11 +195,11 @@ function shouldCommit(details: Element, menu: DetailsMenuElement, event: Event) const menuitem = target.closest('[role="menuitem"], [role="menuitemradio"]') const onlyCommitOnChangeEvent = menuitem && menuitem.tagName === 'LABEL' && menuitem.querySelector('input') if (menuitem && !onlyCommitOnChangeEvent) { - commit(menuitem, details) + commit(menuitem, details, menu) } } else if (event.type === 'change') { const menuitem = target.closest('[role="menuitemradio"], [role="menuitemcheckbox"]') - if (menuitem) commit(menuitem, details) + if (menuitem) commit(menuitem, details, menu) } } @@ -189,10 +214,8 @@ function updateChecked(selected: Element, details: Element) { } } -function commit(selected: Element, details: Element) { +function commit(selected: Element, details: Element, menu: DetailsMenuElement) { if (selected.hasAttribute('disabled') || selected.getAttribute('aria-disabled') === 'true') return - const menu = selected.closest('details-menu') - if (!menu) return const dispatched = menu.dispatchEvent( new CustomEvent('details-menu-select', { @@ -212,6 +235,44 @@ function commit(selected: Element, details: Element) { ) } +let currentMenuId = 0 +function menuId(): string { + return `details-menu-${currentMenuId++}` +} + +function identifyItems(menu: Element) { + let id = 0 + for (const el of menu.querySelectorAll('[role^="menuitem"]:not([id])')) { + el.id = `${menu.id}-item-${id++}` + } +} + +function focus(menu: DetailsMenuElement, item: HTMLElement) { + const state = states.get(menu) + const control = state ? state.control : null + if (!control) return + + control.setAttribute('aria-activedescendant', item.id) + for (const el of menu.querySelectorAll('[role^=menuitem][aria-selected]')) { + el.removeAttribute('aria-selected') + } + item.setAttribute('aria-selected', 'true') + item.scrollIntoView({block: 'nearest'}) +} + +function clearFocus(details: Element, menu: DetailsMenuElement) { + if (details.hasAttribute('open')) return + + const state = states.get(menu) + const control = state ? state.control : null + if (!control) return + + control.removeAttribute('aria-activedescendant') + for (const el of menu.querySelectorAll('[role^="menuitem"][aria-selected]')) { + el.removeAttribute('aria-selected') + } +} + function keydown(details: Element, menu: DetailsMenuElement, event: Event) { if (!(event instanceof KeyboardEvent)) return const isSummaryFocused = event.target instanceof Element && event.target.tagName === 'SUMMARY' @@ -232,8 +293,8 @@ function keydown(details: Element, menu: DetailsMenuElement, event: Event) { if (isSummaryFocused && !details.hasAttribute('open')) { details.setAttribute('open', '') } - const target = sibling(details, true) - if (target) target.focus() + const target = sibling(menu, true) + if (target) focus(menu, target) event.preventDefault() } break @@ -242,16 +303,16 @@ function keydown(details: Element, menu: DetailsMenuElement, event: Event) { if (isSummaryFocused && !details.hasAttribute('open')) { details.setAttribute('open', '') } - const target = sibling(details, false) - if (target) target.focus() + const target = sibling(menu, false) + if (target) focus(menu, target) event.preventDefault() } break case 'n': { if (ctrlBindings && event.ctrlKey) { - const target = sibling(details, true) - if (target) target.focus() + const target = sibling(menu, true) + if (target) focus(menu, target) event.preventDefault() } } @@ -259,8 +320,8 @@ function keydown(details: Element, menu: DetailsMenuElement, event: Event) { case 'p': { if (ctrlBindings && event.ctrlKey) { - const target = sibling(details, false) - if (target) target.focus() + const target = sibling(menu, false) + if (target) focus(menu, target) event.preventDefault() } } @@ -268,7 +329,7 @@ function keydown(details: Element, menu: DetailsMenuElement, event: Event) { case ' ': case 'Enter': { - const selected = document.activeElement + const selected = activeItem(menu) if (selected && isMenuItem(selected) && selected.closest('details') === details) { event.preventDefault() event.stopPropagation() diff --git a/test/test.js b/test/test.js index bd4d4ab..cc7e3e2 100644 --- a/test/test.js +++ b/test/test.js @@ -51,6 +51,7 @@ describe('details-menu element', function() { summary.dispatchEvent(new MouseEvent('mousedown', {bubbles: true})) details.dispatchEvent(new CustomEvent('toggle')) assert.equal(summary, document.activeElement, 'mouse toggle open leaves summary focused') + assert.isNull(details.querySelector('[aria-activedescendant]')) }) it('opens and focuses first item on summary enter', function() { @@ -63,7 +64,8 @@ describe('details-menu element', function() { details.dispatchEvent(new CustomEvent('toggle')) const first = details.querySelector('[role="menuitem"]') - assert.equal(first, document.activeElement, 'toggle open focuses first item') + assert.equal(summary, document.activeElement) + assertActiveDescendant(details, first) }) it('opens and focuses first item on arrow down', function() { @@ -77,7 +79,8 @@ describe('details-menu element', function() { assert(details.open, 'menu is open') const first = details.querySelector('[role="menuitem"]') - assert.equal(first, document.activeElement, 'arrow focuses first item') + assert.equal(summary, document.activeElement) + assertActiveDescendant(details, first) }) it('opens and focuses last item on arrow up', function() { @@ -91,7 +94,8 @@ describe('details-menu element', function() { assert(details.open, 'menu is open') const last = [...details.querySelectorAll('[role="menuitem"]:not([disabled]):not([aria-disabled])')].pop() - assert.equal(last, document.activeElement, 'arrow focuses last item') + assert.equal(summary, document.activeElement) + assertActiveDescendant(details, last) }) it('navigates items with arrow keys', function() { @@ -105,13 +109,16 @@ describe('details-menu element', function() { assert(rest) details.dispatchEvent(new KeyboardEvent('keydown', {key: 'ArrowDown', bubbles: true})) - assert.equal(first, document.activeElement, 'arrow down focuses first item') + assert.equal(summary, document.activeElement) + assertActiveDescendant(details, first) details.dispatchEvent(new KeyboardEvent('keydown', {key: 'ArrowDown', bubbles: true})) - assert.equal(second, document.activeElement, 'arrow down focuses second item') + assert.equal(summary, document.activeElement) + assertActiveDescendant(details, second) details.dispatchEvent(new KeyboardEvent('keydown', {key: 'ArrowUp', bubbles: true})) - assert.equal(first, document.activeElement, 'arrow up focuses first item') + assert.equal(summary, document.activeElement) + assertActiveDescendant(details, first) }) it('closes and focuses summary on escape', function() { @@ -220,7 +227,7 @@ describe('details-menu element', function() { details.dispatchEvent(new KeyboardEvent('keydown', {key: 'ArrowUp', bubbles: true})) const notDisabled = details.querySelectorAll('[role="menuitem"]')[2] - assert.equal(notDisabled, document.activeElement, 'arrow focuses on the last non-disabled item') + assertActiveDescendant(details, notDisabled) const disabled = details.querySelector('[aria-disabled="true"]') document.addEventListener('details-menu-selected', () => eventCounter++, true) @@ -651,3 +658,8 @@ describe('details-menu element', function() { }) }) }) + +function assertActiveDescendant(details, expected) { + const menu = details.querySelector('[role=menu]') + assert.equal(menu.getAttribute('aria-activedescendant'), expected.id) +}