Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions labs/gb/components/menu/_menu-tokens.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
//
// Copyright 2026 Google LLC
// SPDX-License-Identifier: Apache-2.0
//

@mixin root {
--container-color: var(--md-sys-color-surface-container-low);
--container-elevation: var(--md-sys-elevation-shadow-2);
--container-shape: var(--md-sys-shape-corner-lg);
--gap: 2px;
--group-padding: 2px;
--group-shape: var(--md-sys-shape-corner-sm);
--section-label-text-color: var(--md-sys-color-on-surface-variant);
}

@mixin inactive {
--container-shape: var(--md-sys-shape-corner-sm);
}

@mixin active {
--container-shape: var(--md-sys-shape-corner-md);
}

@mixin vibrant {
--container-color: var(--md-sys-color-tertiary-container);
}

@mixin item {
--between-space: 12px;
--bottom-space: 8px;
--container-color: transparent;
--height: 44px;
--inner-corner-corner-size: none;
--label-text-color: var(--md-sys-color-on-surface);
--label-text: var(--md-sys-typescale-label-lg);
--label-text-tracking: var(--md-sys-typescale-label-lg-tracking);
--leading-icon-color: var(--md-sys-color-on-surface-variant);
--leading-icon-size: 20px;
--leading-space: 16px;
--shape: var(--md-sys-shape-corner-xs);
--supporting-text-color: var(--md-sys-color-on-surface-variant);
--supporting-text: var(--md-sys-typescale-body-sm);
--supporting-text-tracking: var(--md-sys-typescale-body-sm-tracking);
--top-space: 8px;
--trailing-icon-color: var(--md-sys-color-on-surface-variant);
--trailing-icon-size: 20px;
--trailing-space: 16px;
--trailing-supporting-text-color: var(--md-sys-color-on-surface-variant);
--trailing-supporting-text: var(--md-sys-typescale-label-lg);
--trailing-supporting-text-tracking: var(--md-sys-typescale-label-lg-tracking);
}

@mixin item-first-child {
--inner-corner-corner-size: var(--md-sys-shape-corner-xs);
--shape: var(--md-sys-shape-corner-md);
}

@mixin item-last-child {
--inner-corner-corner-size: var(--md-sys-shape-corner-xs);
--shape: var(--md-sys-shape-corner-md);
}

@mixin item-vibrant {
--label-text-color: var(--md-sys-color-on-tertiary-container);
--leading-icon-color: var(--md-sys-color-on-tertiary-container);
--supporting-text-color: var(--md-sys-color-on-tertiary-container);
--trailing-icon-color: var(--md-sys-color-on-tertiary-container);
--trailing-supporting-text-color: var(--md-sys-color-on-tertiary-container);
}

@mixin item-vibrant-hovered {
--leading-icon-color: var(--md-sys-color-tertiary);
--trailing-icon-color: var(--md-sys-color-tertiary);
}

@mixin item-vibrant-focused {
--leading-icon-color: var(--md-sys-color-tertiary);
--trailing-icon-color: var(--md-sys-color-tertiary);
}

@mixin item-vibrant-pressed {
--leading-icon-color: var(--md-sys-color-tertiary);
--trailing-icon-color: var(--md-sys-color-tertiary);
}

@mixin item-selected {
--container-color: var(--md-sys-color-tertiary-container);
--label-text-color: var(--md-sys-color-on-tertiary-container);
--leading-icon-color: var(--md-sys-color-on-tertiary-container);
--shape: var(--md-sys-shape-corner-md);
--supporting-text-color: var(--md-sys-color-on-tertiary-container);
--trailing-icon-color: var(--md-sys-color-on-tertiary-container);
--trailing-supporting-text-color: var(--md-sys-color-on-tertiary-container);
}

@mixin item-selected-vibrant {
--container-color: var(--md-sys-color-tertiary);
--label-text-color: var(--md-sys-color-on-tertiary);
--leading-icon-color: var(--md-sys-color-on-tertiary);
--supporting-text-color: var(--md-sys-color-on-tertiary);
--trailing-icon-color: var(--md-sys-color-on-tertiary);
--trailing-supporting-text-color: var(--md-sys-color-on-tertiary);
}

@mixin item-disabled {
--label-text-color: hsl(from var(--md-sys-color-on-surface) h s l / 38%);
--leading-icon-color: hsl(from var(--md-sys-color-on-surface) h s l / 38%);
--supporting-text-color: hsl(from var(--md-sys-color-on-surface) h s l / 38%);
--trailing-icon-color: hsl(from var(--md-sys-color-on-surface) h s l / 38%);
--trailing-supporting-text-color: hsl(from var(--md-sys-color-on-surface) h s l / 38%);
}

@mixin item-disabled-vibrant {
--label-text-color: hsl(from var(--md-sys-color-on-tertiary-container) h s l / 38%);
--leading-icon-color: hsl(from var(--md-sys-color-on-tertiary-container) h s l / 38%);
--supporting-text-color: hsl(from var(--md-sys-color-on-tertiary-container) h s l / 38%);
--trailing-icon-color: hsl(from var(--md-sys-color-on-tertiary-container) h s l / 38%);
--trailing-supporting-text-color: hsl(from var(--md-sys-color-on-tertiary-container) h s l / 38%);
}

@mixin item-disabled-selected {
--container-color: hsl(from var(--md-sys-color-tertiary-container) h s l / 38%);
--label-text-color: hsl(from var(--md-sys-color-on-tertiary-container) h s l / 38%);
--leading-icon-color: hsl(from var(--md-sys-color-on-tertiary-container) h s l / 38%);
--supporting-text-color: hsl(from var(--md-sys-color-on-tertiary-container) h s l / 38%);
--trailing-icon-color: hsl(from var(--md-sys-color-on-tertiary-container) h s l / 38%);
--trailing-supporting-text-color: hsl(from var(--md-sys-color-on-tertiary-container) h s l / 38%);
}
27 changes: 27 additions & 0 deletions labs/gb/components/menu/demo/demo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import './material-collection.js';
import './index.js';

import {
KnobTypesToKnobs,
MaterialCollection,
materialInitsToStoryInits,
setUpDemo,
} from './material-collection.js';
import {boolInput, Knob} from './index.js';

import {stories, StoryKnobs} from './stories.js';

const collection = new MaterialCollection<KnobTypesToKnobs<StoryKnobs>>(
'Menu',
[new Knob('vibrant', {ui: boolInput()})],
);

collection.addStories(...materialInitsToStoryInits(stories));

setUpDemo(collection, {fonts: 'roboto', icons: 'material-symbols'});
80 changes: 80 additions & 0 deletions labs/gb/components/menu/demo/stories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import '@material/web/labs/gb/components/menu/md-menu.js';
import '@material/web/labs/gb/components/menu/md-menu-group.js';
import '@material/web/labs/gb/components/menu/md-menu-item.js';

import {MaterialStoryInit} from './material-collection.js';
import {button} from '@material/web/labs/gb/components/button/button.js';
import {styles as buttonStyles} from '@material/web/labs/gb/components/button/button.cssresult.js';
import {divider} from '@material/web/labs/gb/components/divider/divider.js';
import {styles as dividerStyles} from '@material/web/labs/gb/components/divider/divider.cssresult.js';
import {styles as focusRingStyles} from '@material/web/labs/gb/components/focus/focus-ring.cssresult.js';
import {styles as menuStyles} from '@material/web/labs/gb/components/menu/menu.cssresult.js';
import {styles as rippleStyles} from '@material/web/labs/gb/components/ripple/ripple.cssresult.js';
import {adoptStyles} from '@material/web/labs/gb/styles/adopt-styles.js';
import {styles as m3Styles} from '@material/web/labs/gb/styles/m3.cssresult.js';
import {css, html} from 'lit';

/** Knob types for menu stories. */
export interface StoryKnobs {
vibrant: boolean;
}

adoptStyles(document, [
m3Styles,
css`
:root {
--md-icon-font: 'Material Symbols Outlined';
}
`,
]);

const styles = [
dividerStyles,
menuStyles,
buttonStyles,
focusRingStyles,
rippleStyles,
];

const playground: MaterialStoryInit<StoryKnobs> = {
name: 'Playground',
styles,
render(knobs) {
return html`
<button popovertarget="menu" class=${button({color: 'filled'})}>
Open Menu
</button>
<md-menu id="menu" color=${knobs.vibrant ? 'vibrant' : 'standard'}>
<md-menu-item>Standard Item 1</md-menu-item>
<md-menu-item>
Standard Item 2
<span slot="supporting-text">Supporting text</span>
</md-menu-item>
<md-menu-item disabled>Standard Item 3</md-menu-item>
<hr class=${divider()} />
<md-menu-group checkable="single">
<md-menu-item checked>Radio 1</md-menu-item>
<md-menu-item>Radio 2</md-menu-item>
<md-menu-item disabled>Radio 3</md-menu-item>
</md-menu-group>
<hr class=${divider()} />
<md-menu-group checkable="multiple">
<md-menu-item checked>Checkbox 1</md-menu-item>
<md-menu-item>Checkbox 2</md-menu-item>
<md-menu-item disabled checked>Checkbox 3</md-menu-item>
</md-menu-group>
</md-menu>
`;
},
};

// TODO: add submenu support

/** Menu stories. */
export const stories = [playground];
84 changes: 84 additions & 0 deletions labs/gb/components/menu/md-menu-group.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import {consume, provide} from '@lit/context';
import {
internals,
mixinElementInternals,
} from '@material/web/labs/behaviors/element-internals.js';
import {css, CSSResultOrNative, html, LitElement} from 'lit';
import {customElement, property} from 'lit/decorators.js';

import {
menuContext,
menuItemCheckable,
type MenuContext,
type MenuItemCheckable,
} from './menu.js';

declare global {
interface HTMLElementTagNameMap {
'md-menu-group': MenuGroup;
}
}

// Separate variable needed for closure.
const baseClass = mixinElementInternals(LitElement);

/**
* A Material Design menu group component.
*/
@customElement('md-menu-group')
export class MenuGroup extends baseClass {
static override styles: CSSResultOrNative[] = [
css`
:host {
display: contents;
}
`,
];

@provide({context: menuItemCheckable})
@property({reflect: true})
checkable: MenuItemCheckable | null = null;

// TODO: add optional section label

get menu(): HTMLElement | null {
return this.menuContext?.menu || null;
}

get items(): HTMLElement[] {
return (this.menuContext?.getItems?.() || []).filter(
(item) =>
this.compareDocumentPosition(item) &
Node.DOCUMENT_POSITION_CONTAINED_BY,
);
}

@consume({context: menuContext, subscribe: true})
private readonly menuContext: MenuContext | null = null;

constructor() {
super();
this[internals].role = 'none';
this.addEventListener('change', (event: Event) => {
if (this.checkable === 'single') {
const composedPath = event.composedPath();
const items = this.items as Array<HTMLElement & {checked?: boolean}>;
for (const item of items) {
if (!composedPath.includes(item) && item.checked) {
item.checked = false;
}
}
}
});
}

protected override render() {
return html`<slot></slot>`;
}
}
Loading
Loading