Skip to content
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

Implement Menu #286

Merged
merged 20 commits into from
Apr 18, 2024
Merged
Show file tree
Hide file tree
Changes from 9 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
2 changes: 1 addition & 1 deletion docs-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
"ember-primitives": "workspace:*",
"ember-repl": "3.0.0-beta.8",
"ember-route-template": "^1.0.3",
"ember-velcro": "^2.1.3",
"ember-velcro": "^2.2.0",
"hash": "^0.2.1",
"highlight.js": "^11.8.0",
"highlightjs-glimmer": "^2.2.1",
Expand Down
112 changes: 112 additions & 0 deletions docs-app/public/docs/5-floaty-bits/menu.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# Menu

Menus are built with Popovers, with added features for keyboard navigation and accessibility.

The placement of the menu content is handled by `<Popover>`, so `<Menu>` accepts the same arguments for positioning the dropdown.

Like `<Popover>`, the `<Menu>` component uses portals in a way that totally solves layering issues. No more worrying about tooltips on varying layers of your UI sometimes appearing behind other floaty bits. See the `<Portal>` and `<PortalTargets>` pages for more information.

<div class="featured-demo">

```gjs live preview no-shadow
import { PortalTargets, Menu } from 'ember-primitives';

<template>
<PortalTargets />

<Menu @offsetOptions={{8}} as |m|>
<m.Trigger class="trigger">
<EllipsisVertical />
</m.Trigger>

<m.Content class="content" as |c|>
<c.Item>Item 1</c.Item>
<c.Item>Item 2</c.Item>
<c.Separator />
<c.Item>Item 3</c.Item>
</m.Content>
</Menu>

<style>
.content {
all: unset;
min-width: 180px;
background: #fff;
color: #111827;
padding: 8px 0;
border-radius: 6px;
border: none;
font-size: 14px;
z-index: 10;
box-shadow: 0 0 #0000, 0 0 #0000, 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
display: flex;
flex-direction: column;
}

.content [role="menuitem"] {
all: unset;
display: block;
padding: 4px 12px;
cursor: pointer;
}

.content [role="menuitem"]:focus, .trigger:hover {
background-color: #f9fafb;
}

.content [role="separator"] {
border-bottom: 1px solid rgb(17 24 39 / 0.1);
}

.trigger {
display: inline-block;
border-radius: 4px;
border-width: 0;
background-color: #fff;
color: #111827;
border-radius: 100%;
padding: 10px;
box-shadow: rgba(0, 0, 0, 0.2) 0px 2px 10px;
cursor: pointer;
}

.trigger svg {
width: 15px;
height: 15px;
display: block;
}
</style>
</template>

const EllipsisVertical = <template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 512" fill="currentColor"><!--!Font Awesome Free 6.5.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M64 360a56 56 0 1 0 0 112 56 56 0 1 0 0-112zm0-160a56 56 0 1 0 0 112 56 56 0 1 0 0-112zM120 96A56 56 0 1 0 8 96a56 56 0 1 0 112 0z"/></svg>
</template>;
```

</div>


## API Reference

```gjs live no-shadow
import { ComponentSignature } from 'docs-app/docs-support';

<template>
<ComponentSignature @module="components/menu" @name="Signature" />
</template>
```

## Accessibility

Adheres to the [Menu Button WAI-ARIA design pattern](https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/).

### Keyboard Interactions

| key | description |
| :---: | :----------- |
| <kbd>Space</kbd> <kbd>Enter</kbd> | When focus is on `Trigger`, opens the menu and focuses the first item. When focus is on an `Item`, activates the focused item. |
| <kbd>ArrowDown</kbd> <kbd>ArrowRight</kbd> | When `Content` is open, moves to the next item. |
| <kbd>ArrowUp</kbd> <kbd>ArrowLeft</kbd> | When `Content` is open, moves to the previous item. |
| <kbd>Esc</kbd> | Closes the menu and moves focus to `Trigger`. |

In addition, a label is required so that users know what the switch is for.
3 changes: 2 additions & 1 deletion docs-app/public/docs/5-floaty-bits/popover.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Popovers are built with [ember-velcro][gh-e-velcro], which is an ergonomic wrapp


<!--
The goal of a popover is to provide additional behavioral functionality to make interacting with floaty bits easier easier:
The goal of a popover is to provide additional behavioral functionality to make interacting with floaty bits easier:
- focus trapping (TODO)
- focus returning (TODO)
-->
Expand Down Expand Up @@ -232,4 +232,5 @@ Since a `Popover` isn't an explicit design pattern provided by W3, but instead,
- [Date Picker Dialog](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/examples/datepicker-dialog/)
- [Date Picker Combobox](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-datepicker/)
- [Select-Only Combobox](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-select-only/)
- [Menu Butto](https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/)
miguelcobain marked this conversation as resolved.
Show resolved Hide resolved
- and more
5 changes: 3 additions & 2 deletions ember-primitives/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,9 @@
"@floating-ui/dom": "^1.5.1",
"decorator-transforms": "^1.1.0",
"ember-element-helper": "^0.8.4",
"ember-velcro": "^2.1.3",
"ember-velcro": "^2.2.0",
"reactiveweb": "^1.2.0",
"tabster": "^7.0.1",
"tabster": "^7.1.0",
miguelcobain marked this conversation as resolved.
Show resolved Hide resolved
"tracked-built-ins": "^3.2.0",
"tracked-toolbox": "^2.0.0"
},
Expand Down Expand Up @@ -110,6 +110,7 @@
"./components/external-link.js": "./dist/_app_/components/external-link.js",
"./components/form.js": "./dist/_app_/components/form.js",
"./components/link.js": "./dist/_app_/components/link.js",
"./components/menu.js": "./dist/_app_/components/menu.js",
"./components/popover.js": "./dist/_app_/components/popover.js",
"./components/portal-targets.js": "./dist/_app_/components/portal-targets.js",
"./components/portal.js": "./dist/_app_/components/portal.js",
Expand Down
233 changes: 233 additions & 0 deletions ember-primitives/src/components/menu.gts
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
import { hash, uniqueId } from '@ember/helper';
import { on } from '@ember/modifier';

import { modifier } from 'ember-modifier';
import { cell } from 'ember-resources';
import { getTabster, getTabsterAttribute, Types } from 'tabster';

import { Popover, type Signature as PopoverSignature } from './popover.gts';

import type { TOC } from '@ember/component/template-only';
import type { WithBoundArgs } from '@glint/template';

type Cell<V> = ReturnType<typeof cell<V>>;

const TABSTER_CONFIG_CONTENT = getTabsterAttribute(
{
mover: {
direction: Types.MoverDirections.Both,
cyclic: true,
},
deloser: {},
},
true
);

const TABSTER_CONFIG_TRIGGER = getTabsterAttribute(
{
deloser: {},
},
true
);

export interface Signature {
Args: PopoverSignature['Args'];
Blocks: {
default: [
{
arrow: PopoverSignature['Blocks']['default'][0]['arrow'];
Trigger: WithBoundArgs<typeof Trigger, 'triggerElement' | 'contentId' | 'isOpen' | 'hook'>;
Content: WithBoundArgs<
typeof Content,
'triggerElement' | 'contentId' | 'isOpen' | 'PopoverContent'
>;
isOpen: boolean;
},
];
};
}

const Separator: TOC<{
Element: HTMLDivElement;
Args: {};
Blocks: { default: [] };
}> = <template>
<div role="separator" ...attributes>
{{yield}}
</div>
</template>;

/**
* We focus items on `pointerMove` to achieve the following:
*
* - Mouse over an item (it focuses)
* - Leave mouse where it is and use keyboard to focus a different item
* - Wiggle mouse without it leaving previously focused item
* - Previously focused item should re-focus
*
* If we used `mouseOver`/`mouseEnter` it would not re-focus when the mouse
* wiggles. This is to match native menu implementation.
*/
function focusOnHover(e: PointerEvent) {
const item = e.currentTarget;

if (item instanceof HTMLElement) {
item?.focus();
}
}

const Item: TOC<{
Element: HTMLButtonElement;
Args: { onSelect?: (event: Event) => void };
Blocks: { default: [] };
}> = <template>
<button
type="button"
role="menuitem"
{{! @glint-ignore !}}
{{(if @onSelect (modifier on "click" @onSelect))}}
{{on "pointermove" focusOnHover}}
...attributes
>
{{yield}}
</button>
</template>;

const installContent = modifier<{
Element: HTMLElement;
Args: {
Named: {
isOpen: Cell<boolean>;
triggerElement: Cell<HTMLElement>;
};
};
}>((element, _: [], { isOpen, triggerElement }) => {
// focus first focusable element on the content
const tabster = getTabster(window);
const firstFocusable = tabster?.focusable.findFirst({
container: element,
});

firstFocusable?.focus();

// listen for "outside" clicks
function onDocumentClick(e: MouseEvent) {
miguelcobain marked this conversation as resolved.
Show resolved Hide resolved
if (
isOpen.current &&
e.target &&
!element.contains(e.target as HTMLElement) &&
!triggerElement.current?.contains(e.target as HTMLElement)
) {
isOpen.current = false;
}
}

// listen for the escape key
function onDocumentKeydown(e: KeyboardEvent) {
if (isOpen.current && e.key === 'Escape') {
isOpen.current = false;
}
}

document.addEventListener('click', onDocumentClick);
document.addEventListener('keydown', onDocumentKeydown);

return () => {
document.removeEventListener('click', onDocumentClick);
document.removeEventListener('keydown', onDocumentKeydown);
};
});

const Content: TOC<{
Element: HTMLDivElement;
Args: {
triggerElement: Cell<HTMLElement>;
contentId: string;
isOpen: Cell<boolean>;
PopoverContent: PopoverSignature['Blocks']['default'][0]['Content'];
};
Blocks: { default: [{ Item: typeof Item; Separator: typeof Separator }] };
}> = <template>
{{#if @isOpen.current}}
<@PopoverContent
id={{@contentId}}
role="menu"
data-tabster={{TABSTER_CONFIG_CONTENT}}
tabindex="0"
{{installContent isOpen=@isOpen triggerElement=@triggerElement}}
{{on "click" @isOpen.toggle}}
...attributes
>
{{yield (hash Item=Item Separator=Separator)}}
</@PopoverContent>
{{/if}}
</template>;

const installTrigger = modifier<{
Element: HTMLElement;
Args: { Named: { triggerElement: Cell<HTMLElement> } };
}>((element, _: [], { triggerElement }) => {
triggerElement.current = element;
});

const Trigger: TOC<{
Element: HTMLButtonElement;
Args: {
triggerElement: Cell<HTMLElement>;
contentId: string;
isOpen: Cell<boolean>;
hook: PopoverSignature['Blocks']['default'][0]['hook'];
};
Blocks: { default: [] };
}> = <template>
<button
data-tabster={{TABSTER_CONFIG_TRIGGER}}
type="button"
aria-controls={{if @isOpen.current @contentId}}
aria-haspopup="menu"
aria-expanded={{if @isOpen.current "true" "false"}}
{{@hook}}
{{installTrigger triggerElement=@triggerElement}}
{{on "click" @isOpen.toggle}}
...attributes
>
{{yield}}
</button>
</template>;

const IsOpen = () => cell<boolean>(false);
const TriggerElement = () => cell<HTMLElement>();

export const Menu: TOC<Signature> = <template>
{{#let (IsOpen) (uniqueId) (TriggerElement) as |isOpen contentId triggerEl|}}
miguelcobain marked this conversation as resolved.
Show resolved Hide resolved
<Popover
@flipOptions={{@flipOptions}}
@middleware={{@middleware}}
@offsetOptions={{@offsetOptions}}
@placement={{@placement}}
@shiftOptions={{@shiftOptions}}
@strategy={{@strategy}}
@inline={{@inline}}
as |p|
>
{{yield
(hash
Trigger=(component
Trigger hook=p.hook isOpen=isOpen triggerElement=triggerEl contentId=contentId
)
Content=(component
Content
PopoverContent=p.Content
isOpen=isOpen
triggerElement=triggerEl
contentId=contentId
)
arrow=p.arrow
isOpen=isOpen.current
)
}}
</Popover>
{{/let}}
</template>;

export default Menu;
Loading
Loading