From 0afc5239185eed3dd5fae5caade4eb12eefd0366 Mon Sep 17 00:00:00 2001 From: David Graham Date: Fri, 8 Nov 2019 17:09:05 -0700 Subject: [PATCH 01/10] Focus menu items with aria-activedescendant Leave DOM focus on the summary that opened the menu or a filter input inside the menu. Navigating items with arrow keys should not move focus away from the input field. --- examples/index.html | 3 ++ index.js | 90 ++++++++++++++++++++++++++++++++------------- 2 files changed, 68 insertions(+), 25 deletions(-) diff --git a/examples/index.html b/examples/index.html index 48e7ec3..e22d625 100644 --- a/examples/index.html +++ b/examples/index.html @@ -21,6 +21,9 @@ text-align: left; padding: 0; } + [role^="menuitem"][data-menu-item-focus] { + background: lightblue; + } diff --git a/index.js b/index.js index 07c996b..403c901 100644 --- a/index.js +++ b/index.js @@ -26,7 +26,13 @@ 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 @@ -41,12 +47,14 @@ class DetailsMenuElement extends HTMLElement { 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}) @@ -93,19 +101,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 +147,24 @@ 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 activeItem(menu: DetailsMenuElement): ?HTMLElement { + const id = menu.getAttribute('aria-activedescendant') + return id ? document.getElementById(id) : null } -function sibling(details: Element, next: boolean): ?HTMLElement { +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 +184,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 +203,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 +224,34 @@ 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) { + menu.setAttribute('aria-activedescendant', item.id) + for (const el of menu.querySelectorAll('[role^=menuitem][data-menu-item-focus]')) { + el.removeAttribute('data-menu-item-focus') + } + item.setAttribute('data-menu-item-focus', '') +} + +function clearFocus(details: Element, menu: DetailsMenuElement) { + if (details.hasAttribute('open')) return + menu.removeAttribute('aria-activedescendant') + for (const el of menu.querySelectorAll('[role^="menuitem"][data-menu-item-focus]')) { + el.removeAttribute('data-menu-item-focus') + } +} + 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 +272,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 +282,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 +299,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 +308,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() From 5e112af95e9c5485465d63d33f18fa1371e88e66 Mon Sep 17 00:00:00 2001 From: David Graham Date: Tue, 12 Nov 2019 10:40:38 -0700 Subject: [PATCH 02/10] Reveal menu item when focused --- index.js | 1 + 1 file changed, 1 insertion(+) diff --git a/index.js b/index.js index 403c901..24f7cf5 100644 --- a/index.js +++ b/index.js @@ -242,6 +242,7 @@ function focus(menu: DetailsMenuElement, item: HTMLElement) { el.removeAttribute('data-menu-item-focus') } item.setAttribute('data-menu-item-focus', '') + item.scrollIntoView({block: 'nearest'}) } function clearFocus(details: Element, menu: DetailsMenuElement) { From aa214f7c4098c158e85021d2eeb1d174c3274503 Mon Sep 17 00:00:00 2001 From: David Graham Date: Tue, 12 Nov 2019 13:48:00 -0700 Subject: [PATCH 03/10] Match mouse hover style to keyboard focus --- examples/index.html | 3 +++ 1 file changed, 3 insertions(+) diff --git a/examples/index.html b/examples/index.html index e22d625..f364f51 100644 --- a/examples/index.html +++ b/examples/index.html @@ -24,6 +24,9 @@ [role^="menuitem"][data-menu-item-focus] { background: lightblue; } + [role^="menuitem"]:hover { + background: lightblue; + } From 134317eab44c74033a14f7e5f9eae2c2cf572c61 Mon Sep 17 00:00:00 2001 From: David Graham Date: Tue, 12 Nov 2019 13:50:42 -0700 Subject: [PATCH 04/10] Manage active descendant on menu role A menu with a filtering input at the top cannot itself be role=menu because it hides the input from screen readers. The following relationship is required.
+ + +
+
+ + diff --git a/index.js b/index.js index 24f7cf5..7f053a2 100644 --- a/index.js +++ b/index.js @@ -43,6 +43,8 @@ class DetailsMenuElement extends HTMLElement { if (!summary.hasAttribute('role')) summary.setAttribute('role', 'button') } + const container = this.getAttribute('role') === 'menu' ? this : this.querySelector('[role="menu"]') + const subscriptions = [ fromEvent(details, 'click', e => shouldCommit(details, this, e)), fromEvent(details, 'change', e => shouldCommit(details, this, e)), @@ -57,7 +59,7 @@ class DetailsMenuElement extends HTMLElement { ...focusOnOpen(details, this) ] - states.set(this, {subscriptions, loaded: false}) + states.set(this, {container, subscriptions, loaded: false}) } disconnectedCallback() { @@ -156,7 +158,11 @@ function focusFirstItem(details: Element, menu: DetailsMenuElement) { } function activeItem(menu: DetailsMenuElement): ?HTMLElement { - const id = menu.getAttribute('aria-activedescendant') + const state = states.get(menu) + const container = state ? state.container : null + if (!container) return + + const id = container.getAttribute('aria-activedescendant') return id ? document.getElementById(id) : null } @@ -237,7 +243,11 @@ function identifyItems(menu: Element) { } function focus(menu: DetailsMenuElement, item: HTMLElement) { - menu.setAttribute('aria-activedescendant', item.id) + const state = states.get(menu) + const container = state ? state.container : null + if (!container) return + + container.setAttribute('aria-activedescendant', item.id) for (const el of menu.querySelectorAll('[role^=menuitem][data-menu-item-focus]')) { el.removeAttribute('data-menu-item-focus') } @@ -247,7 +257,12 @@ function focus(menu: DetailsMenuElement, item: HTMLElement) { function clearFocus(details: Element, menu: DetailsMenuElement) { if (details.hasAttribute('open')) return - menu.removeAttribute('aria-activedescendant') + + const state = states.get(menu) + const container = state ? state.container : null + if (!container) return + + container.removeAttribute('aria-activedescendant') for (const el of menu.querySelectorAll('[role^="menuitem"][data-menu-item-focus]')) { el.removeAttribute('data-menu-item-focus') } From 0dcce655700aab1a92b7d9e763c8724293de6497 Mon Sep 17 00:00:00 2001 From: David Graham Date: Tue, 12 Nov 2019 14:09:14 -0700 Subject: [PATCH 05/10] Test active descendant focus management --- test/test.js | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) 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) +} From a026af1b23072f0b3c6137dca377988ef82ae131 Mon Sep 17 00:00:00 2001 From: David Graham Date: Mon, 2 Dec 2019 16:24:19 -0700 Subject: [PATCH 06/10] Use summary as active descendant control NVDA requires the element with DOM focus to manage the active descendant. We use summary or an optional input field as the focused element. Co-authored-by: Mu-An Chiou --- examples/index.html | 6 +++--- index.js | 43 ++++++++++++++++++++++++------------------- 2 files changed, 27 insertions(+), 22 deletions(-) diff --git a/examples/index.html b/examples/index.html index aecf847..9333c79 100644 --- a/examples/index.html +++ b/examples/index.html @@ -21,7 +21,7 @@ text-align: left; padding: 0; } - [role^="menuitem"][data-menu-item-focus] { + [role^="menuitem"][aria-selected="true"] { background: lightblue; } [role^="menuitem"]:hover { @@ -68,8 +68,8 @@
Filter robots - - + +
From 2abaf738b03ba411b24837db7015e16b910eef5c Mon Sep 17 00:00:00 2001 From: Mu-An Chiou Date: Mon, 21 Oct 2019 15:52:26 -0400 Subject: [PATCH 10/10] Describe each demo --- examples/index.html | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/examples/index.html b/examples/index.html index f65a63e..6e08771 100644 --- a/examples/index.html +++ b/examples/index.html @@ -30,6 +30,7 @@ +

Menu with plain buttons.

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

Menu with radio menu items.

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

Menu with checkbox menu items.

Favorite robots @@ -57,14 +60,7 @@
-
- Favorite robots - - - - - -
+

Menu with navigation from input support.

Filter robots