From 2a8c25e59bc9cb098a4bee3f657c5df67910ea28 Mon Sep 17 00:00:00 2001 From: Russell Bicknell Date: Fri, 31 Jul 2026 15:54:01 -0700 Subject: [PATCH] feat(labs): add fieldset and menuitem checkedness support PiperOrigin-RevId: 957372121 --- labs/aria/menu/demo/stories.ts | 137 ++++++++++- labs/aria/menu/fieldset.ts | 90 ++++++++ labs/aria/menu/fieldset_test.ts | 172 ++++++++++++++ labs/aria/menu/md-aria-fieldset.ts | 15 ++ labs/aria/menu/menuitem.ts | 278 +++++++++++++++++++++-- labs/aria/menu/menuitem_test.ts | 155 ++++++++++++- labs/aria/menu/menulist.ts | 45 +--- labs/aria/menu/shared-slotted-content.ts | 52 +++++ 8 files changed, 881 insertions(+), 63 deletions(-) create mode 100644 labs/aria/menu/fieldset.ts create mode 100644 labs/aria/menu/fieldset_test.ts create mode 100644 labs/aria/menu/md-aria-fieldset.ts create mode 100644 labs/aria/menu/shared-slotted-content.ts diff --git a/labs/aria/menu/demo/stories.ts b/labs/aria/menu/demo/stories.ts index 451ee294bd..af4b6c8bc9 100644 --- a/labs/aria/menu/demo/stories.ts +++ b/labs/aria/menu/demo/stories.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import '@material/web/labs/aria/menu/md-aria-fieldset.js'; import '@material/web/labs/aria/menu/md-aria-menuitem.js'; import '@material/web/labs/aria/menu/md-aria-menulist.js'; @@ -91,4 +92,138 @@ const menuWithPopover: MaterialStoryInit = { }, }; -export const stories = [menu, menuWithDialog, menuWithPopover]; +const menuWithFieldset: MaterialStoryInit = { + name: 'Menu with fieldset, not checkable', + styles: css``, + render(knobs) { + return html` + + + + Item 1 + Item 2 + Item 3 + + + `; + }, +}; + +const menuWithFieldsetSingleCheckable: MaterialStoryInit = { + name: 'Menu with fieldset, single-checkable', + styles: css``, + render(knobs) { + return html` + + + + Item 1 + Item 2 + Item 3 + + + `; + }, +}; + +const menuWithFieldsetMultipleCheckable: MaterialStoryInit = { + name: 'Menu with fieldset, multiple-checkable', + styles: css``, + render(knobs) { + return html` + + + + Item 1 + Item 2 + Item 3 + + + `; + }, +}; + +const menuWithFieldsetDisabled: MaterialStoryInit = { + name: 'Menu with disabled fieldset', + styles: css``, + render(knobs) { + return html` + + + + Item 1 + Item 2 + Item 3 + + + `; + }, +}; + +const menuWithMultipleFieldsetsCheckable: MaterialStoryInit = { + name: 'Menu with multiple fieldsets, single- and multiple-checkable', + styles: css``, + render(knobs) { + return html` + + + + Item 1 + Item 2 + Item 3 + +
+ + Item 4 + Item 5 + Item 6 + +
+ `; + }, +}; + +const menuWithStyles: MaterialStoryInit = { + name: 'Menu with styles', + styles: css` + md-aria-menuitem:state(enabled) { + color: blue; + } + + md-aria-menuitem:state(disabled) { + font-style: italic; + } + + md-aria-menuitem:state(checked) { + color: green; + } + + md-aria-menuitem::part(checkmark)::before { + content: '\\25B8'; /* ▸ */ + } + `, + render(knobs) { + return html` + + + + Item 1 + Item 2 + Item 3 + + + `; + }, +}; + +export const stories = [ + menu, + menuWithDialog, + menuWithPopover, + menuWithFieldset, + menuWithFieldsetSingleCheckable, + menuWithFieldsetMultipleCheckable, + menuWithFieldsetDisabled, + menuWithMultipleFieldsetsCheckable, + menuWithStyles, +]; diff --git a/labs/aria/menu/fieldset.ts b/labs/aria/menu/fieldset.ts new file mode 100644 index 0000000000..5898833f2b --- /dev/null +++ b/labs/aria/menu/fieldset.ts @@ -0,0 +1,90 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {CSSResultOrNative, LitElement, css, html} from 'lit'; +import {property} from 'lit/decorators.js'; +import { + internals, + mixinElementInternals, +} from '../../behaviors/element-internals.js'; +import {sharedSlottedContentStyles} from './shared-slotted-content.js'; + +declare global { + interface HTMLElementEventMap { + 'checkable-changed': CustomEvent<{value: 'single' | 'multiple' | null}>; + 'disabled-changed': CustomEvent<{value: boolean}>; + } +} + +const baseClass = mixinElementInternals(LitElement); + +/** + * An element implementing the proposed menu behavior of `
`. + */ +export class AriaFieldsetElement extends baseClass { + static override styles: CSSResultOrNative[] = [ + sharedSlottedContentStyles, + css` + :host { + display: block; + margin-inline: 2px; + border: groove 2px ThreeDFace; + padding-block: 0.35em 0.625em; + padding-inline: 0.75em; + min-inline-size: min-content; + } + `, + ]; + + private internalCheckable: 'single' | 'multiple' | null = null; + private internalDisabled = false; + + /** + * Whether or not menuitems in this fieldset are checkable, and if they are + * exclusively checkable. + */ + @property({type: String, reflect: true}) + get checkable(): 'single' | 'multiple' | null { + return this.internalCheckable; + } + set checkable(value: 'single' | 'multiple' | null) { + if (value === this.internalCheckable) { + return; + } + + const oldValue = this.internalCheckable; + this.internalCheckable = value; + this.dispatchEvent(new CustomEvent('checkable-changed', {detail: {value}})); + this.requestUpdate('checkable', oldValue); + } + + /** + * Whether or not menuitems in this fieldset are disabled. + */ + @property({type: Boolean, reflect: true}) + get disabled(): boolean { + return this.internalDisabled; + } + set disabled(value: boolean) { + if (value === this.internalDisabled) { + return; + } + + const oldValue = this.internalDisabled; + this.internalDisabled = value; + this.dispatchEvent(new CustomEvent('disabled-changed', {detail: {value}})); + this.requestUpdate('disabled', oldValue); + } + + constructor() { + super(); + this[internals].role = 'group'; + } + + override render() { + return html``; + } +} diff --git a/labs/aria/menu/fieldset_test.ts b/labs/aria/menu/fieldset_test.ts new file mode 100644 index 0000000000..a833e6b919 --- /dev/null +++ b/labs/aria/menu/fieldset_test.ts @@ -0,0 +1,172 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// import 'jasmine'; (google3-only) + +import './md-aria-fieldset.js'; +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'; + +describe('md-aria-fieldset', () => { + const env = new Environment(); + + async function setUpTest( + template = html` + + + Item 1 + Item 2 + Item 3 + + + `, + ) { + const root = env.render(template); + await env.waitForStability(); + const menulist = root.querySelector('md-aria-menulist')!; + const fieldset = root.querySelector('md-aria-fieldset')!; + const items = Array.from(root.querySelectorAll('md-aria-menuitem')!); + + await new Promise((resolve) => { + menulist.addEventListener('toggle', (event: ToggleEvent) => { + if (event.newState === 'open') { + resolve(); + } + }); + menulist.showPopover(); + }); + await env.waitForStability(); + expect(menulist.matches(':popover-open')).toBeTrue(); + + return {root, menulist, fieldset, items}; + } + + describe('default state', () => { + it('has role "group"', async () => { + const {fieldset} = await setUpTest(); + + expect(fieldset[internals].role).toBe('group'); + }); + + it('items are not checkable by default', async () => { + const {items} = await setUpTest(); + + items[0].dispatchEvent(new MouseEvent('click', {bubbles: true})); + await env.waitForStability(); + + expect(items.map((x) => x.checked)).toEqual([false, false, false]); + }); + }); + + describe('single-checkable fieldsets', () => { + async function setUpSingleCheckableTest() { + const result = await setUpTest(); + result.fieldset.checkable = 'single'; + await env.waitForStability(); + return result; + } + + it('only one item can be checked at a time', async () => { + const {items} = await setUpSingleCheckableTest(); + + expect(items.map((x) => x.checked)).toEqual([false, false, false]); + + items[0].dispatchEvent(new MouseEvent('click', {bubbles: true})); + await env.waitForStability(); + + expect(items.map((x) => x.checked)).toEqual([true, false, false]); + + items[1].dispatchEvent(new MouseEvent('click', {bubbles: true})); + await env.waitForStability(); + + expect(items.map((x) => x.checked)).toEqual([false, true, false]); + }); + + it('when disabled, no items are checkable', async () => { + const {fieldset, items} = await setUpSingleCheckableTest(); + + fieldset.disabled = true; + await env.waitForStability(); + + expect(items.map((x) => x.checked)).toEqual([false, false, false]); + + items[0].dispatchEvent(new MouseEvent('click', {bubbles: true})); + await env.waitForStability(); + + expect(items.map((x) => x.checked)).toEqual([false, false, false]); + }); + + it('checking an item closes the menu', async () => { + const {menulist, items} = await setUpSingleCheckableTest(); + + expect(menulist.matches(':popover-open')).toBeTrue(); + + items[0].dispatchEvent(new MouseEvent('click', {bubbles: true})); + await env.waitForStability(); + + expect(menulist.matches(':popover-open')).toBeFalse(); + }); + }); + + describe('multiple-checkable fieldsets', () => { + async function setUpMultipleCheckableTest() { + const result = await setUpTest(); + result.fieldset.checkable = 'multiple'; + await env.waitForStability(); + return result; + } + + it('multiple items can be checked at once', async () => { + const {items} = await setUpMultipleCheckableTest(); + + expect(items.map((x) => x.checked)).toEqual([false, false, false]); + + items[0].dispatchEvent(new MouseEvent('click', {bubbles: true})); + await env.waitForStability(); + + expect(items.map((x) => x.checked)).toEqual([true, false, false]); + + items[1].dispatchEvent(new MouseEvent('click', {bubbles: true})); + await env.waitForStability(); + + expect(items.map((x) => x.checked)).toEqual([true, true, false]); + + items[0].dispatchEvent(new MouseEvent('click', {bubbles: true})); + await env.waitForStability(); + + expect(items.map((x) => x.checked)).toEqual([false, true, false]); + }); + + it('when disabled, no items are checkable', async () => { + const {fieldset, items} = await setUpMultipleCheckableTest(); + + fieldset.disabled = true; + await env.waitForStability(); + + expect(items.map((x) => x.checked)).toEqual([false, false, false]); + + items[0].dispatchEvent(new MouseEvent('click', {bubbles: true})); + await env.waitForStability(); + + expect(items.map((x) => x.checked)).toEqual([false, false, false]); + }); + + it('checking an item does not close the menu', async () => { + const {menulist, items} = await setUpMultipleCheckableTest(); + + expect(menulist.matches(':popover-open')).toBeTrue(); + + items[0].dispatchEvent(new MouseEvent('click', {bubbles: true})); + await env.waitForStability(); + + expect(menulist.matches(':popover-open')).toBeTrue(); + }); + }); +}); diff --git a/labs/aria/menu/md-aria-fieldset.ts b/labs/aria/menu/md-aria-fieldset.ts new file mode 100644 index 0000000000..5e65edfc1d --- /dev/null +++ b/labs/aria/menu/md-aria-fieldset.ts @@ -0,0 +1,15 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {AriaFieldsetElement} from './fieldset.js'; + +declare global { + interface HTMLElementTagNameMap { + 'md-aria-fieldset': AriaFieldsetElement; + } +} + +customElements.define('md-aria-fieldset', AriaFieldsetElement); diff --git a/labs/aria/menu/menuitem.ts b/labs/aria/menu/menuitem.ts index 9b23b6e589..76b4286f0a 100644 --- a/labs/aria/menu/menuitem.ts +++ b/labs/aria/menu/menuitem.ts @@ -4,7 +4,6 @@ * 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 { @@ -21,10 +20,8 @@ import { } 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'); +import {AriaFieldsetElement} from './fieldset.js'; +import {AriaMenulistElement} from './menulist.js'; const baseClass = mixinCustomStateSet( mixinFocusable(mixinElementInternals(LitElement)), @@ -33,8 +30,16 @@ const baseClass = mixinCustomStateSet( /** * An element implementing the proposed `` built-in element. * + * Use `::part(checkmark)::before` to style the pseudo-element representing the + * `::checkmark`. (As of writing, pseudo-elements can't be directly exported as + * first-class parts.) + * + * Unlike the proposed `` behavior, this element does not support + * being nested within multiple `
`s. + * * @cssstate enabled - True when the item is enabled. * @cssstate disabled - True when the item is disabled. + * @cssstate checked - True when the item is checked. */ export class AriaMenuitemElement extends baseClass { static override styles: CSSResultOrNative[] = [ @@ -42,29 +47,228 @@ export class AriaMenuitemElement extends baseClass { :host { display: inline-flex; } + + [part='checkmark'] { + &::before { + content: '\\2713' / ''; + } + + :host(:not(:state(_checkable))) & { + display: none; + } + + :host(:not(:state(checked))) & { + visibility: hidden; + } + } `, ]; - @consume({context: ancestorMenulistContext, subscribe: true}) - [ancestorMenulist]?: AriaMenulistElement; + private ancestorMenulist?: AriaMenulistElement; + private internalAncestorFieldset?: AriaFieldsetElement; + private get ancestorFieldset(): AriaFieldsetElement | undefined { + return this.internalAncestorFieldset; + } + private set ancestorFieldset(value: AriaFieldsetElement | undefined) { + const oldFieldset = this.internalAncestorFieldset; + if (oldFieldset) { + oldFieldset.removeEventListener( + 'checkable-changed', + this.fieldsetCheckableChanged, + ); + oldFieldset.removeEventListener( + 'disabled-changed', + this.fieldsetDisabledChanged, + ); - @property({type: Boolean, reflect: true, noAccessor: true}) - get disabled() { - return this[internals].ariaDisabled === 'true'; + this.fieldsetCheckable = null; + this.fieldsetDisabled = false; + } + + this.internalAncestorFieldset = value; + + const newFieldset = this.ancestorFieldset; + if (newFieldset) { + newFieldset.addEventListener( + 'checkable-changed', + this.fieldsetCheckableChanged, + ); + newFieldset.addEventListener( + 'disabled-changed', + this.fieldsetDisabledChanged, + ); + + this.fieldsetCheckable = newFieldset.checkable; + this.fieldsetDisabled = newFieldset.disabled; + } + } + private get fieldsetCheckable(): 'single' | 'multiple' | null { + return this.ancestorFieldset?.checkable ?? null; + } + private set fieldsetCheckable(value: 'single' | 'multiple' | null) { + this.internalCheckable = this.fieldsetCheckable; + this.internalChecked = this.fieldsetCheckable + ? Boolean(this.internalChecked) + : null; + } + private get fieldsetDisabled(): boolean | null { + return this.ancestorFieldset?.disabled ?? null; + } + private set fieldsetDisabled(value: boolean | null) { + this.internalDisabled = + Boolean(this.fieldsetDisabled) || Boolean(this.disabled); + } + private dirtyCheckedness = false; + private internalDefaultChecked = false; + + private readonly fieldsetCheckableChanged = (event: Event) => { + this.fieldsetCheckable = ( + event as CustomEvent<{value: 'single' | 'multiple' | null}> + ).detail.value; + }; + private readonly fieldsetDisabledChanged = (event: Event) => { + this.fieldsetDisabled = ( + event as CustomEvent<{value: boolean}> + ).detail.value; + }; + + @property({type: Boolean, reflect: true}) + get disabled(): boolean { + return this.internalDisabled; } set disabled(value: boolean) { - const oldValue = this.disabled; value = Boolean(value); + const oldValue = this.internalDisabled; + + this.internalDisabled = Boolean(this.fieldsetDisabled) || Boolean(value); + + this.requestUpdate('disabled', oldValue); + } + + @property({type: Boolean, reflect: true}) + get defaultChecked(): boolean { + return this.internalDefaultChecked; + } + set defaultChecked(value: boolean) { + value = Boolean(value); + const oldValue = this.internalDefaultChecked; + + this.internalDefaultChecked = value; + + if (!this.dirtyCheckedness) { + this.internalChecked = value; + } + + this.requestUpdate('defaultchecked', oldValue); + } + internalDirtyCheckedness = false; + + @property({type: Boolean}) + get checked(): boolean { + return Boolean(this.internalChecked); + } + set checked(value: boolean) { + value = Boolean(value); + + if (this.internalCheckable === null) { + return; + } + + this.dirtyCheckedness = true; + + if (value && this.fieldsetCheckable === 'single') { + // This naively queries for these elements because it doesn't need to + // support nested fieldsets yet. + const items: Iterable = + this.ancestorFieldset?.querySelectorAll('md-aria-menuitem') ?? []; + for (const item of items) { + if (item !== this) { + item.internalChecked = false; + } + } + } + + this.internalChecked = value; + } + + /** + * The internal disabled state of this element, canonically stored in the + * item's ARIA disabled state. This value is set in `updated` based on both + * the `disabledContext` from the ancestor `` (if any) and + * the `disabled` attribute. + */ + private get internalDisabled(): boolean { + return this[internals].ariaDisabled === 'true'; + } + private set internalDisabled(value: boolean) { this[internals].ariaDisabled = String(value); this[toggleState]('disabled', value); this[toggleState]('enabled', !value); - this.requestUpdate('disabled', oldValue); + } + + /** + * The internal checkable state of this element, canonically stored in the + * item's ARIA role. This value is set in `updated` based on the + * `checkableContext` from the ancestor ``. Setting this + * property does not automatically update `internalChecked`. + */ + private get internalCheckable(): 'single' | 'multiple' | null { + switch (this[internals].role) { + case 'menuitemradio': + return 'single'; + case 'menuitemcheckbox': + return 'multiple'; + case 'menuitem': + return null; + default: + // Unreachable + return null; + } + } + private set internalCheckable(value: 'single' | 'multiple' | null) { + switch (value) { + case 'single': + this[internals].role = 'menuitemradio'; + this[toggleState]('_checkable', true); + break; + case 'multiple': + this[internals].role = 'menuitemcheckbox'; + this[toggleState]('_checkable', true); + break; + case null: + this[internals].role = 'menuitem'; + this[toggleState]('_checkable', false); + break; + default: + // Unreachable + } + } + + /** + * The internal checked state of this element, canonically stored in the + * item's ARIA checked state. Setting this property does not automatically + * update `internalCheckable`. + */ + private get internalChecked(): boolean | null { + const value = this[internals].ariaChecked as string | null; + return value === null ? (value as null) : value === 'true'; + } + private set internalChecked(value: boolean | null) { + if (value === null) { + this[internals].ariaChecked = null; + this[toggleState]('checked', false); + return; + } + + this[internals].ariaChecked = String(value); + this[toggleState]('checked', value); } constructor() { super(); - this[internals].role = 'menuitem'; - this.disabled = false; + this.internalDisabled = false; + this.internalCheckable = null; + this.internalChecked = null; setupDispatchHooks(this, 'keydown', 'click'); this.addEventListener('keydown', (event: KeyboardEvent) => { @@ -80,22 +284,60 @@ export class AriaMenuitemElement extends baseClass { }); this.addEventListener('click', (event: Event) => { afterDispatch(event, () => { - if (event.defaultPrevented || this.disabled) { + if (event.defaultPrevented || this.internalDisabled) { return; } - this[ancestorMenulist]?.hidePopover(); + if (this.internalCheckable !== null) { + // Use the user-facing setter to update other items in this fieldset, + // if any. + this.checked = !this.checked; + } + + if (this.internalCheckable !== 'multiple') { + this.ancestorMenulist?.hidePopover(); + } sharedCommandInvokerActivationSteps(this, event); }); }); } + override connectedCallback() { + super.connectedCallback(); + this.resetAncestorElementCache(); + if (this.defaultChecked && !this.dirtyCheckedness) { + this.checked = true; + this.dirtyCheckedness = false; + } + } + + override disconnectedCallback() { + super.disconnectedCallback(); + this.resetAncestorElementCache(); + } + + private resetAncestorElementCache() { + this.ancestorMenulist = undefined; + this.ancestorFieldset = undefined; + + for (let node = this.parentNode; node; node = node.parentNode) { + if (node instanceof AriaMenulistElement) { + this.ancestorMenulist = node; + break; + } + + if (node instanceof AriaFieldsetElement) { + this.ancestorFieldset = node; + } + } + } + override render() { - return html``; + return html``; } override click() { - if (!this.disabled) { + if (!this.internalDisabled) { super.click(); } } diff --git a/labs/aria/menu/menuitem_test.ts b/labs/aria/menu/menuitem_test.ts index 28db02b0d5..924b91f2a2 100644 --- a/labs/aria/menu/menuitem_test.ts +++ b/labs/aria/menu/menuitem_test.ts @@ -6,6 +6,7 @@ // import 'jasmine'; (google3-only) +import './md-aria-fieldset.js'; import './md-aria-menuitem.js'; import './md-aria-menulist.js'; @@ -31,6 +32,24 @@ function expectDisabled(menuitem: AriaMenuitemElement) { expect(menuitem.matches(':state(disabled)')).toBeTrue(); } +function expectChecked(menuitem: AriaMenuitemElement) { + expect(menuitem.checked).toBeTrue(); + expect(menuitem[internals].ariaChecked).toBe('true'); + expect(menuitem.matches(':state(checked)')).toBeTrue(); +} + +function expectNotChecked(menuitem: AriaMenuitemElement) { + expect(menuitem.checked).toBeFalse(); + expect(menuitem[internals].ariaChecked).toBe('false'); + expect(menuitem.matches(':state(checked)')).toBeFalse(); +} + +function expectNotCheckable(menuitem: AriaMenuitemElement) { + expect(menuitem.checked).toBeFalse(); + expect(menuitem[internals].ariaChecked).toBe(null); + expect(menuitem.matches(':state(checked)')).toBeFalse(); +} + describe('md-aria-menuitem', () => { const env = new Environment(); @@ -52,12 +71,13 @@ describe('md-aria-menuitem', () => { expect(menuitem[internals].role).toBe('menuitem'); }); - it('sets initial custom state to enabled and not disabled', async () => { + it('sets initial custom state to enabled, not disabled, and not checkable', async () => { const {menuitem} = await setUpTest(); await env.waitForStability(); expectEnabled(menuitem); + expectNotCheckable(menuitem); }); }); @@ -217,4 +237,137 @@ describe('md-aria-menuitem', () => { expect(menulist.matches(':popover-open')).toBeTrue(); }); }); + + describe('Checkedness behavior', () => { + async function setUpCheckednessTest() { + const {root} = await setUpTest(html` + + + Item 1 + + + `); + const menulist = root.querySelector('md-aria-menulist')!; + const fieldset = root.querySelector('md-aria-fieldset')!; + const menuitem = root.querySelector('md-aria-menuitem')!; + + menulist.showPopover(); + await env.waitForStability(); + + return {menulist, fieldset, menuitem}; + } + + it('has role "menuitemcheckbox" in multiple-checkable fieldsets', async () => { + const {menuitem} = await setUpCheckednessTest(); + + expect(menuitem[internals].role).toEqual('menuitemcheckbox'); + }); + + it('has role "menuitemradio" in single-checkable fieldsets', async () => { + const {fieldset, menuitem} = await setUpCheckednessTest(); + + fieldset.checkable = 'single'; + await env.waitForStability(); + + expect(menuitem[internals].role).toEqual('menuitemradio'); + }); + + it('becomes checked when clicked', async () => { + const {menuitem} = await setUpCheckednessTest(); + + expectNotChecked(menuitem); + + menuitem.click(); + await env.waitForStability(); + + expectChecked(menuitem); + }); + + it('does not become checked when clicked if disabled', async () => { + const {menuitem} = await setUpCheckednessTest(); + + expectNotChecked(menuitem); + + menuitem.disabled = true; + await env.waitForStability(); + menuitem.click(); + await env.waitForStability(); + + expectNotChecked(menuitem); + }); + + it('does not become checked when the fieldset is not checkable', async () => { + const {fieldset, menuitem} = await setUpCheckednessTest(); + + expectNotChecked(menuitem); + + fieldset.checkable = null; + await env.waitForStability(); + + expectNotCheckable(menuitem); + + menuitem.click(); + await env.waitForStability(); + + expectNotCheckable(menuitem); + }); + + it('items with `defaultchecked` are checked by default', async () => { + const {root} = await setUpTest(html` + + + Default checked item + Other item + + + `); + const menulist = root.querySelector('md-aria-menulist')!; + const menuitems = Array.from(root.querySelectorAll('md-aria-menuitem')!); + + menulist.showPopover(); + await env.waitForStability(); + + expect(menuitems.map((x) => x.checked)).toEqual([true, false]); + }); + + it('the last item in a single-checkable fieldset with `defaultchecked` is checked by default', async () => { + const {root} = await setUpTest(html` + + + Item 1 + Item 2 + Item 3 + + + `); + const menulist = root.querySelector('md-aria-menulist')!; + const menuitems = Array.from(root.querySelectorAll('md-aria-menuitem')!); + + menulist.showPopover(); + await env.waitForStability(); + + expect(menuitems.map((x) => x.checked)).toEqual([false, false, true]); + }); + + it('multiple items in a muliple-checkable fieldset with `defaultchecked` are checked by default', async () => { + const {root} = await setUpTest(html` + + + Item 1 + Item 2 + Item 3 + + + `); + const menulist = root.querySelector('md-aria-menulist')!; + const menuitems = Array.from(root.querySelectorAll('md-aria-menuitem')!); + + menulist.showPopover(); + await env.waitForStability(); + + expect(menuitems.map((x) => x.checked)).toEqual([true, true, true]); + }); + }); }); diff --git a/labs/aria/menu/menulist.ts b/labs/aria/menu/menulist.ts index a24e406bbe..3de8c3c48d 100644 --- a/labs/aria/menu/menulist.ts +++ b/labs/aria/menu/menulist.ts @@ -6,18 +6,13 @@ /// -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'), -); +import {sharedSlottedContentStyles} from './shared-slotted-content.js'; // `focus` is defined on `HTMLElement` and `SVGElement` directly, not `Element`. interface MaybeFocusableElement extends Element { @@ -31,6 +26,7 @@ const baseClass = mixinElementInternals(LitElement); */ export class AriaMenulistElement extends baseClass { static override styles: CSSResultOrNative[] = [ + sharedSlottedContentStyles, css` /* Unset UA |[popover]| styles. */ @layer { @@ -76,38 +72,6 @@ export class AriaMenulistElement extends baseClass { :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; - } `, ]; @@ -120,11 +84,6 @@ export class AriaMenulistElement extends baseClass { 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)); diff --git a/labs/aria/menu/shared-slotted-content.ts b/labs/aria/menu/shared-slotted-content.ts new file mode 100644 index 0000000000..2c78862924 --- /dev/null +++ b/labs/aria/menu/shared-slotted-content.ts @@ -0,0 +1,52 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {css} from 'lit'; + +/** + * Shared styles used by `` and ``, to + * simulate the descendant combinator in UA styles for ``. + */ +export const sharedSlottedContentStyles = css` + ::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(md-aria-fieldset) { + margin-inline: 0; + border: none; + padding-block: 0; + padding-inline: 0; + } + + ::slotted(hr) { + color: inherit; + margin-inline: 0; + border: none; + border-block-start: 1px solid currentColor; + border-image: none; + } + + ::slotted(a:any-link), + ::slotted(img[usemap]) { + display: none; + } +`;