Skip to content

Commit

Permalink
feat(menu): add popover functionality
Browse files Browse the repository at this point in the history
Uses popover API for browsers that support it. Falls back to fixed positioning on browsers that do not.

fixes #2023
fixes #5120

PiperOrigin-RevId: 578695083
  • Loading branch information
Elliott Marquez authored and copybara-github committed Nov 7, 2023
1 parent f08ecce commit c5a465c
Show file tree
Hide file tree
Showing 14 changed files with 428 additions and 64 deletions.
30 changes: 30 additions & 0 deletions docs/components/figures/menu/usage-document.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<div class="figure-wrapper">
<figure
style="justify-content: center"
aria-label="A filled button that says open document menu. Interact with the button to open a document menu."
>
<div>
<div style="margin: 16px">
<md-filled-button id="usage-document-anchor">Open document menu</md-filled-button>
</div>
<md-menu positioning="document" id="usage-document" anchor="usage-document-anchor">
<md-menu-item>
<div slot="headline">Apple</div>
</md-menu-item>
<md-menu-item>
<div slot="headline">Banana</div>
</md-menu-item>
<md-menu-item>
<div slot="headline">Cucumber</div>
</md-menu-item>
</md-menu>
</div>
<script type="module">
const anchorEl = document.body.querySelector("#usage-document-anchor");
const menuEl = document.body.querySelector("#usage-document");
anchorEl.addEventListener("click", () => {
menuEl.open = !menuEl.open;
});
</script>
</figure>
</div>
30 changes: 30 additions & 0 deletions docs/components/figures/menu/usage-popover.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<div class="figure-wrapper">
<figure
style="justify-content: center"
aria-label="A filled button that says open popover menu. Interact with the button to open a popover menu."
>
<div>
<div style="margin: 16px">
<md-filled-button id="usage-popover-anchor">Open popover menu</md-filled-button>
</div>
<md-menu positioning="popover" id="usage-popover" anchor="usage-popover-anchor">
<md-menu-item>
<div slot="headline">Apple</div>
</md-menu-item>
<md-menu-item>
<div slot="headline">Banana</div>
</md-menu-item>
<md-menu-item>
<div slot="headline">Cucumber</div>
</md-menu-item>
</md-menu>
</div>
<script type="module">
const anchorEl = document.body.querySelector("#usage-popover-anchor");
const menuEl = document.body.querySelector("#usage-popover");
anchorEl.addEventListener("click", () => {
menuEl.open = !menuEl.open;
});
</script>
</figure>
</div>
Binary file added docs/components/images/menu/usage-document.webp
Binary file not shown.
Binary file added docs/components/images/menu/usage-popover.webp
Binary file not shown.
124 changes: 118 additions & 6 deletions docs/components/menu.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ choices on a temporary surface.
When opened, menus position themselves to an anchor. Thus, either `anchor` or
`anchorElement` must be supplied to `md-menu` before opening. Additionally, a
shared parent of `position:relative` should be present around the menu and it's
anchor.
anchor, because the default menu is positioned relative to the anchor element.

Menus also render menu items such as `md-menu-item` and handle keyboard
navigation between `md-menu-item`s as well as typeahead functionality.
Expand Down Expand Up @@ -215,14 +215,69 @@ Granny Smith, and Red Delicious."](images/menu/usage-submenu.webp)
</script>
```

### Fixed menus
### Popover-positioned menus

Internally menu uses `position: absolute` by default. Though there are cases
when the anchor and the node cannot share a common ancestor that is `position:
relative`, or sometimes, menu will render below another item due to limitations
with `position: absolute`. In most of these cases, you would want to use the
`positioning="fixed"` attribute to position the menu relative to the window
instead of relative to the parent.
with `position: absolute`.

Popover-positioned menus use the native
[Popover API](https://developer.mozilla.org/en-US/docs/Web/API/Popover_API)<!-- {.external} -->
to render above all other content. This may fix most issues where the default
menu positioning (`positioning="absolute"`) is not positioning as expected by
rendering into the
[top layer](google3/third_party/javascript/material/web/g3doc/docs/components/figures/menu/usage-fixed.html)<!-- {.external} -->.

> Warning: Popover API support was added in Chrome 114 and Safari 17. At the
> time of writing, Firefox does not support the Popover API
> ([see latest browser compatiblity](#fixed-positioned-menus)<!-- {.external} -->).
>
> For browsers that do not support the Popover API, `md-menu` will fall back to
> using [fixed-positioned menus](#fixed-positioned-menus).
<!-- no-catalog-start -->

!["A filled button that says open popover menu. There is an open menu anchored
to the bottom of the button with three items, Apple, Banana, and
Cucumber."](images/menu/usage-popover.webp)

<!-- no-catalog-end -->
<!-- catalog-include "figures/menu/usage-fixed.html" -->

```html
<!-- Note the lack of position: relative parent. -->
<div style="margin: 16px;">
<md-filled-button id="usage-popover-anchor">Open popover menu</md-filled-button>
</div>

<!-- popover menus do not require a common ancestor with the anchor. -->
<md-menu positioning="popover" id="usage-popover" anchor="usage-popover-anchor">
<md-menu-item>
<div slot="headline">Apple</div>
</md-menu-item>
<md-menu-item>
<div slot="headline">Banana</div>
</md-menu-item>
<md-menu-item>
<div slot="headline">Cucumber</div>
</md-menu-item>
</md-menu>

<script type="module">
const anchorEl = document.body.querySelector('#usage-popover-anchor');
const menuEl = document.body.querySelector('#usage-popover');
anchorEl.addEventListener('click', () => { menuEl.open = !menuEl.open; });
</script>
```

### Fixed-positioned menus

This is the fallback implementation of
[popover-positioned menus](#popover-positioned-menus) and uses `position: fixed`
rather than the default `position: absolute` which calculates its position
relative to the window rather than the element.

> Note: Fixed menu positions are positioned relative to the window and not the
> document. This means that the menu will not scroll with the anchor as the page
Expand Down Expand Up @@ -264,6 +319,64 @@ Cucumber."](images/menu/usage-fixed.webp)
</script>
```

### Document-positioned menus

When set to `positioning="document"`, `md-menu` will position itself relative to
the document as opposed to the element or the window from `"absolute"` and
`"fixed"` values respectively.

Document level positioning is useful for the following cases:

- There are no ancestor elements that produce a `relative` positioning
context.
- `position: relative`
- `position: absolute`
- `position: fixed`
- `transform: translate(x, y)`
- etc.
- The menu is hoisted to the top of the DOM
- The last child of `<body>`
- [Top layer](https://developer.mozilla.org/en-US/docs/Glossary/Top_layer)
<!-- {.external} -->
- The same `md-menu` is being reused and the contents and anchors are being
dynamically changed

<!-- no-catalog-start -->

!["A filled button that says open document menu. There is an open menu anchored
to the bottom of the button with three items, Apple, Banana, and
Cucumber."](images/menu/usage-document.webp)

<!-- no-catalog-end -->
<!-- catalog-include "figures/menu/usage-document.html" -->

```html
<!-- Note the lack of position: relative parent. -->
<div style="margin: 16px;">
<md-filled-button id="usage-document-anchor">Open document menu</md-filled-button>
</div>

<!-- document menus do not require a common ancestor with the anchor. -->
<md-menu positioning="document" id="usage-document" anchor="usage-document-anchor">
<md-menu-item>
<div slot="headline">Apple</div>
</md-menu-item>
<md-menu-item>
<div slot="headline">Banana</div>
</md-menu-item>
<md-menu-item>
<div slot="headline">Cucumber</div>
</md-menu-item>
</md-menu>

<script type="module">
const anchorEl = document.body.querySelector('#usage-document-anchor');
const menuEl = document.body.querySelector('#usage-document');
anchorEl.addEventListener('click', () => { menuEl.open = !menuEl.open; });
</script>
```

## Accessibility

By default Menu is set up to function as a `role="menu"` with children as
Expand Down Expand Up @@ -395,7 +508,6 @@ a sharp 0px border radius.](images/menu/theming.webp)

## API


### MdMenu <code>&lt;md-menu&gt;</code>

#### Properties
Expand Down
4 changes: 3 additions & 1 deletion menu/demo/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,12 @@ const collection = new MaterialCollection<KnobTypesToKnobs<StoryKnobs>>(
}),
new Knob('positioning', {
defaultValue: 'absolute' as const,
ui: selectDropdown<'absolute' | 'fixed'>({
ui: selectDropdown<'absolute' | 'fixed' | 'document' | 'popover'>({
options: [
{label: 'absolute', value: 'absolute'},
{label: 'fixed', value: 'fixed'},
{label: 'document', value: 'document'},
{label: 'popover', value: 'popover'},
],
}),
}),
Expand Down
21 changes: 16 additions & 5 deletions menu/demo/stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export interface StoryKnobs {
anchorCorner: Corner | undefined;
menuCorner: Corner | undefined;
defaultFocus: FocusState | undefined;
positioning: 'absolute' | 'fixed' | undefined;
positioning: 'absolute' | 'fixed' | 'document' | 'popover' | undefined;
open: boolean;
quick: boolean;
hasOverflow: boolean;
Expand Down Expand Up @@ -98,7 +98,10 @@ const standard: MaterialStoryInit<StoryKnobs> = {
render(knobs) {
return html`
<div class="root">
<div style="position:relative;">
<div
style="${knobs.positioning === 'document'
? ''
: 'position:relative;'}">
<md-filled-button
@click=${toggleMenu}
@keydown=${toggleMenu}
Expand Down Expand Up @@ -169,7 +172,10 @@ const linkable: MaterialStoryInit<StoryKnobs> = {

return html`
<div class="root">
<div style="position:relative;">
<div
style="${knobs.positioning === 'document'
? ''
: 'position:relative;'}">
<md-filled-button
@click=${toggleMenu}
@keydown=${toggleMenu}
Expand Down Expand Up @@ -301,7 +307,10 @@ const submenu: MaterialStoryInit<StoryKnobs> = {

return html`
<div class="root">
<div style="position:relative;">
<div
style="${knobs.positioning === 'document'
? ''
: 'position:relative;'}">
<md-filled-button
@click=${toggleMenu}
@keydown=${toggleMenu}
Expand Down Expand Up @@ -361,7 +370,9 @@ const menuWithoutButton: MaterialStoryInit<StoryKnobs> = {
],
render(knobs) {
return html`
<div class="root" style="position:relative;">
<div
class="root"
style="${knobs.positioning === 'document' ? '' : 'position:relative;'}">
<div id="anchor"> This is the anchor (use the "open" knob) </div>
<md-menu
slot="menu"
Expand Down
13 changes: 12 additions & 1 deletion menu/internal/_menu.scss
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@
.menu {
border-radius: map.get($tokens, 'container-shape');
display: none;
inset: auto;
border: none;
padding: 0px;
overflow: visible;
// [popover] adds a canvas background
background-color: transparent;
opacity: 0;
z-index: 20;
position: absolute;
Expand All @@ -70,6 +76,10 @@
max-width: inherit;
}

.menu::backdrop {
display: none;
}

.fixed {
position: fixed;
}
Expand All @@ -93,10 +103,11 @@
padding-block: 8px;
}

.has-overflow .items {
.has-overflow:not([popover]) .items {
overflow: visible;
}

.has-overflow.animating .items,
.animating .items {
overflow: hidden;
}
Expand Down
8 changes: 8 additions & 0 deletions menu/internal/controllers/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,14 @@ export interface MenuSelf {
* An array of items managed by the list.
*/
items: MenuItem[];
/**
* The positioning strategy of the menu.
*
* - `absolute` is relative to the anchor element.
* - `fixed` is relative to the window
* - `document` is relative to the document
*/
positioning?: 'absolute' | 'fixed' | 'document';
/**
* Opens the menu.
*/
Expand Down
Loading

0 comments on commit c5a465c

Please sign in to comment.