From 025483e2008001cb7e935cb80084e55d830713c6 Mon Sep 17 00:00:00 2001 From: Elliott Marquez Date: Mon, 6 Nov 2023 19:41:17 -0800 Subject: [PATCH] fix(select): select menu render is over most stacking contexts with popover Fixes #4812 This feature defaults to using the popover API for the underlying menu in select. If not available (FF and older versions of Safari and Chrome), defaults to using fixed. PiperOrigin-RevId: 580029028 --- .../figures/menu/usage-popover.html | 30 +++++++++ .../components/images/menu/usage-popover.webp | Bin 0 -> 6336 bytes docs/components/menu.md | 63 ++++++++++++++++-- menu/demo/demo.ts | 3 +- menu/demo/stories.ts | 2 +- menu/internal/_menu.scss | 13 +++- .../controllers/surfacePositionController.ts | 21 ++++++ menu/internal/menu.ts | 46 ++++++++++--- select/demo/demo.ts | 5 +- select/demo/stories.ts | 2 +- select/internal/select.ts | 2 +- 11 files changed, 166 insertions(+), 21 deletions(-) create mode 100644 docs/components/figures/menu/usage-popover.html create mode 100644 docs/components/images/menu/usage-popover.webp 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 0000000000000000000000000000000000000000..0a86a82ecd6a81f411c6dd48a6989f517a3c47cb GIT binary patch literal 6336 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 X^XMu~!*Ba5%uK`zxbgR!;@|lnf^SbM literal 0 HcmV?d00001 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..d49a29cdca2 100644 --- a/menu/internal/controllers/surfacePositionController.ts +++ b/menu/internal/controllers/surfacePositionController.ts @@ -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(); @@ -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(); + } } } 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`