Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Focus menu items with aria-activedescendant #43

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 10 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,26 @@ If the `preload` attribute is present, the server fetch will begin on mouse
hover over the `<details>` button, so the content may be loaded by the time
the menu is opened.

### Focus management via `<input>`

```html
<details>
<summary>Robots</summary>
<details-menu input="filter-robots">
<input id="filter-robots">
<div role="menu">
<button role="menuitem">Bender</button>
<button role="menuitem">Hubot</button>
<button role="menuitem">R2-D2</button>
</div>
</details-menu>
</details>
```

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][].
Expand Down
26 changes: 20 additions & 6 deletions examples/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,16 @@
text-align: left;
padding: 0;
}
[role^="menuitem"][aria-selected="true"] {
background: lightblue;
}
[role^="menuitem"]:hover {
background: lightblue;
}
</style>
</head>
<body>
<p>Menu with plain buttons.</p>
<details>
<summary>Best robot: <span data-menu-button>Unknown</span></summary>
<details-menu>
Expand All @@ -33,6 +40,7 @@
</details-menu>
</details>

<p>Menu with radio menu items.</p>
<details>
<summary>Best robot: <span data-menu-button>Unknown</span></summary>
<details-menu>
Expand All @@ -42,6 +50,7 @@
</details-menu>
</details>

<p>Menu with checkbox menu items.</p>
<details>
<summary>Favorite robots</summary>
<details-menu>
Expand All @@ -51,17 +60,22 @@
</details-menu>
</details>

<p>Menu with navigation from input support.</p>

<details>
<summary data-menu-button>Favorite robots</summary>
<details-menu>
<button type="submit" name="robot" value="Hubot" role="menuitemradio" data-menu-button-text>Hubot</button>
<button type="submit" name="robot" value="Bender" role="menuitemradio" data-menu-button-text>Bender</button>
<button type="submit" name="robot" value="BB-8" role="menuitemradio" data-menu-button-text>BB-8</button>
<summary data-menu-button>Filter robots</summary>
<details-menu role="none" input="filter-input">
<input autofocus aria-owns="filter-menu" id="filter-input">
<div role="menu" id="filter-menu">
<button type="submit" name="robot" value="Hubot" role="menuitemradio" data-menu-button-text>Hubot</button>
<button type="submit" name="robot" value="Bender" role="menuitemradio" data-menu-button-text>Bender</button>
<button type="submit" name="robot" value="BB-8" role="menuitemradio" data-menu-button-text>BB-8</button>
</div>
</details-menu>
</details>

<script type="text/javascript">
document.addEventListener('details-menu-selected', e => console.log(e))
document.addEventListener('details-menu-selected', e => console.log(e), {capture: true})
</script>
<!-- <script src="../dist/index.umd.js"></script> -->
<script type="text/javascript" src="https://unpkg.com/@github/details-menu-element@latest"></script>
Expand Down
119 changes: 90 additions & 29 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}),
Copy link
Contributor

@keithamus keithamus Nov 13, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this mean runtime mutations of the HTML will break focussing?

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() {
Expand Down Expand Up @@ -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<Subscription> {
function focusOnOpen(details: Element, menu: DetailsMenuElement): Array<Subscription> {
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 [
Expand Down Expand Up @@ -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]
Expand All @@ -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)
}
}

Expand All @@ -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', {
Expand All @@ -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'
Expand All @@ -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
Expand All @@ -242,33 +303,33 @@ 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()
}
}
break
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()
}
}
break
case ' ':
case 'Enter':
{
const selected = document.activeElement
const selected = activeItem(menu)
if (selected && isMenuItem(selected) && selected.closest('details') === details) {
event.preventDefault()
event.stopPropagation()
Expand Down
26 changes: 19 additions & 7 deletions test/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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() {
Expand All @@ -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() {
Expand All @@ -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() {
Expand All @@ -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() {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}