From 51e58337b039b73465b21c6387c71f87394527c9 Mon Sep 17 00:00:00 2001 From: Russell Bicknell Date: Tue, 6 Oct 2026 15:22:37 -0700 Subject: [PATCH] feat(labs): add aria accordion component This CL adds a new `` component, which implements the [accordion](https://www.w3.org/WAI/ARIA/apg/patterns/accordion/) and [disclosure](https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/) design patterns. Both patterns toggle content visibility using an element with role `button` and manage its `aria-expanded` and `aria-controls` attributes, but the accordion pattern expects the button to be wrapped in a heading, which can be optionally specified using this component's `headingLevel` property. This element also follows conventions from `
`: the `open` property can be used to get or change the open state, the element dispatches a `toggle` event when opened or closed by the user, and the `name` property can be used to assign many accordion sections in the same tree to an exclusive group. PiperOrigin-RevId: 994660525 --- labs/aria/accordion/accordion.ts | 237 +++++++ labs/aria/accordion/accordion_test.ts | 861 +++++++++++++++++++++++ labs/aria/accordion/demo/demo.ts | 19 + labs/aria/accordion/demo/stories.ts | 143 ++++ labs/aria/accordion/harness.ts | 103 +++ labs/aria/accordion/md-aria-accordion.ts | 15 + 6 files changed, 1378 insertions(+) create mode 100644 labs/aria/accordion/accordion.ts create mode 100644 labs/aria/accordion/accordion_test.ts create mode 100644 labs/aria/accordion/demo/demo.ts create mode 100644 labs/aria/accordion/demo/stories.ts create mode 100644 labs/aria/accordion/harness.ts create mode 100644 labs/aria/accordion/md-aria-accordion.ts diff --git a/labs/aria/accordion/accordion.ts b/labs/aria/accordion/accordion.ts new file mode 100644 index 0000000000..787367984c --- /dev/null +++ b/labs/aria/accordion/accordion.ts @@ -0,0 +1,237 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + CSSResultOrNative, + LitElement, + PropertyValues, + css, + html, + nothing, +} 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'; + +const baseClass = mixinCustomStateSet(mixinElementInternals(LitElement)); + +/** + * An expandable accordion item. + * + * @fires {ToggleEvent} toggle - Dispatched when the item is opened or closed by + * the user. + * @csspart button - The button that opens the item. This button's `::before` + * produces the disclosure triangle. + * @cssstate open - Set when the item is open. + */ +export class AriaAccordionElement extends baseClass { + static override styles: CSSResultOrNative[] = [ + css` + :host { + display: flex; + flex-direction: column; + align-items: stretch; + } + + #heading { + display: contents; + } + + button { + all: unset; + + display: inline-flex; + align-items: center; + gap: 1ch; + + &:focus-visible { + outline: 1px auto AccentColor; + } + + &::before { + content: '\u25B6' / ''; /* ▸ */ + font-size: 0.75em; + } + + :host(:state(open)) &::before { + transform: rotate(90deg); + } + } + + slot[name='content'] { + display: block; + + :host(:not(:state(open))) & { + display: none; + } + } + `, + ]; + + private internalOpen = false; + private internalName: string | null = null; + + /** True if the accordion is open. */ + @property({type: Boolean, reflect: true}) + get open(): boolean { + return this.internalOpen; + } + set open(value: boolean) { + const oldValue = this.internalOpen; + this.internalOpen = value; + if (!oldValue && value) { + this.ensureExclusivity(true); + } + this.requestUpdate('open', oldValue); + } + + /** + * Only one accordion in a root with the same, non-empty name may be at a + * time. + */ + @property({type: String, reflect: true}) + get name(): string | null { + return this.internalName; + } + set name(value: string | null) { + const oldValue = this.internalName; + this.internalName = value; + this.ensureExclusivity(false); + this.requestUpdate('name', oldValue); + } + + @property({type: Number}) + headingLevel?: number; + + /** The heading level, if `headingLevel` is a valid heading level. */ + private get usedHeadingLevel(): number | undefined { + if ( + this.headingLevel !== undefined && + Number.isInteger(this.headingLevel) && + this.headingLevel >= 1 + ) { + return this.headingLevel; + } else { + return undefined; + } + } + + constructor() { + super(); + + this[internals].role = 'group'; + this[toggleState]('open', false); + + setupDispatchHooks(this, 'click'); + } + + private handleClick(event: Event) { + afterDispatch(event, () => { + if (event.defaultPrevented) { + return; + } + + const wasOpen = this.open; + this.open = !wasOpen; + this.dispatchEvent( + new ToggleEvent('toggle', { + oldState: wasOpen ? 'open' : 'closed', + newState: wasOpen ? 'closed' : 'open', + }), + ); + }); + } + + override connectedCallback() { + super.connectedCallback(); + this.ensureExclusivity(false); + } + + protected override updated(changedProperties: PropertyValues) { + super.updated(changedProperties); + + if (changedProperties.has('open')) { + this[toggleState]('open', this.open); + } + } + + /** + * Implements both the "ensure details exclusivity by closing other elements + * if needed" and "ensure details exclusivity by closing the given element if + * needed" steps, switching based on the passed flag. + */ + private ensureExclusivity(closeOthers: boolean) { + // Do nothing if potentially closing this element and it is already closed. + if (!closeOthers && !this.open) { + return; + } + + // Both `null` and the empty string imply that this element is not part of a + // group. + if (!this.name) { + return; + } + + const others = (this.getRootNode() as ParentNode).querySelectorAll( + `md-aria-accordion`, + ) as NodeListOf; + for (const other of others) { + if (other === this) { + continue; + } + + // Filter here instead of using an attribute selector in case it isn't + // synced with the value. + if (other.name !== this.name) { + continue; + } + + if (other.internalOpen) { + if (closeOthers) { + other.internalOpen = false; + other.requestUpdate('open', true); + } else { + this.internalOpen = false; + this.requestUpdate('open', true); + break; + } + } + } + } + + protected override render() { + return html` +
+ +
+ + `; + } +} diff --git a/labs/aria/accordion/accordion_test.ts b/labs/aria/accordion/accordion_test.ts new file mode 100644 index 0000000000..1097ed525e --- /dev/null +++ b/labs/aria/accordion/accordion_test.ts @@ -0,0 +1,861 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// import 'jasmine'; (google3-only) + +import './md-aria-accordion.js'; + +import {html} from 'lit'; +import {Environment} from '../../../testing/environment.js'; +import {internals} from '../../behaviors/element-internals.js'; + +import {AriaAccordionElement} from './accordion.js'; +import {AriaAccordionHarness} from './harness.js'; + +describe('md-aria-accordion', () => { + const env = new Environment(); + + async function setUpTest( + template = html` + + Title +
Content
+
+ `, + ) { + const root = env.render(template); + await env.waitForStability(); + + const accordions = Array.from(root.querySelectorAll('md-aria-accordion')); + const harnesses = accordions.map( + (accordion) => new AriaAccordionHarness(accordion), + ); + const titles = accordions.map( + (accordion) => accordion.querySelector('[slot="title"]')!, + ); + const contents = accordions.map( + (accordion) => accordion.querySelector('[slot="content"]')!, + ); + return { + root, + accordions, + accordion: accordions[0], + harnesses, + harness: harnesses[0], + titles, + title: titles[0], + contents, + content: contents[0], + }; + } + + function openStates(accordions: AriaAccordionElement[]): boolean[] { + return accordions.map((accordion) => accordion.open); + } + + describe('ARIA', () => { + it('has role "group"', async () => { + const {accordion} = await setUpTest(); + + expect(accordion[internals].role).toBe('group'); + }); + }); + + describe('open', () => { + it('is closed by default', async () => { + const {accordion} = await setUpTest(); + + expect(accordion.open).toBeFalse(); + expect(accordion.hasAttribute('open')).toBeFalse(); + }); + + it('hides content when closed', async () => { + const {content} = await setUpTest(); + + expect(content.checkVisibility()).toBeFalse(); + }); + + it('shows content when open', async () => { + const {content} = await setUpTest(html` + + Title +
Content
+
+ `); + + expect(content.checkVisibility()).toBeTrue(); + }); + + it('toggles content visibility when the property changes', async () => { + const {accordion, content} = await setUpTest(); + + accordion.open = true; + await accordion.updateComplete; + expect(content.checkVisibility()).toBeTrue(); + + accordion.open = false; + await accordion.updateComplete; + expect(content.checkVisibility()).toBeFalse(); + }); + + it('updates aria-expanded on the button', async () => { + const {accordion, harness} = await setUpTest(); + const button = harness.getButton(); + + expect(button.getAttribute('aria-expanded')).toBe('false'); + + accordion.open = true; + await accordion.updateComplete; + expect(button.getAttribute('aria-expanded')).toBe('true'); + }); + + it('toggles when the button is clicked', async () => { + const {accordion, harness, content} = await setUpTest(); + + await harness.clickWithMouse(); + await accordion.updateComplete; + expect(accordion.open).toBeTrue(); + expect(content.checkVisibility()).toBeTrue(); + + await harness.clickWithMouse(); + await accordion.updateComplete; + expect(accordion.open).toBeFalse(); + expect(content.checkVisibility()).toBeFalse(); + }); + + it('toggles when the title is clicked', async () => { + const {accordion, title} = await setUpTest(); + + title.click(); + + expect(accordion.open).toBeTrue(); + }); + + it('does not toggle when the content is clicked', async () => { + const {accordion, content} = await setUpTest(html` + + Title +
Content
+
+ `); + + content.click(); + + expect(accordion.open).toBeTrue(); + }); + + it('does not toggle when the click is canceled on the accordion', async () => { + const {accordion, harness} = await setUpTest(); + accordion.addEventListener('click', (event) => { + event.preventDefault(); + }); + + await harness.clickWithMouse(); + await accordion.updateComplete; + + expect(accordion.open).toBeFalse(); + expect(accordion.hasAttribute('open')).toBeFalse(); + }); + + it('does not toggle when the click is canceled by an ancestor', async () => { + const {root, accordion, harness} = await setUpTest(); + root.addEventListener('click', (event) => { + event.preventDefault(); + }); + + await harness.clickWithMouse(); + await accordion.updateComplete; + + expect(accordion.open).toBeFalse(); + expect(accordion.hasAttribute('open')).toBeFalse(); + }); + + it('toggles when an ancestor stops propagation of the click', async () => { + const {root, accordion, harness} = await setUpTest(); + root.addEventListener('click', (event) => { + event.stopPropagation(); + }); + + await harness.clickWithMouse(); + + expect(accordion.open).toBeTrue(); + }); + + it('does not toggle an outer accordion when a nested accordion is clicked', async () => { + const {accordions, harnesses} = await setUpTest(html` + + Outer + + Inner + + + `); + const outerEvents: ToggleEvent[] = []; + accordions[0].addEventListener('toggle', (event) => { + outerEvents.push(event as ToggleEvent); + }); + + await harnesses[1].clickWithMouse(); + + expect(openStates(accordions)).toEqual([true, true]); + expect(outerEvents).toEqual([]); + }); + + it('toggles once the click has been seen by ancestor listeners', async () => { + const {root, accordion, harness} = await setUpTest(); + const openDuringAncestorListener: boolean[] = []; + root.addEventListener('click', () => { + openDuringAncestorListener.push(accordion.open); + }); + + await harness.clickWithMouse(); + + expect(openDuringAncestorListener).toEqual([false]); + expect(accordion.open).toBeTrue(); + }); + + describe('reflection', () => { + it('reflects the property to the attribute', async () => { + const {accordion} = await setUpTest(); + + accordion.open = true; + await accordion.updateComplete; + expect(accordion.hasAttribute('open')).toBeTrue(); + + accordion.open = false; + await accordion.updateComplete; + expect(accordion.hasAttribute('open')).toBeFalse(); + }); + + it('updates the property from the attribute', async () => { + const {accordion, content} = await setUpTest(); + + accordion.setAttribute('open', ''); + expect(accordion.open).toBeTrue(); + await accordion.updateComplete; + expect(content.checkVisibility()).toBeTrue(); + + accordion.removeAttribute('open'); + expect(accordion.open).toBeFalse(); + await accordion.updateComplete; + expect(content.checkVisibility()).toBeFalse(); + }); + + it('reflects user toggles to the attribute', async () => { + const {accordion, harness} = await setUpTest(); + + await harness.clickWithMouse(); + await accordion.updateComplete; + expect(accordion.hasAttribute('open')).toBeTrue(); + + await harness.clickWithMouse(); + await accordion.updateComplete; + expect(accordion.hasAttribute('open')).toBeFalse(); + }); + }); + }); + + describe(':state(open)', () => { + it('does not match when closed', async () => { + const {accordion} = await setUpTest(); + + expect(accordion.matches(':state(open)')).toBeFalse(); + }); + + it('matches when initially open', async () => { + const {accordion} = await setUpTest(html` + + `); + + expect(accordion.matches(':state(open)')).toBeTrue(); + }); + + it('follows the open property', async () => { + const {accordion} = await setUpTest(); + + accordion.open = true; + await accordion.updateComplete; + expect(accordion.matches(':state(open)')).toBeTrue(); + + accordion.open = false; + await accordion.updateComplete; + expect(accordion.matches(':state(open)')).toBeFalse(); + }); + + it('follows user toggles', async () => { + const {accordion, harness} = await setUpTest(); + + await harness.clickWithMouse(); + await accordion.updateComplete; + expect(accordion.matches(':state(open)')).toBeTrue(); + + await harness.clickWithMouse(); + await accordion.updateComplete; + expect(accordion.matches(':state(open)')).toBeFalse(); + }); + + it('is removed when closed by another accordion in the group', async () => { + const {accordions} = await setUpTest(html` + + + `); + + accordions[1].open = true; + await env.waitForStability(); + + expect(accordions[0].matches(':state(open)')).toBeFalse(); + expect(accordions[1].matches(':state(open)')).toBeTrue(); + }); + }); + + describe('name', () => { + describe('ungrouped accordions', () => { + it('accordions without a name may all be open', async () => { + const {accordions} = await setUpTest(html` + + + + `); + + for (const accordion of accordions) { + accordion.open = true; + } + + expect(openStates(accordions)).toEqual([true, true, true]); + }); + + it('accordions with an empty name may all be open', async () => { + const {accordions} = await setUpTest(html` + + + + `); + + for (const accordion of accordions) { + accordion.open = true; + } + + expect(openStates(accordions)).toEqual([true, true, true]); + }); + + it('accordions without a name are unaffected by user toggles of others', async () => { + const {accordions, harnesses} = await setUpTest(html` + + + + `); + + await harnesses[1].clickWithMouse(); + await harnesses[2].clickWithMouse(); + + expect(openStates(accordions)).toEqual([true, true, true]); + }); + + it('accordions without a name are unaffected by a named group', async () => { + const {accordions} = await setUpTest(html` + + + + `); + + accordions[2].open = true; + + expect(openStates(accordions)).toEqual([true, true, true]); + }); + + it('treats a missing name the same as an empty name', async () => { + const {accordions} = await setUpTest(html` + + + `); + + accordions[0].open = true; + accordions[1].open = true; + expect(openStates(accordions)).toEqual([true, true]); + + // Changing a name from missing to empty, or empty to missing, does not + // cause either to close. + accordions[0].setAttribute('name', ''); + accordions[1].removeAttribute('name'); + expect(openStates(accordions)).toEqual([true, true]); + }); + }); + + describe('exclusivity', () => { + function renderGroup() { + return setUpTest(html` + +
Content 1
+
+ +
Content 2
+
+ +
Content 3
+
+ +
Content 4
+
+ `); + } + + it('closes others in the group when opened programmatically', async () => { + const {accordions} = await renderGroup(); + + accordions[0].open = true; + expect(openStates(accordions)).toEqual([true, false, false, false]); + + accordions[1].open = true; + expect(openStates(accordions)).toEqual([false, true, false, false]); + + accordions[2].open = true; + expect(openStates(accordions)).toEqual([false, false, true, false]); + }); + + it('closes others in the group when opened via the attribute', async () => { + const {accordions} = await renderGroup(); + + accordions[0].setAttribute('open', ''); + await env.waitForStability(); + accordions[1].setAttribute('open', ''); + await env.waitForStability(); + + expect(openStates(accordions)).toEqual([false, true, false, false]); + expect(accordions[0].hasAttribute('open')).toBeFalse(); + expect(accordions[1].hasAttribute('open')).toBeTrue(); + }); + + it('updates attributes and visibility of accordions closed by the group', async () => { + const {accordions, harnesses, contents} = await renderGroup(); + + accordions[0].open = true; + await env.waitForStability(); + accordions[1].open = true; + await env.waitForStability(); + + expect(accordions[0].hasAttribute('open')).toBeFalse(); + expect(contents[0].checkVisibility()).toBeFalse(); + expect(contents[1].checkVisibility()).toBeTrue(); + expect(harnesses[0].getButton().getAttribute('aria-expanded')).toBe( + 'false', + ); + }); + + it('closes others in the group when opened by the user', async () => { + const {accordions, harnesses} = await renderGroup(); + + await harnesses[0].clickWithMouse(); + expect(openStates(accordions)).toEqual([true, false, false, false]); + + await harnesses[1].clickWithMouse(); + expect(openStates(accordions)).toEqual([false, true, false, false]); + + await harnesses[2].clickWithMouse(); + expect(openStates(accordions)).toEqual([false, false, true, false]); + }); + + it('allows the user to close the open accordion in a group', async () => { + const {accordions, harnesses} = await renderGroup(); + + await harnesses[0].clickWithMouse(); + await env.waitForStability(); + await harnesses[0].clickWithMouse(); + + expect(openStates(accordions)).toEqual([false, false, false, false]); + }); + + it('does not affect accordions in a different group', async () => { + const {accordions, harnesses} = await renderGroup(); + + accordions[3].open = true; + await env.waitForStability(); + accordions[0].open = true; + await env.waitForStability(); + await harnesses[1].clickWithMouse(); + + expect(openStates(accordions)).toEqual([false, true, false, true]); + }); + + it('closes an inserted open accordion if the group already has an open accordion', async () => { + const {root, accordions} = await renderGroup(); + + accordions[0].open = true; + await env.waitForStability(); + + const inserted = document.createElement('md-aria-accordion'); + inserted.setAttribute('name', 'group'); + inserted.setAttribute('open', ''); + root.appendChild(inserted); + const all = [...accordions, inserted]; + await env.waitForStability(); + + expect(openStates(all)).toEqual([true, false, false, false, false]); + expect(inserted.hasAttribute('open')).toBeFalse(); + expect(inserted.matches(':state(open)')).toBeFalse(); + }); + + it('keeps an inserted open accordion open if no other accordion in the group is open', async () => { + const {root, accordions} = await renderGroup(); + + const inserted = document.createElement('md-aria-accordion'); + inserted.setAttribute('name', 'group'); + inserted.setAttribute('open', ''); + root.appendChild(inserted); + const all = [...accordions, inserted]; + + expect(openStates(all)).toEqual([false, false, false, false, true]); + }); + + it('closes an open accordion when its name changes to that of a group with an open accordion', async () => { + const {accordions} = await renderGroup(); + + accordions[0].open = true; + accordions[3].open = true; + await env.waitForStability(); + + accordions[3].setAttribute('name', 'group'); + + expect(openStates(accordions)).toEqual([true, false, false, false]); + }); + + it('does not close others in the group when a closed accordion is set to closed', async () => { + const {accordions} = await renderGroup(); + + accordions[0].open = true; + await env.waitForStability(); + + accordions[1].open = false; + + expect(openStates(accordions)).toEqual([true, false, false, false]); + }); + + it('groups accordions whose name is set via the property', async () => { + const {accordions} = await setUpTest(html` + + + `); + + accordions[0].name = 'group'; + accordions[1].name = 'group'; + accordions[0].open = true; + await env.waitForStability(); + accordions[1].open = true; + + expect(openStates(accordions)).toEqual([false, true]); + }); + + it('groups accordions whose name is set via the property before they update', async () => { + const {accordions} = await setUpTest(html` + + + `); + + accordions[0].name = 'group'; + accordions[1].name = 'group'; + accordions[0].open = true; + accordions[1].open = true; + + expect(openStates(accordions)).toEqual([false, true]); + }); + }); + }); + + describe('toggle event', () => { + const userActivations: Array<{ + name: string; + activate: (harness: AriaAccordionHarness) => Promise; + }> = [ + {name: 'click', activate: (harness) => harness.clickWithMouse()}, + {name: 'Enter key', activate: (harness) => harness.pressEnter()}, + {name: 'Space key', activate: (harness) => harness.pressSpace()}, + ]; + + for (const {name, activate} of userActivations) { + describe(`via ${name}`, () => { + it('is dispatched with oldState "closed" and newState "open" when opened', async () => { + const {accordion, harness} = await setUpTest(); + const events: ToggleEvent[] = []; + accordion.addEventListener('toggle', (event) => { + events.push(event as ToggleEvent); + }); + + await activate(harness); + + expect(accordion.open).toBeTrue(); + expect(events).toEqual([jasmine.any(ToggleEvent)]); + expect(events).toEqual([ + jasmine.objectContaining({ + oldState: 'closed', + newState: 'open', + }), + ]); + }); + + it('is dispatched with oldState "open" and newState "closed" when closed', async () => { + const {accordion, harness} = await setUpTest(html` + + `); + const events: ToggleEvent[] = []; + accordion.addEventListener('toggle', (event) => { + events.push(event as ToggleEvent); + }); + + await activate(harness); + + expect(accordion.open).toBeFalse(); + expect(events).toEqual([jasmine.any(ToggleEvent)]); + expect(events).toEqual([ + jasmine.objectContaining({ + oldState: 'open', + newState: 'closed', + }), + ]); + }); + }); + } + + it('is dispatched after the open property has been updated', async () => { + const {accordion, harness} = await setUpTest(); + const openDuringEvent: boolean[] = []; + accordion.addEventListener('toggle', () => { + openDuringEvent.push(accordion.open); + }); + + await harness.clickWithMouse(); + await harness.clickWithMouse(); + + expect(openDuringEvent).toEqual([true, false]); + }); + + it('is not dispatched when the click is canceled', async () => { + const {root, accordion, harness} = await setUpTest(); + const events: ToggleEvent[] = []; + accordion.addEventListener('toggle', (event) => { + events.push(event as ToggleEvent); + }); + root.addEventListener('click', (event) => { + event.preventDefault(); + }); + + await harness.clickWithMouse(); + await harness.pressEnter(); + await harness.pressSpace(); + + expect(events).toEqual([]); + expect(accordion.open).toBeFalse(); + }); + + it('is not dispatched when the open property is set', async () => { + const {accordion} = await setUpTest(); + const events: ToggleEvent[] = []; + accordion.addEventListener('toggle', (event) => { + events.push(event as ToggleEvent); + }); + + accordion.open = true; + await accordion.updateComplete; + accordion.open = false; + + expect(events).toEqual([]); + }); + + it('is not dispatched when the open attribute is changed', async () => { + const {accordion} = await setUpTest(); + const events: ToggleEvent[] = []; + accordion.addEventListener('toggle', (event) => { + events.push(event as ToggleEvent); + }); + + accordion.setAttribute('open', ''); + await accordion.updateComplete; + accordion.removeAttribute('open'); + + expect(events).toEqual([]); + }); + + it('is not dispatched on an accordion closed programmatically by its group', async () => { + const {accordions} = await setUpTest(html` + + + `); + const firstEvents: ToggleEvent[] = []; + accordions[0].addEventListener('toggle', (event) => { + firstEvents.push(event as ToggleEvent); + }); + const secondEvents: ToggleEvent[] = []; + accordions[1].addEventListener('toggle', (event) => { + secondEvents.push(event as ToggleEvent); + }); + + accordions[1].open = true; + + expect(openStates(accordions)).toEqual([false, true]); + expect(firstEvents).toEqual([]); + expect(secondEvents).toEqual([]); + }); + + it('is only dispatched on the accordion the user toggled when it closes another in its group', async () => { + const {accordions, harnesses} = await setUpTest(html` + + + `); + const firstEvents: ToggleEvent[] = []; + accordions[0].addEventListener('toggle', (event) => { + firstEvents.push(event as ToggleEvent); + }); + const secondEvents: ToggleEvent[] = []; + accordions[1].addEventListener('toggle', (event) => { + secondEvents.push(event as ToggleEvent); + }); + + await harnesses[1].clickWithMouse(); + + expect(openStates(accordions)).toEqual([false, true]); + expect(firstEvents).toEqual([]); + expect(secondEvents).toEqual([ + jasmine.objectContaining({oldState: 'closed', newState: 'open'}), + ]); + }); + + it('is not dispatched when an inserted accordion is closed by its group', async () => { + const {root, accordions} = await setUpTest(html` + + `); + const existingEvents: ToggleEvent[] = []; + accordions[0].addEventListener('toggle', (event) => { + existingEvents.push(event as ToggleEvent); + }); + + const inserted = document.createElement('md-aria-accordion'); + inserted.setAttribute('name', 'group'); + inserted.setAttribute('open', ''); + const insertedEvents: ToggleEvent[] = []; + inserted.addEventListener('toggle', (event) => { + insertedEvents.push(event as ToggleEvent); + }); + root.appendChild(inserted); + + expect(inserted.open).toBeFalse(); + expect(existingEvents).toEqual([]); + expect(insertedEvents).toEqual([]); + }); + }); + + describe('headingLevel', () => { + it('does not wrap the button in a heading by default', async () => { + const {harness} = await setUpTest(); + + expect(harness.getHeading()).toBeNull(); + }); + + it('does not set region semantics on the content by default', async () => { + const {harness} = await setUpTest(); + const slot = harness.getContentSlot(); + + expect(slot.hasAttribute('role')).toBeFalse(); + expect(slot.hasAttribute('aria-labelledby')).toBeFalse(); + }); + + for (const level of [1, 6, 7, 10]) { + describe(`set to ${level}`, () => { + async function setUpHeadingTest() { + return setUpTest(html` + + Title +
Content
+
+ `); + } + + it(`wraps the button in a level ${level} heading`, async () => { + const {harness} = await setUpHeadingTest(); + + expect(harness.getHeadingLevel()).toBe(level); + expect( + harness.getHeading()?.contains(harness.getButton()), + ).toBeTrue(); + }); + + it('gives the content role="region" labelled by the button', async () => { + const {harness} = await setUpHeadingTest(); + + expect(harness.getContentSlot().getAttribute('role')).toBe('region'); + expect(harness.getContentLabelElements()).toEqual([ + harness.getButton(), + ]); + }); + }); + } + + it('updates the heading when headingLevel changes', async () => { + const {accordion, harness} = await setUpTest(); + + accordion.headingLevel = 2; + await accordion.updateComplete; + expect(harness.getHeadingLevel()).toBe(2); + expect(harness.getHeading()?.contains(harness.getButton())).toBeTrue(); + expect(harness.getContentSlot().getAttribute('role')).toBe('region'); + + accordion.headingLevel = 4; + await accordion.updateComplete; + expect(harness.getHeadingLevel()).toBe(4); + expect(harness.getHeading()?.contains(harness.getButton())).toBeTrue(); + + accordion.headingLevel = undefined; + await accordion.updateComplete; + expect(harness.getHeading()).toBeNull(); + expect(harness.getContentSlot().hasAttribute('role')).toBeFalse(); + expect( + harness.getContentSlot().hasAttribute('aria-labelledby'), + ).toBeFalse(); + }); + + for (const level of [-1, 0, 2.5, Infinity]) { + it(`does not set region semantics on the content when set to unsupported level ${level}`, async () => { + const {harness} = await setUpTest(html` + + `); + const slot = harness.getContentSlot(); + + expect(harness.getHeading()).toBeNull(); + expect(slot.hasAttribute('role')).toBeFalse(); + expect(slot.hasAttribute('aria-labelledby')).toBeFalse(); + }); + } + + it('keeps the button focused when a heading is added', async () => { + const {accordion, harness} = await setUpTest(); + harness.getButton().focus(); + + accordion.headingLevel = 2; + await accordion.updateComplete; + + expect(accordion.shadowRoot!.activeElement).toBe(harness.getButton()); + }); + + it('keeps the button focused when a heading is removed', async () => { + const {accordion, harness} = await setUpTest(html` + + `); + harness.getButton().focus(); + + accordion.headingLevel = undefined; + await accordion.updateComplete; + + expect(accordion.shadowRoot!.activeElement).toBe(harness.getButton()); + }); + + it('wraps the button in a heading when set via the attribute', async () => { + const {harness} = await setUpTest(html` + + `); + + expect(harness.getHeadingLevel()).toBe(3); + expect(harness.getHeading()?.contains(harness.getButton())).toBeTrue(); + expect(harness.getContentSlot().getAttribute('role')).toBe('region'); + }); + }); +}); diff --git a/labs/aria/accordion/demo/demo.ts b/labs/aria/accordion/demo/demo.ts new file mode 100644 index 0000000000..e9193844e0 --- /dev/null +++ b/labs/aria/accordion/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 accordion', []); + +collection.addStories(...materialInitsToStoryInits(stories)); + +setUpDemo(collection); diff --git a/labs/aria/accordion/demo/stories.ts b/labs/aria/accordion/demo/stories.ts new file mode 100644 index 0000000000..50e7fa0f7b --- /dev/null +++ b/labs/aria/accordion/demo/stories.ts @@ -0,0 +1,143 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import '@material/web/labs/aria/accordion/md-aria-accordion.js'; + +import {MaterialStoryInit} from './material-collection.js'; +import {css, html} from 'lit'; + +export interface StoryKnobs {} + +const accordion: MaterialStoryInit = { + name: 'ARIA Accordion', + styles: css``, + render(knobs) { + return html` + +
An accordion
+
+ This is the expandable content. This content is only visible and + accessible when the accordion item is expanded. +
+
+
+ +
An open accordion
+
+ This is the expandable content. This content is only visible and + accessible when the accordion item is expanded. +
+
+ `; + }, +}; + +const accordionExclusive: MaterialStoryInit = { + name: 'Exclusivity', + styles: css``, + render(knobs) { + return html` + +
Group A, Item 1
+
+ This is the expandable content. This content is only visible and + accessible when the accordion item is expanded. +
+
+ +
Group A, Item 2
+
+ This is the expandable content. This content is only visible and + accessible when the accordion item is expanded. +
+
+ +
Group A, Item 3
+
+ This is the expandable content. This content is only visible and + accessible when the accordion item is expanded. +
+
+
+ +
Group B, Item 1
+
+ This is the expandable content. This content is only visible and + accessible when the accordion item is expanded. +
+
+ +
Group B, Item 2
+
+ This is the expandable content. This content is only visible and + accessible when the accordion item is expanded. +
+
+ +
Group B, Item 3
+
+ This is the expandable content. This content is only visible and + accessible when the accordion item is expanded. +
+
+ `; + }, +}; + +const accordionHeadings: MaterialStoryInit = { + name: 'Nesting & Headings', + styles: css` + #root { + min-width: 20em; + } + + md-aria-accordion::part(button):hover { + background: lightgray; + } + + [slot='content'] { + border-left: 2px solid lightgray; + padding-left: 1em; + } + `, + render(knobs) { + return html` + + +
Root
+
+ +
Item 1
+
+ +
Item 1-1
+
Item 1-1 content
+
+ +
Item 1-2
+
Item 1-2 content
+
+ +
Item 1-3
+
Item 1-3 content
+
+
+
+ +
Item 2
+
Item 2 content
+
+ +
Item 3
+
Item 3 content
+
+
+
+ `; + }, +}; + +export const stories = [accordion, accordionExclusive, accordionHeadings]; diff --git a/labs/aria/accordion/harness.ts b/labs/aria/accordion/harness.ts new file mode 100644 index 0000000000..f45da07f00 --- /dev/null +++ b/labs/aria/accordion/harness.ts @@ -0,0 +1,103 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {Harness} from '../../../testing/harness.js'; + +import {AriaAccordionElement} from './accordion.js'; + +const HEADING_SELECTOR = 'h1,h2,h3,h4,h5,h6,[role="heading"]'; + +/** + * Test harness for `md-aria-accordion` elements. + */ +export class AriaAccordionHarness extends Harness { + protected override async getInteractiveElement() { + await this.element.updateComplete; + return this.getButton(); + } + + /** + * Simulates the user activating the accordion's button with the Enter key. + * Like a native button, this dispatches a single `click` on keydown. + */ + async pressEnter() { + const button = await this.getInteractiveElement(); + await this.focusWithKeyboard(); + this.simulateKeydown(button, 'Enter'); + this.simulateClick(button); + this.simulateKeyup(button, 'Enter'); + } + + /** + * Simulates the user activating the accordion's button with the Space key. + * Like a native button, this dispatches a single `click` on keyup. + */ + async pressSpace() { + const button = await this.getInteractiveElement(); + await this.focusWithKeyboard(); + this.simulateKeydown(button, ' '); + this.simulateKeyup(button, ' '); + this.simulateClick(button); + } + + /** + * Returns the button that toggles the accordion. + */ + getButton(): HTMLButtonElement { + return this.element.renderRoot.querySelector('button')!; + } + + /** + * Returns the slot that contains the accordion's content. + */ + getContentSlot(): HTMLSlotElement { + return this.element.renderRoot.querySelector( + 'slot[name="content"]', + )!; + } + + /** + * Returns the heading that wraps the button, or `null` if the button is not + * wrapped in a heading. Both native heading elements (`

`-`

`) and + * elements with `role="heading"` are considered headings. + */ + getHeading(): HTMLElement | null { + return this.element.renderRoot.querySelector(HEADING_SELECTOR); + } + + /** + * Returns the level of the heading that wraps the button, or `null` if the + * button is not wrapped in a heading. + */ + getHeadingLevel(): number | null { + const heading = this.getHeading(); + if (!heading) { + return null; + } + + if (heading.getAttribute('role') === 'heading') { + // `aria-level` defaults to 2 for the `heading` role. + return Number(heading.getAttribute('aria-level') ?? 2); + } + + return Number(heading.localName.slice(1)); + } + + /** + * Returns the elements referenced by the content slot's `aria-labelledby` + * attribute, resolved within the accordion's shadow root. Unresolvable IDs + * are omitted. + */ + getContentLabelElements(): Element[] { + const ids = this.getContentSlot().getAttribute('aria-labelledby') ?? ''; + const root = this.element.renderRoot; + return ids + .split(/\s+/) + .filter((id) => id) + .map((id) => root.querySelector(`#${CSS.escape(id)}`)) + .filter((element): element is Element => element !== null); + } +} diff --git a/labs/aria/accordion/md-aria-accordion.ts b/labs/aria/accordion/md-aria-accordion.ts new file mode 100644 index 0000000000..f7aa543f10 --- /dev/null +++ b/labs/aria/accordion/md-aria-accordion.ts @@ -0,0 +1,15 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {AriaAccordionElement} from './accordion.js'; + +declare global { + interface HTMLElementTagNameMap { + 'md-aria-accordion': AriaAccordionElement; + } +} + +customElements.define('md-aria-accordion', AriaAccordionElement);