ui: aria-menu modifier (#12262)

aria-menu modifier plus Menu component (#12266)
This commit is contained in:
John Cowen 2022-02-09 09:47:45 +00:00 committed by GitHub
parent 1c71b407f6
commit b179f9fa91
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 290 additions and 0 deletions

View File

@ -0,0 +1,52 @@
# Menu
A component use for menu systems with the correct aria attributes applied.
Internally uses our `{{aria-menu}}` modifier for aria keyboarding.
Additionally it is made to work in tandem with the `<Disclosure />` component if
required (a relatively common usecase)
This component should not be used for top site navigation, but it should be used
for menus within the top site navigation for choosing options, for example
choosing a namespace or partition etc.
```hbs preview-template
<Menu as |menu|>
<menu.Item>
<menu.Action>Item 1</menu.Action>
</menu.Item>
<menu.Separator />
<menu.Item>
<menu.Action>Item 2</menu.Action>
</menu.Item>
<menu.Separator>
Title
</menu.Separator>
<menu.Item>
<menu.Action>Item 3</menu.Action>
</menu.Item>
</Menu>
```
## Arguments
| Argument | Type | Default | Description |
| --- | --- | --- | --- |
| `disclosure` | `DisclosureInterface` | | An object with following the `<Disclosure />` components API. When used no other arguments are necessary |
| `onclose` | `function` | | A function to call when a menu close is requested |
| `event` | `Event` | | A potential event used to open the menu |
## Exported API
| Name | Type | Description |
| --- | --- | --- |
| `Item` | `GlimmerComponent` | A component for adding a menu item with aria attributes correctly applied |
| `Separator` | `GlimmerComponent` | A component to be used for separating sections in the menu with aria attributes correctly applied. When used as block component you can add some sort of testual title to the separator |
| `Action` | `GlimmerComponent` | A contextual '<Action />' component with aria attributes correctly applied |
## See
- [Template Source Code](./index.hbs)
---

View File

@ -0,0 +1,6 @@
<Action
role="menuitem"
...attributes
>
{{yield}}
</Action>

View File

@ -0,0 +1,16 @@
<ul
role="menu"
aria-labelledby={{@disclosure.button}}
id={{@disclosure.panel}}
...attributes
{{aria-menu
onclose=(or @onclose @disclosure.close)
openEvent=(or @event @disclosure.event)
}}
>
{{yield (hash
Action=(component 'menu/action')
Item=(component 'menu/item')
Separator=(component 'menu/separator')
)}}
</ul>

View File

@ -0,0 +1,7 @@
<li
role="none"
...attributes
>
{{yield}}
</li>

View File

@ -0,0 +1,6 @@
<li
role="separator"
...attributes
>
{{yield}}
</li>

View File

@ -0,0 +1,107 @@
import Modifier from 'ember-modifier';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
const TAB = 9;
const ESC = 27;
const END = 35;
const HOME = 36;
const ARROW_UP = 38;
const ARROW_DOWN = 40;
const keys = {
vertical: {
[ARROW_DOWN]: ($items, i = -1) => {
return (i + 1) % $items.length;
},
[ARROW_UP]: ($items, i = 0) => {
if (i === 0) {
return $items.length - 1;
} else {
return i - 1;
}
},
[HOME]: ($items, i) => {
return 0;
},
[END]: ($items, i) => {
return $items.length - 1;
},
},
horizontal: {},
};
const MENU_ITEMS = '[role^="menuitem"]';
export default class AriaMenuModifier extends Modifier {
@service('-document') doc;
orientation = 'vertical';
@action
async keydown(e) {
if (e.keyCode === ESC) {
this.options.onclose(e);
this.$trigger.focus();
return;
}
const $items = [...this.element.querySelectorAll(MENU_ITEMS)];
const pos = $items.findIndex($item => $item === this.doc.activeElement);
if (e.keyCode === TAB) {
if (e.shiftKey) {
if (pos === 0) {
this.options.onclose(e);
this.$trigger.focus();
}
} else {
if (pos === $items.length - 1) {
await new Promise(resolve => setTimeout(resolve, 0));
this.options.onclose(e);
}
}
return;
}
if (typeof keys[this.orientation][e.keyCode] === 'undefined') {
return;
}
$items[keys[this.orientation][e.keyCode]($items, pos)].focus();
e.stopPropagation();
e.preventDefault();
}
@action
async focus(e) {
if (e.pointerType === '') {
await Promise.resolve();
this.keydown({
keyCode: HOME,
stopPropagation: () => {},
preventDefault: () => {},
});
}
}
connect(params, named) {
this.$trigger = this.doc.getElementById(this.element.getAttribute('aria-labelledby'));
if (typeof named.openEvent !== 'undefined') {
this.focus(named.openEvent);
}
this.doc.addEventListener('keydown', this.keydown);
}
disconnect() {
this.doc.removeEventListener('keydown', this.keydown);
}
didReceiveArguments() {
this.params = this.args.positional;
this.options = this.args.named;
}
didInstall() {
this.connect(this.args.positional, this.args.named);
}
willRemove() {
this.disconnect();
}
}

View File

@ -0,0 +1,96 @@
# aria-menu
Modifier based `{{aria-menu}}` helper based on GitHub top menu keyboard interactions.
Functionality is based on a11y focussed keyboard navigation of aria menus and currently only supports vertical-like navigation (but feel free to add horizontal, it should be straight forwards.
Features:
- `Enter`/`Space` to open the menu and immediately focus the first item
- Click to open the menu but _not_ focus the first item
- `Escape` to close the menu and focus the original trigger (`aria-labelledby`)
- When open, arrow keys will cycle through the menu items, and therefore not leave the menu.
- When open, tabbing through the menu items will _not_ cycle but instead return to the natural DOM tabbing flow once the start/end is reached.
ARIA attributes are not automatically added for you and you should make use of `role="menu"`, `role="menuitem"`, `role="none"` and `role="separator"` (if required). You should also take care to use the `aria-labelledby` attribute along with a correct `id` attribute on the trigger for the menu.
You should also take care to use `aria-haspopup="menu"` and `aria-controls="id"` if required. BUt only if you require the additional disclosure type functionality. These additional aria attributes are not functionally relevant to `{{aria-menu}}` itself.
Clicking outside will _not_ close the menu by default, if you require this functionality please combine with our `{{on-outside 'click'}}` modifier (see example).
In the example below, the Before Trigger and After Trigger don't do anything, they are only there to demonstrate tabbing functionality with natural DOM tabbing order.
```hbs preview-template
<div
style={{style-map
(array 'display' 'flex')
}}
>
<button
type="button"
>
Before trigger
</button>
<div
style={{style-map
(array 'position' 'relative')
}}
>
<button
{{on 'click'
(queue
(set this 'event')
(set this 'open' (not this.open))
)
}}
id="trigger"
type="button"
aria-haspopup="menu"
aria-controls="menu-id"
>
Trigger
</button>
{{#if this.open}}
<ul
id="menu-id"
style={{style-map
(array 'position' 'absolute')
(array 'padding' '1rem')
(array 'border' '1px solid rgb(var(--tone-gray-500))')
(array 'top' '2rem')
(array 'background-color' 'rgb(var(--tone-gray-000))')
}}
role="menu"
aria-labelledby="trigger"
{{on-outside 'click' (set this 'open' false)}}
{{aria-menu
openEvent=this.event
onclose=(set this 'open' false)
}}
>
<li role="none">
<button type="button" role="menuitem">Item 1</button>
</li>
<li role="none">
<button type="button" role="menuitem">Item 2</button>
</li>
<li role="none">
<button type="button" role="menuitem">Item 3</button>
</li>
</ul>
{{/if}}
</div>
<button
type="button"
>
After trigger
</button>
</div>
```
## Named Arguments
| Argument | Type | Default | Description |
| --- | --- | --- | --- |
| `openEvent` | `Event` | | The Event used to open the menu, if `pointerType` is empty the first menu element is focussed when open |
| `onclose` | `function` | | A callback called when the menu is closed |