diff --git a/labs/gb/components/menu/_menu-tokens.scss b/labs/gb/components/menu/_menu-tokens.scss new file mode 100644 index 0000000000..6d7481e9d8 --- /dev/null +++ b/labs/gb/components/menu/_menu-tokens.scss @@ -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%); +} diff --git a/labs/gb/components/menu/demo/demo.ts b/labs/gb/components/menu/demo/demo.ts new file mode 100644 index 0000000000..a56088f645 --- /dev/null +++ b/labs/gb/components/menu/demo/demo.ts @@ -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>( + 'Menu', + [new Knob('vibrant', {ui: boolInput()})], +); + +collection.addStories(...materialInitsToStoryInits(stories)); + +setUpDemo(collection, {fonts: 'roboto', icons: 'material-symbols'}); diff --git a/labs/gb/components/menu/demo/stories.ts b/labs/gb/components/menu/demo/stories.ts new file mode 100644 index 0000000000..6b0b078811 --- /dev/null +++ b/labs/gb/components/menu/demo/stories.ts @@ -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 = { + name: 'Playground', + styles, + render(knobs) { + return html` + + + Standard Item 1 + + Standard Item 2 + Supporting text + + Standard Item 3 +
+ + Radio 1 + Radio 2 + Radio 3 + +
+ + Checkbox 1 + Checkbox 2 + Checkbox 3 + +
+ `; + }, +}; + +// TODO: add submenu support + +/** Menu stories. */ +export const stories = [playground]; diff --git a/labs/gb/components/menu/md-menu-group.ts b/labs/gb/components/menu/md-menu-group.ts new file mode 100644 index 0000000000..09c4a70364 --- /dev/null +++ b/labs/gb/components/menu/md-menu-group.ts @@ -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; + for (const item of items) { + if (!composedPath.includes(item) && item.checked) { + item.checked = false; + } + } + } + }); + } + + protected override render() { + return html``; + } +} diff --git a/labs/gb/components/menu/md-menu-item.ts b/labs/gb/components/menu/md-menu-item.ts new file mode 100644 index 0000000000..82b6850d25 --- /dev/null +++ b/labs/gb/components/menu/md-menu-item.ts @@ -0,0 +1,190 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {consume} from '@lit/context'; +import { + afterDispatch, + setupDispatchHooks, +} from '@material/web/internal/events/dispatch-hooks.js'; +import { + internals, + mixinElementInternals, +} from '@material/web/labs/behaviors/element-internals.js'; +import { + isFocusable, + mixinFocusable, +} from '@material/web/labs/behaviors/focusable.js'; +import {hasSlotted} from '@material/web/labs/gb/components/shared/has-slotted.js'; +import {css, CSSResultOrNative, html, LitElement, nothing} from 'lit'; +import {customElement, property, state} from 'lit/decorators.js'; + +import focusRingStyles from '@material/web/labs/gb/components/focus/focus-ring.css' with {type: 'css'}; // github-only +// import focusRingStyles from '@material/web/labs/gb/components/focus/focus-ring.cssresult.js'; // google3-only +import rippleStyles from '@material/web/labs/gb/components/ripple/ripple.css' with {type: 'css'}; // github-only +// import rippleStyles from '@material/web/labs/gb/components/ripple/ripple.cssresult.js'; // google3-only +import menuStyles from './menu.css' with {type: 'css'}; // github-only +// import {styles as menuStyles} from './menu.cssresult.js'; // google3-only + +import { + menuContext, + type MenuContext, + menuItem, + menuItemCheckable, + type MenuItemCheckable, +} from './menu.js'; + +declare global { + interface HTMLElementTagNameMap { + 'md-menu-item': MenuItem; + } +} + +// Separate variable needed for closure. +const baseClass = mixinElementInternals(mixinFocusable(LitElement)); + +/** + * A Material Design menu item component. + */ +@customElement('md-menu-item') +export class MenuItem extends baseClass { + static override styles: CSSResultOrNative[] = [ + focusRingStyles, + rippleStyles, + menuStyles, + css` + :host { + display: flex; + outline: none; + } + .menu-item { + flex: 1; + } + :is(.menu-item-leading, .menu-item-trailing):not( + :has(.has-slotted, .checkmark) + ) { + display: none; + } + slot:not(.has-slotted) { + display: contents; + } + .checkmark { + display: flex; + font: var(--md-icon-size) var(--md-icon-font); + } + `, + ]; + + @property({type: Boolean, reflect: true}) checked = false; + @property({type: Boolean, reflect: true}) disabled = false; + + get menu(): HTMLElement | null { + return this.menuContext?.menu || null; + } + + @consume({context: menuContext, subscribe: true}) + private readonly menuContext?: MenuContext; + + @state() + @consume({context: menuItemCheckable, subscribe: true}) + private readonly checkable?: MenuItemCheckable | null; + + constructor() { + super(); + this[internals].role = 'menuitem'; + setupDispatchHooks(this, 'click'); + this.addEventListener('click', (e) => { + if (this.disabled) { + e.stopImmediatePropagation(); + return; + } + + const wasChecked = this.checked; + afterDispatch(e, () => { + if (e.defaultPrevented) return; + if (this.checkable) { + this.checked = !wasChecked; + this.dispatchEvent(new Event('change', {bubbles: true})); + this.dispatchEvent( + new InputEvent('input', {bubbles: true, composed: true}), + ); + } + + if (this.checkable !== 'multiple') { + this.menu?.hidePopover(); + } + }); + }); + + this.addEventListener('keydown', (e: KeyboardEvent) => { + if (this.disabled) return; + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + this.click(); + } + }); + } + + override connectedCallback() { + super.connectedCallback(); + this.menuContext?.itemConnected(this); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + this.menuContext?.itemDisconnected(this); + } + + protected override render() { + return html`
+ ${this.renderContent()} +
`; + } + + private renderContent() { + return html` + + + ${this.checked ? html`check` : nothing} + + + + + + + + + + + `; + } + + protected override updated() { + if (this.checkable === 'single') { + this[internals].role = 'menuitemradio'; + } else if (this.checkable === 'multiple') { + this[internals].role = 'menuitemcheckbox'; + } else { + this[internals].role = 'menuitem'; + } + + if (this.checkable) { + this[internals].ariaChecked = String(this.checked); + } else { + this[internals].ariaChecked = null; + } + + this[internals].ariaDisabled = String(this.disabled); + this[isFocusable] = !this.disabled; + } +} diff --git a/labs/gb/components/menu/md-menu.ts b/labs/gb/components/menu/md-menu.ts new file mode 100644 index 0000000000..a5d1b2311e --- /dev/null +++ b/labs/gb/components/menu/md-menu.ts @@ -0,0 +1,165 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {ContextProvider} from '@lit/context'; +import { + internals, + mixinElementInternals, +} from '@material/web/labs/behaviors/element-internals.js'; +import {mixinFocusable} from '@material/web/labs/behaviors/focusable.js'; +import {css, CSSResultOrNative, html, LitElement} from 'lit'; +import {customElement, property} from 'lit/decorators.js'; + +import menuStyles from './menu.css' with {type: 'css'}; // github-only +// import {styles as menuStyles} from './menu.cssresult.js'; // google3-only + +import {menu, MENU_COLORS, menuContext, type MenuColor} from './menu.js'; + +declare global { + interface HTMLElementTagNameMap { + /** A Material Design menu component. */ + 'md-menu': Menu; + } +} + +// Separate variable needed for closure. +const baseClass = mixinElementInternals(mixinFocusable(LitElement)); + +/** + * A Material Design menu component. + */ +@customElement('md-menu') +export class Menu extends baseClass { + static override styles: CSSResultOrNative[] = [menuStyles, css``]; + + @property() color: MenuColor = MENU_COLORS.standard; + + get items(): HTMLElement[] { + return Array.from(this.itemsSet).sort((a, b) => { + return a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_PRECEDING + ? 1 + : -1; + }); + } + + private previouslyFocused?: HTMLElement; + private readonly itemsSet = new Set(); + + constructor() { + super(); + this[internals].role = 'menu'; + this.addController( + new ContextProvider(this, { + context: menuContext, + initialValue: { + menu: this, + getItems: () => this.items, + itemConnected: (item: HTMLElement) => { + this.itemsSet.add(item); + }, + itemDisconnected: (item: HTMLElement) => { + this.itemsSet.delete(item); + }, + }, + }), + ); + + // TODO: move event listeners to setupMenu() + // Handle keyboard navigation + this.addEventListener('keydown', (event: KeyboardEvent) => { + if (event.key === 'Tab') { + event.preventDefault(); + this.hidePopover(); + return; + } + + const items = this.items.filter( + (item) => !item.matches(':disabled,[disabled]'), + ); + const index = items.findIndex((item) => item.matches(':focus-within')); + if (index === -1 && items.length > 0) { + // If no item is focused, focus the first one on arrow key + if ( + [ + 'ArrowDown', + 'ArrowRight', + 'ArrowUp', + 'ArrowLeft', + 'Home', + 'End', + ].includes(event.key) + ) { + event.preventDefault(); + items[0].focus(); + } + return; + } + + switch (event.key) { + case 'ArrowDown': + case 'ArrowRight': + event.preventDefault(); + if (index < items.length - 1) { + items[index + 1].focus(); + } else { + items[0].focus(); + } + break; + case 'ArrowUp': + case 'ArrowLeft': + event.preventDefault(); + if (index > 0) { + items[index - 1].focus(); + } else { + items[items.length - 1].focus(); + } + break; + case 'Home': + event.preventDefault(); + items[0].focus(); + break; + case 'End': + event.preventDefault(); + items[items.length - 1].focus(); + break; + default: + break; + } + }); + + // Handle focus on open + this.addEventListener( + 'toggle', + (event: ToggleEvent & {source?: Element | null}) => { + if (event.newState === 'open') { + this.previouslyFocused = event.source as HTMLElement; + // Focus the first non-disabled item + setTimeout(() => { + this.items + .find((item) => !item.matches(':disabled,.disabled,[disabled]')) + ?.focus(); + }); + } else { + this.previouslyFocused?.focus(); + this.previouslyFocused = undefined; + } + }, + ); + } + + override connectedCallback() { + super.connectedCallback(); + // Set popover behavior in connectedCallback since constructor may not + // sprout attributes. + this.popover = 'auto'; + } + + protected override render() { + return html``; + } +} diff --git a/labs/gb/components/menu/menu.scss b/labs/gb/components/menu/menu.scss new file mode 100644 index 0000000000..441eb5f78c --- /dev/null +++ b/labs/gb/components/menu/menu.scss @@ -0,0 +1,171 @@ +/*! + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// go/keep-sorted start by_regex='(.+) prefix_order=sass: +@use 'menu-tokens'; +// go/keep-sorted end + +@layer md.sys, md.comp.ripple, md.comp.focus-ring, md.comp.divider; +@layer md.comp.menu { + .menu { + & { + @include menu-tokens.root; + } + &.menu-vibrant { + @include menu-tokens.vibrant; + --menu-vibrant: true; + } + + & { + flex-direction: column; + box-shadow: var(--container-elevation); + border-radius: var(--container-shape); + background-color: var(--container-color); + padding-block: var(--group-padding); + } + + &:popover-open, + &.menu-host { + display: flex; + } + + &:popover-open, + :host(:popover-open):has(&.menu-host) { + position-area: end span-end; + border: none; + } + + :host(:popover-open):has(&.menu-host) { + overflow: visible; + background: none; + padding: 0; + } + + &.menu-host { + overflow: auto; + } + + .divider, + ::slotted(.divider) { + margin: 8px 16px; + } + } + + .menu-item { + & { + @include menu-tokens.item; + } + &:first-child { + @include menu-tokens.item-first-child; + } + &:last-child { + @include menu-tokens.item-last-child; + } + @container style(--menu-vibrant: true) { + & { + @include menu-tokens.item-vibrant; + } + &:where(:hover, .hover) { + @include menu-tokens.item-vibrant-hovered; + } + &:where(:focus-within, .focus) { + @include menu-tokens.item-vibrant-focused; + } + &:where(:active, .active) { + @include menu-tokens.item-vibrant-pressed; + } + } + + &:is(:checked, .checked) { + @include menu-tokens.item-selected; + @container style(--menu-vibrant: true) { + @include menu-tokens.item-selected-vibrant; + } + } + + &:is(:disabled, .disabled) { + @include menu-tokens.item-disabled; + @container style(--menu-vibrant: true) { + @include menu-tokens.item-disabled-vibrant; + } + &:where(:checked, .checked) { + @include menu-tokens.item-disabled-selected; + } + } + + & { + position: relative; + isolation: isolate; + display: flex; + align-items: center; + user-select: none; + box-sizing: border-box; + gap: var(--between-space); + min-height: calc(var(--height) + 4px); + border-radius: var(--shape); + padding-block: var(--top-space) var(--bottom-space); + padding-inline: var(--leading-space) var(--trailing-space); + background-image: none; + outline: none; + color: var(--label-text-color); + font: var(--label-text); + letter-spacing: var(--label-text-tracking); + } + + &::before { + display: flex; + content: ''; + position: absolute; + inset: 2px 4px; + z-index: -1; + border-radius: inherit; + background-color: var(--container-color); + background-image: var(--ripple); + animation: var(--ripple-animation), var(--focus-ring-animation); + transition: var(--ripple-transition); + outline: var(--focus-ring-outline); + outline-offset: var(--focus-ring-offset); + } + + &:not(:disabled, .disabled) { + cursor: pointer; + } + + .menu-item-content { + display: flex; + flex-direction: column; + flex: 1; + } + + .menu-item-supporting-text { + color: var(--supporting-text-color); + font: var(--supporting-text); + letter-spacing: var(--supporting-text-tracking); + } + + .menu-item-trailing-text { + color: var(--trailing-supporting-text-color); + font: var(--trailing-supporting-text); + letter-spacing: var(--trailing-supporting-text-tracking); + } + + .menu-item-leading, + .menu-item-trailing { + display: flex; + align-items: center; + gap: 8px; + } + + .menu-item-leading { + --md-icon-color: var(--leading-icon-color); + --md-icon-size: var(--leading-icon-size); + } + + .menu-item-trailing { + --md-icon-color: var(--trailing-icon-color); + --md-icon-size: var(--trailing-icon-size); + } + } +} diff --git a/labs/gb/components/menu/menu.ts b/labs/gb/components/menu/menu.ts new file mode 100644 index 0000000000..232ccc03b0 --- /dev/null +++ b/labs/gb/components/menu/menu.ts @@ -0,0 +1,177 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {createContext} from '@lit/context'; +import { + FOCUS_RING_TYPES, + focusRingClasses, +} from '@material/web/labs/gb/components/focus/focus-ring.js'; +import { + rippleClasses, + setupRipple, +} from '@material/web/labs/gb/components/ripple/ripple.js'; +import {createClassMapDirective} from '@material/web/labs/gb/components/shared/directives.js'; +import {PSEUDO_CLASSES} from '@material/web/labs/gb/components/shared/pseudo-classes.js'; +import {type ClassInfo} from 'lit/directives/class-map.js'; + +/** Menu context provided to menu items. */ +export interface MenuContext { + /** The item's parent menu. */ + readonly menu: HTMLElement; + /** Returns the menu's items. */ + getItems: () => HTMLElement[]; + /** Callback for menu items to register themselves with the menu. */ + itemConnected(item: HTMLElement): void; + /** Callback for menu items to unregister themselves with the menu. */ + itemDisconnected(item: HTMLElement): void; +} + +/** Menu context to provide to menu items. */ +export const menuContext = createContext(Symbol('menuContext')); + +/** Menu color configuration types. */ +export type MenuColor = 'standard' | 'vibrant'; + +/** Menu color configurations. */ +export const MENU_COLORS = { + standard: 'standard', + vibrant: 'vibrant', +} as const; + +/** Menu classes. */ +export const MENU_CLASSES = { + menu: 'menu', + menuVibrant: 'menu-vibrant', +} as const; + +/** The state provided to the `menuClasses()` function. */ +export interface MenuClassesState { + /** The color of the menu. */ + color?: MenuColor; +} + +/** + * Returns the menu classes to apply to an element based on the given state. + * + * @param state The state of the menu. + * @return An object of class names and truthy values if they apply. + */ +export function menuClasses({color}: MenuClassesState = {}): ClassInfo { + return { + [MENU_CLASSES.menu]: true, + [MENU_CLASSES.menuVibrant]: color === MENU_COLORS.vibrant, + }; +} + +/** + * Sets up menu functionality for the given element. + * + * @param menu The element on which to set up menu functionality. + * @param opts Setup options, supports a cleanup `signal`. + */ +export function setupMenu( + menu: HTMLElement, + opts?: {signal?: AbortSignal}, +): void { + // TODO: add event listeners from +} + +/** + * A Lit directive that adds menu styling and functionality to its element. + * + * @example + * ```ts + * html`
TODO: add examples
`; + * ``` + */ +export const menu = createClassMapDirective({ + getClasses: menuClasses, + setupElement: setupMenu, +}); + +/** Whether a group of menu items are single or multiple selectable. */ +export type MenuItemCheckable = 'single' | 'multiple'; + +/** Context provided to menu items for the checkable state of a menu item group. */ +export const menuItemCheckable = createContext( + Symbol('menuItemCheckable'), +); + +/** Menu item classes. */ +export const MENU_ITEM_CLASSES = { + menuItem: 'menu-item', + checked: PSEUDO_CLASSES.checked, + hover: PSEUDO_CLASSES.hover, + focus: PSEUDO_CLASSES.focus, + active: PSEUDO_CLASSES.active, + disabled: PSEUDO_CLASSES.disabled, +} as const; + +/** The state provided to the `menuItemClasses()` function. */ +export interface MenuItemClassesState { + /** Emulates `:checked`. */ + checked?: boolean; + /** Emulates `:hover`. */ + hover?: boolean; + /** Emulates `:focus`. */ + focus?: boolean; + /** Emulates `:active`. */ + active?: boolean; + /** Emulates `:disabled`. */ + disabled?: boolean; +} + +/** + * Returns the menu item classes to apply to an element based on the given + * state. + * + * @param state The state of the menu item. + * @return An object of class names and truthy values if they apply. + */ +export function menuItemClasses({ + checked = false, + hover = false, + focus = false, + active = false, + disabled = false, +}: MenuItemClassesState = {}): ClassInfo { + return { + ...rippleClasses(), + ...focusRingClasses({type: FOCUS_RING_TYPES.inner}), + [MENU_ITEM_CLASSES.menuItem]: true, + [MENU_ITEM_CLASSES.checked]: checked, + [MENU_ITEM_CLASSES.hover]: hover, + [MENU_ITEM_CLASSES.focus]: focus, + [MENU_ITEM_CLASSES.active]: active, + [MENU_ITEM_CLASSES.disabled]: disabled, + }; +} + +/** + * Sets up menu item functionality for the given element. + * + * @param menuItem The element on which to set up menu item functionality. + * @param opts Setup options, supports a cleanup `signal`. + */ +export function setupMenuItem( + menuItem: HTMLElement, + opts?: {signal?: AbortSignal}, +): void { + setupRipple(menuItem, opts); +} + +/** + * A Lit directive that adds menu item styling and functionality to its element. + * + * @example + * ```ts + * html`
TODO: add examples
`; + * ``` + */ +export const menuItem = createClassMapDirective({ + getClasses: menuItemClasses, + setupElement: setupMenuItem, +});