diff --git a/ui/packages/consul-ui/app/components/menu/README.mdx b/ui/packages/consul-ui/app/components/menu/README.mdx new file mode 100644 index 0000000000..8563fdfcd7 --- /dev/null +++ b/ui/packages/consul-ui/app/components/menu/README.mdx @@ -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 `` 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 + + + Item 1 + + + + Item 2 + + + Title + + + Item 3 + + +``` + +## Arguments + +| Argument | Type | Default | Description | +| --- | --- | --- | --- | +| `disclosure` | `DisclosureInterface` | | An object with following the `` 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 '' component with aria attributes correctly applied | + + +## See + +- [Template Source Code](./index.hbs) + +--- diff --git a/ui/packages/consul-ui/app/components/menu/action/index.hbs b/ui/packages/consul-ui/app/components/menu/action/index.hbs new file mode 100644 index 0000000000..1c32e9bd9f --- /dev/null +++ b/ui/packages/consul-ui/app/components/menu/action/index.hbs @@ -0,0 +1,6 @@ + + {{yield}} + diff --git a/ui/packages/consul-ui/app/components/menu/index.hbs b/ui/packages/consul-ui/app/components/menu/index.hbs new file mode 100644 index 0000000000..e86b2079eb --- /dev/null +++ b/ui/packages/consul-ui/app/components/menu/index.hbs @@ -0,0 +1,16 @@ + \ No newline at end of file diff --git a/ui/packages/consul-ui/app/components/menu/item/index.hbs b/ui/packages/consul-ui/app/components/menu/item/index.hbs new file mode 100644 index 0000000000..6cf4952310 --- /dev/null +++ b/ui/packages/consul-ui/app/components/menu/item/index.hbs @@ -0,0 +1,7 @@ +
  • + {{yield}} +
  • + diff --git a/ui/packages/consul-ui/app/components/menu/separator/index.hbs b/ui/packages/consul-ui/app/components/menu/separator/index.hbs new file mode 100644 index 0000000000..3866446032 --- /dev/null +++ b/ui/packages/consul-ui/app/components/menu/separator/index.hbs @@ -0,0 +1,6 @@ +
  • + {{yield}} +
  • diff --git a/ui/packages/consul-ui/app/modifiers/aria-menu.js b/ui/packages/consul-ui/app/modifiers/aria-menu.js new file mode 100644 index 0000000000..f153cd8c23 --- /dev/null +++ b/ui/packages/consul-ui/app/modifiers/aria-menu.js @@ -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(); + } +} diff --git a/ui/packages/consul-ui/app/modifiers/aria-menu.mdx b/ui/packages/consul-ui/app/modifiers/aria-menu.mdx new file mode 100644 index 0000000000..753922f7f5 --- /dev/null +++ b/ui/packages/consul-ui/app/modifiers/aria-menu.mdx @@ -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 +
    + +
    + +{{#if this.open}} + +{{/if}} +
    + +
    +``` + +## 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 |