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: 580633646
  • Loading branch information
Elliott Marquez authored and copybara-github committed Nov 8, 2023
1 parent 5bb4a42 commit 7859b39
Show file tree
Hide file tree
Showing 8 changed files with 166 additions and 19 deletions.
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-popover.webp
Binary file not shown.
63 changes: 59 additions & 4 deletions docs/components/menu.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,14 +215,69 @@ Granny Smith, and Red Delicious."](images/menu/usage-submenu.webp)
</script>
```

### Fixed-positioned 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 element.
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](https://developer.mozilla.org/en-US/docs/Glossary/Top_layer)<!-- {.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 compatibility](https://developer.mozilla.org/en-US/docs/Web/API/Popover_API#browser_compatibility)<!-- {.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
3 changes: 2 additions & 1 deletion menu/demo/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,12 @@ const collection = new MaterialCollection<KnobTypesToKnobs<StoryKnobs>>(
}),
new Knob('positioning', {
defaultValue: 'absolute' as const,
ui: selectDropdown<'absolute' | 'fixed' | 'document'>({
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
2 changes: 1 addition & 1 deletion 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' | 'document' | undefined;
positioning: 'absolute' | 'fixed' | 'document' | 'popover' | undefined;
open: boolean;
quick: boolean;
hasOverflow: boolean;
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
21 changes: 21 additions & 0 deletions menu/internal/controllers/surfacePositionController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,17 @@ export class SurfacePositionController implements ReactiveController {
this.host.requestUpdate();
await this.host.updateComplete;

// Safari has a bug that makes popovers render incorrectly if the node is
// made visible + Animation Frame before calling showPopover().
// https://bugs.webkit.org/show_bug.cgi?id=264069
// also the cast is required due to differing TS types in Google and OSS.
if (
(surfaceEl as unknown as {popover: string}).popover &&
surfaceEl.isConnected
) {
(surfaceEl as unknown as {showPopover: () => void}).showPopover();
}

const surfaceRect = surfaceEl.getSurfacePositionClientRect
? surfaceEl.getSurfacePositionClientRect()
: surfaceEl.getBoundingClientRect();
Expand Down Expand Up @@ -600,5 +611,15 @@ export class SurfacePositionController implements ReactiveController {
'display': 'none',
};
this.host.requestUpdate();
const surfaceEl = this.getProperties().surfaceEl;

// The following type casts are required due to differing TS types in Google
// and open source.
if (
(surfaceEl as unknown as {popover?: string})?.popover &&
surfaceEl?.isConnected
) {
(surfaceEl as unknown as {hidePopover: () => void}).hidePopover();
}
}
}
53 changes: 41 additions & 12 deletions menu/internal/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import '../../elevation/elevation.js';
import '../../focus/md-focus-ring.js';

import {html, isServer, LitElement, PropertyValues} from 'lit';
import {LitElement, PropertyValues, html, isServer, nothing} from 'lit';
import {property, query, queryAssignedElements, state} from 'lit/decorators.js';
import {ClassInfo, classMap} from 'lit/directives/class-map.js';
import {styleMap} from 'lit/directives/style-map.js';
Expand All @@ -16,7 +16,7 @@ import {
polyfillARIAMixin,
polyfillElementInternalsAria,
} from '../../internal/aria/aria.js';
import {createAnimationSignal, EASING} from '../../internal/motion/animation.js';
import {EASING, createAnimationSignal} from '../../internal/motion/animation.js';
import {
ListController,
NavigableKeys,
Expand Down Expand Up @@ -107,9 +107,12 @@ export abstract class Menu extends LitElement {
@property() anchor = '';
/**
* Whether the positioning algorithim should calculate relative to the parent
* of the anchor element (absolute) or relative to the window (fixed).
* of the anchor element (`absolute`), relative to the window (`fixed`), or
* relative to the document (`document`). `popover` will use the popover API
* to render the menu in the top-layer. If your browser does not support the
* popover API, it will fall back to `fixed`.
*
* Examples for `position = 'fixed'`:
* __Examples for `position = 'fixed'`:__
*
* - If there is no `position:relative` in the given parent tree and the
* surface is `position:absolute`
Expand All @@ -118,7 +121,7 @@ export abstract class Menu extends LitElement {
* - The anchor and the surface do not share a common `position:relative`
* ancestor
*
* When using positioning = fixed, in most cases, the menu should position
* When using `positioning=fixed`, in most cases, the menu should position
* itself above most other `position:absolute` or `position:fixed` elements
* when placed inside of them. e.g. using a menu inside of an `md-dialog`.
*
Expand All @@ -134,14 +137,21 @@ export abstract class Menu extends LitElement {
* end of the `<body>` to render over everything or in a top-layer.
* - You are reusing a single `md-menu` element that dynamically renders
* content.
*
* __Examples for `position = 'popover'`:__
*
* - Your browser supports `popover`.
* - Most cases. Once popover is in browsers, this will become the default.
*/
@property() positioning: 'absolute' | 'fixed' | 'document' = 'absolute';
@property() positioning: 'absolute' | 'fixed' | 'document' | 'popover' =
'absolute';
/**
* Skips the opening and closing animations.
*/
@property({type: Boolean}) quick = false;
/**
* Displays overflow content like a submenu.
* Displays overflow content like a submenu. Not required in most cases when
* using `positioning="popover"`.
*
* __NOTE__: This may cause adverse effects if you set
* `md-menu {max-height:...}`
Expand Down Expand Up @@ -362,7 +372,8 @@ export abstract class Menu extends LitElement {
surfaceCorner: this.menuCorner,
surfaceEl: this.surfaceEl,
anchorEl: this.anchorElement,
positioning: this.positioning,
positioning:
this.positioning === 'popover' ? 'document' : this.positioning,
isOpen: this.open,
xOffset: this.xOffset,
yOffset: this.yOffset,
Expand All @@ -371,8 +382,13 @@ export abstract class Menu extends LitElement {
onClose: this.onClosed,
// We can't resize components that have overflow like menus with
// submenus because the overflow-y will show menu items / content
// outside the bounds of the menu. (to be fixed w/ popover API)
repositionStrategy: this.hasOverflow ? 'move' : 'resize',
// outside the bounds of the menu. Popover API fixes this because each
// submenu is hoisted to the top-layer and are not considered overflow
// content.
repositionStrategy:
this.hasOverflow && this.positioning !== 'popover'
? 'move'
: 'resize',
};
},
);
Expand Down Expand Up @@ -407,13 +423,25 @@ export abstract class Menu extends LitElement {
}
}

// Firefox does not support popover. Fall-back to using fixed.
if (
changed.has('positioning') &&
this.positioning === 'popover' &&
// type required for Google JS conformance
!(this as unknown as {showPopover?: () => void}).showPopover
) {
this.positioning = 'fixed';
}

super.update(changed);
}

private readonly onWindowResize = () => {
if (
this.isRepositioning ||
(this.positioning !== 'document' && this.positioning !== 'fixed')
(this.positioning !== 'document' &&
this.positioning !== 'fixed' &&
this.positioning !== 'popover')
) {
return;
}
Expand Down Expand Up @@ -445,7 +473,8 @@ export abstract class Menu extends LitElement {
return html`
<div
class="menu ${classMap(this.getSurfaceClasses())}"
style=${styleMap(this.menuPositionController.surfaceStyles)}>
style=${styleMap(this.menuPositionController.surfaceStyles)}
popover=${this.positioning === 'popover' ? 'manual' : nothing}>
${this.renderElevation()}
<div class="items">
<div class="item-padding"> ${this.renderMenuItems()} </div>
Expand Down

0 comments on commit 7859b39

Please sign in to comment.