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

Skip to content
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" aria-owns="menu-container">
<div role="menu" id="menu-container">
<button role="menuitem" id="Bender">Bender</button>
<button role="menuitem" id="Hubot">Hubot</button>
<button role="menuitem" id="R2-D2">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
30 changes: 24 additions & 6 deletions examples/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@
<meta charset="utf-8">
<title>details-menu demo</title>
<style>
details-menu [role][aria-selected="true"],
details-menu [role]:focus {
background: blue;
color: white;
}
details-menu {
background: white;
border: 1px solid;
Expand All @@ -21,9 +26,15 @@
text-align: left;
padding: 0;
}
input[type="text"] {
box-sizing: border-box;
margin-bottom: 4px;
width: 100%;
}
</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 +44,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 +54,7 @@
</details-menu>
</details>

<p>Menu with checkbox menu items.</p>
<details>
<summary>Favorite robots</summary>
<details-menu>
Expand All @@ -51,17 +64,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>Best robot: <span data-menu-button>Unknown</span></summary>
<details-menu input="filter-input">
<input id="filter-input" type="text" autofocus aria-owns="filter-menu">
<div role="menu" id="filter-menu">
<button type="button" role="menuitem" data-menu-button-text id="Hubot">Hubot</button>
<button type="button" role="menuitem" data-menu-button-text id="Bender">Bender</button>
<button type="button" role="menuitem" data-menu-button-text id="BB-8">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), 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
67 changes: 56 additions & 11 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand All @@ -251,15 +274,15 @@ function keydown(event: KeyboardEvent) {
details.setAttribute('open', '')
}
const target = sibling(details, false)
if (target) target.focus()
if (target) focus(target)
event.preventDefault()
}
break
case 'n':
{
if (ctrlBindings && event.ctrlKey) {
const target = sibling(details, true)
if (target) target.focus()
if (target) focus(target)
event.preventDefault()
}
}
Expand All @@ -268,16 +291,16 @@ function keydown(event: KeyboardEvent) {
{
if (ctrlBindings && event.ctrlKey) {
const target = sibling(details, false)
if (target) target.focus()
if (target) focus(target)
event.preventDefault()
}
}
break
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()
Expand All @@ -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'
Expand Down
1 change: 1 addition & 0 deletions index.js.flow
Original file line number Diff line number Diff line change
Expand Up @@ -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' {
Expand Down
44 changes: 44 additions & 0 deletions test/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `
<details open>
<summary data-menu-button><em>Click</em></summary>
<details-menu input="filter-input">
<input id="filter-input">
<button type="button" role="menuitem">Hubot</button>
<button type="button" role="menuitem">Bender</button>
<button type="button" role="menuitem">BB-8</button>
</details-menu>
</details>
`
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'))
})
})
})