Skip to content

Commit 95dd57c

Browse files
asynclizcopybara-github
authored andcommitted
feat(labs): add expressive menu utility class component
PiperOrigin-RevId: 901281235
1 parent 33d8df2 commit 95dd57c

8 files changed

Lines changed: 1022 additions & 0 deletions

File tree

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
//
2+
// Copyright 2026 Google LLC
3+
// SPDX-License-Identifier: Apache-2.0
4+
//
5+
6+
@mixin root {
7+
--container-color: var(--md-sys-color-surface-container-low);
8+
--container-elevation: var(--md-sys-elevation-shadow-2);
9+
--container-shape: var(--md-sys-shape-corner-lg);
10+
--gap: 2px;
11+
--group-padding: 2px;
12+
--group-shape: var(--md-sys-shape-corner-sm);
13+
--section-label-text-color: var(--md-sys-color-on-surface-variant);
14+
}
15+
16+
@mixin inactive {
17+
--container-shape: var(--md-sys-shape-corner-sm);
18+
}
19+
20+
@mixin active {
21+
--container-shape: var(--md-sys-shape-corner-md);
22+
}
23+
24+
@mixin vibrant {
25+
--container-color: var(--md-sys-color-tertiary-container);
26+
}
27+
28+
@mixin item {
29+
--between-space: 12px;
30+
--bottom-space: 8px;
31+
--container-color: transparent;
32+
--height: 44px;
33+
--inner-corner-corner-size: none;
34+
--label-text-color: var(--md-sys-color-on-surface);
35+
--label-text: var(--md-sys-typescale-label-lg);
36+
--label-text-tracking: var(--md-sys-typescale-label-lg-tracking);
37+
--leading-icon-color: var(--md-sys-color-on-surface-variant);
38+
--leading-icon-size: 20px;
39+
--leading-space: 16px;
40+
--shape: var(--md-sys-shape-corner-xs);
41+
--supporting-text-color: var(--md-sys-color-on-surface-variant);
42+
--supporting-text: var(--md-sys-typescale-body-sm);
43+
--supporting-text-tracking: var(--md-sys-typescale-body-sm-tracking);
44+
--top-space: 8px;
45+
--trailing-icon-color: var(--md-sys-color-on-surface-variant);
46+
--trailing-icon-size: 20px;
47+
--trailing-space: 16px;
48+
--trailing-supporting-text-color: var(--md-sys-color-on-surface-variant);
49+
--trailing-supporting-text: var(--md-sys-typescale-label-lg);
50+
--trailing-supporting-text-tracking: var(--md-sys-typescale-label-lg-tracking);
51+
}
52+
53+
@mixin item-first-child {
54+
--inner-corner-corner-size: var(--md-sys-shape-corner-xs);
55+
--shape: var(--md-sys-shape-corner-md);
56+
}
57+
58+
@mixin item-last-child {
59+
--inner-corner-corner-size: var(--md-sys-shape-corner-xs);
60+
--shape: var(--md-sys-shape-corner-md);
61+
}
62+
63+
@mixin item-vibrant {
64+
--label-text-color: var(--md-sys-color-on-tertiary-container);
65+
--leading-icon-color: var(--md-sys-color-on-tertiary-container);
66+
--supporting-text-color: var(--md-sys-color-on-tertiary-container);
67+
--trailing-icon-color: var(--md-sys-color-on-tertiary-container);
68+
--trailing-supporting-text-color: var(--md-sys-color-on-tertiary-container);
69+
}
70+
71+
@mixin item-vibrant-hovered {
72+
--leading-icon-color: var(--md-sys-color-tertiary);
73+
--trailing-icon-color: var(--md-sys-color-tertiary);
74+
}
75+
76+
@mixin item-vibrant-focused {
77+
--leading-icon-color: var(--md-sys-color-tertiary);
78+
--trailing-icon-color: var(--md-sys-color-tertiary);
79+
}
80+
81+
@mixin item-vibrant-pressed {
82+
--leading-icon-color: var(--md-sys-color-tertiary);
83+
--trailing-icon-color: var(--md-sys-color-tertiary);
84+
}
85+
86+
@mixin item-selected {
87+
--container-color: var(--md-sys-color-tertiary-container);
88+
--label-text-color: var(--md-sys-color-on-tertiary-container);
89+
--leading-icon-color: var(--md-sys-color-on-tertiary-container);
90+
--shape: var(--md-sys-shape-corner-md);
91+
--supporting-text-color: var(--md-sys-color-on-tertiary-container);
92+
--trailing-icon-color: var(--md-sys-color-on-tertiary-container);
93+
--trailing-supporting-text-color: var(--md-sys-color-on-tertiary-container);
94+
}
95+
96+
@mixin item-selected-vibrant {
97+
--container-color: var(--md-sys-color-tertiary);
98+
--label-text-color: var(--md-sys-color-on-tertiary);
99+
--leading-icon-color: var(--md-sys-color-on-tertiary);
100+
--supporting-text-color: var(--md-sys-color-on-tertiary);
101+
--trailing-icon-color: var(--md-sys-color-on-tertiary);
102+
--trailing-supporting-text-color: var(--md-sys-color-on-tertiary);
103+
}
104+
105+
@mixin item-disabled {
106+
--label-text-color: hsl(from var(--md-sys-color-on-surface) h s l / 38%);
107+
--leading-icon-color: hsl(from var(--md-sys-color-on-surface) h s l / 38%);
108+
--supporting-text-color: hsl(from var(--md-sys-color-on-surface) h s l / 38%);
109+
--trailing-icon-color: hsl(from var(--md-sys-color-on-surface) h s l / 38%);
110+
--trailing-supporting-text-color: hsl(from var(--md-sys-color-on-surface) h s l / 38%);
111+
}
112+
113+
@mixin item-disabled-vibrant {
114+
--label-text-color: hsl(from var(--md-sys-color-on-tertiary-container) h s l / 38%);
115+
--leading-icon-color: hsl(from var(--md-sys-color-on-tertiary-container) h s l / 38%);
116+
--supporting-text-color: hsl(from var(--md-sys-color-on-tertiary-container) h s l / 38%);
117+
--trailing-icon-color: hsl(from var(--md-sys-color-on-tertiary-container) h s l / 38%);
118+
--trailing-supporting-text-color: hsl(from var(--md-sys-color-on-tertiary-container) h s l / 38%);
119+
}
120+
121+
@mixin item-disabled-selected {
122+
--container-color: hsl(from var(--md-sys-color-tertiary-container) h s l / 38%);
123+
--label-text-color: hsl(from var(--md-sys-color-on-tertiary-container) h s l / 38%);
124+
--leading-icon-color: hsl(from var(--md-sys-color-on-tertiary-container) h s l / 38%);
125+
--supporting-text-color: hsl(from var(--md-sys-color-on-tertiary-container) h s l / 38%);
126+
--trailing-icon-color: hsl(from var(--md-sys-color-on-tertiary-container) h s l / 38%);
127+
--trailing-supporting-text-color: hsl(from var(--md-sys-color-on-tertiary-container) h s l / 38%);
128+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import './material-collection.js';
8+
import './index.js';
9+
10+
import {
11+
KnobTypesToKnobs,
12+
MaterialCollection,
13+
materialInitsToStoryInits,
14+
setUpDemo,
15+
} from './material-collection.js';
16+
import {boolInput, Knob} from './index.js';
17+
18+
import {stories, StoryKnobs} from './stories.js';
19+
20+
const collection = new MaterialCollection<KnobTypesToKnobs<StoryKnobs>>(
21+
'Menu',
22+
[new Knob('vibrant', {ui: boolInput()})],
23+
);
24+
25+
collection.addStories(...materialInitsToStoryInits(stories));
26+
27+
setUpDemo(collection, {fonts: 'roboto', icons: 'material-symbols'});
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import '@material/web/labs/gb/components/menu/md-menu.js';
8+
import '@material/web/labs/gb/components/menu/md-menu-group.js';
9+
import '@material/web/labs/gb/components/menu/md-menu-item.js';
10+
11+
import {MaterialStoryInit} from './material-collection.js';
12+
import {button} from '@material/web/labs/gb/components/button/button.js';
13+
import {styles as buttonStyles} from '@material/web/labs/gb/components/button/button.cssresult.js';
14+
import {divider} from '@material/web/labs/gb/components/divider/divider.js';
15+
import {styles as dividerStyles} from '@material/web/labs/gb/components/divider/divider.cssresult.js';
16+
import {styles as focusRingStyles} from '@material/web/labs/gb/components/focus/focus-ring.cssresult.js';
17+
import {styles as menuStyles} from '@material/web/labs/gb/components/menu/menu.cssresult.js';
18+
import {styles as rippleStyles} from '@material/web/labs/gb/components/ripple/ripple.cssresult.js';
19+
import {adoptStyles} from '@material/web/labs/gb/styles/adopt-styles.js';
20+
import {styles as m3Styles} from '@material/web/labs/gb/styles/m3.cssresult.js';
21+
import {css, html} from 'lit';
22+
23+
/** Knob types for menu stories. */
24+
export interface StoryKnobs {
25+
vibrant: boolean;
26+
}
27+
28+
adoptStyles(document, [
29+
m3Styles,
30+
css`
31+
:root {
32+
--md-icon-font: 'Material Symbols Outlined';
33+
}
34+
`,
35+
]);
36+
37+
const styles = [
38+
dividerStyles,
39+
menuStyles,
40+
buttonStyles,
41+
focusRingStyles,
42+
rippleStyles,
43+
];
44+
45+
const playground: MaterialStoryInit<StoryKnobs> = {
46+
name: 'Playground',
47+
styles,
48+
render(knobs) {
49+
return html`
50+
<button popovertarget="menu" class=${button({color: 'filled'})}>
51+
Open Menu
52+
</button>
53+
<md-menu id="menu" color=${knobs.vibrant ? 'vibrant' : 'standard'}>
54+
<md-menu-item>Standard Item 1</md-menu-item>
55+
<md-menu-item>
56+
Standard Item 2
57+
<span slot="supporting-text">Supporting text</span>
58+
</md-menu-item>
59+
<md-menu-item disabled>Standard Item 3</md-menu-item>
60+
<hr class=${divider()} />
61+
<md-menu-group checkable="single">
62+
<md-menu-item checked>Radio 1</md-menu-item>
63+
<md-menu-item>Radio 2</md-menu-item>
64+
<md-menu-item disabled>Radio 3</md-menu-item>
65+
</md-menu-group>
66+
<hr class=${divider()} />
67+
<md-menu-group checkable="multiple">
68+
<md-menu-item checked>Checkbox 1</md-menu-item>
69+
<md-menu-item>Checkbox 2</md-menu-item>
70+
<md-menu-item disabled checked>Checkbox 3</md-menu-item>
71+
</md-menu-group>
72+
</md-menu>
73+
`;
74+
},
75+
};
76+
77+
// TODO: add submenu support
78+
79+
/** Menu stories. */
80+
export const stories = [playground];
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import {consume, provide} from '@lit/context';
8+
import {
9+
internals,
10+
mixinElementInternals,
11+
} from '@material/web/labs/behaviors/element-internals.js';
12+
import {css, CSSResultOrNative, html, LitElement} from 'lit';
13+
import {customElement, property} from 'lit/decorators.js';
14+
15+
import {
16+
menuContext,
17+
menuItemCheckable,
18+
type MenuContext,
19+
type MenuItemCheckable,
20+
} from './menu.js';
21+
22+
declare global {
23+
interface HTMLElementTagNameMap {
24+
'md-menu-group': MenuGroup;
25+
}
26+
}
27+
28+
// Separate variable needed for closure.
29+
const baseClass = mixinElementInternals(LitElement);
30+
31+
/**
32+
* A Material Design menu group component.
33+
*/
34+
@customElement('md-menu-group')
35+
export class MenuGroup extends baseClass {
36+
static override styles: CSSResultOrNative[] = [
37+
css`
38+
:host {
39+
display: contents;
40+
}
41+
`,
42+
];
43+
44+
@provide({context: menuItemCheckable})
45+
@property({reflect: true})
46+
checkable: MenuItemCheckable | null = null;
47+
48+
// TODO: add optional section label
49+
50+
get menu(): HTMLElement | null {
51+
return this.menuContext?.menu || null;
52+
}
53+
54+
get items(): HTMLElement[] {
55+
return (this.menuContext?.getItems?.() || []).filter(
56+
(item) =>
57+
this.compareDocumentPosition(item) &
58+
Node.DOCUMENT_POSITION_CONTAINED_BY,
59+
);
60+
}
61+
62+
@consume({context: menuContext, subscribe: true})
63+
private readonly menuContext: MenuContext | null = null;
64+
65+
constructor() {
66+
super();
67+
this[internals].role = 'none';
68+
this.addEventListener('change', (event: Event) => {
69+
if (this.checkable === 'single') {
70+
const composedPath = event.composedPath();
71+
const items = this.items as Array<HTMLElement & {checked?: boolean}>;
72+
for (const item of items) {
73+
if (!composedPath.includes(item) && item.checked) {
74+
item.checked = false;
75+
}
76+
}
77+
}
78+
});
79+
}
80+
81+
protected override render() {
82+
return html`<slot></slot>`;
83+
}
84+
}

0 commit comments

Comments
 (0)