From de306d304792e9355fceafaa4aa5ed123c67dd95 Mon Sep 17 00:00:00 2001 From: Mu-An Chiou Date: Mon, 21 Oct 2019 15:33:55 -0400 Subject: [PATCH 01/11] Add support This is so menus can be navigated without leaving an input focus --- index.js | 62 +++++++++++++++++++++++++++++++++++++++++++-------- index.js.flow | 1 + 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/index.js b/index.js index 9a550ca..af13649 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 + const selected = getCurrentFocus(details) if (selected && isMenuItem(selected) && details.contains(selected)) return const target = sibling(details, true) - if (target) target.focus() + if (target) focus(target) +} + +function getCurrentFocus(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 } 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 = getCurrentFocus(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,7 +299,7 @@ function keydown(event: KeyboardEvent) { case ' ': case 'Enter': { - const selected = document.activeElement + const selected = getCurrentFocus(details) if (selected && isMenuItem(selected) && selected.closest('details') === details) { event.preventDefault() event.stopPropagation() @@ -287,6 +310,27 @@ function keydown(event: KeyboardEvent) { } } +function focus(target) { + const menu = target.closest('details-menu') + if (!(menu instanceof DetailsMenuElement)) return + clearFocus(menu) + + if (menu.input && document.activeElement === menu.input) { + if (!target.id) target.id = `rand-${(Math.random() * 1000).toFixed(0)}` + menu.input.setAttribute('aria-activedescendant', target.id) + target.setAttribute('aria-selected', 'true') + } 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' { From 21fb63e2ec8e9ef69e84f88d576852257e73614e Mon Sep 17 00:00:00 2001 From: Mu-An Chiou Date: Mon, 21 Oct 2019 15:34:39 -0400 Subject: [PATCH 02/11] Add test for navigating without leaving focus --- test/test.js | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) 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')) + }) + }) }) From 1eebd1fc576ca5c507f5ef8eb86d397faea3a945 Mon Sep 17 00:00:00 2001 From: Mu-An Chiou Date: Mon, 21 Oct 2019 15:48:34 -0400 Subject: [PATCH 03/11] Fix event logging; this event hasn't been bubbling for a while --- examples/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/index.html b/examples/index.html index 48e7ec3..a3a483f 100644 --- a/examples/index.html +++ b/examples/index.html @@ -61,7 +61,7 @@ From 0174a48e8ce9757f8a9e31e9986eef194347f229 Mon Sep 17 00:00:00 2001 From: Mu-An Chiou Date: Mon, 21 Oct 2019 15:49:37 -0400 Subject: [PATCH 04/11] Add an input demo + styling to show focus --- examples/index.html | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/examples/index.html b/examples/index.html index a3a483f..ecb4686 100644 --- a/examples/index.html +++ b/examples/index.html @@ -4,6 +4,11 @@ details-menu demo @@ -60,6 +70,18 @@
+
+ Best robot: Unknown + + +
+ + + +
+
+
+ From c6f6b18561ae8344b2fa4fd53316b34364981c4d Mon Sep 17 00:00:00 2001 From: Mu-An Chiou Date: Mon, 21 Oct 2019 15:50:00 -0400 Subject: [PATCH 05/11] Add focus management via input note in README --- README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) 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][]. From 35da4320ed6fd52ef72862de4682da503d32ac39 Mon Sep 17 00:00:00 2001 From: Mu-An Chiou Date: Mon, 21 Oct 2019 15:52:26 -0400 Subject: [PATCH 06/11] 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 ecb4686..b969f49 100644 --- a/examples/index.html +++ b/examples/index.html @@ -34,6 +34,7 @@ +

Menu with plain buttons.

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

Menu with radio menu items.

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

Menu with checkbox menu items.

Favorite robots @@ -61,14 +64,7 @@
-
- Favorite robots - - - - - -
+

Menu with navigation from input support.

Best robot: Unknown From cb080c72b9d3ba5358e9cc703543d38679e03392 Mon Sep 17 00:00:00 2001 From: Mu-An Chiou Date: Mon, 21 Oct 2019 16:02:04 -0400 Subject: [PATCH 07/11] Add static ID so VoiceOver knows them by default --- examples/index.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/index.html b/examples/index.html index b969f49..93c70ea 100644 --- a/examples/index.html +++ b/examples/index.html @@ -71,9 +71,9 @@
- - - + + +
From 62fa62e3cf87ed6c637ef92936a4b7b964792aa8 Mon Sep 17 00:00:00 2001 From: Mu-An Chiou Date: Mon, 21 Oct 2019 16:02:23 -0400 Subject: [PATCH 08/11] Fix typing --- index.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/index.js b/index.js index af13649..514d161 100644 --- a/index.js +++ b/index.js @@ -314,11 +314,12 @@ function focus(target) { const menu = target.closest('details-menu') if (!(menu instanceof DetailsMenuElement)) return clearFocus(menu) + const input = menu.input - if (menu.input && document.activeElement === menu.input) { + if (input && document.activeElement === input) { if (!target.id) target.id = `rand-${(Math.random() * 1000).toFixed(0)}` - menu.input.setAttribute('aria-activedescendant', target.id) target.setAttribute('aria-selected', 'true') + input.setAttribute('aria-activedescendant', target.id) } else { target.focus() } From 5ee51b32056c52b404739ad12e444814e59dc7c7 Mon Sep 17 00:00:00 2001 From: Mu-An Chiou Date: Mon, 21 Oct 2019 16:17:32 -0400 Subject: [PATCH 09/11] Add aria-owns to input to establish container relationship --- examples/index.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/index.html b/examples/index.html index 93c70ea..f06de5a 100644 --- a/examples/index.html +++ b/examples/index.html @@ -69,8 +69,8 @@
Best robot: Unknown - -
+ +