diff --git a/docs/components/figures/menu/usage-popover.html b/docs/components/figures/menu/usage-popover.html new file mode 100644 index 00000000000..6c0652e5eec --- /dev/null +++ b/docs/components/figures/menu/usage-popover.html @@ -0,0 +1,30 @@ +
+
+
+
+ Open popover menu +
+ + +
Apple
+
+ +
Banana
+
+ +
Cucumber
+
+
+
+ +
+
diff --git a/docs/components/images/menu/usage-popover.webp b/docs/components/images/menu/usage-popover.webp new file mode 100644 index 00000000000..0a86a82ecd6 Binary files /dev/null and b/docs/components/images/menu/usage-popover.webp differ diff --git a/docs/components/menu.md b/docs/components/menu.md index d835bc78342..fa104fde013 100644 --- a/docs/components/menu.md +++ b/docs/components/menu.md @@ -215,14 +215,69 @@ Granny Smith, and Red Delicious."](images/menu/usage-submenu.webp) ``` -### 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) +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). + +> 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)). +> +> For browsers that do not support the Popover API, `md-menu` will fall back to +> using [fixed-positioned menus](#fixed-positioned-menus). + + + +!["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) + + + + +```html + +
+ Open popover menu +
+ + + + +
Apple
+
+ +
Banana
+
+ +
Cucumber
+
+
+ + +``` + +### 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 diff --git a/menu/demo/demo.ts b/menu/demo/demo.ts index 453a578d02a..05b04a82560 100644 --- a/menu/demo/demo.ts +++ b/menu/demo/demo.ts @@ -64,11 +64,12 @@ const collection = new MaterialCollection>( }), 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'}, ], }), }), diff --git a/menu/demo/stories.ts b/menu/demo/stories.ts index e6ecfac021d..fc48769745f 100644 --- a/menu/demo/stories.ts +++ b/menu/demo/stories.ts @@ -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; diff --git a/menu/internal/_menu.scss b/menu/internal/_menu.scss index f089f081774..53d328eacc3 100644 --- a/menu/internal/_menu.scss +++ b/menu/internal/_menu.scss @@ -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; @@ -70,6 +76,10 @@ max-width: inherit; } + .menu::backdrop { + display: none; + } + .fixed { position: fixed; } @@ -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; } diff --git a/menu/internal/controllers/surfacePositionController.ts b/menu/internal/controllers/surfacePositionController.ts index 77b8941f746..5d402d61f7b 100644 --- a/menu/internal/controllers/surfacePositionController.ts +++ b/menu/internal/controllers/surfacePositionController.ts @@ -196,6 +196,14 @@ 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 as unknown as {showPopover: () => void}).showPopover(); + } + const surfaceRect = surfaceEl.getSurfacePositionClientRect ? surfaceEl.getSurfacePositionClientRect() : surfaceEl.getBoundingClientRect(); @@ -600,5 +608,12 @@ 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 as unknown as {hidePopover: () => void}).hidePopover(); + } } } diff --git a/menu/internal/menu.ts b/menu/internal/menu.ts index 572e3e2f42d..183c6162a7e 100644 --- a/menu/internal/menu.ts +++ b/menu/internal/menu.ts @@ -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'; @@ -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, @@ -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 revert 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` @@ -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`. * @@ -134,8 +137,14 @@ export abstract class Menu extends LitElement { * end of the `` 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. */ @@ -362,7 +371,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, @@ -372,7 +382,10 @@ export abstract class Menu extends LitElement { // 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', + repositionStrategy: + this.hasOverflow && this.positioning !== 'popover' + ? 'move' + : 'resize', }; }, ); @@ -407,13 +420,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; } @@ -445,7 +470,8 @@ export abstract class Menu extends LitElement { return html`