From 23b638c1d2338b9fcaccef81cf54298cce9c27ed Mon Sep 17 00:00:00 2001 From: Russell Bicknell Date: Tue, 4 Aug 2026 12:24:31 -0700 Subject: [PATCH] feat(labs): add aria menu elements PiperOrigin-RevId: 959166038 --- labs/aria/command.ts | 266 ++++++++++++++++++ labs/aria/command_test.ts | 417 +++++++++++++++++++++++++++++ labs/aria/menu/demo/demo.ts | 19 ++ labs/aria/menu/demo/stories.ts | 94 +++++++ labs/aria/menu/md-aria-menuitem.ts | 15 ++ labs/aria/menu/md-aria-menulist.ts | 15 ++ labs/aria/menu/menuitem.ts | 102 +++++++ labs/aria/menu/menuitem_test.ts | 220 +++++++++++++++ labs/aria/menu/menulist.ts | 155 +++++++++++ labs/aria/menu/menulist_test.ts | 123 +++++++++ types/command-event.d.ts | 22 ++ types/popover.d.ts | 22 ++ 12 files changed, 1470 insertions(+) create mode 100644 labs/aria/command.ts create mode 100644 labs/aria/command_test.ts create mode 100644 labs/aria/menu/demo/demo.ts create mode 100644 labs/aria/menu/demo/stories.ts create mode 100644 labs/aria/menu/md-aria-menuitem.ts create mode 100644 labs/aria/menu/md-aria-menulist.ts create mode 100644 labs/aria/menu/menuitem.ts create mode 100644 labs/aria/menu/menuitem_test.ts create mode 100644 labs/aria/menu/menulist.ts create mode 100644 labs/aria/menu/menulist_test.ts create mode 100644 types/command-event.d.ts create mode 100644 types/popover.d.ts diff --git a/labs/aria/command.ts b/labs/aria/command.ts new file mode 100644 index 0000000000..2c2db1bb77 --- /dev/null +++ b/labs/aria/command.ts @@ -0,0 +1,266 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/// +/// + +import {queryAssociatedById} from './query-associated.js'; + +/** + * An element's `command` attribute state: either one of a fixed set of commands + * with spec-defined actions, `custom` (if it begins with `--`), or `unknown`. + * + * https://whatpr.org/html/12011/popover.html#attr-command + */ +type CommandState = + | 'toggle-popover' + | 'show-popover' + | 'hide-popover' + | 'close' + | 'request-close' + | 'show-modal' + | 'custom' + | 'unknown'; + +/** + * Determines an element's `command` attribute state, given the attribute value. + */ +function getCommandState(attrValue: string | null): CommandState { + switch (attrValue) { + case 'toggle-popover': + case 'show-popover': + case 'hide-popover': + case 'close': + case 'request-close': + case 'show-modal': + return attrValue; + default: + return attrValue?.startsWith('--') ? 'custom' : 'unknown'; + } +} + +/** + * An element's popover state, as determined by its type or `popover` attribute. + * + * https://whatpr.org/html/12011/popover.html#popover-state + */ +type PopoverState = 'auto' | 'manual' | 'hint' | 'no-popover'; + +/** Determines the popover state of an element. */ +function getPopoverState(element: HTMLElement): PopoverState { + if (element.localName === 'md-aria-menulist') { + return 'auto'; + } + + const attrValue = element.getAttribute('popover'); + switch (attrValue) { + case '': + return 'auto'; + case 'auto': + case 'manual': + case 'hint': + return attrValue; + case null: + default: + return 'no-popover'; + } +} + +/** + * An element's popover target action state: the action taken on the popover + * referenced by the element's `popovertarget` attribute. + * + * https://whatpr.org/html/12011/popover.html#attr-popovertargetaction + */ +type PopoverTargetActionState = 'toggle' | 'show' | 'hide'; + +/** + * Determines the popover target action from an element's `popovertargetaction` + * attribute value. + */ +function getPopoverTargetActionState( + attrValue: string, +): PopoverTargetActionState { + switch (attrValue) { + case 'toggle': + case 'show': + case 'hide': + return attrValue; + default: + return 'toggle'; + } +} + +/** + * Performs the 'shared command invoker activation steps' for a button or + * menuitem, to be called when that element is activated: either runs a command + * (i.e. the element has `command` and `commandfor` attributes) or changes a + * popover's state (i.e. the element has the `popovertarget` and possibly + * `popovertargetaction` attributes). + * + * https://whatpr.org/html/12011/popover.html#shared-command-invoker-activation-steps + * + * @param element The button or menuitem being activated. + * @param event The event that activated `element`. + */ +export function sharedCommandInvokerActivationSteps( + element: HTMLElement, + event: Event, +) { + const idref = element.getAttribute('commandfor'); + const commandTarget = queryAssociatedById( + element, + idref ?? '', + ) as HTMLElement | null; + if (!commandTarget) { + popoverTargetAttributeActivationBehavior(element, event.target as Node); + return; + } + + const command = element.getAttribute('command'); + const commandState = getCommandState(command); + if (commandState === 'unknown' || command === null) { + return; + } + + const isPopover = getPopoverState(commandTarget) !== 'no-popover'; + if (isPopover && commandState !== 'custom') { + // `` is the only element that defines 'is valid command steps'. + if ( + commandTarget.localName === 'dialog' && + !['close', 'request-close', 'show-modal'].includes(commandState) + ) { + return; + } + } + + const continueSteps = commandTarget.dispatchEvent( + new CommandEvent('command', {source: element, command, cancelable: true}), + ); + + if ( + !continueSteps || + !commandTarget.isConnected || + commandState === 'custom' + ) { + return; + } + + switch (command) { + case 'hide-popover': + try { + // Use `togglePopover` because `hidePopover` doesn't support `source`. + commandTarget.togglePopover({force: false, source: element}); + } catch (e) { + // Do nothing. No exception should be thrown by these steps if the + // popover isn't in the expected state. + } + break; + case 'toggle-popover': + try { + commandTarget.togglePopover({source: element}); + } catch (e) { + // Do nothing. No exception should be thrown by these steps if the + // popover isn't in the expected state. + } + break; + case 'show-popover': + try { + commandTarget.showPopover({source: element}); + } catch (e) { + // Do nothing. No exception should be thrown by these steps if the + // popover isn't in the expected state. + } + break; + default: + // An element can have 'command steps', which are run if the element's + // command is not a popover command. Currently, only `` defines + // 'command steps', so they're inlined here. + // + // https://whatpr.org/html/12011/popover.html#command-steps + // https://whatpr.org/html/12011/interactive-elements.html#the-dialog-element:command-steps + if (commandTarget.localName === 'dialog') { + const dialog = commandTarget as HTMLDialogElement; + if (dialog.matches(':popover-open')) { + return; + } + + switch (commandState) { + case 'close': + if (dialog.hasAttribute('open')) { + dialog.close(element.getAttribute('value') ?? undefined); + } + break; + case 'request-close': + if (dialog.hasAttribute('open')) { + dialog.requestClose(element.getAttribute('value') ?? undefined); + } + break; + case 'show-modal': + if (!dialog.hasAttribute('open')) { + dialog.showModal(); + } + break; + default: + // Do nothing. + break; + } + } + } +} + +/** + * Performs the 'popover target attribute activation behavior' for a button or + * menuitem. + * + * These steps are taken when a button or menuitem is activated, but doesn't + * target an element with a `command` attribute. + * + * https://whatpr.org/html/12011/popover.html#popover-target-attribute-activation-behavior + * + * @param element The button or menuitem being activated. + * @param eventTarget The target of the event that activated `element`. + */ +function popoverTargetAttributeActivationBehavior( + element: HTMLElement, + eventTarget: Node, +) { + const idref = element.getAttribute('popovertarget'); + const popover = queryAssociatedById( + element, + idref ?? '', + ) as HTMLElement | null; + if (!popover) { + return; + } + + // TODO: The spec describes a case here that's relevant when the popover is + // nested inside its invoker itself, which would cause clicking anywhere in + // the popover to close the popover. + // + // https://github.com/whatwg/html/pull/10770 + + const open = popover.matches(':popover-open'); + const action = getPopoverTargetActionState( + element.getAttribute('popovertargetaction') ?? '', + ); + if (!open && (action === 'show' || action === 'toggle')) { + try { + popover.showPopover({source: element}); + } catch (e) { + // Do nothing. No exception should be thrown by these steps if the + // popover isn't in the expected state. + } + } else if (open && (action === 'hide' || action === 'toggle')) { + try { + // Use `togglePopover` because `hidePopover` doesn't support `source`. + popover.togglePopover({force: false, source: element}); + } catch (e) { + // Do nothing. No exception should be thrown by these steps if the + // popover isn't in the expected state. + } + } +} diff --git a/labs/aria/command_test.ts b/labs/aria/command_test.ts new file mode 100644 index 0000000000..7b13197b5d --- /dev/null +++ b/labs/aria/command_test.ts @@ -0,0 +1,417 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// import 'jasmine'; (google3-only) + +import './menu/md-aria-menuitem.js'; +import './menu/md-aria-menulist.js'; + +import {html} from 'lit'; +import {Environment} from '../../testing/environment.js'; +import {sharedCommandInvokerActivationSteps} from './command.js'; + +describe('command', () => { + const env = new Environment(); + + describe('sharedCommandInvokerActivationSteps()', () => { + describe('popovertarget behavior (when commandfor is absent)', () => { + it('does nothing if target element does not exist', async () => { + const root = env.render(html` + + `); + await env.waitForStability(); + const button = root.querySelector('button')!; + const event = new MouseEvent('click', {bubbles: true}); + + expect(() => { + sharedCommandInvokerActivationSteps(button, event); + }).not.toThrow(); + }); + + it('shows popover when popovertargetaction is "show"', async () => { + const root = env.render(html` + +
Content
+ `); + await env.waitForStability(); + const button = root.querySelector('button')!; + const popover = root.querySelector('#test-popover') as HTMLElement; + expect(popover.matches(':popover-open')).toBeFalse(); + + sharedCommandInvokerActivationSteps( + button, + new MouseEvent('click', {bubbles: true}), + ); + await env.waitForStability(); + + expect(popover.matches(':popover-open')).toBeTrue(); + }); + + it('hides popover when popovertargetaction is "hide"', async () => { + const root = env.render(html` + +
Content
+ `); + await env.waitForStability(); + const button = root.querySelector('button')!; + const popover = root.querySelector('#test-popover') as HTMLElement; + popover.showPopover(); + expect(popover.matches(':popover-open')).toBeTrue(); + + sharedCommandInvokerActivationSteps( + button, + new MouseEvent('click', {bubbles: true}), + ); + await env.waitForStability(); + + expect(popover.matches(':popover-open')).toBeFalse(); + }); + + it('toggles popover when popovertargetaction is "toggle" or unspecified', async () => { + const root = env.render(html` + +
Content
+ `); + await env.waitForStability(); + const button = root.querySelector('button')!; + const popover = root.querySelector('#test-popover') as HTMLElement; + expect(popover.matches(':popover-open')).toBeFalse(); + + sharedCommandInvokerActivationSteps( + button, + new MouseEvent('click', {bubbles: true}), + ); + await env.waitForStability(); + + expect(popover.matches(':popover-open')).toBeTrue(); + + sharedCommandInvokerActivationSteps( + button, + new MouseEvent('click', {bubbles: true}), + ); + await env.waitForStability(); + + expect(popover.matches(':popover-open')).toBeFalse(); + }); + }); + + describe('command behavior (when commandfor is present)', () => { + it('does nothing when target exists but command attribute is missing or unknown', async () => { + const root = env.render(html` + +
Content
+ `); + await env.waitForStability(); + const button = root.querySelector('button')!; + const popover = root.querySelector('#test-popover') as HTMLElement; + const commandSpy = jasmine.createSpy('commandSpy'); + popover.addEventListener('command', commandSpy); + + sharedCommandInvokerActivationSteps( + button, + new MouseEvent('click', {bubbles: true}), + ); + await env.waitForStability(); + + expect(commandSpy).not.toHaveBeenCalled(); + expect(popover.matches(':popover-open')).toBeFalse(); + }); + + it('dispatches a CommandEvent on the associated target element', async () => { + const root = env.render(html` + +
Content
+ `); + await env.waitForStability(); + const button = root.querySelector('button')!; + const popover = root.querySelector('#test-popover') as HTMLElement; + + const commandEventPromise = new Promise((resolve) => { + popover.addEventListener('command', (event: CommandEvent) => { + resolve(event); + }); + }); + + sharedCommandInvokerActivationSteps( + button, + new MouseEvent('click', {bubbles: true}), + ); + await env.waitForStability(); + + const event = await commandEventPromise; + expect(event).toBeDefined(); + expect(event.command).toBe('show-popover'); + expect(event.source).toBe(button); + }); + + it('supports custom commands prefixed with -- without invoking built-in action', async () => { + const root = env.render(html` + +
Content
+ `); + await env.waitForStability(); + const button = root.querySelector('button')!; + const popover = root.querySelector('#test-popover') as HTMLElement; + + const commandSpy = jasmine.createSpy('commandSpy'); + popover.addEventListener('command', commandSpy); + + sharedCommandInvokerActivationSteps( + button, + new MouseEvent('click', {bubbles: true}), + ); + await env.waitForStability(); + + expect(commandSpy).toHaveBeenCalledTimes(1); + expect(popover.matches(':popover-open')).toBeFalse(); + }); + + it('aborts command execution if CommandEvent is canceled via preventDefault()', async () => { + const root = env.render(html` + +
Content
+ `); + await env.waitForStability(); + const button = root.querySelector('button')!; + const popover = root.querySelector('#test-popover') as HTMLElement; + + popover.addEventListener('command', (event: Event) => { + event.preventDefault(); + }); + + sharedCommandInvokerActivationSteps( + button, + new MouseEvent('click', {bubbles: true}), + ); + await env.waitForStability(); + + expect(popover.matches(':popover-open')).toBeFalse(); + }); + + it('aborts command execution if target is disconnected during command event dispatch', async () => { + const root = env.render(html` + +
Content
+ `); + await env.waitForStability(); + const button = root.querySelector('button')!; + const popover = root.querySelector('#test-popover') as HTMLElement; + + popover.addEventListener('command', () => { + popover.remove(); + }); + + expect(() => { + sharedCommandInvokerActivationSteps( + button, + new MouseEvent('click', {bubbles: true}), + ); + }).not.toThrow(); + }); + }); + + describe('popover commands', () => { + it('shows popover on "show-popover" command', async () => { + const root = env.render(html` + +
Content
+ `); + await env.waitForStability(); + const button = root.querySelector('button')!; + const popover = root.querySelector('#test-popover') as HTMLElement; + + sharedCommandInvokerActivationSteps( + button, + new MouseEvent('click', {bubbles: true}), + ); + await env.waitForStability(); + + expect(popover.matches(':popover-open')).toBeTrue(); + }); + + it('hides popover on "hide-popover" command', async () => { + const root = env.render(html` + +
Content
+ `); + await env.waitForStability(); + const button = root.querySelector('button')!; + const popover = root.querySelector('#test-popover') as HTMLElement; + popover.showPopover(); + expect(popover.matches(':popover-open')).toBeTrue(); + + sharedCommandInvokerActivationSteps( + button, + new MouseEvent('click', {bubbles: true}), + ); + await env.waitForStability(); + + expect(popover.matches(':popover-open')).toBeFalse(); + }); + + it('toggles popover on "toggle-popover" command', async () => { + const root = env.render(html` + +
Content
+ `); + await env.waitForStability(); + const button = root.querySelector('button')!; + const popover = root.querySelector('#test-popover') as HTMLElement; + expect(popover.matches(':popover-open')).toBeFalse(); + + sharedCommandInvokerActivationSteps( + button, + new MouseEvent('click', {bubbles: true}), + ); + await env.waitForStability(); + expect(popover.matches(':popover-open')).toBeTrue(); + + sharedCommandInvokerActivationSteps( + button, + new MouseEvent('click', {bubbles: true}), + ); + await env.waitForStability(); + expect(popover.matches(':popover-open')).toBeFalse(); + }); + + it('recognizes md-aria-menulist as a popover target', async () => { + const root = env.render(html` + + + Item 1 + + `); + await env.waitForStability(); + const button = root.querySelector('button')!; + const menulist = root.querySelector('md-aria-menulist')!; + + sharedCommandInvokerActivationSteps( + button, + new MouseEvent('click', {bubbles: true}), + ); + await env.waitForStability(); + + expect(menulist.matches(':popover-open')).toBeTrue(); + }); + }); + + describe('dialog commands', () => { + it('opens closed dialog as modal on "show-modal" command', async () => { + const root = env.render(html` + + Content + `); + await env.waitForStability(); + const button = root.querySelector('button')!; + const dialog = root.querySelector('dialog')!; + + sharedCommandInvokerActivationSteps( + button, + new MouseEvent('click', {bubbles: true}), + ); + await env.waitForStability(); + + expect(dialog.matches(':open')).toBeTrue(); + expect(dialog.matches(':modal')).toBeTrue(); + }); + + it('closes open dialog on "close" command with invoker value attribute', async () => { + const root = env.render(html` + + Content + `); + await env.waitForStability(); + const button = root.querySelector('button')!; + const dialog = root.querySelector('dialog')!; + + const returnValue = new Promise((resolve) => { + dialog.addEventListener( + 'close', + (event) => { + resolve(dialog.returnValue); + }, + {once: true}, + ); + }); + + sharedCommandInvokerActivationSteps( + button, + new MouseEvent('click', {bubbles: true}), + ); + await env.waitForStability(); + + expect(dialog.matches(':open')).toBeFalse(); + expect(await returnValue).toEqual('confirmed'); + }); + + it('calls requestClose on open dialog with invoker value on "request-close" command', async () => { + const root = env.render(html` + + Content + `); + await env.waitForStability(); + const button = root.querySelector('button')!; + const dialog = root.querySelector('dialog')!; + + const returnValue = new Promise((resolve) => { + dialog.addEventListener( + 'close', + (event) => { + resolve(dialog.returnValue); + }, + {once: true}, + ); + }); + + sharedCommandInvokerActivationSteps( + button, + new MouseEvent('click', {bubbles: true}), + ); + await env.waitForStability(); + + expect(dialog.matches(':open')).toBeFalse(); + expect(await returnValue).toEqual('canceled'); + }); + + it('ignores dialog commands when dialog is currently open as a popover', async () => { + const root = env.render(html` + + Content + `); + await env.waitForStability(); + const button = root.querySelector('button')!; + const dialog = root.querySelector('dialog')!; + dialog.showPopover(); + expect(dialog.matches(':popover-open')).toBeTrue(); + + sharedCommandInvokerActivationSteps( + button, + new MouseEvent('click', {bubbles: true}), + ); + await env.waitForStability(); + + expect(dialog.matches(':popover-open')).toBeTrue(); + }); + }); + }); +}); diff --git a/labs/aria/menu/demo/demo.ts b/labs/aria/menu/demo/demo.ts new file mode 100644 index 0000000000..19fb7c9150 --- /dev/null +++ b/labs/aria/menu/demo/demo.ts @@ -0,0 +1,19 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + MaterialCollection, + materialInitsToStoryInits, + setUpDemo, +} from './material-collection.js'; + +import {stories} from './stories.js'; + +const collection = new MaterialCollection('ARIA menu elements', []); + +collection.addStories(...materialInitsToStoryInits(stories)); + +setUpDemo(collection); diff --git a/labs/aria/menu/demo/stories.ts b/labs/aria/menu/demo/stories.ts new file mode 100644 index 0000000000..451ee294bd --- /dev/null +++ b/labs/aria/menu/demo/stories.ts @@ -0,0 +1,94 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import '@material/web/labs/aria/menu/md-aria-menuitem.js'; +import '@material/web/labs/aria/menu/md-aria-menulist.js'; + +import {MaterialStoryInit} from './material-collection.js'; +import {css, html} from 'lit'; + +export interface StoryKnobs {} + +const menu: MaterialStoryInit = { + name: 'Menu', + styles: css``, + render(knobs) { + return html` + + + Item 1 + Item 2 +
+ Item 3 + Item 4 +
+ `; + }, +}; + +const menuWithDialog: MaterialStoryInit = { + name: 'Menu with dialog', + styles: css``, + render(knobs) { + return html` + + + + Open dialog... + + + + + + + Close + + + Request close + + +
+ This is a dialog. +
+ `; + }, +}; + +const menuWithPopover: MaterialStoryInit = { + name: 'Menu with popover', + styles: css``, + render(knobs) { + return html` + + + + Open popover... + + +
+ + + + Hide + + +
+ This is a popover. +
+ `; + }, +}; + +export const stories = [menu, menuWithDialog, menuWithPopover]; diff --git a/labs/aria/menu/md-aria-menuitem.ts b/labs/aria/menu/md-aria-menuitem.ts new file mode 100644 index 0000000000..b59e830ac3 --- /dev/null +++ b/labs/aria/menu/md-aria-menuitem.ts @@ -0,0 +1,15 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {AriaMenuitemElement} from './menuitem.js'; + +declare global { + interface HTMLElementTagNameMap { + 'md-aria-menuitem': AriaMenuitemElement; + } +} + +customElements.define('md-aria-menuitem', AriaMenuitemElement); diff --git a/labs/aria/menu/md-aria-menulist.ts b/labs/aria/menu/md-aria-menulist.ts new file mode 100644 index 0000000000..2d800e291b --- /dev/null +++ b/labs/aria/menu/md-aria-menulist.ts @@ -0,0 +1,15 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {AriaMenulistElement} from './menulist.js'; + +declare global { + interface HTMLElementTagNameMap { + 'md-aria-menulist': AriaMenulistElement; + } +} + +customElements.define('md-aria-menulist', AriaMenulistElement); diff --git a/labs/aria/menu/menuitem.ts b/labs/aria/menu/menuitem.ts new file mode 100644 index 0000000000..9b23b6e589 --- /dev/null +++ b/labs/aria/menu/menuitem.ts @@ -0,0 +1,102 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {consume} from '@lit/context'; +import {CSSResultOrNative, LitElement, css, html} from 'lit'; +import {property} from 'lit/decorators.js'; +import { + afterDispatch, + setupDispatchHooks, +} from '../../../internal/events/dispatch-hooks.js'; +import { + mixinCustomStateSet, + toggleState, +} from '../../behaviors/custom-state-set.js'; +import { + internals, + mixinElementInternals, +} from '../../behaviors/element-internals.js'; +import {mixinFocusable} from '../../behaviors/focusable.js'; +import {sharedCommandInvokerActivationSteps} from '../command.js'; +import {AriaMenulistElement, ancestorMenulistContext} from './menulist.js'; + +/** Private property key for the `ancestorMenulist` context. */ +const ancestorMenulist = Symbol('ancestorMenulist'); + +const baseClass = mixinCustomStateSet( + mixinFocusable(mixinElementInternals(LitElement)), +); + +/** + * An element implementing the proposed `` built-in element. + * + * @cssstate enabled - True when the item is enabled. + * @cssstate disabled - True when the item is disabled. + */ +export class AriaMenuitemElement extends baseClass { + static override styles: CSSResultOrNative[] = [ + css` + :host { + display: inline-flex; + } + `, + ]; + + @consume({context: ancestorMenulistContext, subscribe: true}) + [ancestorMenulist]?: AriaMenulistElement; + + @property({type: Boolean, reflect: true, noAccessor: true}) + get disabled() { + return this[internals].ariaDisabled === 'true'; + } + set disabled(value: boolean) { + const oldValue = this.disabled; + value = Boolean(value); + this[internals].ariaDisabled = String(value); + this[toggleState]('disabled', value); + this[toggleState]('enabled', !value); + this.requestUpdate('disabled', oldValue); + } + + constructor() { + super(); + this[internals].role = 'menuitem'; + this.disabled = false; + + setupDispatchHooks(this, 'keydown', 'click'); + this.addEventListener('keydown', (event: KeyboardEvent) => { + afterDispatch(event, () => { + if (event.defaultPrevented) { + return; + } + + if (event.key === 'Enter' || event.key === ' ') { + this.click(); + } + }); + }); + this.addEventListener('click', (event: Event) => { + afterDispatch(event, () => { + if (event.defaultPrevented || this.disabled) { + return; + } + + this[ancestorMenulist]?.hidePopover(); + sharedCommandInvokerActivationSteps(this, event); + }); + }); + } + + override render() { + return html``; + } + + override click() { + if (!this.disabled) { + super.click(); + } + } +} diff --git a/labs/aria/menu/menuitem_test.ts b/labs/aria/menu/menuitem_test.ts new file mode 100644 index 0000000000..28db02b0d5 --- /dev/null +++ b/labs/aria/menu/menuitem_test.ts @@ -0,0 +1,220 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// import 'jasmine'; (google3-only) + +import './md-aria-menuitem.js'; +import './md-aria-menulist.js'; + +import {html} from 'lit'; +import {Environment} from '../../../testing/environment.js'; +import {internals} from '../../behaviors/element-internals.js'; +import {AriaMenuitemElement} from './menuitem.js'; +import {AriaMenulistElement} from './menulist.js'; + +function expectEnabled(menuitem: AriaMenuitemElement) { + expect(menuitem.disabled).toBeFalse(); + expect(menuitem[internals].ariaDisabled).toBe('false'); + expect(menuitem.hasAttribute('disabled')).toBeFalse(); + expect(menuitem.matches(':state(enabled)')).toBeTrue(); + expect(menuitem.matches(':state(disabled)')).toBeFalse(); +} + +function expectDisabled(menuitem: AriaMenuitemElement) { + expect(menuitem.disabled).toBeTrue(); + expect(menuitem[internals].ariaDisabled).toBe('true'); + expect(menuitem.hasAttribute('disabled')).toBeTrue(); + expect(menuitem.matches(':state(enabled)')).toBeFalse(); + expect(menuitem.matches(':state(disabled)')).toBeTrue(); +} + +describe('md-aria-menuitem', () => { + const env = new Environment(); + + async function setUpTest( + template = html`Menu Item`, + ) { + const root = env.render(template); + await env.waitForStability(); + const menuitem = root.querySelector('md-aria-menuitem')!; + return {root, menuitem}; + } + + describe('ARIA roles and internals', () => { + it('sets element role to "menuitem"', async () => { + const {menuitem} = await setUpTest(); + + await env.waitForStability(); + + expect(menuitem[internals].role).toBe('menuitem'); + }); + + it('sets initial custom state to enabled and not disabled', async () => { + const {menuitem} = await setUpTest(); + + await env.waitForStability(); + + expectEnabled(menuitem); + }); + }); + + describe('Disabled state and property', () => { + it('updates ariaDisabled and custom state when disabled property is set to true', async () => { + const {menuitem} = await setUpTest(); + + menuitem.disabled = true; + await env.waitForStability(); + + expectDisabled(menuitem); + }); + + it('restores enabled state when disabled property is set back to false', async () => { + const {menuitem} = await setUpTest(); + menuitem.disabled = true; + await env.waitForStability(); + + menuitem.disabled = false; + await env.waitForStability(); + + expectEnabled(menuitem); + }); + + it('initializes correctly when disabled attribute is set in HTML', async () => { + const {menuitem} = await setUpTest( + html`Disabled Item`, + ); + + expectDisabled(menuitem); + }); + }); + + describe('Click behavior and command invocation', () => { + it('opens an associated popover when popovertarget action is show', async () => { + const {root, menuitem} = await setUpTest(html` + + Open popover + +
Popover content
+ `); + const popover = root.querySelector('#test-popover')!; + expect(popover.matches(':popover-open')).toBeFalse(); + + menuitem.click(); + await env.waitForStability(); + + expect(popover.matches(':popover-open')).toBeTrue(); + }); + + it('closes an associated popover when popovertarget action is hide', async () => { + const {root, menuitem} = await setUpTest(html` + + Hide popover + +
Popover content
+ `); + const popover = root.querySelector('#test-popover') as HTMLElement; + popover.showPopover(); + expect(popover.matches(':popover-open')).toBeTrue(); + + menuitem.click(); + await env.waitForStability(); + + expect(popover.matches(':popover-open')).toBeFalse(); + }); + + it('invokes command on associated target using commandfor and command attributes', async () => { + const {root, menuitem} = await setUpTest(html` + + Show popover + +
Popover content
+ `); + const popover = root.querySelector('#test-popover')!; + expect(popover.matches(':popover-open')).toBeFalse(); + + menuitem.click(); + await env.waitForStability(); + + expect(popover.matches(':popover-open')).toBeTrue(); + }); + + it('does not execute command steps when click event default is prevented', async () => { + const {root, menuitem} = await setUpTest(html` + + Open popover + +
Popover content
+ `); + const popover = root.querySelector('#test-popover')!; + menuitem.addEventListener('click', (event) => { + event.preventDefault(); + }); + + menuitem.click(); + await env.waitForStability(); + + expect(popover.matches(':popover-open')).toBeFalse(); + }); + + it('does not execute command steps when the item is disabled', async () => { + const {root, menuitem} = await setUpTest(html` + + Open popover + +
Popover content
+ `); + const popover = root.querySelector('#test-popover')!; + + menuitem.click(); + await env.waitForStability(); + + expect(popover.matches(':popover-open')).toBeFalse(); + }); + + it('closes containing menu when an enabled menuitem is clicked', async () => { + const {root} = await setUpTest(html` + + Item 1 + + `); + const menulist = root.querySelector('#menu') as AriaMenulistElement; + const menuitem = root.querySelector('#item1') as AriaMenuitemElement; + menulist.showPopover(); + expect(menulist.matches(':popover-open')).toBeTrue(); + + menuitem.click(); + await env.waitForStability(); + + expect(menulist.matches(':popover-open')).toBeFalse(); + }); + + it('does not close containing menu when a disabled menuitem is clicked', async () => { + const {root} = await setUpTest(html` + + Disabled Item + + `); + const menulist = root.querySelector('#menu') as AriaMenulistElement; + const menuitem = root.querySelector('#item1') as AriaMenuitemElement; + menulist.showPopover(); + expect(menulist.matches(':popover-open')).toBeTrue(); + + menuitem.click(); + await env.waitForStability(); + + expect(menulist.matches(':popover-open')).toBeTrue(); + }); + }); +}); diff --git a/labs/aria/menu/menulist.ts b/labs/aria/menu/menulist.ts new file mode 100644 index 0000000000..a24e406bbe --- /dev/null +++ b/labs/aria/menu/menulist.ts @@ -0,0 +1,155 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/// + +import {ContextProvider, createContext} from '@lit/context'; +import {CSSResultOrNative, LitElement, css, html} from 'lit'; +import {property} from 'lit/decorators.js'; +import { + internals, + mixinElementInternals, +} from '../../behaviors/element-internals.js'; + +/** The `` that should own descendant ``s. */ +export const ancestorMenulistContext = createContext( + Symbol('ancestorMenulistContext'), +); + +// `focus` is defined on `HTMLElement` and `SVGElement` directly, not `Element`. +interface MaybeFocusableElement extends Element { + focus?: HTMLElement['focus']; +} + +const baseClass = mixinElementInternals(LitElement); + +/** + * An element implementing the proposed `` built-in element. + */ +export class AriaMenulistElement extends baseClass { + static override styles: CSSResultOrNative[] = [ + css` + /* Unset UA |[popover]| styles. */ + @layer { + :host([popover]) { + position: unset; + width: unset; + height: unset; + color: unset; + background-color: unset; + inset: unset; + margin: unset; + border: unset; + border-image: unset; + padding: unset; + overflow: unset; + } + + :host([popover]:popover-open) { + display: unset; + overlay: unset; + } + } + + :host { + display: block; + position: fixed; + width: max-content; + max-block-size: stretch; + color: canvastext; + background-color: canvas; + margin: 0px; + inset: auto; + overflow: auto; + border: 1px solid currentColor; + border-image: none; + padding: 0.25em; + + /* This should really only apply when the implicit anchor is a menuitem + * in a menubar, but applies to buttons too? */ + position-area: block-end span-inline-end; + } + + :host(:not(:popover-open)) { + display: none; + } + + ::slotted(md-aria-menuitem) { + display: flex; + align-items: center; + user-select: none; + min-inline-size: 24px; + min-block-size: max(24px, 1lh); + font-weight: inherit; + gap: 0.5em; + padding-inline: 0.5em; + } + + ::slotted(md-aria-menuitem:state(enabled):hover) { + background-color: color-mix(in lab, currentColor 10%, transparent); + } + + ::slotted(md-aria-menuitem:state(disabled)) { + color: color-mix(in lab, currentColor 50%, transparent); + } + + ::slotted(hr) { + color: inherit; + margin-inline: 0px; + border: none; + border-block-start: 1px solid currentColor; + border-image: none; + } + + ::slotted(a:any-link), + ::slotted(img[usemap]) { + display: none; + } + `, + ]; + + @property({reflect: true}) + override popover = 'auto'; + + @property({reflect: true}) + focusGroup = 'menu'; + + constructor() { + super(); + + const provider = new ContextProvider(this, { + context: ancestorMenulistContext, + }); + provider.setValue(this); + + this[internals].role = 'menu'; + this[internals].ariaOrientation = 'vertical'; + this.addEventListener('toggle', this.handleToggle.bind(this)); + this.addEventListener('focusout', this.handleFocusout.bind(this)); + } + + override render() { + return html``; + } + + private opener: MaybeFocusableElement | null = null; + + private handleToggle(event: ToggleEvent) { + if (event.newState === 'open') { + this.opener = event.source as MaybeFocusableElement | null; + } else if (event.newState === 'closed' && this.matches(':focus-within')) { + // When closing, restore focus if focus remained in the menulist (e.g. + // during _keyboard_ light dismiss). + this.opener?.focus?.(); + } + } + + private handleFocusout() { + if (!this.matches(':focus-within')) { + this.hidePopover(); + } + } +} diff --git a/labs/aria/menu/menulist_test.ts b/labs/aria/menu/menulist_test.ts new file mode 100644 index 0000000000..a4cad13d9c --- /dev/null +++ b/labs/aria/menu/menulist_test.ts @@ -0,0 +1,123 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/// + +// import 'jasmine'; (google3-only) + +import './md-aria-menuitem.js'; +import './md-aria-menulist.js'; + +import {html} from 'lit'; +import {Environment} from '../../../testing/environment.js'; +import {internals} from '../../behaviors/element-internals.js'; + +import {AriaMenulistElement} from './menulist.js'; + +describe('md-aria-menulist', () => { + const env = new Environment(); + + async function setUpTest( + template = html` + + + Item 1 + Item 2 + Item 3 + + + `, + ) { + const root = env.render(template); + await env.waitForStability(); + + const openButton = root.querySelector('#openButton') as HTMLButtonElement; + const menulist = root.querySelector( + 'md-aria-menulist', + ) as AriaMenulistElement; + const otherButton = root.querySelector('#otherButton') as HTMLButtonElement; + + return {root, openButton, menulist, otherButton}; + } + + async function setUpOpenMenu() { + const {root, openButton, menulist, otherButton} = await setUpTest(); + + await new Promise((resolve) => { + menulist.addEventListener('toggle', (event: ToggleEvent) => { + if (event.newState === 'open') { + resolve(); + } + }); + openButton.focus(); + openButton.click(); + }); + await env.waitForStability(); + expect(menulist.matches(':popover-open')).toBeTrue(); + + return {root, openButton, menulist, otherButton}; + } + + describe('ARIA roles, orientation, and popover defaults', () => { + it('sets element role to "menu" and ariaOrientation to "vertical"', async () => { + const {menulist} = await setUpTest(); + + expect(menulist[internals].role).toBe('menu'); + expect(menulist[internals].ariaOrientation).toBe('vertical'); + }); + + it('defaults popover attribute and property to "auto"', async () => { + const {menulist} = await setUpTest(); + + expect(menulist.popover).toBe('auto'); + expect(menulist.getAttribute('popover')).toBe('auto'); + }); + + it('defaults focusgroup attribute and property to "menu"', async () => { + const {menulist} = await setUpTest(); + + expect(menulist.focusGroup).toBe('menu'); + expect(menulist.getAttribute('focusgroup')).toBe('menu'); + }); + }); + + describe('Closing behavior', () => { + it('restores focus to opener when hidden', async () => { + const {openButton, menulist} = await setUpOpenMenu(); + expect(menulist.matches(':popover-open')).toBeTrue(); + + await new Promise((resolve) => { + menulist.addEventListener('toggle', (event: ToggleEvent) => { + if (event.newState === 'closed') { + resolve(); + } + }); + menulist.hidePopover(); + }); + await env.waitForStability(); + + expect(menulist.matches(':popover-open')).toBeFalse(); + expect(openButton.matches(':focus-within')).toBeTrue(); + }); + + it('closes the popover on focusout', async () => { + const {menulist, otherButton} = await setUpOpenMenu(); + expect(menulist.matches(':popover-open')).toBeTrue(); + + await new Promise((resolve) => { + menulist.addEventListener('toggle', (event: ToggleEvent) => { + if (event.newState === 'closed') { + resolve(); + } + }); + otherButton.focus(); + }); + await env.waitForStability(); + + expect(menulist.matches(':popover-open')).toBeFalse(); + }); + }); +}); diff --git a/types/command-event.d.ts b/types/command-event.d.ts new file mode 100644 index 0000000000..a6d73cd307 --- /dev/null +++ b/types/command-event.d.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +interface CommandEventInit extends EventInit { + source?: Element; + command: string; +} + +interface CommandEvent extends Event { + new (type: string, init: CommandEventInit): CommandEvent; + source?: Element; + command: string; +} + +interface HTMLElementEventMap { + command: CommandEvent; +} + +declare let CommandEvent: CommandEvent; diff --git a/types/popover.d.ts b/types/popover.d.ts new file mode 100644 index 0000000000..2a02bcaf12 --- /dev/null +++ b/types/popover.d.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +interface ShowPopoverOptions { + source?: HTMLElement; +} + +interface TogglePopoverOptions extends ShowPopoverOptions { + force?: boolean; +} + +interface HTMLElement { + showPopover(options?: ShowPopoverOptions | boolean): void; + togglePopover(options?: TogglePopoverOptions | boolean): boolean; +} + +interface ToggleEvent { + source: Element | null; +}