feat(menu)!: allow anchoring with idref string and set element ref on anchorElement

BREAKING: `MdMenu.prototype.anchor` now only accepts a string which will querySelector the rootNode of the menu. The method now to anchor to an element reference is to set `MdMenu.prototype.anchorElement`. This matches the `popover` anchoring proposal more closely, but that proposal may not pass in favor of a CSS approach.
PiperOrigin-RevId: 560955779
This commit is contained in:
Elliott Marquez 2023-08-29 01:40:02 -07:00 committed by Copybara-Service
parent 3d59608571
commit 5ba348dfd0
8 changed files with 52 additions and 48 deletions

View File

@ -10,7 +10,7 @@ import '@material/web/icon/icon.js';
import type {MdIconButton} from '@material/web/iconbutton/icon-button.js';
import {css, html, LitElement} from 'lit';
import {customElement, query, state} from 'lit/decorators.js';
import {customElement, state} from 'lit/decorators.js';
import {live} from 'lit/directives/live.js';
import {drawerOpenSignal} from '../signals/drawer-open-state.js';
@ -28,8 +28,6 @@ import {materialDesign} from '../svg/material-design-logo.js';
*/
@state() private menuOpen = false;
@query('.end md-icon-button') private paletteButton!: MdIconButton;
render() {
return html`
<header>
@ -61,7 +59,7 @@ import {materialDesign} from '../svg/material-design-logo.js';
id="menu-island"
>
<md-menu
.anchor=${this.paletteButton}
anchor="theme-button"
menu-corner="START_END"
anchor-corner="END_END"
stay-open-on-focusout
@ -70,7 +68,7 @@ import {materialDesign} from '../svg/material-design-logo.js';
>
<theme-changer></theme-changer>
</md-menu>
<md-icon-button @click="${this.onPaletteClick}">
<md-icon-button id="theme-button" @click="${this.onPaletteClick}">
<md-icon>palette</md-icon>
</md-icon-button>
</lit-island>

View File

@ -99,20 +99,16 @@ const standard: MaterialStoryInit<StoryKnobs> = {
};
const menu = renderMenu(
knobs, firstAnchorRef, firstMenuRef, displayCloseEvent(firstOutputRef),
false, renderItems(fruitNames, knobs));
knobs, firstMenuRef, displayCloseEvent(firstOutputRef), false,
renderItems(fruitNames, knobs));
return html`
<div class="root">
<div style="position:relative;">
<md-filled-button
@click=${showMenu}
${ref(firstAnchorRef)}>
<md-filled-button @click=${showMenu} id="button">
Open Menu
</md-filled-button>
${menu}
</div>
<div class="output" ${ref(firstOutputRef)}></div>
</div>
@ -129,8 +125,7 @@ const linkable: MaterialStoryInit<StoryKnobs> = {
};
const menu = renderMenu(
knobs, secondAnchorRef, secondMenuRef,
displayCloseEvent(secondOutputRef), false,
knobs, secondMenuRef, displayCloseEvent(secondOutputRef), false,
renderLinkableItems(fruitNames, knobs));
return html`
@ -138,8 +133,7 @@ const linkable: MaterialStoryInit<StoryKnobs> = {
<div style="position:relative;">
<md-filled-button
@click=${showMenu}
${ref(secondAnchorRef)}>
@click=${showMenu} id="button">
Open Menu
</md-filled-button>
${menu}
@ -167,16 +161,13 @@ const submenu: MaterialStoryInit<StoryKnobs> = {
};
const rootMenu = renderMenu(
knobs, thirdAnchorRef, thirdMenuRef, displayCloseEvent(thirdOutputRef),
true, layer0);
knobs, thirdMenuRef, displayCloseEvent(thirdOutputRef), true, layer0);
return html`
<div class="root">
<div style="position:relative;">
<md-filled-button
@click=${showMenu}
${ref(thirdAnchorRef)}>
<md-filled-button @click=${showMenu} id="button">
Open Menu
</md-filled-button>
${rootMenu}
@ -208,15 +199,15 @@ const menuWithoutButton: MaterialStoryInit<StoryKnobs> = {
render(knobs) {
return html`
<div class="root" style="position:relative;">
<div id="anchor" ${ref(fourthAnchorRef)}>
<div id="anchor">
This is the anchor (use the "open" knob)
</div>
<md-menu slot="menu"
anchor="anchor"
.open=${knobs.open}
.quick=${knobs.quick}
.hasOverflow=${knobs.hasOverflow}
.ariaLabel=${knobs.ariaLabel}
.anchor=${fourthAnchorRef.value || null}
.anchorCorner="${knobs.anchorCorner!}"
.menuCorner="${knobs.menuCorner!}"
.xOffset=${knobs.xOffset}
@ -308,15 +299,13 @@ function renderSubMenu(
}
function renderMenu(
knobs: StoryKnobs, anchorRef: Ref<HTMLElement>, menuRef: Ref<MdMenu>,
knobs: StoryKnobs, menuRef: Ref<MdMenu>,
onClose: (event: CloseMenuEvent) => void, hasOverflow: boolean,
...content: unknown[]) {
return html`
<md-menu
${ref(menuRef)}
${ref(() => {
menuRef.value!.anchor = anchorRef.value || null;
})}
anchor="button"
.quick=${knobs.quick}
.hasOverflow=${hasOverflow ?? knobs.hasOverflow}
.ariaLabel=${knobs.ariaLabel}
@ -335,13 +324,9 @@ function renderMenu(
</md-menu>`;
}
const firstAnchorRef = createRef<HTMLElement>();
const firstMenuRef = createRef<MdMenu>();
const secondAnchorRef = createRef<HTMLElement>();
const secondMenuRef = createRef<MdMenu>();
const thirdAnchorRef = createRef<HTMLElement>();
const thirdMenuRef = createRef<MdMenu>();
const fourthAnchorRef = createRef<HTMLElement>();
const firstOutputRef = createRef<HTMLElement>();
const secondOutputRef = createRef<HTMLElement>();
const thirdOutputRef = createRef<HTMLElement>();

View File

@ -72,10 +72,13 @@ export abstract class Menu extends LitElement {
@query('slot') private readonly slotEl!: HTMLSlotElement|null;
/**
* The element in which the menu should align to.
* The ID of the element in the same root node in which the menu should align
* to. Overrides setting `anchorElement = elementReference`.
*
* __NOTE__: anchor or anchorElement must either be an HTMLElement or resolve
* to an HTMLElement in order for menu to open.
*/
@property({attribute: false})
anchor: HTMLElement&Partial<SurfacePositionTarget>|null = null;
@property() anchor = '';
/**
* Makes the element use `position:fixed` instead of `position:absolute`. In
* most cases, the menu should position itself above most other
@ -200,6 +203,27 @@ export abstract class Menu extends LitElement {
};
});
private currentAnchorElement: HTMLElement|null = null;
/**
* The element which the menu should align to. If `anchor` is set to a
* non-empty idref string, then `anchorEl` will resolve to the element with
* the given id in the same root node. Otherwise, `null`.
*/
get anchorElement(): HTMLElement&Partial<SurfacePositionTarget>|null {
if (this.anchor) {
return (this.getRootNode() as Document | ShadowRoot)
.querySelector(`#${this.anchor}`);
}
return this.currentAnchorElement;
}
set anchorElement(element: HTMLElement&Partial<SurfacePositionTarget>|null) {
this.currentAnchorElement = element;
this.requestUpdate('anchorElement');
}
/**
* Handles positioning the surface and aligning it to the anchor.
*/
@ -209,7 +233,7 @@ export abstract class Menu extends LitElement {
anchorCorner: this.anchorCorner,
surfaceCorner: this.menuCorner,
surfaceEl: this.surfaceEl,
anchorEl: this.anchor,
anchorEl: this.anchorElement,
isTopLayer: this.fixed,
isOpen: this.open,
xOffset: this.xOffset,

View File

@ -216,7 +216,7 @@ export class SubMenuItem extends MenuItemEl {
menu.hasOverflow = true;
menu.anchorCorner = this.anchorCorner;
menu.menuCorner = this.menuCorner;
menu.anchor = this;
menu.anchorElement = this;
// We manually set focus with `active` on keyboard navigation. And we
// want to focus the root on hover, so the user can pick up navigation with
// keyboard after hover.

View File

@ -38,8 +38,7 @@ declare global {
* ```html
* <div style="position:relative;">
* <button
* class="anchor"
* ${ref(anchorRef)}
* id="anchor"
* @click=${() => this.menuRef.value.show()}>
* Click to open menu
* </button>
@ -47,8 +46,8 @@ declare global {
* `has-overflow` is required when using a submenu which overflows the
* menu's contents
* -->
* <md-menu has-overflow ${ref(menuRef)} ${(el) => el.anchor =
* anchorRef.value}> <md-menu-item header="This is a header"></md-menu-item>
* <md-menu anchor="anchor" has-overflow ${ref(menuRef)}>
* <md-menu-item header="This is a header"></md-menu-item>
* <md-sub-menu-item header="this is a submenu item">
* <md-menu slot="submenu">
* <md-menu-item

View File

@ -40,7 +40,7 @@ describe('<md-menu>', () => {
const button = root.querySelector('button')!;
const menu = root.querySelector('md-menu')!;
menu.anchor = button;
menu.anchorElement = button;
menu.show();
await menu.updateComplete;
const listEl = menu.renderRoot.querySelector('md-list')!;

View File

@ -42,8 +42,7 @@ declare global {
* ```html
* <div style="position:relative;">
* <button
* class="anchor"
* ${ref(anchorRef)}
* id="anchor"
* @click=${() => this.menuRef.value.show()}>
* Click to open menu
* </button>
@ -51,8 +50,8 @@ declare global {
* `has-overflow` is required when using a submenu which overflows the
* menu's contents
* -->
* <md-menu has-overflow ${ref(menuRef)} ${(el) => el.anchor =
* anchorRef.value}> <md-menu-item header="This is a header"></md-menu-item>
* <md-menu anchor="anchor" has-overflow ${ref(menuRef)}>
* <md-menu-item header="This is a header"></md-menu-item>
* <md-sub-menu-item header="this is a submenu item">
* <md-menu slot="submenu">
* <md-menu-item

View File

@ -11,7 +11,6 @@ import {property, query, queryAssignedElements, state} from 'lit/decorators.js';
import {classMap} from 'lit/directives/class-map.js';
import {html as staticHtml, StaticValue} from 'lit/static-html.js';
import {Field} from '../../field/internal/field.js';
import {List} from '../../list/internal/list.js';
import {DEFAULT_TYPEAHEAD_BUFFER_TIME, Menu} from '../../menu/internal/menu.js';
import {CloseMenuEvent, isElementInSubtree, isSelectableKey} from '../../menu/internal/shared.js';
@ -93,7 +92,6 @@ export abstract class Select extends LitElement {
@state() private focused = false;
@state() private open = false;
@query('.field') private readonly field!: Field|null;
@query('md-menu') private readonly menu!: Menu|null;
@queryAssignedElements({slot: 'leadingicon', flatten: true})
private readonly leadingIcons!: Element[];
@ -196,6 +194,7 @@ export abstract class Select extends LitElement {
aria-haspopup="listbox"
role="combobox"
part="field"
id="field"
tabindex=${this.disabled ? '-1' : '0'}
aria-expanded=${this.open ? 'true' : 'false'}
class="field"
@ -262,7 +261,7 @@ export abstract class Select extends LitElement {
stay-open-on-focusout
part="menu"
exportparts="focus-ring: menu-focus-ring"
.anchor=${this.field}
anchor="field"
.open=${this.open}
.quick=${this.quick}
.fixed=${this.menuFixed}