From 695ebd06022e56c3c62b61389e108191181ad4f5 Mon Sep 17 00:00:00 2001 From: Emily Lin Date: Tue, 6 Oct 2026 07:07:59 -0700 Subject: [PATCH] feat(labs): add segmented button PiperOrigin-RevId: 994371656 --- .../_segmented-button-tokens.scss | 59 + .../components/segmentedbutton/demo/demo.ts | 98 + .../segmentedbutton/demo/stories.ts | 279 +++ .../md-gb-segmented-button-set.ts | 18 + .../segmentedbutton/md-gb-segmented-button.ts | 16 + .../segmented-button-element.ts | 349 ++++ .../segmented-button-element_test.ts | 609 +++++++ .../segmented-button-set-element.ts | 321 ++++ .../segmented-button-set-element_test.ts | 1279 +++++++++++++ .../segmentedbutton/segmented-button.scss | 404 +++++ .../segmentedbutton/segmented-button.ts | 386 ++++ .../segmentedbutton/segmented-button_test.ts | 1612 +++++++++++++++++ 12 files changed, 5430 insertions(+) create mode 100644 labs/gb/components/segmentedbutton/_segmented-button-tokens.scss create mode 100644 labs/gb/components/segmentedbutton/demo/demo.ts create mode 100644 labs/gb/components/segmentedbutton/demo/stories.ts create mode 100644 labs/gb/components/segmentedbutton/md-gb-segmented-button-set.ts create mode 100644 labs/gb/components/segmentedbutton/md-gb-segmented-button.ts create mode 100644 labs/gb/components/segmentedbutton/segmented-button-element.ts create mode 100644 labs/gb/components/segmentedbutton/segmented-button-element_test.ts create mode 100644 labs/gb/components/segmentedbutton/segmented-button-set-element.ts create mode 100644 labs/gb/components/segmentedbutton/segmented-button-set-element_test.ts create mode 100644 labs/gb/components/segmentedbutton/segmented-button.scss create mode 100644 labs/gb/components/segmentedbutton/segmented-button.ts create mode 100644 labs/gb/components/segmentedbutton/segmented-button_test.ts diff --git a/labs/gb/components/segmentedbutton/_segmented-button-tokens.scss b/labs/gb/components/segmentedbutton/_segmented-button-tokens.scss new file mode 100644 index 0000000000..e94d48c497 --- /dev/null +++ b/labs/gb/components/segmentedbutton/_segmented-button-tokens.scss @@ -0,0 +1,59 @@ +// +// Copyright 2026 Google LLC +// SPDX-License-Identifier: Apache-2.0 +// + +// Hand-authored from tokens/versions/latest/sass/_md-comp-outlined-segmented-button.scss. + +@mixin set-root { + // LINT.IfChange + --container-height: 40px; + --container-shape: var(--md-sys-shape-corner-full); + --outline-color: var(--md-sys-color-outline); + --outline-width: 1px; + --touch-target-size: 48px; // EDIT: Material 48px minimum touch target (exceeds WCAG 2.5.8); no upstream design token exists + // LINT.ThenChange(segmented-button-set-element.ts) keep in sync with `@cssprop` jsdoc tags +} + +@mixin dense-1 { + --container-height: 36px; // EDIT: density -1 reduces default 40px height by 4px per M3 density scale +} + +@mixin dense-2 { + --container-height: 32px; // EDIT: density -2 reduces default 40px height by 8px per M3 density scale +} + +@mixin dense-3 { + --container-height: 28px; // EDIT: density -3 reduces default 40px height by 12px per M3 density scale +} + +@mixin root { + // LINT.IfChange + --container-color: transparent; // EDIT: unselected outlined segment has no background fill; transparent container matches M3 outlined button + --icon-color: var(--md-sys-color-on-surface); + --icon-label-space: 8px; // EDIT: 8px space between leading icon/checkmark and label text per M3 spec spacing + --icon-size: 18px; + --label-text: var(--md-sys-typescale-label-lg); + --label-text-axes: var(--md-sys-typescale-label-lg-axes); + --label-text-color: var(--md-sys-color-on-surface); + --label-text-tracking: var(--md-sys-typescale-label-lg-tracking); + --leading-space: 12px; // EDIT: 12px leading padding for selected and icon+label segments per M3 spec + --state-layer-color: var(--md-sys-color-on-surface); + --trailing-space: 12px; // EDIT: 12px trailing padding for selected and icon+label segments per M3 spec + // LINT.ThenChange(segmented-button-element.ts) keep in sync with `@cssprop` jsdoc tags +} + +@mixin selected { + --container-color: var(--md-sys-color-secondary-container); + --icon-color: var(--md-sys-color-on-secondary-container); + --label-text-color: var(--md-sys-color-on-secondary-container); + --state-layer-color: var(--md-sys-color-on-secondary-container); +} + +@mixin disabled { + --container-color: transparent; + --icon-color: hsl(from var(--md-sys-color-on-surface) h s l / 38%); + --label-text-color: hsl(from var(--md-sys-color-on-surface) h s l / 38%); + --outline-color: hsl(from var(--md-sys-color-on-surface) h s l / 12%); + --state-layer-color: transparent; +} diff --git a/labs/gb/components/segmentedbutton/demo/demo.ts b/labs/gb/components/segmentedbutton/demo/demo.ts new file mode 100644 index 0000000000..168459a1ac --- /dev/null +++ b/labs/gb/components/segmentedbutton/demo/demo.ts @@ -0,0 +1,98 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import './material-collection.js'; +import './index.js'; + +import { + type KnobTypesToKnobs, + MaterialCollection, + materialInitsToStoryInits, + setUpDemo, +} from './material-collection.js'; +import {type SegmentedButtonSelection} from '@material/web/labs/gb/components/segmentedbutton/segmented-button.js'; +import { + boolInput, + Knob, + selectDropdown, + textInput, +} from './index.js'; + +import {stories, type StoryKnobs} from './stories.js'; + +const collection = new MaterialCollection>( + 'Segmented Button', + [ + new Knob('selection', { + defaultValue: 'single', + ui: selectDropdown({ + options: [ + {value: 'single', label: 'Single'}, + {value: 'multiple', label: 'Multiple'}, + ], + }), + }), + new Knob<'2' | '3' | '4' | '5' | undefined, 'segments'>('segments', { + defaultValue: '3', + ui: selectDropdown<'2' | '3' | '4' | '5'>({ + options: [ + {value: '2', label: '2'}, + {value: '3', label: '3'}, + {value: '4', label: '4'}, + {value: '5', label: '5'}, + ], + }), + }), + new Knob<'label' | 'icon' | 'both' | undefined, 'content'>('content', { + defaultValue: 'label', + ui: selectDropdown<'label' | 'icon' | 'both'>({ + options: [ + {value: 'label', label: 'Label'}, + {value: 'icon', label: 'Icon'}, + {value: 'both', label: 'Both'}, + ], + }), + }), + new Knob<'0' | '-1' | '-2' | '-3' | undefined, 'density'>('density', { + defaultValue: '0', + ui: selectDropdown<'0' | '-1' | '-2' | '-3'>({ + options: [ + {value: '0', label: '0 (Default)'}, + {value: '-1', label: '-1'}, + {value: '-2', label: '-2'}, + {value: '-3', label: '-3'}, + ], + }), + }), + new Knob('disabled', { + ui: boolInput(), + }), + new Knob< + 'none' | '0' | '1' | '2' | '3' | '4' | undefined, + 'disabledSegment' + >('disabledSegment', { + defaultValue: 'none', + ui: selectDropdown<'none' | '0' | '1' | '2' | '3' | '4'>({ + options: [ + {value: 'none', label: 'None'}, + {value: '0', label: 'First'}, + {value: '1', label: 'Second'}, + {value: '2', label: 'Third'}, + {value: '3', label: 'Fourth'}, + {value: '4', label: 'Fifth'}, + ], + }), + }), + new Knob('ariaLabel', { + ui: textInput(), + defaultValue: 'Select option', + }), + ], +); + +collection.addStories(...materialInitsToStoryInits(stories)); + +setUpDemo(collection, {fonts: 'roboto', icons: 'material-symbols'}); diff --git a/labs/gb/components/segmentedbutton/demo/stories.ts b/labs/gb/components/segmentedbutton/demo/stories.ts new file mode 100644 index 0000000000..ac21a9db03 --- /dev/null +++ b/labs/gb/components/segmentedbutton/demo/stories.ts @@ -0,0 +1,279 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import '@material/web/labs/gb/components/segmentedbutton/md-gb-segmented-button.js'; +import '@material/web/labs/gb/components/segmentedbutton/md-gb-segmented-button-set.js'; +import '@material/web/labs/gb/styles/icon/md-gb-icon.js'; + +import {type MaterialStoryInit} from './material-collection.js'; +import { + type SegmentedButtonDensity, + type SegmentedButtonSelection, +} from '@material/web/labs/gb/components/segmentedbutton/segmented-button.js'; +import {adoptStyles} from '@material/web/labs/gb/styles/adopt-styles.js'; +import {styles as m3Styles} from '@material/web/labs/gb/styles/m3.cssresult.js'; +import {css, html, nothing} from 'lit'; + +/** Knob types for segmented button stories. */ +export interface StoryKnobs { + selection?: SegmentedButtonSelection; + segments?: '2' | '3' | '4' | '5'; + content?: 'label' | 'icon' | 'both'; + density?: '0' | '-1' | '-2' | '-3'; + disabled?: boolean; + disabledSegment?: 'none' | '0' | '1' | '2' | '3' | '4'; + ariaLabel?: string; +} + +adoptStyles(document, [ + m3Styles, + css` + :root { + --md-icon-font: 'Material Symbols Outlined'; + } + `, +]); + +const styles = [ + css` + .story-column { + display: flex; + flex-direction: column; + gap: 24px; + align-items: flex-start; + text-align: left; + direction: ltr; + } + [dir='rtl'] .story-column md-gb-segmented-button-set { + direction: rtl; + } + .story-row { + display: flex; + flex-direction: row; + gap: 16px; + align-items: center; + flex-wrap: wrap; + } + `, +]; + +interface SegmentConfig { + value: string; + label: string; + icon: string; +} + +const DEFAULT_SEGMENTS: SegmentConfig[] = [ + {value: 'day', label: 'Day', icon: 'calendar_today'}, + {value: 'week', label: 'Week', icon: 'calendar_view_week'}, + {value: 'month', label: 'Month', icon: 'calendar_month'}, + {value: 'quarter', label: 'Quarter', icon: 'view_agenda'}, + {value: 'year', label: 'Year', icon: 'event_note'}, +]; + +const playground: MaterialStoryInit = { + name: 'Playground', + styles, + render(knobs) { + const count = Number(knobs.segments ?? '3'); + const content = knobs.content ?? 'label'; + const items = DEFAULT_SEGMENTS.slice(0, count); + const density = Number(knobs.density ?? '0') as SegmentedButtonDensity; + const disabledSegIndex = + knobs.disabledSegment === 'none' || !knobs.disabledSegment + ? -1 + : Number(knobs.disabledSegment); + + return html` +
+ + ${items.map((item, index) => { + const isDisabled = disabledSegIndex === index; + const isIconOnly = content === 'icon'; + const hasIcon = content === 'icon' || content === 'both'; + const hasLabel = content === 'label' || content === 'both'; + + return html` + + ${hasIcon + ? html`${item.icon}` + : ''} + ${hasLabel ? item.label : ''} + + `; + })} + +
+ `; + }, +}; + +const singleSelect: MaterialStoryInit = { + name: 'Single-select', + styles, + render() { + return html` +
+

Calendar View

+ + Day + Week + Month + Year + +
+ `; + }, +}; + +const multiSelect: MaterialStoryInit = { + name: 'Multi-select', + styles, + render() { + return html` +
+

Text Formatting

+ + + format_bold + + + format_italic + + + format_underlined + + +
+ `; + }, +}; + +const iconsAndCheckmarks: MaterialStoryInit = { + name: 'Icons and checkmarks', + styles, + render() { + return html` +
+

Label-only (checkmark prepends when selected)

+ + Walking + Transit + Driving + + +

Icon-only (checkmark appears at inline-start)

+ + + favorite + + + star + + + bookmark + + + +

Label and icon (checkmark replaces icon)

+ + + view_list + List + + + grid_view + Grid + + +
+ `; + }, +}; + +const densities: MaterialStoryInit = { + name: 'Densities', + styles, + render() { + return html` +
+

Density 0 (Default - 40px)

+ + Day + Week + Month + + +

Density -1 (36px)

+ + Day + Week + Month + + +

Density -2 (32px)

+ + Day + Week + Month + + +

Density -3 (28px)

+ + Day + Week + Month + +
+ `; + }, +}; + +/** Segmented Button stories. */ +export const stories = [ + playground, + singleSelect, + multiSelect, + iconsAndCheckmarks, + densities, +]; diff --git a/labs/gb/components/segmentedbutton/md-gb-segmented-button-set.ts b/labs/gb/components/segmentedbutton/md-gb-segmented-button-set.ts new file mode 100644 index 0000000000..3e22311a6e --- /dev/null +++ b/labs/gb/components/segmentedbutton/md-gb-segmented-button-set.ts @@ -0,0 +1,18 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import './md-gb-segmented-button.js'; + +import {SegmentedButtonSetElement} from './segmented-button-set-element.js'; + +declare global { + interface HTMLElementTagNameMap { + /** A Material Design segmented button set component. */ + 'md-gb-segmented-button-set': SegmentedButtonSetElement; + } +} + +customElements.define('md-gb-segmented-button-set', SegmentedButtonSetElement); diff --git a/labs/gb/components/segmentedbutton/md-gb-segmented-button.ts b/labs/gb/components/segmentedbutton/md-gb-segmented-button.ts new file mode 100644 index 0000000000..f8da775308 --- /dev/null +++ b/labs/gb/components/segmentedbutton/md-gb-segmented-button.ts @@ -0,0 +1,16 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {SegmentedButtonElement} from './segmented-button-element.js'; + +declare global { + interface HTMLElementTagNameMap { + /** A Material Design segmented button component. */ + 'md-gb-segmented-button': SegmentedButtonElement; + } +} + +customElements.define('md-gb-segmented-button', SegmentedButtonElement); diff --git a/labs/gb/components/segmentedbutton/segmented-button-element.ts b/labs/gb/components/segmentedbutton/segmented-button-element.ts new file mode 100644 index 0000000000..a4f6db15d4 --- /dev/null +++ b/labs/gb/components/segmentedbutton/segmented-button-element.ts @@ -0,0 +1,349 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + css, + type CSSResultOrNative, + html, + LitElement, + nothing, + type PropertyValues, +} from 'lit'; +import {property, query, state} from 'lit/decorators.js'; + +import type {ARIAMixinStrict} from '../../../../internal/aria/aria.js'; +import {mixinDelegatesAria} from '../../../../internal/aria/delegate.js'; +import {redispatchEvent} from '../../../../internal/events/redispatch-event.js'; +import {mixinElementInternals} from '../../../behaviors/element-internals.js'; +import focusRingStyles from '../focus/focus-ring.css' with {type: 'css'}; // github-only +// import focusRingStyles from '../focus/focus-ring.cssresult.js'; // google3-only +import rippleStyles from '../ripple/ripple.css' with {type: 'css'}; // github-only +// import rippleStyles from '../ripple/ripple.cssresult.js'; // google3-only + +import { + renderSegmentedButtonCheckmark, + SEGMENTED_BUTTON_CLASSES, + segmentedButton, + type SegmentedButtonSelection, +} from './segmented-button.js'; +import segmentedButtonStyles from './segmented-button.css' with {type: 'css'}; // github-only +// import segmentedButtonStyles from './segmented-button.cssresult.js'; // google3-only + +/** Symbol for the segment's selection mode ('single' | 'multiple'). */ +export const segmentSelectionMode = Symbol('segmentSelectionMode'); + +/** Symbol for whether the parent segment group is disabled. */ +export const segmentGroupDisabled = Symbol('segmentGroupDisabled'); + +/** Symbol for the segment's owner set. */ +export const segmentOwner = Symbol('segmentOwner'); + +/** Symbol for the segment's 1-based position in set. */ +export const segmentPosInSet = Symbol('segmentPosInSet'); + +/** Symbol for the segment's set size. */ +export const segmentSetSize = Symbol('segmentSetSize'); + +/** Symbol for notifying the owner set when a segment is selected. */ +export const segmentSelected = Symbol('segmentSelected'); + +/** Interface for a segment's owner (e.g. SegmentedButtonSetElement). */ +export interface SegmentOwner { + [segmentSelected](segment: SegmentedButtonElement): void; +} + +const baseClass = mixinDelegatesAria(mixinElementInternals(LitElement)); + +// LINT.IfChange +/** + * A Material Design segmented button component. + * + * Segmented buttons are set-only components and must be used within an + * `md-gb-segmented-button-set` container. Icon-only segments must set + * `aria-label`. + * + * @slot - Default slot for label text. + * @slot icon - Slot for optional leading icon. + * @csspart segmented-btn - The native button element in the shadow DOM. + * @fires {InputEvent} input - Fired when a user toggles the segment. --bubbles --composed + * @fires {Event} change - Fired when a user toggles the segment. --bubbles + * @cssprop --container-color + * @cssprop --icon-color + * @cssprop --icon-label-space + * @cssprop --icon-size + * @cssprop --label-text + * @cssprop --label-text-axes + * @cssprop --label-text-color + * @cssprop --label-text-tracking + * @cssprop --leading-space + * @cssprop --state-layer-color + * @cssprop --trailing-space + */ +// LINT.ThenChange(_segmented-button-tokens.scss) +export class SegmentedButtonElement extends baseClass { + /** @nocollapse */ + static override shadowRootOptions: ShadowRootInit = { + mode: 'open', + delegatesFocus: true, + }; + + /** @nocollapse */ + static override styles: CSSResultOrNative[] = [ + focusRingStyles, + rippleStyles, + segmentedButtonStyles, + css` + :host { + display: inline-flex; + } + :host([hidden]) { + display: none !important; + } + .segmented-btn { + flex: 1; + } + `, + ]; + + /** Whether the segmented button is selected. */ + @property({type: Boolean, reflect: true}) + get selected(): boolean { + return this.privateSelected; + } + set selected(selected: boolean) { + const next = selected && !this.disabled && !this.privateGroupDisabled; + if (!next && this.hasAttribute('selected')) { + this.removeAttribute('selected'); + } + const prev = this.privateSelected; + if (prev === next) return; + this.privateSelected = next; + this.requestUpdate('selected', prev); + this.buttonElement?.setAttribute('aria-checked', String(next)); + this.buttonElement?.classList.toggle( + SEGMENTED_BUTTON_CLASSES.segmentedBtnSelected, + next, + ); + if (next) { + this[segmentOwner]?.[segmentSelected](this); + } + } + private privateSelected = false; + + /** Whether the segmented button is disabled. */ + @property({type: Boolean, reflect: true}) + get disabled(): boolean { + return this.privateDisabled; + } + set disabled(disabled: boolean) { + const prev = this.disabled; + if (prev === disabled) return; + this.privateDisabled = disabled; + this.requestUpdate('disabled', prev); + if (disabled && this.selected) { + this.selected = false; + } + } + private privateDisabled = false; + + /** The value associated with this segmented button. */ + @property() value = ''; + + get [segmentSelectionMode](): SegmentedButtonSelection { + return this.privateSelectionMode; + } + set [segmentSelectionMode](mode: SegmentedButtonSelection) { + if (this.privateSelectionMode !== mode) { + this.privateSelectionMode = mode; + this.requestUpdate(); + } + } + private privateSelectionMode: SegmentedButtonSelection = 'single'; + + get [segmentGroupDisabled](): boolean { + return this.privateGroupDisabled; + } + set [segmentGroupDisabled](disabled: boolean) { + if (this.privateGroupDisabled !== disabled) { + this.privateGroupDisabled = disabled; + if (disabled && this.selected) { + this.selected = false; + } + this.requestUpdate(); + } + } + private privateGroupDisabled = false; + + [segmentOwner]: SegmentOwner | null = null; + + get [segmentPosInSet](): number { + return this.privatePosInSet; + } + set [segmentPosInSet](pos: number) { + if (this.privatePosInSet !== pos) { + this.privatePosInSet = pos; + this.requestUpdate(); + } + } + private privatePosInSet = 0; + + get [segmentSetSize](): number { + return this.privateSetSize; + } + set [segmentSetSize](size: number) { + if (this.privateSetSize !== size) { + this.privateSetSize = size; + this.requestUpdate(); + } + } + private privateSetSize = 0; + + @state() private hasIcon = false; + @state() private hasLabel = false; + + @query('button') private readonly buttonElement!: HTMLButtonElement | null; + + // Editing text inside an existing text node does not fire `slotchange`. + private readonly labelObserver = new MutationObserver(() => { + const slot = + this.renderRoot?.querySelector('slot:not([name])'); + if (slot) this.hasLabel = SegmentedButtonElement.hasContent(slot); + }); + + override connectedCallback() { + super.connectedCallback(); + if (!this.hasUpdated) { + this.initSlottedContentFromChildren(); + } + this.labelObserver.observe(this, {characterData: true, subtree: true}); + } + + private initSlottedContentFromChildren() { + let hasIcon = false; + let hasLabel = false; + for (const child of Array.from(this.childNodes)) { + if (child.nodeType === Node.ELEMENT_NODE) { + if ((child as Element).getAttribute('slot') === 'icon') { + hasIcon = true; + } else { + hasLabel = true; + } + } else if (child.nodeType === Node.TEXT_NODE) { + if ((child.textContent ?? '').trim() !== '') { + hasLabel = true; + } + } + } + this.hasIcon = hasIcon; + this.hasLabel = hasLabel; + } + + override disconnectedCallback() { + super.disconnectedCallback(); + this.labelObserver.disconnect(); + } + + override click() { + this.buttonElement?.click(); + } + + // Unlike shared/has-slotted, ignore whitespace-only text so formatted markup + // is not treated as a label; the result drives classes on the inner `; + } +} diff --git a/labs/gb/components/segmentedbutton/segmented-button-element_test.ts b/labs/gb/components/segmentedbutton/segmented-button-element_test.ts new file mode 100644 index 0000000000..080e4ee510 --- /dev/null +++ b/labs/gb/components/segmentedbutton/segmented-button-element_test.ts @@ -0,0 +1,609 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// import 'jasmine'; (google3-only) +import './md-gb-segmented-button.js'; + +import {html} from 'lit'; + +import {Environment} from '../../../../testing/environment.js'; +import {Harness} from '../../../../testing/harness.js'; +import {internals} from '../../../behaviors/element-internals.js'; +import {adoptStyles} from '../../styles/adopt-styles.js'; +import {styles as m3Styles} from '../../styles/m3.cssresult.js'; + +import {SEGMENTED_BUTTON_CLASSES} from './segmented-button.js'; +import { + SegmentedButtonElement, + segmentGroupDisabled, + segmentSelectionMode, +} from './segmented-button-element.js'; + +class SegmentHarness extends Harness { + override async getInteractiveElement(): Promise { + await this.element.updateComplete; + return this.element.shadowRoot!.querySelector( + '[part="segmented-btn"]', + ) as HTMLElement; + } +} + +describe('SegmentedButtonElement', () => { + const env = new Environment(); + + beforeAll(() => { + const noTransitions = new CSSStyleSheet(); + noTransitions.replaceSync(` + md-gb-segmented-button, + md-gb-segmented-button::part(segmented-btn) { + transition: none !important; + } + `); + adoptStyles(document, [m3Styles, noTransitions]); + }); + + it('is registered', () => { + const el = document.createElement('md-gb-segmented-button'); + expect(el).toBeInstanceOf(SegmentedButtonElement); + expect(customElements.get('md-gb-segmented-button')).toBeDefined(); + }); + + it('has expected defaults', async () => { + const root = env.render( + html``, + ); + await env.waitForStability(); + const segment = root.querySelector( + 'md-gb-segmented-button', + ) as SegmentedButtonElement; + + expect(segment.selected).toBeFalse(); + expect(segment.disabled).toBeFalse(); + expect(segment.value).toBe(''); + }); + + it('renders button[type=button][part=segmented-btn]', async () => { + const root = env.render( + html`Day`, + ); + await env.waitForStability(); + const segment = root.querySelector( + 'md-gb-segmented-button', + ) as SegmentedButtonElement; + const button = segment.shadowRoot!.querySelector('button'); + + expect(button).not.toBeNull(); + expect(button!.getAttribute('type')).toBe('button'); + expect(button!.getAttribute('part')).toBe('segmented-btn'); + }); + + it('reflects selected and disabled to attributes', async () => { + const root = env.render( + html``, + ); + await env.waitForStability(); + const segment = root.querySelector( + 'md-gb-segmented-button', + ) as SegmentedButtonElement; + + segment.selected = true; + await segment.updateComplete; + expect(segment.hasAttribute('selected')).toBeTrue(); + + segment.selected = false; + await segment.updateComplete; + expect(segment.hasAttribute('selected')).toBeFalse(); + + segment.disabled = true; + await segment.updateComplete; + expect(segment.hasAttribute('disabled')).toBeTrue(); + + segment.disabled = false; + await segment.updateComplete; + expect(segment.hasAttribute('disabled')).toBeFalse(); + }); + + it('enforces that a segment cannot be simultaneously selected and disabled', async () => { + const root = env.render(html` + Option 1 + + Option 2 + + `); + await env.waitForStability(); + const s1 = root.querySelector('#s1') as SegmentedButtonElement; + const s2 = root.querySelector('#s2') as SegmentedButtonElement; + const b1 = s1.shadowRoot!.querySelector('button')!; + const b2 = s2.shadowRoot!.querySelector('button')!; + + // 4) Initial markup with selected and disabled has selected removed + expect(s2.selected).toBeFalse(); + expect(s2.hasAttribute('selected')).toBeFalse(); + expect(s2.disabled).toBeTrue(); + expect(b2.getAttribute('aria-checked')).toBe('false'); + + // 1) Setting segment.disabled = true on a selected segment immediately deselects it + s1.selected = true; + await s1.updateComplete; + expect(s1.selected).toBeTrue(); + expect(s1.hasAttribute('selected')).toBeTrue(); + expect(b1.getAttribute('aria-checked')).toBe('true'); + expect( + b1.classList.contains(SEGMENTED_BUTTON_CLASSES.segmentedBtnSelected), + ).toBeTrue(); + + s1.disabled = true; + await s1.updateComplete; + expect(s1.selected).toBeFalse(); + expect(s1.hasAttribute('selected')).toBeFalse(); + expect(b1.getAttribute('aria-checked')).toBe('false'); + expect( + b1.classList.contains(SEGMENTED_BUTTON_CLASSES.segmentedBtnSelected), + ).toBeFalse(); + + // 3) Setting segment.selected = true while segment.disabled is true is ignored + s1.selected = true; + await s1.updateComplete; + expect(s1.selected).toBeFalse(); + expect(s1.hasAttribute('selected')).toBeFalse(); + + // 2) Setting segment[segmentGroupDisabled] = true on a selected segment immediately deselects it + s1.disabled = false; + s1.selected = true; + await s1.updateComplete; + expect(s1.selected).toBeTrue(); + + s1[segmentGroupDisabled] = true; + await s1.updateComplete; + expect(s1.selected).toBeFalse(); + expect(s1.hasAttribute('selected')).toBeFalse(); + expect(b1.getAttribute('aria-checked')).toBe('false'); + expect( + b1.classList.contains(SEGMENTED_BUTTON_CLASSES.segmentedBtnSelected), + ).toBeFalse(); + + // Setting segment.selected = true while segmentGroupDisabled is true is ignored + s1.selected = true; + await s1.updateComplete; + expect(s1.selected).toBeFalse(); + }); + + it('aria-checked mirrors selected', async () => { + const root = env.render( + html`Day`, + ); + await env.waitForStability(); + const segment = root.querySelector( + 'md-gb-segmented-button', + ) as SegmentedButtonElement; + const button = segment.shadowRoot!.querySelector('button')!; + + expect(button.getAttribute('aria-checked')).toBe('false'); + + segment.selected = true; + await segment.updateComplete; + expect(button.getAttribute('aria-checked')).toBe('true'); + }); + + it('updates aria-checked and selected class synchronously on setter', async () => { + const root = env.render( + html`Day`, + ); + await env.waitForStability(); + const segment = root.querySelector( + 'md-gb-segmented-button', + ) as SegmentedButtonElement; + const button = segment.shadowRoot!.querySelector('button')!; + + expect(button.getAttribute('aria-checked')).toBe('false'); + expect( + button.classList.contains(SEGMENTED_BUTTON_CLASSES.segmentedBtnSelected), + ).toBeFalse(); + + segment.selected = true; + expect(button.getAttribute('aria-checked')).toBe('true'); + expect( + button.classList.contains(SEGMENTED_BUTTON_CLASSES.segmentedBtnSelected), + ).toBeTrue(); + }); + + it('role mirrors selectionMode', async () => { + const root = env.render( + html`Day`, + ); + await env.waitForStability(); + const segment = root.querySelector( + 'md-gb-segmented-button', + ) as SegmentedButtonElement; + const button = segment.shadowRoot!.querySelector('button')!; + + expect(button.getAttribute('role')).toBe('radio'); + + segment[segmentSelectionMode] = 'multiple'; + await segment.updateComplete; + expect(button.getAttribute('role')).toBe('checkbox'); + }); + + it('disabled sets native disabled on inner button so it cannot be focused', async () => { + const root = env.render( + html`Day`, + ); + await env.waitForStability(); + const segment = root.querySelector( + 'md-gb-segmented-button', + ) as SegmentedButtonElement; + const button = segment.shadowRoot!.querySelector('button')!; + + expect(button.disabled).toBeTrue(); + }); + + it('group disabled sets native disabled on inner button', async () => { + const root = env.render( + html`Day`, + ); + await env.waitForStability(); + const segment = root.querySelector( + 'md-gb-segmented-button', + ) as SegmentedButtonElement; + const button = segment.shadowRoot!.querySelector('button')!; + + segment[segmentGroupDisabled] = true; + await segment.updateComplete; + expect(button.disabled).toBeTrue(); + }); + + it('host.focus() moves focus to the inner button via delegatesFocus', async () => { + const root = env.render( + html`Day`, + ); + await env.waitForStability(); + const segment = root.querySelector( + 'md-gb-segmented-button', + ) as SegmentedButtonElement; + const button = segment.shadowRoot!.querySelector('button')!; + + segment.focus(); + expect(segment.shadowRoot!.activeElement).toBe(button); + }); + + it('host aria-label is delegated and host exposes no role', async () => { + const root = env.render( + html``, + ); + await env.waitForStability(); + const segment = root.querySelector( + 'md-gb-segmented-button', + ) as SegmentedButtonElement; + const button = segment.shadowRoot!.querySelector('button')!; + + expect(button.getAttribute('aria-label')).toBe('Favorite'); + expect(segment[internals].role).toBeNull(); + expect(segment.hasAttribute('aria-label')).toBeFalse(); + }); + + it('multiple mode omits aria-posinset on inner button even when set on host', async () => { + const root = env.render( + html`Day`, + ); + await env.waitForStability(); + const segment = root.querySelector( + 'md-gb-segmented-button', + ) as SegmentedButtonElement; + segment[segmentSelectionMode] = 'multiple'; + await segment.updateComplete; + const button = segment.shadowRoot!.querySelector('button')!; + expect(button.hasAttribute('aria-posinset')).toBeFalse(); + }); + + it('the graphic is aria-hidden', async () => { + const root = env.render( + html`Day`, + ); + await env.waitForStability(); + const segment = root.querySelector( + 'md-gb-segmented-button', + ) as SegmentedButtonElement; + const graphic = segment.shadowRoot!.querySelector( + '.segmented-btn-graphic', + )!; + + expect(graphic.getAttribute('aria-hidden')).toBe('true'); + }); + + it('detects label-only, label+icon, and icon-only from slots', async () => { + const root = env.render(html` + Day + + * + Day + + + * + + `); + await env.waitForStability(); + const lOnly = root.querySelector('#label-only') as SegmentedButtonElement; + const lIcon = root.querySelector('#label-icon') as SegmentedButtonElement; + const iOnly = root.querySelector('#icon-only') as SegmentedButtonElement; + + const bLabelOnly = lOnly.shadowRoot!.querySelector('button')!; + const bLabelIcon = lIcon.shadowRoot!.querySelector('button')!; + const bIconOnly = iOnly.shadowRoot!.querySelector('button')!; + + expect( + bLabelOnly.classList.contains('segmented-btn-with-icon'), + ).toBeFalse(); + expect( + bLabelOnly.classList.contains('segmented-btn-icon-only'), + ).toBeFalse(); + + expect(bLabelIcon.classList.contains('segmented-btn-with-icon')).toBeTrue(); + expect( + bLabelIcon.classList.contains('segmented-btn-icon-only'), + ).toBeFalse(); + + expect(bIconOnly.classList.contains('segmented-btn-with-icon')).toBeTrue(); + expect(bIconOnly.classList.contains('segmented-btn-icon-only')).toBeTrue(); + }); + + it('whitespace-only text does not count as a label', async () => { + const root = env.render(html` + + * + + `); + await env.waitForStability(); + const segment = root.querySelector('#ws-only') as SegmentedButtonElement; + const button = segment.shadowRoot!.querySelector('button')!; + + expect(button.classList.contains('segmented-btn-icon-only')).toBeTrue(); + }); + + it('dynamically appending and removing an icon updates with-icon and icon-only classes', async () => { + const root = env.render(html` + Label + `); + await env.waitForStability(); + const segment = root.querySelector('#dyn-icon') as SegmentedButtonElement; + const button = segment.shadowRoot!.querySelector('button')!; + + expect(button.classList.contains('segmented-btn-with-icon')).toBeFalse(); + expect(button.classList.contains('segmented-btn-icon-only')).toBeFalse(); + + const iconSpan = document.createElement('span'); + iconSpan.setAttribute('slot', 'icon'); + iconSpan.textContent = '*'; + segment.appendChild(iconSpan); + await env.waitForStability(); + + expect(button.classList.contains('segmented-btn-with-icon')).toBeTrue(); + expect(button.classList.contains('segmented-btn-icon-only')).toBeFalse(); + + iconSpan.remove(); + await env.waitForStability(); + + expect(button.classList.contains('segmented-btn-with-icon')).toBeFalse(); + expect(button.classList.contains('segmented-btn-icon-only')).toBeFalse(); + }); + + it('editing an existing label text node shows the label and updates accessible name', async () => { + const textNode = document.createTextNode(''); + const root = env.render(html` + + `); + await env.waitForStability(); + const segment = root.querySelector( + '#dynamic-btn', + ) as SegmentedButtonElement; + segment.appendChild(textNode); + await env.waitForStability(); + + const labelSpan = segment.shadowRoot!.querySelector( + '.segmented-btn-label', + )!; + const button = segment.shadowRoot!.querySelector('button')!; + + textNode.data = 'Day'; + await env.waitForStability(); + + expect(labelSpan.hasAttribute('hidden')).toBeFalse(); + expect(button.classList.contains('segmented-btn-icon-only')).toBeFalse(); + const slot = + segment.shadowRoot!.querySelector('slot:not([name])')!; + expect(slot.assignedNodes()).toContain(textNode); + expect(segment.textContent?.trim()).toBe('Day'); + }); + + it('a click selects it and fires input and change from the host', async () => { + const root = env.render( + html`Day`, + ); + await env.waitForStability(); + const segment = root.querySelector( + 'md-gb-segmented-button', + ) as SegmentedButtonElement; + + let inputFired = false; + let changeFired = false; + let selectedOnInput = false; + + segment.addEventListener('input', () => { + inputFired = true; + selectedOnInput = segment.selected; + }); + segment.addEventListener('change', () => { + changeFired = true; + }); + + const harness = new SegmentHarness(segment); + await harness.clickWithMouse(); + + expect(segment.selected).toBeTrue(); + expect(inputFired).toBeTrue(); + expect(changeFired).toBeTrue(); + expect(selectedOnInput).toBeTrue(); + }); + + it('clicking inside a
does not submit it', async () => { + let submitted = false; + const root = env.render(html` + { + e.preventDefault(); + submitted = true; + }}> + Day +
+ `); + await env.waitForStability(); + const segment = root.querySelector( + 'md-gb-segmented-button', + ) as SegmentedButtonElement; + + const harness = new SegmentHarness(segment); + await harness.clickWithMouse(); + + expect(submitted).toBeFalse(); + }); + + it('shows the focus ring when focused with the keyboard', async () => { + const root = env.render( + html`Day`, + ); + await env.waitForStability(); + const segment = root.querySelector( + 'md-gb-segmented-button', + ) as SegmentedButtonElement; + const button = segment.shadowRoot!.querySelector('button')!; + + await new SegmentHarness(segment).focusWithKeyboard(); + + expect(getComputedStyle(button).outlineStyle).toBe('solid'); + }); + + it('hides the element when hidden attribute is set', async () => { + const root = env.render( + html``, + ); + await env.waitForStability(); + const segment = root.querySelector( + 'md-gb-segmented-button', + ) as SegmentedButtonElement; + + expect(getComputedStyle(segment).display).toBe('none'); + }); + + it('selected segment checkmark and graphic are 18x18 with 8px gap to label', async () => { + const root = env.render(html` +
+ + Day + + + *Day + +
+ `); + await env.waitForStability(); + + for (const id of ['label-only', 'label-icon']) { + const segment = root.querySelector(`#${id}`) as SegmentedButtonElement; + await segment.updateComplete; + const shadow = segment.shadowRoot!; + const checkmark = shadow.querySelector( + '.segmented-btn-checkmark', + ) as HTMLElement; + const graphic = shadow.querySelector( + '.segmented-btn-graphic', + ) as HTMLElement; + const label = shadow.querySelector('.segmented-btn-label') as HTMLElement; + + const checkmarkRect = checkmark.getBoundingClientRect(); + expect(Math.round(checkmarkRect.width)).toBe(18); + expect(Math.round(checkmarkRect.height)).toBe(18); + + const graphicRect = graphic.getBoundingClientRect(); + expect(Math.round(graphicRect.width)).toBe(18); + expect(Math.round(graphicRect.height)).toBe(18); + + const labelRect = label.getBoundingClientRect(); + expect(Math.round(labelRect.left - checkmarkRect.right)).toBe(8); + } + }); + + it('hides the label element in icon-only mode', async () => { + const root = env.render(html` + + * + + `); + await env.waitForStability(); + const segment = root.querySelector( + 'md-gb-segmented-button', + ) as SegmentedButtonElement; + await segment.updateComplete; + const label = segment.shadowRoot!.querySelector( + '.segmented-btn-label', + ) as HTMLElement; + expect(label.hidden).toBeTrue(); + expect(getComputedStyle(label).display).toBe('none'); + }); + + it('deselects on connection when rendered with disabled and selected', async () => { + const root = env.render(html` + Disabled + `); + await env.waitForStability(); + const segment = root.querySelector( + 'md-gb-segmented-button', + ) as SegmentedButtonElement; + await segment.updateComplete; + expect(segment.selected).toBeFalse(); + expect(segment.hasAttribute('selected')).toBeFalse(); + }); + + it('detects label from non-icon slotted element child during initial slotted content setup', async () => { + const root = env.render(html` + + Element Label + + `); + await env.waitForStability(); + const segment = root.querySelector( + 'md-gb-segmented-button', + ) as SegmentedButtonElement; + await segment.updateComplete; + const label = segment.shadowRoot!.querySelector( + '.segmented-btn-label', + ) as HTMLElement; + expect(label.hidden).toBeFalse(); + expect( + segment.shadowRoot!.querySelector('.segmented-btn-icon-only'), + ).toBeNull(); + }); + + it('click() delegates to internal button element', async () => { + const root = env.render(html` + Option + `); + await env.waitForStability(); + const segment = root.querySelector( + 'md-gb-segmented-button', + ) as SegmentedButtonElement; + await segment.updateComplete; + let clicked = false; + segment.addEventListener('click', () => { + clicked = true; + }); + segment.click(); + expect(clicked).toBeTrue(); + }); +}); diff --git a/labs/gb/components/segmentedbutton/segmented-button-set-element.ts b/labs/gb/components/segmentedbutton/segmented-button-set-element.ts new file mode 100644 index 0000000000..2b8d401bff --- /dev/null +++ b/labs/gb/components/segmentedbutton/segmented-button-set-element.ts @@ -0,0 +1,321 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + css, + type CSSResultOrNative, + html, + LitElement, + type PropertyValues, +} from 'lit'; +import {property, queryAssignedElements} from 'lit/decorators.js'; + +import { + internals, + mixinElementInternals, +} from '../../../behaviors/element-internals.js'; +import {adoptStyles} from '../../styles/adopt-styles.js'; +import {updateClassList} from '../shared/update-class-list.js'; + +import { + normalizeDensity, + normalizeSegmentSelection, + type SegmentedButtonDensity, + type SegmentedButtonSelection, + segmentedButtonSetClasses, +} from './segmented-button.js'; +import { + SegmentedButtonElement, + segmentGroupDisabled, + segmentOwner, + type SegmentOwner, + segmentPosInSet, + segmentSelected, + segmentSelectionMode, + segmentSetSize, +} from './segmented-button-element.js'; +import segmentedButtonStyles from './segmented-button.css' with {type: 'css'}; // github-only +// import segmentedButtonStyles from './segmented-button.cssresult.js'; // google3-only + +const baseClass = mixinElementInternals(LitElement); + +// LINT.IfChange +/** + * A Material Design segmented button set component. + * + * Authors should set `aria-label` or `aria-labelledby` on the set. + * + * @slot - Default slot for segmented buttons. + * @fires {InputEvent} input - Fired when the selected segment changes. --bubbles --composed + * @fires {Event} change - Fired when the selected segment changes. --bubbles + * @cssprop --container-height + * @cssprop --container-shape + * @cssprop --outline-color + * @cssprop --outline-width + * @cssprop --touch-target-size + */ +// LINT.ThenChange(_segmented-button-tokens.scss) +export class SegmentedButtonSetElement + extends baseClass + implements SegmentOwner +{ + /** @nocollapse */ + static override styles: CSSResultOrNative[] = [ + segmentedButtonStyles, + css` + :host { + display: inline-grid; + } + :host([hidden]) { + display: none !important; + } + `, + ]; + + /** The selection mode ('single' or 'multiple'). */ + @property({reflect: true}) selection: SegmentedButtonSelection = 'single'; + + /** The visual density scale (0, -1, -2, -3). */ + @property({type: Number, reflect: true}) density: SegmentedButtonDensity = 0; + + /** Whether the entire segmented button set is disabled. */ + @property({type: Boolean, reflect: true}) disabled = false; + + @queryAssignedElements({flatten: true}) + private readonly assignedElementsList!: HTMLElement[]; + + private prevSegments: SegmentedButtonElement[] = []; + + constructor() { + super(); + this[internals].role = 'radiogroup'; + + // Capture on the host runs before any bubble listener on it, including + // listeners added before upgrade. The one-shot listener on the segment runs + // after the segment's own listeners, then contains and re-dispatches. + const relay = (event: Event): void => { + if (event.target === this) return; + const path = event.composedPath(); + const child = this.segments.find((s) => path.includes(s)); + if (!child) return; + + child.addEventListener( + event.type, + (atTarget) => { + if (atTarget !== event) return; + event.stopPropagation(); + this.dispatchEvent( + event.type === 'input' + ? new InputEvent('input', {bubbles: true, composed: true}) + : new Event('change', {bubbles: true}), + ); + }, + {once: true}, + ); + }; + + this.addEventListener('input', relay, {capture: true}); + this.addEventListener('change', relay, {capture: true}); + } + + /** The effective selection mode; unknown values behave as 'single'. */ + private get mode(): SegmentedButtonSelection { + return this.selection === 'multiple' ? 'multiple' : 'single'; + } + + /** Returns all child `md-gb-segmented-button` elements. */ + get segments(): SegmentedButtonElement[] { + // Before the first render there is no ; read light-DOM children so + // `value` bound before first render still applies. + const nodes = this.hasUpdated + ? (this.assignedElementsList ?? []) + : Array.from(this.children).filter( + (child) => !child.hasAttribute('slot'), + ); + return nodes.filter( + (node): node is SegmentedButtonElement => + node instanceof SegmentedButtonElement, + ); + } + + /** Returns currently selected segments. */ + get selectedSegments(): SegmentedButtonElement[] { + return this.segments.filter((s) => s.selected); + } + + /** Returns the index of the first selected segment, or -1 if none. */ + get selectedIndex(): number { + return this.segments.findIndex((s) => s.selected); + } + + /** Returns indices of all selected segments. */ + get selectedIndices(): number[] { + const indices: number[] = []; + const segments = this.segments; + for (let i = 0; i < segments.length; i++) { + if (segments[i].selected) { + indices.push(i); + } + } + return indices; + } + + /** Value of the currently selected segment (or first selected). */ + get value(): string { + return this.selectedSegments[0]?.value ?? ''; + } + + /** Sets selected segment by value in single-select mode. */ + set value(val: string) { + if (this.mode !== 'single') return; + for (const segment of this.segments) { + segment.selected = segment.value === val; + } + } + + /** Values of all currently selected segments. */ + get values(): string[] { + return this.selectedSegments.map((s) => s.value); + } + + // Exclusivity is enforced synchronously from the segment's `selected` setter + // instead of a MutationObserver (cf. ButtonGroup): the set owns its segments, + // so there is never a transient double selection and programmatic changes + // stay silent and deterministic. + [segmentSelected](segment: SegmentedButtonElement): void { + // Fast path: a non-member resolves to preferred -1, which would not change the selection. + if (this.mode !== 'single' || !this.segments.includes(segment)) return; + this.normalizeSelection(segment); + } + + // Toggling `hidden` does not fire `slotchange` but changes posinset/setsize. + private readonly hiddenObserver = new MutationObserver(() => { + this.syncChildren(); + }); + private hasAwaitedSegmentDefinition = false; + + override connectedCallback(): void { + super.connectedCallback(); + adoptStyles(this, segmentedButtonStyles); + this.updateClasses(); + this.hiddenObserver.observe(this, { + attributes: true, + attributeFilter: ['hidden'], + subtree: true, + }); + // Assumes the standard custom element tag 'md-gb-segmented-button'. + // Segments upgraded after the set connects do not fire `slotchange`. + // Waiting for definition ensures child segments upgrade and sync their + // positions, selection mode, and disabled state once defined. + if (customElements.get('md-gb-segmented-button')) { + this.handleSlotChange(); + } else if (!this.hasAwaitedSegmentDefinition) { + this.hasAwaitedSegmentDefinition = true; + void customElements.whenDefined('md-gb-segmented-button').then(() => { + if (this.isConnected) this.handleSlotChange(); + }); + } + } + + override disconnectedCallback(): void { + super.disconnectedCallback(); + this.hiddenObserver.disconnect(); + } + + protected override updated(changedProperties: PropertyValues): void { + super.updated(changedProperties); + + if (changedProperties.has('density') || changedProperties.has('disabled')) { + this.updateClasses(); + } + + if ( + changedProperties.has('selection') || + changedProperties.has('disabled') + ) { + this[internals].role = this.mode === 'multiple' ? 'group' : 'radiogroup'; + this.syncChildren(); + this.normalizeSelection(); + } + + if (changedProperties.has('disabled')) { + this[internals].ariaDisabled = this.disabled ? 'true' : null; + } + } + + private normalizeSelection(preferred?: SegmentedButtonElement): void { + const segments = this.segments; + const isSetDisabled = this.disabled; + const next = normalizeSegmentSelection( + segments.map((s) => !isSetDisabled && !s.disabled && s.selected), + { + selection: this.mode, + preferred: + preferred && !isSetDisabled && !preferred.disabled + ? segments.indexOf(preferred) + : undefined, + }, + ); + for (let i = 0; i < segments.length; i++) { + const segment = segments[i]; + const isSelected = !isSetDisabled && !segment.disabled && next[i]; + segment.selected = isSelected; + } + } + + private updateClasses(): void { + updateClassList( + this, + segmentedButtonSetClasses({ + density: normalizeDensity(this.density), + disabled: this.disabled, + }), + ); + } + + private syncChildren(): void { + const segments = this.segments; + const mode = this.mode; + const visible = segments.filter((s) => !s.hidden); + for (const segment of segments) { + segment[segmentOwner] = this; + segment[segmentSelectionMode] = mode; + segment[segmentGroupDisabled] = this.disabled; + const pos = mode === 'single' ? visible.indexOf(segment) + 1 : 0; + segment[segmentPosInSet] = pos; + segment[segmentSetSize] = pos > 0 ? visible.length : 0; + } + } + + protected handleSlotChange(): void { + const currentSegments = this.segments; + for (const segment of this.prevSegments) { + if ( + !currentSegments.includes(segment) && + segment[segmentOwner] === this + ) { + segment[segmentOwner] = null; + segment[segmentGroupDisabled] = false; + segment[segmentSelectionMode] = 'single'; + segment[segmentPosInSet] = 0; + segment[segmentSetSize] = 0; + } + } + const addedSegments = currentSegments.filter( + (s) => !this.prevSegments.includes(s), + ); + this.prevSegments = [...currentSegments]; + + this.syncChildren(); + + const addedSelected = addedSegments.filter((s) => s.selected); + this.normalizeSelection(addedSelected[addedSelected.length - 1]); + } + + protected override render() { + return html``; + } +} diff --git a/labs/gb/components/segmentedbutton/segmented-button-set-element_test.ts b/labs/gb/components/segmentedbutton/segmented-button-set-element_test.ts new file mode 100644 index 0000000000..9c95647c03 --- /dev/null +++ b/labs/gb/components/segmentedbutton/segmented-button-set-element_test.ts @@ -0,0 +1,1279 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// import 'jasmine'; (google3-only) +import './md-gb-segmented-button.js'; +import './md-gb-segmented-button-set.js'; + +import {html} from 'lit'; + +import {Environment} from '../../../../testing/environment.js'; +import {Harness} from '../../../../testing/harness.js'; +import {internals} from '../../../behaviors/element-internals.js'; +import {adoptStyles} from '../../styles/adopt-styles.js'; +import {styles as m3Styles} from '../../styles/m3.cssresult.js'; + +import {type SegmentedButtonSelection} from './segmented-button.js'; +import { + SegmentedButtonElement, + segmentGroupDisabled, + segmentOwner, + segmentPosInSet, + segmentSelected, + segmentSelectionMode, + segmentSetSize, +} from './segmented-button-element.js'; +import {SegmentedButtonSetElement} from './segmented-button-set-element.js'; + +function parsePx(value: string): number { + return Number(value.replace(/px$/, '')); +} + +class SegmentHarness extends Harness { + override async getInteractiveElement(): Promise { + await this.element.updateComplete; + return this.element.shadowRoot!.querySelector( + '[part="segmented-btn"]', + ) as HTMLElement; + } +} + +describe('SegmentedButtonSetElement', () => { + const env = new Environment(); + + beforeAll(() => { + const noTransitions = new CSSStyleSheet(); + noTransitions.replaceSync(` + md-gb-segmented-button-set, + md-gb-segmented-button, + md-gb-segmented-button::part(segmented-btn) { + transition: none !important; + } + `); + adoptStyles(document, [m3Styles, noTransitions]); + }); + + it('is registered', () => { + const el = document.createElement('md-gb-segmented-button-set'); + expect(el).toBeInstanceOf(SegmentedButtonSetElement); + expect(customElements.get('md-gb-segmented-button-set')).toBeDefined(); + }); + + it('segmentSelected hook is symbol-keyed and not exposed on the public API', () => { + const set = document.createElement( + 'md-gb-segmented-button-set', + ) as unknown as Record; + expect(set['segmentSelected']).toBeUndefined(); + expect(typeof set[segmentSelected]).toBe('function'); + }); + + it('has expected defaults', async () => { + const root = env.render( + html``, + ); + await env.waitForStability(); + const set = root.querySelector( + 'md-gb-segmented-button-set', + ) as SegmentedButtonSetElement; + + expect(set.selection).toBe('single'); + expect(set.density).toBe(0); + expect(set.disabled).toBeFalse(); + expect(set.segments).toEqual([]); + expect(set.selectedSegments).toEqual([]); + expect(set.selectedIndex).toBe(-1); + expect(set.selectedIndices).toEqual([]); + expect(set.value).toBe(''); + expect(set.values).toEqual([]); + }); + + it('setting --outline-color on set changes segment computed border color', async () => { + const root = env.render(html` + + Item + + `); + await env.waitForStability(); + const segment = root.querySelector( + 'md-gb-segmented-button', + ) as SegmentedButtonElement; + const button = segment.shadowRoot!.querySelector('button')!; + expect(getComputedStyle(button).borderColor).toBe('rgb(255, 0, 0)'); + }); + + it('sets host class and mirrors density', async () => { + const root = env.render( + html``, + ); + await env.waitForStability(); + const set = root.querySelector( + 'md-gb-segmented-button-set', + ) as SegmentedButtonSetElement; + + expect(set.classList.contains('segmented-btn-set')).toBeTrue(); + expect(set.classList.contains('segmented-btn-set-dense-1')).toBeTrue(); + + set.density = -2; + await set.updateComplete; + expect(set.classList.contains('segmented-btn-set-dense-2')).toBeTrue(); + expect(set.classList.contains('segmented-btn-set-dense-1')).toBeFalse(); + + set.density = -3; + await set.updateComplete; + expect(set.classList.contains('segmented-btn-set-dense-3')).toBeTrue(); + + set.disabled = true; + await set.updateComplete; + expect(set.classList.contains('disabled')).toBeTrue(); + }); + + it('synchronizes child segment properties', async () => { + const root = env.render(html` + + Day + Week + Month + + `); + await env.waitForStability(); + const set = root.querySelector( + 'md-gb-segmented-button-set', + ) as SegmentedButtonSetElement; + const segments = set.segments; + + expect(segments.length).toBe(3); + for (let i = 0; i < 3; i++) { + expect(segments[i][segmentOwner]).toBe(set); + expect(segments[i][segmentSelectionMode]).toBe('single'); + expect(segments[i][segmentGroupDisabled]).toBeFalse(); + expect(segments[i][segmentPosInSet]).toBe(i + 1); + expect(segments[i][segmentSetSize]).toBe(3); + } + }); + + it('exposes position only on visible radios and updates on hidden toggle', async () => { + const root = env.render(html` + + A + + C + + `); + await env.waitForStability(); + const set = root.querySelector( + 'md-gb-segmented-button-set', + ) as SegmentedButtonSetElement; + const inner = (s: Element) => s.shadowRoot!.querySelector('button')!; + const [a, b, c] = set.segments; + expect(inner(c).getAttribute('aria-posinset')).toBe('2'); + expect(inner(c).getAttribute('aria-setsize')).toBe('2'); + + b.removeAttribute('hidden'); + await env.waitForStability(); + expect(inner(c).getAttribute('aria-posinset')).toBe('3'); + expect(inner(c).getAttribute('aria-setsize')).toBe('3'); + + set.selection = 'multiple'; + await env.waitForStability(); + expect(inner(a).hasAttribute('aria-posinset')).toBeFalse(); + expect(inner(a).hasAttribute('aria-setsize')).toBeFalse(); + }); + + it('defaults to single mode (radiogroup with exclusivity) when selection is none, unknown, or removed', async () => { + const root = env.render(html` + + A + B + + `); + await env.waitForStability(); + const set = root.querySelector( + 'md-gb-segmented-button-set', + ) as SegmentedButtonSetElement; + expect(set.selectedSegments.length).toBe(1); + expect(set.value).toBe('b'); + + // Test with typo + set.selection = 'invalid-mode' as unknown as SegmentedButtonSelection; + await env.waitForStability(); + set.segments[0].selected = true; + set.segments[1].selected = true; + expect(set.selectedSegments.length).toBe(1); + + // Test with attribute removed + set.removeAttribute('selection'); + await env.waitForStability(); + set.segments[0].selected = true; + set.segments[1].selected = true; + expect(set.selectedSegments.length).toBe(1); + }); + + it('single mode: enforces at most one selected segment, preferring the last one on init', async () => { + const root = env.render(html` + + Day + Week + Month + + `); + await env.waitForStability(); + const set = root.querySelector( + 'md-gb-segmented-button-set', + ) as SegmentedButtonSetElement; + const [day, week, month] = set.segments; + + expect(day.selected).toBeFalse(); + expect(week.selected).toBeTrue(); + expect(month.selected).toBeFalse(); + expect(set.selectedIndex).toBe(1); + expect(set.value).toBe('week'); + }); + + it('single mode: selecting a segment synchronously unselects others', async () => { + const root = env.render(html` + + Day + Week + Month + + `); + await env.waitForStability(); + const set = root.querySelector( + 'md-gb-segmented-button-set', + ) as SegmentedButtonSetElement; + const [day, week, month] = set.segments; + + expect(day.selected).toBeTrue(); + month.selected = true; + + expect(day.selected).toBeFalse(); + expect(week.selected).toBeFalse(); + expect(month.selected).toBeTrue(); + expect(set.selectedIndex).toBe(2); + expect(set.value).toBe('month'); + }); + + it('segment selected setter updates selectedIndex and value and can clear the selection', async () => { + const root = env.render(html` + + Day + Week + + `); + await env.waitForStability(); + const set = root.querySelector( + 'md-gb-segmented-button-set', + ) as SegmentedButtonSetElement; + const [day, week] = set.segments; + + week.selected = true; + expect(day.selected).toBeFalse(); + expect(week.selected).toBeTrue(); + expect(set.selectedIndex).toBe(1); + expect(set.value).toBe('week'); + + week.selected = false; + expect(day.selected).toBeFalse(); + expect(week.selected).toBeFalse(); + expect(set.selectedIndex).toBe(-1); + expect(set.value).toBe(''); + }); + + it('applies a value bound before the first render', async () => { + const root = env.render(html` + + Day + Week + + `); + await env.waitForStability(); + const set = root.querySelector( + 'md-gb-segmented-button-set', + ) as SegmentedButtonSetElement; + expect(set.value).toBe('week'); + expect(set.segments[1].selected).toBeTrue(); + expect(set.segments[0].selected).toBeFalse(); + }); + + it('value setter updates selection in single mode', async () => { + const root = env.render(html` + + Day + Week + + `); + await env.waitForStability(); + const set = root.querySelector( + 'md-gb-segmented-button-set', + ) as SegmentedButtonSetElement; + const [day, week] = set.segments; + + set.value = 'day'; + expect(day.selected).toBeTrue(); + expect(week.selected).toBeFalse(); + expect(set.selectedIndex).toBe(0); + + set.value = 'nonexistent'; + expect(day.selected).toBeFalse(); + expect(week.selected).toBeFalse(); + expect(set.selectedIndex).toBe(-1); + }); + + it('value setter in multiple mode does nothing', async () => { + const root = env.render(html` + + Bold + Italic + + `); + await env.waitForStability(); + const set = root.querySelector( + 'md-gb-segmented-button-set', + ) as SegmentedButtonSetElement; + + set.value = 'b'; + expect(set.segments[0].selected).toBeFalse(); + expect(set.value).toBe(''); + expect(set.values).toEqual([]); + }); + + it('multiple mode: allows multiple selected segments', async () => { + const root = env.render(html` + + Bold + Italic + Underline + + `); + await env.waitForStability(); + const set = root.querySelector( + 'md-gb-segmented-button-set', + ) as SegmentedButtonSetElement; + const [b, i, u] = set.segments; + + expect(b.selected).toBeTrue(); + expect(i.selected).toBeFalse(); + expect(u.selected).toBeTrue(); + expect(set.selectedIndices).toEqual([0, 2]); + expect(set.values).toEqual(['b', 'u']); + + b.selected = false; + i.selected = true; + expect(b.selected).toBeFalse(); + expect(i.selected).toBeTrue(); + expect(u.selected).toBeTrue(); + expect(set.values).toEqual(['i', 'u']); + }); + + it('multiple mode: child buttons have role="checkbox"', async () => { + const root = env.render(html` + + Item + + `); + await env.waitForStability(); + const set = root.querySelector( + 'md-gb-segmented-button-set', + ) as SegmentedButtonSetElement; + const button = set.segments[0].shadowRoot!.querySelector('button')!; + + expect(button.getAttribute('role')).toBe('checkbox'); + }); + + it('disables child segments when set is disabled', async () => { + const root = env.render(html` + + Item 1 + Item 2 + + `); + await env.waitForStability(); + const set = root.querySelector( + 'md-gb-segmented-button-set', + ) as SegmentedButtonSetElement; + + for (const segment of set.segments) { + const button = segment.shadowRoot!.querySelector('button')!; + expect(button.disabled).toBeTrue(); + } + }); + + it('aria-disabled attribute on element set does not style or disable child segments', async () => { + const root = env.render(html` + + Item + + `); + await env.waitForStability(); + const set = root.querySelector( + 'md-gb-segmented-button-set', + ) as SegmentedButtonSetElement; + const segment = set.segments[0]; + const button = segment.shadowRoot!.querySelector('button')!; + expect(button.disabled).toBeFalse(); + expect(getComputedStyle(button).pointerEvents).toBe('auto'); + }); + + it('click event handling: single mode clicks select segment and dispatch set-level input and change', async () => { + const root = env.render(html` + + Day + Week + + `); + await env.waitForStability(); + const set = root.querySelector( + 'md-gb-segmented-button-set', + ) as SegmentedButtonSetElement; + const [day, week] = set.segments; + + let inputCount = 0; + let changeCount = 0; + set.addEventListener('input', () => inputCount++); + set.addEventListener('change', () => changeCount++); + + const harness = new SegmentHarness(day); + await harness.clickWithMouse(); + + expect(day.selected).toBeTrue(); + expect(set.value).toBe('day'); + expect(inputCount).toBe(1); + expect(changeCount).toBe(1); + + // Clicking already selected segment in single mode does nothing + await harness.clickWithMouse(); + expect(day.selected).toBeTrue(); + expect(inputCount).toBe(1); + expect(changeCount).toBe(1); + + // Clicking another segment selects it and unselects first + const weekHarness = new SegmentHarness(week); + await weekHarness.clickWithMouse(); + expect(day.selected).toBeFalse(); + expect(week.selected).toBeTrue(); + expect(set.value).toBe('week'); + expect(inputCount).toBe(2); + expect(changeCount).toBe(2); + }); + + it('click event handling: child input and change do not leak past the set', async () => { + let parentInputCount = 0; + let parentChangeCount = 0; + const root = env.render(html` +
parentInputCount++} + @change=${() => parentChangeCount++}> + + Day + +
+ `); + await env.waitForStability(); + const set = root.querySelector( + 'md-gb-segmented-button-set', + ) as SegmentedButtonSetElement; + const day = set.segments[0]; + + const harness = new SegmentHarness(day); + await harness.clickWithMouse(); + + // The set emits one input and one change, so parent receives exactly 1 + // each (not 2 from child + set) + expect(parentInputCount).toBe(1); + expect(parentChangeCount).toBe(1); + }); + + it('set input observes the committed selection', async () => { + const root = env.render(html` + + Day + Week + + `); + await env.waitForStability(); + const set = root.querySelector( + 'md-gb-segmented-button-set', + ) as SegmentedButtonSetElement; + const [day, week] = set.segments; + let valueOnInput = ''; + let daySelectedOnInput = true; + let segmentInputs = 0; + let segmentChanges = 0; + week.addEventListener('input', () => segmentInputs++); + week.addEventListener('change', () => segmentChanges++); + set.addEventListener('input', () => { + valueOnInput = set.value; + daySelectedOnInput = day.selected; + }); + + await new SegmentHarness(week).clickWithMouse(); + + expect(valueOnInput).toBe('week'); + expect(daySelectedOnInput).toBeFalse(); + expect(segmentInputs).toBe(1); + expect(segmentChanges).toBe(1); + }); + + it('re-asserts rendered state when change listener rejects the switch', async () => { + const root = env.render(html` + + Day + Week + + `); + await env.waitForStability(); + const set = root.querySelector( + 'md-gb-segmented-button-set', + ) as SegmentedButtonSetElement; + const [day, week] = set.segments; + set.addEventListener('change', () => { + set.value = 'day'; + }); + + await new SegmentHarness(week).clickWithMouse(); + await env.waitForStability(); + + const innerDay = day.shadowRoot!.querySelector('button')!; + const innerWeek = week.shadowRoot!.querySelector('button')!; + expect(day.selected).toBeTrue(); + expect(week.selected).toBeFalse(); + expect(innerDay.getAttribute('aria-checked')).toBe('true'); + expect(innerDay.classList.contains('segmented-btn-selected')).toBeTrue(); + expect(innerWeek.getAttribute('aria-checked')).toBe('false'); + expect(innerWeek.classList.contains('segmented-btn-selected')).toBeFalse(); + }); + + it('multiple mode clicks toggle segment and dispatch set-level events', async () => { + const root = env.render(html` + + Bold + Italic + + `); + await env.waitForStability(); + const set = root.querySelector( + 'md-gb-segmented-button-set', + ) as SegmentedButtonSetElement; + const [b, i] = set.segments; + + let inputCount = 0; + let changeCount = 0; + set.addEventListener('input', () => inputCount++); + set.addEventListener('change', () => changeCount++); + + const bHarness = new SegmentHarness(b); + await bHarness.clickWithMouse(); + expect(b.selected).toBeTrue(); + expect(set.values).toEqual(['b']); + expect(inputCount).toBe(1); + expect(changeCount).toBe(1); + + const iHarness = new SegmentHarness(i); + await iHarness.clickWithMouse(); + expect(b.selected).toBeTrue(); + expect(i.selected).toBeTrue(); + expect(set.values).toEqual(['b', 'i']); + expect(inputCount).toBe(2); + expect(changeCount).toBe(2); + + // Clicking b again deselects it + await bHarness.clickWithMouse(); + expect(b.selected).toBeFalse(); + expect(i.selected).toBeTrue(); + expect(set.values).toEqual(['i']); + expect(inputCount).toBe(3); + expect(changeCount).toBe(3); + }); + + it('Space and Enter each activate a multi-select segment once', async () => { + const root = env.render(html` + + Bold + + `); + await env.waitForStability(); + const set = root.querySelector( + 'md-gb-segmented-button-set', + ) as SegmentedButtonSetElement; + const [bold] = set.segments; + let inputs = 0; + let changes = 0; + set.addEventListener('input', () => inputs++); + set.addEventListener('change', () => changes++); + const harness = new SegmentHarness(bold); + + await harness.startClickWithKeyboard({key: ' '}); + expect(bold.selected).toBeTrue(); + expect([inputs, changes]).toEqual([1, 1]); + + await harness.startClickWithKeyboard({key: 'Enter'}); + expect(bold.selected).toBeFalse(); + expect([inputs, changes]).toEqual([2, 2]); + }); + + it('removing a selected segment leaves none selected and does not fire events', async () => { + const root = env.render(html` + + S1 + S2 + + `); + await env.waitForStability(); + const set = root.querySelector( + 'md-gb-segmented-button-set', + ) as SegmentedButtonSetElement; + const s1 = root.querySelector('#s1') as SegmentedButtonElement; + const s2 = root.querySelector('#s2') as SegmentedButtonElement; + + let inputFired = false; + let changeFired = false; + set.addEventListener('input', () => (inputFired = true)); + set.addEventListener('change', () => (changeFired = true)); + + s1.remove(); + await env.waitForStability(); + + expect(set.segments.length).toBe(1); + expect(s2.selected).toBeFalse(); + expect(set.selectedIndex).toBe(-1); + expect(inputFired).toBeFalse(); + expect(changeFired).toBeFalse(); + }); + + it('resets state on removed segments and ignores selection calls from removed segments', async () => { + const root = env.render(html` + + R1 + R2 + + `); + await env.waitForStability(); + const set = root.querySelector( + 'md-gb-segmented-button-set', + ) as SegmentedButtonSetElement; + const r1 = root.querySelector('#r1') as SegmentedButtonElement; + const r2 = root.querySelector('#r2') as SegmentedButtonElement; + + expect(r1[segmentGroupDisabled]).toBeTrue(); + expect(r1[segmentOwner]).toBe(set); + + r1.remove(); + await env.waitForStability(); + + // Removed segment is cleaned up + expect(r1[segmentOwner]).toBeNull(); + expect(r1[segmentGroupDisabled]).toBeFalse(); + expect(r1[segmentPosInSet]).toBe(0); + expect(r1[segmentSetSize]).toBe(0); + const innerR1 = r1.shadowRoot!.querySelector('button')!; + expect(innerR1.hasAttribute('aria-posinset')).toBeFalse(); + expect(innerR1.hasAttribute('aria-setsize')).toBeFalse(); + + // Selecting removed segment does not change the old set + set.disabled = false; + await env.waitForStability(); + r2.selected = true; + expect(set.value).toBe('r2'); + r1.selected = true; + expect(set.value).toBe('r2'); + expect(r2.selected).toBeTrue(); + }); + + it('deselects selected segment when segment or set becomes disabled, and stays unselected when enabled again', async () => { + const root = env.render(html` + + S1 + S2 + + `); + await env.waitForStability(); + const set = root.querySelector( + 'md-gb-segmented-button-set', + ) as SegmentedButtonSetElement; + const s1 = root.querySelector('#s1') as SegmentedButtonElement; + const s2 = root.querySelector('#s2') as SegmentedButtonElement; + + expect(s1.selected).toBeTrue(); + expect(s2.selected).toBeFalse(); + expect(set.value).toBe('s1'); + expect(set.selectedSegments).toEqual([s1]); + + // Disabling the segment deselects it + s1.disabled = true; + await env.waitForStability(); + expect(s1.selected).toBeFalse(); + expect(set.value).toBe(''); + expect(set.selectedSegments).toEqual([]); + + // Re-enabling does not automatically re-select + s1.disabled = false; + await env.waitForStability(); + expect(s1.selected).toBeFalse(); + expect(set.value).toBe(''); + + // Re-select s1, then disable the entire set + s1.selected = true; + await env.waitForStability(); + expect(s1.selected).toBeTrue(); + expect(set.value).toBe('s1'); + + set.disabled = true; + await env.waitForStability(); + expect(s1.selected).toBeFalse(); + expect(set.value).toBe(''); + expect(set.selectedSegments).toEqual([]); + + // Re-enabling the set does not automatically re-select + set.disabled = false; + await env.waitForStability(); + expect(s1.selected).toBeFalse(); + expect(set.value).toBe(''); + }); + + it('selecting a removed segment synchronously does not deselect set segments or mutate set value', async () => { + const root = env.render(html` + + A + B + + `); + await env.waitForStability(); + const set = root.querySelector( + 'md-gb-segmented-button-set', + ) as SegmentedButtonSetElement; + const [a, b] = set.segments; + + b.remove(); + b.selected = true; + + expect(a.selected).toBeTrue(); + expect(b.selected).toBeTrue(); + expect(set.value).toBe('a'); + }); + + it('moving a segment between sets updates owner and mode to setB and leaves setA unaffected', async () => { + const root = env.render(html` +
+ + A + B + + + C + +
+ `); + await env.waitForStability(); + const setA = root.querySelector('#set-a') as SegmentedButtonSetElement; + const setB = root.querySelector('#set-b') as SegmentedButtonSetElement; + const segB = root.querySelector('#seg-b') as SegmentedButtonElement; + + // Signal setB's slot first in the same task so setB's slotchange runs first. + setB.append(document.createElement('md-gb-segmented-button')); + setB.appendChild(segB); + await env.waitForStability(); + + expect(segB[segmentOwner]).toBe(setB); + expect(segB[segmentSelectionMode]).toBe('multiple'); + expect(setA.segments.length).toBe(1); + expect(setA.value).toBe('a'); + expect(setB.segments.length).toBe(3); + }); + + it('inserting a pre-selected C before a selected A normalizes value to c', async () => { + const root = env.render(html` + + A + + `); + await env.waitForStability(); + const set = root.querySelector( + 'md-gb-segmented-button-set', + ) as SegmentedButtonSetElement; + const a = set.segments[0]; + + const c = document.createElement('md-gb-segmented-button'); + c.value = 'c'; + c.selected = true; + set.insertBefore(c, a); + await env.waitForStability(); + + expect(set.value).toBe('c'); + expect(c.selected).toBeTrue(); + expect(a.selected).toBeFalse(); + }); + + it('removing and re-appending the set then toggling hidden updates positions and click relays once', async () => { + const root = env.render(html` +
+ + A + B + C + +
+ `); + await env.waitForStability(); + const container = root.querySelector('#container') as HTMLElement; + const set = root.querySelector( + 'md-gb-segmented-button-set', + ) as SegmentedButtonSetElement; + const segB = root.querySelector('#seg-b') as SegmentedButtonElement; + + set.remove(); + container.appendChild(set); + await env.waitForStability(); + + segB.hidden = true; + await env.waitForStability(); + + const [a, , c] = set.segments; + expect(a[segmentPosInSet]).toBe(1); + expect(a[segmentSetSize]).toBe(2); + expect(c[segmentPosInSet]).toBe(2); + expect(c[segmentSetSize]).toBe(2); + + let inputCount = 0; + let changeCount = 0; + set.addEventListener('input', () => inputCount++); + set.addEventListener('change', () => changeCount++); + + await new SegmentHarness(c).clickWithMouse(); + + expect(inputCount).toBe(1); + expect(changeCount).toBe(1); + expect(c.selected).toBeTrue(); + }); + + it('reordering segments inside a set updates positions', async () => { + const root = env.render(html` + + A + B + + `); + await env.waitForStability(); + const set = root.querySelector( + 'md-gb-segmented-button-set', + ) as SegmentedButtonSetElement; + const segA = root.querySelector('#seg-a') as SegmentedButtonElement; + const segB = root.querySelector('#seg-b') as SegmentedButtonElement; + + expect(segA[segmentPosInSet]).toBe(1); + expect(segB[segmentPosInSet]).toBe(2); + + set.insertBefore(segB, segA); + await env.waitForStability(); + + expect(segB[segmentPosInSet]).toBe(1); + expect(segA[segmentPosInSet]).toBe(2); + }); + + describe('element-mode styles', () => { + it('applies grid layout to host', async () => { + const root = env.render(html` + + 1 + 2 + + `); + await env.waitForStability(); + const set = root.querySelector( + 'md-gb-segmented-button-set', + ) as SegmentedButtonSetElement; + + const style = getComputedStyle(set); + expect(['grid', 'inline-grid']).toContain(style.display); + expect(style.gridAutoFlow).toBe('column'); + }); + + it('first and last children receive container-shape border radii on inner button', async () => { + const root = env.render(html` + + 1 + 2 + 3 + + `); + await env.waitForStability(); + const first = root.querySelector('#first') as SegmentedButtonElement; + const middle = root.querySelector('#middle') as SegmentedButtonElement; + const last = root.querySelector('#last') as SegmentedButtonElement; + + const firstBtn = first.shadowRoot!.querySelector('button')!; + const middleBtn = middle.shadowRoot!.querySelector('button')!; + const lastBtn = last.shadowRoot!.querySelector('button')!; + + const firstStyle = getComputedStyle(firstBtn); + const middleStyle = getComputedStyle(middleBtn); + const lastStyle = getComputedStyle(lastBtn); + + expect(parsePx(firstStyle.borderStartStartRadius)).toBeGreaterThan(0); + expect(parsePx(firstStyle.borderEndStartRadius)).toBeGreaterThan(0); + expect(parsePx(firstStyle.borderStartEndRadius)).toBe(0); + expect(parsePx(firstStyle.borderEndEndRadius)).toBe(0); + + expect(parsePx(middleStyle.borderStartStartRadius)).toBe(0); + expect(parsePx(middleStyle.borderEndStartRadius)).toBe(0); + expect(parsePx(middleStyle.borderStartEndRadius)).toBe(0); + expect(parsePx(middleStyle.borderEndEndRadius)).toBe(0); + + expect(parsePx(lastStyle.borderStartStartRadius)).toBe(0); + expect(parsePx(lastStyle.borderEndStartRadius)).toBe(0); + expect(parsePx(lastStyle.borderStartEndRadius)).toBeGreaterThan(0); + expect(parsePx(lastStyle.borderEndEndRadius)).toBeGreaterThan(0); + }); + + it('divider is dropped on non-last segments', async () => { + const root = env.render(html` + + 1 + 2 + + `); + await env.waitForStability(); + const first = root.querySelector('#first') as SegmentedButtonElement; + const last = root.querySelector('#last') as SegmentedButtonElement; + + const firstBtn = first.shadowRoot!.querySelector('button')!; + const lastBtn = last.shadowRoot!.querySelector('button')!; + + expect(getComputedStyle(firstBtn).borderInlineEndWidth).toBe('0px'); + expect(getComputedStyle(lastBtn).borderInlineEndWidth).toBe('1px'); + }); + + it('skips hidden children when computing radii and divider dropping', async () => { + const root = env.render(html` + + + 1 + 2 + + + `); + await env.waitForStability(); + const first = root.querySelector('#first') as SegmentedButtonElement; + const last = root.querySelector('#last') as SegmentedButtonElement; + + const firstBtn = first.shadowRoot!.querySelector('button')!; + const lastBtn = last.shadowRoot!.querySelector('button')!; + + const firstStyle = getComputedStyle(firstBtn); + const lastStyle = getComputedStyle(lastBtn); + + expect(parsePx(firstStyle.borderStartStartRadius)).toBeGreaterThan(0); + expect(parsePx(firstStyle.borderEndStartRadius)).toBeGreaterThan(0); + expect(firstStyle.borderInlineEndWidth).toBe('0px'); + + expect(parsePx(lastStyle.borderStartEndRadius)).toBeGreaterThan(0); + expect(parsePx(lastStyle.borderEndEndRadius)).toBeGreaterThan(0); + expect(lastStyle.borderInlineEndWidth).toBe('1px'); + }); + + it('elementFromPoint 4px above the visual top of a dense (-3) segment returns that segment', async () => { + const root = env.render(html` +
+ + Touch + +
+ `); + await env.waitForStability(); + const seg = root.querySelector('#dense-seg') as SegmentedButtonElement; + const button = seg.shadowRoot!.querySelector('button')!; + const rect = button.getBoundingClientRect(); + const centerX = rect.left + rect.width / 2; + + const abovePoint = document.elementFromPoint(centerX, rect.top - 4); + expect(abovePoint).toBe(seg); + }); + }); + + describe('internals role and accessible attributes', () => { + it('set internals role follows selection property', async () => { + const root = env.render(html` + + 1 + + `); + await env.waitForStability(); + const set = root.querySelector( + 'md-gb-segmented-button-set', + ) as SegmentedButtonSetElement; + + expect(set[internals].role).toBe('radiogroup'); + + set.selection = 'multiple'; + await env.waitForStability(); + expect(set[internals].role).toBe('group'); + + set.selection = 'single'; + await env.waitForStability(); + expect(set[internals].role).toBe('radiogroup'); + }); + + it('exposes aria-disabled on internals when set.disabled is true', async () => { + const root = env.render(html` + + 1 + + `); + await env.waitForStability(); + const set = root.querySelector( + 'md-gb-segmented-button-set', + ) as SegmentedButtonSetElement; + + expect(set[internals].ariaDisabled).toBeNull(); + + set.disabled = true; + await env.waitForStability(); + expect(set[internals].ariaDisabled).toBe('true'); + + set.disabled = false; + await env.waitForStability(); + expect(set[internals].ariaDisabled).toBeNull(); + }); + + it('setters and owner hook fire no events', async () => { + const root = env.render(html` + + A + B + C + + `); + await env.waitForStability(); + const set = root.querySelector( + 'md-gb-segmented-button-set', + ) as SegmentedButtonSetElement; + let events = 0; + set.addEventListener('input', () => events++); + set.addEventListener('change', () => events++); + + set.value = 'a'; + set[segmentSelected](set.segments[1]); + + expect(events).toBe(0); + }); + + it('clicking a disabled segment inside a set does nothing', async () => { + const root = env.render(html` + + A + B + + `); + await env.waitForStability(); + const set = root.querySelector( + 'md-gb-segmented-button-set', + ) as SegmentedButtonSetElement; + const [a, b] = set.segments; + let events = 0; + set.addEventListener('input', () => events++); + set.addEventListener('change', () => events++); + + await new SegmentHarness(b).clickWithMouse(); + + expect(events).toBe(0); + expect(a.selected).toBeTrue(); + expect(b.selected).toBeFalse(); + }); + + it('setting set.disabled inside a set input listener still contains and redispatches change', async () => { + const root = env.render(html` + + Day + Week + + `); + await env.waitForStability(); + const set = root.querySelector( + 'md-gb-segmented-button-set', + ) as SegmentedButtonSetElement; + const [, week] = set.segments; + + const changeTargets: EventTarget[] = []; + set.addEventListener('input', () => { + set.disabled = true; + }); + set.addEventListener('change', (e) => { + changeTargets.push(e.target!); + }); + + const harness = new SegmentHarness(week); + await harness.clickWithMouse(); + + expect(changeTargets.length).toBe(1); + expect(changeTargets[0]).toBe(set); + }); + + it('toggling set.disabled at runtime enables/disables child segments', async () => { + const root = env.render(html` + + A + + `); + await env.waitForStability(); + const set = root.querySelector( + 'md-gb-segmented-button-set', + ) as SegmentedButtonSetElement; + const segment = set.segments[0]; + const inner = segment.shadowRoot!.querySelector('button')!; + + expect(segment[segmentGroupDisabled]).toBeFalse(); + expect(inner.disabled).toBeFalse(); + + set.disabled = true; + await env.waitForStability(); + expect(segment[segmentGroupDisabled]).toBeTrue(); + expect(inner.disabled).toBeTrue(); + + set.disabled = false; + await env.waitForStability(); + expect(segment[segmentGroupDisabled]).toBeFalse(); + expect(inner.disabled).toBeFalse(); + }); + + it('switching from multiple to single normalizes to at most one selection', async () => { + const root = env.render(html` + + A + B + C + + `); + await env.waitForStability(); + const set = root.querySelector( + 'md-gb-segmented-button-set', + ) as SegmentedButtonSetElement; + + expect(set.selectedSegments.length).toBe(3); + + set.selection = 'single'; + await env.waitForStability(); + + expect(set.selectedSegments.length).toBe(1); + expect(set.value).toBe('c'); + }); + + it('listeners added to set before upgrade receive exactly 1 input and 1 change with target === set', async () => { + const doc = document.implementation.createHTMLDocument(''); + const set = doc.createElement( + 'md-gb-segmented-button-set', + ) as SegmentedButtonSetElement; + set.innerHTML = ` + A + B + `; + const inputs: Event[] = []; + const changes: Event[] = []; + set.addEventListener('input', (e) => inputs.push(e)); + set.addEventListener('change', (e) => changes.push(e)); + + const root = env.render(html`
`); + await env.waitForStability(); + root.appendChild(set); + await env.waitForStability(); + + const segA = set.querySelector('#btn-a') as SegmentedButtonElement; + await new SegmentHarness(segA).clickWithMouse(); + + expect(inputs.length).toBe(1); + expect(changes.length).toBe(1); + expect(inputs[0].target).toBe(set); + expect(changes[0].target).toBe(set); + }); + + it('disabled segment following enabled segment keeps solid outline on shared divider in custom elements', async () => { + const root = env.render(html` + + 1 + 2 + 3 + + + + `); + await env.waitForStability(); + const s2 = root.querySelector('#s2') as SegmentedButtonElement; + const b2 = s2.shadowRoot!.querySelector('button')!; + const probeEnabled = root.querySelector('#probe-enabled') as HTMLElement; + const probeDisabled = root.querySelector( + '#probe-disabled', + ) as HTMLElement; + + expect(getComputedStyle(b2).borderInlineStartColor).toBe( + getComputedStyle(probeEnabled).color, + ); + expect(getComputedStyle(b2).borderBlockStartColor).toBe( + getComputedStyle(probeDisabled).color, + ); + expect(getComputedStyle(b2).borderBlockEndColor).toBe( + getComputedStyle(probeDisabled).color, + ); + }); + + it('syncs segments on reconnection without re-registering whenDefined listener', async () => { + const root = env.render(html` +
+ + One + Two + +
+ `); + await env.waitForStability(); + const set = root.querySelector( + '#reconnect-set', + ) as SegmentedButtonSetElement; + const r1 = root.querySelector('#r1') as SegmentedButtonElement; + const r2 = root.querySelector('#r2') as SegmentedButtonElement; + + expect(r1[segmentPosInSet]).toBe(1); + expect(r2[segmentPosInSet]).toBe(2); + + set.remove(); + await env.waitForStability(); + root.querySelector('#container')!.appendChild(set); + await env.waitForStability(); + + expect(r1[segmentPosInSet]).toBe(1); + expect(r2[segmentPosInSet]).toBe(2); + }); + }); +}); diff --git a/labs/gb/components/segmentedbutton/segmented-button.scss b/labs/gb/components/segmentedbutton/segmented-button.scss new file mode 100644 index 0000000000..dacccbfb08 --- /dev/null +++ b/labs/gb/components/segmentedbutton/segmented-button.scss @@ -0,0 +1,404 @@ +/*! + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +@use 'segmented-button-tokens' as tokens; + +@layer md.sys, md.comp.ripple, md.comp.focus-ring; +@layer md.comp.segmented-button { + .segmented-btn-set { + @include tokens.set-root; + + --_enabled-outline-color: var(--outline-color); + + display: inline-grid; + grid-auto-flow: column; + grid-auto-columns: minmax(0, 1fr); + isolation: isolate; + + &.segmented-btn-set-dense-1 { + @include tokens.dense-1; + } + + &.segmented-btn-set-dense-2 { + @include tokens.dense-2; + } + + &.segmented-btn-set-dense-3 { + @include tokens.dense-3; + } + + &:is(.disabled, [aria-disabled='true']) > .segmented-btn { + @include tokens.disabled; + pointer-events: none; + cursor: default; + + &:is(.segmented-btn-selected, [aria-checked='true']) { + @include tokens.disabled; + } + } + + &:not(.disabled, [aria-disabled='true']) { + > :not([disabled], :disabled, .disabled, [aria-disabled='true'], [hidden]) + + .segmented-btn:is(:disabled, .disabled, [aria-disabled='true']), + > :not([disabled], :disabled, .disabled, [aria-disabled='true'], [hidden]) + + [hidden] + + .segmented-btn:is(:disabled, .disabled, [aria-disabled='true']), + > :not([disabled], :disabled, .disabled, [aria-disabled='true'], [hidden]) + + :is([disabled], :disabled, .disabled, [aria-disabled='true'])::part( + segmented-btn + ), + > :not([disabled], :disabled, .disabled, [aria-disabled='true'], [hidden]) + + [hidden] + + :is([disabled], :disabled, .disabled, [aria-disabled='true'])::part( + segmented-btn + ) { + border-inline-start-color: var(--_enabled-outline-color); + } + } + + > [hidden] { + display: none; + } + + > .segmented-btn:not(:nth-last-child(1 of :not([hidden]))), + > :not(:nth-last-child(1 of :not([hidden])))::part(segmented-btn) { + border-inline-end-width: 0; + } + + > .segmented-btn:nth-child(1 of :not([hidden])), + > :nth-child(1 of :not([hidden]))::part(segmented-btn) { + border-start-start-radius: var(--container-shape); + border-end-start-radius: var(--container-shape); + } + + > .segmented-btn:nth-last-child(1 of :not([hidden])), + > :nth-last-child(1 of :not([hidden]))::part(segmented-btn) { + border-start-end-radius: var(--container-shape); + border-end-end-radius: var(--container-shape); + } + + // Elevates the focused segment host above sibling borders and focus rings + // so the focus indicator renders unclipped in element mode. + > :focus-within { + z-index: 1; + } + } + + .segmented-btn { + @include tokens.root; + + position: relative; + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + vertical-align: middle; + min-inline-size: 0; + block-size: var(--container-height); + border: var(--outline-width) solid var(--outline-color); + border-radius: 0; + background-color: var(--container-color); + color: var(--label-text-color); + font: var(--label-text); + letter-spacing: var(--label-text-tracking); + font-variation-settings: var(--label-text-axes); + white-space: nowrap; + text-decoration: none; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + + --md-icon-color: var(--icon-color); + --md-icon-size: var(--icon-size); + --ripple-color: var(--state-layer-color); + + // Default width-stability padding (unselected label-only and unselected icon-only) + padding-inline: calc( + var(--leading-space) + (var(--icon-size) + var(--icon-label-space)) / 2 + ); + gap: 0; + transition: + padding-inline 150ms cubic-bezier(0.2, 0, 0, 1), + gap 150ms cubic-bezier(0.2, 0, 0, 1); + + // Elevates the segment button above adjacent segment borders during focus. + // In plain-markup mode, this applies directly to the focused `); + await env.waitForStability(); + const btn = root.querySelector('.segmented-btn') as HTMLElement; + expect(getComputedStyle(btn).getPropertyValue('--icon-size').trim()).toBe( + '18px', + ); + }); + + it('setting --outline-color on set changes segment computed border color', async () => { + const root = env.render(html` +
+ +
+ `); + await env.waitForStability(); + const btn = root.querySelector('.segmented-btn') as HTMLElement; + expect(getComputedStyle(btn).borderColor).toBe('rgb(255, 0, 0)'); + }); + + it('density classes set --container-height 36/32/28px', async () => { + const root = env.render(html` +
+
+
+ `); + await env.waitForStability(); + const d1 = root.querySelector('#d1') as HTMLElement; + const d2 = root.querySelector('#d2') as HTMLElement; + const d3 = root.querySelector('#d3') as HTMLElement; + expect( + getComputedStyle(d1).getPropertyValue('--container-height').trim(), + ).toBe('36px'); + expect( + getComputedStyle(d2).getPropertyValue('--container-height').trim(), + ).toBe('32px'); + expect( + getComputedStyle(d3).getPropertyValue('--container-height').trim(), + ).toBe('28px'); + }); + + it('computes cursor pointer when enabled and default when disabled', async () => { + const root = env.render(html` +
+ + +
+ `); + await env.waitForStability(); + const enabledBtn = root.querySelector('#enabled-btn') as HTMLElement; + const disabledBtn = root.querySelector('#disabled-btn') as HTMLElement; + expect(getComputedStyle(enabledBtn).cursor).toBe('pointer'); + expect(getComputedStyle(disabledBtn).cursor).toBe('default'); + }); +}); + +describe('SegmentedButton class API', () => { + const env = new Environment(); + + describe('constants', () => { + it('defines SEGMENTED_BUTTON_SET_CLASSES', () => { + expect(SEGMENTED_BUTTON_SET_CLASSES.segmentedBtnSet).toBe( + 'segmented-btn-set', + ); + expect(SEGMENTED_BUTTON_SET_CLASSES.segmentedBtnSetDense1).toBe( + 'segmented-btn-set-dense-1', + ); + expect(SEGMENTED_BUTTON_SET_CLASSES.segmentedBtnSetDense2).toBe( + 'segmented-btn-set-dense-2', + ); + expect(SEGMENTED_BUTTON_SET_CLASSES.segmentedBtnSetDense3).toBe( + 'segmented-btn-set-dense-3', + ); + }); + + it('defines SEGMENTED_BUTTON_CLASSES', () => { + expect(SEGMENTED_BUTTON_CLASSES.segmentedBtn).toBe('segmented-btn'); + expect(SEGMENTED_BUTTON_CLASSES.segmentedBtnSelected).toBe( + 'segmented-btn-selected', + ); + expect(SEGMENTED_BUTTON_CLASSES.segmentedBtnWithIcon).toBe( + 'segmented-btn-with-icon', + ); + expect(SEGMENTED_BUTTON_CLASSES.segmentedBtnIconOnly).toBe( + 'segmented-btn-icon-only', + ); + expect(SEGMENTED_BUTTON_CLASSES.segmentedBtnGraphic).toBe( + 'segmented-btn-graphic', + ); + expect(SEGMENTED_BUTTON_CLASSES.segmentedBtnCheckmark).toBe( + 'segmented-btn-checkmark', + ); + expect(SEGMENTED_BUTTON_CLASSES.segmentedBtnIcon).toBe( + 'segmented-btn-icon', + ); + expect(SEGMENTED_BUTTON_CLASSES.segmentedBtnLabel).toBe( + 'segmented-btn-label', + ); + }); + + it('defines selections and densities', () => { + expect(SEGMENTED_BUTTON_SELECTIONS).toEqual({ + single: 'single', + multiple: 'multiple', + }); + expect(SEGMENTED_BUTTON_DENSITIES).toEqual([0, -1, -2, -3]); + }); + }); + + describe('normalizeDensity()', () => { + it('clamps to [-3, 0], rounds, and maps NaN or undefined to 0', () => { + expect(normalizeDensity(undefined)).toBe(0); + expect(normalizeDensity(NaN)).toBe(0); + expect(normalizeDensity(0)).toBe(0); + expect(normalizeDensity(-1)).toBe(-1); + expect(normalizeDensity(-2)).toBe(-2); + expect(normalizeDensity(-3)).toBe(-3); + expect(normalizeDensity(-0.8)).toBe(-1); + expect(normalizeDensity(-2.4)).toBe(-2); + expect(normalizeDensity(-5)).toBe(-3); + expect(normalizeDensity(2)).toBe(0); + }); + }); + + describe('segmentedButtonSetClasses()', () => { + it('returns only the base class by default', () => { + const classes = segmentedButtonSetClasses(); + expect(classes['segmented-btn-set']).toBeTrue(); + expect(classes['segmented-btn-set-dense-1']).toBeFalsy(); + expect(classes['segmented-btn-set-dense-2']).toBeFalsy(); + expect(classes['segmented-btn-set-dense-3']).toBeFalsy(); + }); + + it('adds dense-1/2/3 for density -1/-2/-3', () => { + expect( + segmentedButtonSetClasses({density: -1})['segmented-btn-set-dense-1'], + ).toBeTrue(); + expect( + segmentedButtonSetClasses({density: -2})['segmented-btn-set-dense-2'], + ).toBeTrue(); + expect( + segmentedButtonSetClasses({density: -3})['segmented-btn-set-dense-3'], + ).toBeTrue(); + }); + + it('handles disabled state', () => { + expect( + segmentedButtonSetClasses({disabled: true})['disabled'], + ).toBeTrue(); + }); + }); + + describe('segmentedButtonClasses()', () => { + it('includes ripple and focus-ring classes', () => { + const classes = segmentedButtonClasses(); + expect(classes['segmented-btn']).toBeTrue(); + expect(classes['ripple']).toBeTrue(); + expect(classes['focus-ring-outer']).toBeTrue(); + }); + + it('toggles selected / with-icon / icon-only / disabled', () => { + const classes = segmentedButtonClasses({ + selected: true, + withIcon: true, + iconOnly: true, + disabled: true, + }); + expect(classes['segmented-btn-selected']).toBeTrue(); + expect(classes['segmented-btn-with-icon']).toBeTrue(); + expect(classes['segmented-btn-icon-only']).toBeTrue(); + expect(classes['disabled']).toBeTrue(); + }); + }); + + describe('renderSegmentedButtonCheckmark()', () => { + it('renders an aria-hidden svg with checkmark class and path', async () => { + const root = env.render( + html`
${renderSegmentedButtonCheckmark()}
`, + ); + await env.waitForStability(); + const svg = root.querySelector('svg'); + expect(svg).not.toBeNull(); + expect(svg!.classList.contains('segmented-btn-checkmark')).toBeTrue(); + expect(svg!.getAttribute('aria-hidden')).toBe('true'); + expect(svg!.getAttribute('viewBox')).toBe('0 0 24 24'); + expect(svg!.getAttribute('preserveAspectRatio')).toBe('xMidYMid slice'); + const path = svg!.querySelector('path'); + expect(path).not.toBeNull(); + expect(path!.getAttribute('d')).toBe(SEGMENTED_BUTTON_CHECKMARK_PATH); + expect(path!.getAttribute('pathLength')).toBe('1'); + }); + }); + + describe('directives', () => { + it('segmentedButtonSet applies its classes in a Lit render', async () => { + const root = env.render( + html`
`, + ); + await env.waitForStability(); + const el = root.querySelector('.segmented-btn-set'); + expect(el).not.toBeNull(); + expect(el!.classList.contains('segmented-btn-set-dense-1')).toBeTrue(); + }); + + it('segmentedButton applies its classes in a Lit render', async () => { + const root = env.render( + html``, + ); + await env.waitForStability(); + const el = root.querySelector('.segmented-btn'); + expect(el).not.toBeNull(); + expect(el!.classList.contains('segmented-btn-selected')).toBeTrue(); + }); + }); +}); + +describe('pure selection logic', () => { + describe('normalizeSegmentSelection()', () => { + it('single: keeps preferred when it is selected', () => { + const result = normalizeSegmentSelection([true, true, false], { + selection: 'single', + preferred: 0, + }); + expect(result).toEqual([true, false, false]); + }); + + it('single: otherwise keeps the last selected', () => { + const result = normalizeSegmentSelection([true, true, false], { + selection: 'single', + }); + expect(result).toEqual([false, true, false]); + }); + + it('single: never auto-selects', () => { + const result = normalizeSegmentSelection([false, false, false], { + selection: 'single', + }); + expect(result).toEqual([false, false, false]); + }); + + it('multiple: returns the input unchanged', () => { + const input = [true, false, true]; + const result = normalizeSegmentSelection(input, {selection: 'multiple'}); + expect(result).toEqual(input); + expect(result).not.toBe(input); + }); + }); +}); + +describe('plain-markup setup functions', () => { + const env = new Environment(); + + describe('setupSegmentedButton', () => { + it('radio: clicking an unchecked segment sets aria-checked=true, then fires input then change', async () => { + const root = env.render(html` + + `); + await env.waitForStability(); + const btn = root.querySelector('button') as HTMLElement; + setupSegmentedButton(btn); + + const events: string[] = []; + btn.addEventListener('input', () => events.push('input')); + btn.addEventListener('change', () => events.push('change')); + + const harness = new TestHarness(btn); + await harness.clickWithMouse(); + + expect(btn.getAttribute('aria-checked')).toBe('true'); + expect(btn.classList.contains('segmented-btn-selected')).toBeTrue(); + expect(events).toEqual(['input', 'change']); + }); + + it('radio: clicking a checked segment changes nothing and fires nothing', async () => { + const root = env.render(html` + + `); + await env.waitForStability(); + const btn = root.querySelector('button') as HTMLElement; + setupSegmentedButton(btn); + + const events: string[] = []; + btn.addEventListener('input', () => events.push('input')); + btn.addEventListener('change', () => events.push('change')); + + const harness = new TestHarness(btn); + await harness.clickWithMouse(); + + expect(btn.getAttribute('aria-checked')).toBe('true'); + expect(events.length).toBe(0); + }); + + it('checkbox: a click toggles aria-checked both ways and fires input + change each time', async () => { + const root = env.render(html` + + `); + await env.waitForStability(); + const btn = root.querySelector('button') as HTMLElement; + setupSegmentedButton(btn); + + const events: string[] = []; + btn.addEventListener('input', () => events.push('input')); + btn.addEventListener('change', () => events.push('change')); + + const harness = new TestHarness(btn); + await harness.clickWithMouse(); + expect(btn.getAttribute('aria-checked')).toBe('true'); + expect(btn.classList.contains('segmented-btn-selected')).toBeTrue(); + expect(events).toEqual(['input', 'change']); + + await harness.clickWithMouse(); + expect(btn.getAttribute('aria-checked')).toBe('false'); + expect(btn.classList.contains('segmented-btn-selected')).toBeFalse(); + expect(events).toEqual(['input', 'change', 'input', 'change']); + }); + + it('input is bubbles + composed; change bubbles and is not composed', async () => { + const root = env.render(html` + + `); + await env.waitForStability(); + const btn = root.querySelector('button') as HTMLElement; + setupSegmentedButton(btn); + + let inputEvent: InputEvent | null = null; + let changeEvent: Event | null = null; + btn.addEventListener('input', (e) => { + inputEvent = e as InputEvent; + }); + btn.addEventListener('change', (e) => { + changeEvent = e; + }); + + const harness = new TestHarness(btn); + await harness.clickWithMouse(); + + expect(inputEvent).not.toBeNull(); + expect(inputEvent!.bubbles).toBeTrue(); + expect(inputEvent!.composed).toBeTrue(); + + expect(changeEvent).not.toBeNull(); + expect(changeEvent!.bubbles).toBeTrue(); + expect(changeEvent!.composed).toBeFalse(); + }); + + it('ignores clicks when disabled or aria-disabled="true"', async () => { + const root = env.render(html` + + + `); + await env.waitForStability(); + const [btn1, btn2] = Array.from( + root.querySelectorAll('button'), + ) as HTMLElement[]; + setupSegmentedButton(btn1); + setupSegmentedButton(btn2); + + const events: string[] = []; + btn1.addEventListener('input', () => events.push('btn1')); + btn2.addEventListener('input', () => events.push('btn2')); + + await new TestHarness(btn1).clickWithMouse(); + await new TestHarness(btn2).clickWithMouse(); + + expect(btn1.getAttribute('aria-checked')).toBe('false'); + expect(btn2.getAttribute('aria-checked')).toBe('false'); + expect(events.length).toBe(0); + }); + + it('respects preventDefault() from a consumer click listener', async () => { + const root = env.render(html` + + `); + await env.waitForStability(); + const btn = root.querySelector('button') as HTMLElement; + setupSegmentedButton(btn); + + btn.addEventListener('click', (e) => { + e.preventDefault(); + }); + + const events: string[] = []; + btn.addEventListener('input', () => events.push('input')); + + await new TestHarness(btn).clickWithMouse(); + expect(btn.getAttribute('aria-checked')).toBe('false'); + expect(events.length).toBe(0); + }); + + it('Space and Enter each activate a multi-select segment once in plain markup', async () => { + const root = env.render(html` +
+ +
+ `); + await env.waitForStability(); + const btn = root.querySelector('button') as HTMLElement; + setupSegmentedButton(btn); + let inputs = 0; + let changes = 0; + btn.addEventListener('input', () => inputs++); + btn.addEventListener('change', () => changes++); + const harness = new TestHarness(btn); + + await harness.startClickWithKeyboard({key: ' '}); + expect(btn.getAttribute('aria-checked')).toBe('true'); + expect([inputs, changes]).toEqual([1, 1]); + + await harness.startClickWithKeyboard({key: 'Enter'}); + expect(btn.getAttribute('aria-checked')).toBe('false'); + expect([inputs, changes]).toEqual([2, 2]); + }); + + it('stops after the AbortSignal aborts', async () => { + const root = env.render(html` + + `); + await env.waitForStability(); + const btn = root.querySelector('button') as HTMLElement; + const controller = new AbortController(); + setupSegmentedButton(btn, {signal: controller.signal}); + + controller.abort(); + await new TestHarness(btn).clickWithMouse(); + + expect(btn.getAttribute('aria-checked')).toBe('false'); + }); + }); + + describe('setupSegmentedButtonSet', () => { + it('checking a radio unchecks sibling radios', async () => { + const root = env.render(html` +
+ + + +
+ `); + await env.waitForStability(); + const set = root.querySelector('.segmented-btn-set') as HTMLElement; + const [btn1, btn2, btn3] = Array.from( + set.querySelectorAll('button'), + ) as HTMLElement[]; + setupSegmentedButtonSet(set); + setupSegmentedButton(btn1); + setupSegmentedButton(btn2); + setupSegmentedButton(btn3); + + await new TestHarness(btn2).clickWithMouse(); + + expect(btn1.getAttribute('aria-checked')).toBe('false'); + expect(btn1.classList.contains('segmented-btn-selected')).toBeFalse(); + expect(btn2.getAttribute('aria-checked')).toBe('true'); + expect(btn2.classList.contains('segmented-btn-selected')).toBeTrue(); + expect(btn3.getAttribute('aria-checked')).toBe('false'); + }); + + it('leaves checkbox siblings alone', async () => { + const root = env.render(html` +
+ + +
+ `); + await env.waitForStability(); + const set = root.querySelector('.segmented-btn-set') as HTMLElement; + const [btn1, btn2] = Array.from( + set.querySelectorAll('button'), + ) as HTMLElement[]; + setupSegmentedButtonSet(set); + setupSegmentedButton(btn1); + setupSegmentedButton(btn2); + + await new TestHarness(btn2).clickWithMouse(); + + expect(btn1.getAttribute('aria-checked')).toBe('true'); + expect(btn2.getAttribute('aria-checked')).toBe('true'); + }); + + it('ignores non-segment children and nested descendants', async () => { + const root = env.render(html` +
+ +
+ +
+
+ `); + await env.waitForStability(); + const set = root.querySelector('.segmented-btn-set') as HTMLElement; + const btn1 = root.querySelector('#btn1') as HTMLElement; + const nestedBtn = root.querySelector('#nested-btn') as HTMLElement; + setupSegmentedButtonSet(set); + setupSegmentedButton(btn1); + setupSegmentedButton(nestedBtn); + + await new TestHarness(nestedBtn).clickWithMouse(); + // btn1 should not be unchecked because nestedBtn is not a direct segment + // child of set + expect(btn1.getAttribute('aria-checked')).toBe('true'); + }); + + it('fires input then change on the set once per user activation and stops child events', async () => { + const root = env.render(html` +
+ +
+ `); + await env.waitForStability(); + const set = root.querySelector('.segmented-btn-set') as HTMLElement; + const btn1 = root.querySelector('#btn1') as HTMLElement; + setupSegmentedButtonSet(set); + setupSegmentedButton(btn1); + + const setEvents: string[] = []; + set.addEventListener('input', (e) => { + expect(e.target).toBe(set); + setEvents.push('set:input'); + }); + set.addEventListener('change', (e) => { + expect(e.target).toBe(set); + setEvents.push('set:change'); + }); + + await new TestHarness(btn1).clickWithMouse(); + + expect(setEvents).toEqual(['set:input', 'set:change']); + }); + + it('consumer listener registered before setupSegmentedButtonSet receives exactly 1 input and 1 change with target === set', async () => { + const root = env.render(html` +
+ + +
+ `); + await env.waitForStability(); + const set = root.querySelector('.segmented-btn-set') as HTMLElement; + const b1 = root.querySelector('#b1') as HTMLElement; + const b2 = root.querySelector('#b2') as HTMLElement; + + const inputTargets: EventTarget[] = []; + const changeTargets: EventTarget[] = []; + set.addEventListener('input', (e) => inputTargets.push(e.target!)); + set.addEventListener('change', (e) => changeTargets.push(e.target!)); + + setupSegmentedButtonSet(set); + setupSegmentedButton(b1); + setupSegmentedButton(b2); + + await new TestHarness(b1).clickWithMouse(); + + expect(inputTargets.length).toBe(1); + expect(inputTargets[0]).toBe(set); + expect(changeTargets.length).toBe(1); + expect(changeTargets[0]).toBe(set); + }); + + it('directive-managed set relays exactly once after its LitElement host moves', async () => { + class SbHost extends LitElement { + protected override createRenderRoot() { + return this; + } + protected override render() { + return html`
+ + +
`; + } + } + customElements.define('test-sb-host', SbHost); + const root = env.render(html`
`); + const host = document.createElement('test-sb-host'); + root.querySelector('#from')!.append(host); + await env.waitForStability(); + root.querySelector('#to')!.append(host); + await env.waitForStability(); + const set = host.querySelector('.segmented-btn-set')!; + let inputs = 0; + let changes = 0; + set.addEventListener('input', () => inputs++); + set.addEventListener('change', () => changes++); + await new TestHarness( + host.querySelector('#b1') as HTMLElement, + ).clickWithMouse(); + expect([inputs, changes]).toEqual([1, 1]); + }); + + it('plain markup: setting set disabled in set input listener still contains child change and dispatches set change', async () => { + const root = env.render(html` +
+ +
+ `); + await env.waitForStability(); + const set = root.querySelector('.segmented-btn-set') as HTMLElement; + const b1 = root.querySelector('#b1') as HTMLElement; + setupSegmentedButtonSet(set); + setupSegmentedButton(b1); + + const changeTargets: EventTarget[] = []; + set.addEventListener('input', () => { + set.classList.add('disabled'); + set.setAttribute('aria-disabled', 'true'); + }); + set.addEventListener('change', (e) => changeTargets.push(e.target!)); + + await new TestHarness(b1).clickWithMouse(); + + expect(changeTargets.length).toBe(1); + expect(changeTargets[0]).toBe(set); + }); + + it('plain markup: pre-checked radios [a, b] normalize to b at setup with no events', async () => { + const root = env.render(html` +
+ + +
+ `); + await env.waitForStability(); + const set = root.querySelector('.segmented-btn-set') as HTMLElement; + const a = root.querySelector('#a') as HTMLElement; + const b = root.querySelector('#b') as HTMLElement; + + let events = 0; + set.addEventListener('input', () => events++); + set.addEventListener('change', () => events++); + + setupSegmentedButtonSet(set); + setupSegmentedButton(a); + setupSegmentedButton(b); + + expect(events).toBe(0); + expect(a.getAttribute('aria-checked')).toBe('false'); + expect(a.classList.contains('segmented-btn-selected')).toBeFalse(); + expect(b.getAttribute('aria-checked')).toBe('true'); + expect(b.classList.contains('segmented-btn-selected')).toBeTrue(); + }); + + it('stops after the AbortSignal aborts', async () => { + const root = env.render(html` +
+ + +
+ `); + await env.waitForStability(); + const set = root.querySelector('.segmented-btn-set') as HTMLElement; + const [btn1, btn2] = Array.from( + set.querySelectorAll('button'), + ) as HTMLElement[]; + const controller = new AbortController(); + setupSegmentedButtonSet(set, {signal: controller.signal}); + setupSegmentedButton(btn1); + setupSegmentedButton(btn2); + + controller.abort(); + + await new TestHarness(btn2).clickWithMouse(); + // Sibling radio was NOT unchecked because set setup was aborted + expect(btn1.getAttribute('aria-checked')).toBe('true'); + }); + }); +}); + +describe('plain-markup styles', () => { + const env = new Environment(); + + it('segments have equal widths regardless of label length', async () => { + const root = env.render(html` +
+ + + +
+ `); + await env.waitForStability(); + const btns = Array.from( + root.querySelectorAll('.segmented-btn'), + ) as HTMLElement[]; + const widths = btns.map((b) => b.getBoundingClientRect().width); + expect(Math.abs(widths[0] - widths[1])).toBeLessThanOrEqual(1); + expect(Math.abs(widths[1] - widths[2])).toBeLessThanOrEqual(1); + }); + + it('labels do not wrap and ellipsize when the set is constrained', async () => { + const root = env.render(html` +
+ + +
+ `); + await env.waitForStability(); + const label = root.querySelector('.segmented-btn-label') as HTMLElement; + const computed = getComputedStyle(label); + expect(computed.overflow).toBe('hidden'); + expect(computed.textOverflow).toBe('ellipsis'); + expect(computed.whiteSpace).toBe('nowrap'); + }); + + it('height is 40/36/32/28px for density 0/-1/-2/-3', async () => { + const root = env.render(html` +
+
+
+
+ `); + await env.waitForStability(); + const b0 = root.querySelector('#s0 .segmented-btn') as HTMLElement; + const b1 = root.querySelector('#s1 .segmented-btn') as HTMLElement; + const b2 = root.querySelector('#s2 .segmented-btn') as HTMLElement; + const b3 = root.querySelector('#s3 .segmented-btn') as HTMLElement; + expect(Math.round(b0.getBoundingClientRect().height)).toBe(40); + expect(Math.round(b1.getBoundingClientRect().height)).toBe(36); + expect(Math.round(b2.getBoundingClientRect().height)).toBe(32); + expect(Math.round(b3.getBoundingClientRect().height)).toBe(28); + }); + + it('outer corners are full and inner corners are 0 (LTR)', async () => { + const root = env.render(html` +
+ + + +
+ `); + await env.waitForStability(); + const first = root.querySelector('#first') as HTMLElement; + const mid = root.querySelector('#mid') as HTMLElement; + const last = root.querySelector('#last') as HTMLElement; + + const firstStyle = getComputedStyle(first); + expect(parsePx(firstStyle.borderTopLeftRadius)).toBeGreaterThan(0); + expect(parsePx(firstStyle.borderBottomLeftRadius)).toBeGreaterThan(0); + expect(parsePx(firstStyle.borderTopRightRadius)).toBe(0); + expect(parsePx(firstStyle.borderBottomRightRadius)).toBe(0); + + const midStyle = getComputedStyle(mid); + expect(parsePx(midStyle.borderTopLeftRadius)).toBe(0); + expect(parsePx(midStyle.borderBottomLeftRadius)).toBe(0); + expect(parsePx(midStyle.borderTopRightRadius)).toBe(0); + expect(parsePx(midStyle.borderBottomRightRadius)).toBe(0); + + const lastStyle = getComputedStyle(last); + expect(parsePx(lastStyle.borderTopLeftRadius)).toBe(0); + expect(parsePx(lastStyle.borderBottomLeftRadius)).toBe(0); + expect(parsePx(lastStyle.borderTopRightRadius)).toBeGreaterThan(0); + expect(parsePx(lastStyle.borderBottomRightRadius)).toBeGreaterThan(0); + }); + + it('corners mirror in RTL', async () => { + const root = env.render(html` +
+ + +
+ `); + await env.waitForStability(); + const first = root.querySelector('#rtl-first') as HTMLElement; + const last = root.querySelector('#rtl-last') as HTMLElement; + + const firstStyle = getComputedStyle(first); + expect(parsePx(firstStyle.borderTopRightRadius)).toBeGreaterThan(0); + expect(parsePx(firstStyle.borderBottomRightRadius)).toBeGreaterThan(0); + expect(parsePx(firstStyle.borderTopLeftRadius)).toBe(0); + expect(parsePx(firstStyle.borderBottomLeftRadius)).toBe(0); + + const lastStyle = getComputedStyle(last); + expect(parsePx(lastStyle.borderTopLeftRadius)).toBeGreaterThan(0); + expect(parsePx(lastStyle.borderBottomLeftRadius)).toBeGreaterThan(0); + expect(parsePx(lastStyle.borderTopRightRadius)).toBe(0); + expect(parsePx(lastStyle.borderBottomRightRadius)).toBe(0); + }); + + it('a lone segment gets all four corners', async () => { + const root = env.render(html` +
+ +
+ `); + await env.waitForStability(); + const lone = root.querySelector('#lone') as HTMLElement; + const style = getComputedStyle(lone); + expect(parsePx(style.borderTopLeftRadius)).toBeGreaterThan(0); + expect(parsePx(style.borderBottomLeftRadius)).toBeGreaterThan(0); + expect(parsePx(style.borderTopRightRadius)).toBeGreaterThan(0); + expect(parsePx(style.borderBottomRightRadius)).toBeGreaterThan(0); + }); + + it('a hidden segment is skipped by corner and divider rules', async () => { + const root = env.render(html` +
+ + + +
+ `); + await env.waitForStability(); + const hiddenBtn = root.querySelector('#hidden-btn') as HTMLElement; + const firstVis = root.querySelector('#first-visible') as HTMLElement; + const lastVis = root.querySelector('#last-visible') as HTMLElement; + + expect(getComputedStyle(hiddenBtn).display).toBe('none'); + + const firstStyle = getComputedStyle(firstVis); + expect(parsePx(firstStyle.borderTopLeftRadius)).toBeGreaterThan(0); + expect(parsePx(firstStyle.borderBottomLeftRadius)).toBeGreaterThan(0); + expect(firstStyle.borderInlineEndWidth).toBe('0px'); + + const lastStyle = getComputedStyle(lastVis); + expect(parsePx(lastStyle.borderTopRightRadius)).toBeGreaterThan(0); + expect(parsePx(lastStyle.borderBottomRightRadius)).toBeGreaterThan(0); + expect(lastStyle.borderInlineEndWidth).toBe('1px'); + }); + + it('one 1px divider: only non-last visible segments drop border-inline-end', async () => { + const root = env.render(html` +
+ + +
+ `); + await env.waitForStability(); + const b1 = root.querySelector('#d-btn1') as HTMLElement; + const b2 = root.querySelector('#d-btn2') as HTMLElement; + + expect(getComputedStyle(b1).borderInlineEndWidth).toBe('0px'); + expect(getComputedStyle(b2).borderInlineEndWidth).toBe('1px'); + }); + + it('the touch target is 48px at every density', async () => { + const root = env.render(html` +
+
+ +
+
+ `); + await env.waitForStability(); + const btn = root.querySelector('#dense-btn') as HTMLElement; + const rect = btn.getBoundingClientRect(); + const centerX = rect.left + rect.width / 2; + + const abovePoint = document.elementFromPoint(centerX, rect.top - 5); + const belowPoint = document.elementFromPoint(centerX, rect.bottom + 5); + expect(abovePoint).toBe(btn); + expect(belowPoint).toBe(btn); + }); + + it('a keyboard-focused segment has z-index 1', async () => { + const root = env.render(html` +
+ +
+ `); + await env.waitForStability(); + const btn = root.querySelector('#f-btn') as HTMLElement; + expect(getComputedStyle(btn).zIndex).toBe('auto'); + + await new TestHarness(btn).focusWithKeyboard(); + expect(getComputedStyle(btn).zIndex).toBe('1'); + }); + + it('shows the focus ring when focused with the keyboard in class API', async () => { + const root = env.render(html` + + `); + await env.waitForStability(); + const btn = root.querySelector('.segmented-btn') as HTMLElement; + await new TestHarness(btn).focusWithKeyboard(); + expect(getComputedStyle(btn).outlineStyle).toBe('solid'); + }); + + it('disabled segment has pointer-events: none', async () => { + const root = env.render(html` + + `); + await env.waitForStability(); + const btn = root.querySelector('#dis-btn') as HTMLElement; + expect(getComputedStyle(btn).pointerEvents).toBe('none'); + }); + + it('segment with .disabled class has pointer-events: none', async () => { + const root = env.render(html` + + `); + await env.waitForStability(); + const btn = root.querySelector('.segmented-btn') as HTMLElement; + expect(getComputedStyle(btn).pointerEvents).toBe('none'); + }); + + it('segment with .disabled class has disabled colors', async () => { + const root = env.render(html` +
+ + +
+ `); + await env.waitForStability(); + const btn = root.querySelector('.segmented-btn') as HTMLElement; + const probe = root.querySelector('#probe') as HTMLElement; + expect(getComputedStyle(btn).color).toBe(getComputedStyle(probe).color); + expect(getComputedStyle(btn).borderColor).toBe( + getComputedStyle(probe).borderColor, + ); + }); + + it('selected segment with .disabled class has pointer-events: none', async () => { + const root = env.render(html` + + `); + await env.waitForStability(); + const btn = root.querySelector('.segmented-btn') as HTMLElement; + expect(getComputedStyle(btn).pointerEvents).toBe('none'); + }); + + it('selected segment with .disabled class has transparent background and 0px checkmark', async () => { + const root = env.render(html` + + + `); + await env.waitForStability(); + const btn = root.querySelector('.segmented-btn') as HTMLElement; + const probe = root.querySelector('#probe') as HTMLElement; + const checkmark = root.querySelector( + '.segmented-btn-checkmark', + ) as HTMLElement; + expect(getComputedStyle(btn).backgroundColor).toBe('rgba(0, 0, 0, 0)'); + expect(getComputedStyle(btn).color).toBe(getComputedStyle(probe).color); + expect(parsePx(getComputedStyle(checkmark).inlineSize)).toBe(0); + expect(Number(getComputedStyle(checkmark).opacity)).toBe(0); + }); + + it('segment with aria-disabled="true" has pointer-events: none', async () => { + const root = env.render(html` + + `); + await env.waitForStability(); + const btn = root.querySelector('.segmented-btn') as HTMLElement; + expect(getComputedStyle(btn).pointerEvents).toBe('none'); + }); + + it('plain-markup set with aria-disabled="true" styles children as disabled with transparent background', async () => { + const root = env.render(html` +
+ + + +
+ `); + await env.waitForStability(); + const child = root.querySelector('#aria-child') as HTMLElement; + const selChild = root.querySelector('#aria-sel-child') as HTMLElement; + const probe = root.querySelector('#probe') as HTMLElement; + const checkmark = selChild.querySelector( + '.segmented-btn-checkmark', + ) as HTMLElement; + + expect(getComputedStyle(child).pointerEvents).toBe('none'); + expect(getComputedStyle(child).color).toBe(getComputedStyle(probe).color); + expect(getComputedStyle(selChild).backgroundColor).toBe('rgba(0, 0, 0, 0)'); + expect(parsePx(getComputedStyle(checkmark).inlineSize)).toBe(0); + expect(Number(getComputedStyle(checkmark).opacity)).toBe(0); + }); + + it('disabled set in plain markup blocks child activation, deselects children, and styles as transparent', async () => { + const root = env.render(html` +
+ + +
+ `); + await env.waitForStability(); + const set = root.querySelector('.segmented-btn-set') as HTMLElement; + setupSegmentedButtonSet(set); + const b1 = root.querySelector('#b1') as HTMLElement; + setupSegmentedButton(b1); + const b2 = root.querySelector('#b2') as HTMLElement; + setupSegmentedButton(b2); + + expect(getComputedStyle(b1).pointerEvents).toBe('none'); + expect(getComputedStyle(b2).pointerEvents).toBe('none'); + expect(getComputedStyle(b2).backgroundColor).toBe('rgba(0, 0, 0, 0)'); + + let events = 0; + set.addEventListener('input', () => events++); + set.addEventListener('change', () => events++); + b1.addEventListener('input', () => events++); + b1.addEventListener('change', () => events++); + + await new TestHarness(b1).clickWithMouse(); + + expect(events).toBe(0); + expect(b1.getAttribute('aria-checked')).toBe('false'); + expect(b2.getAttribute('aria-checked')).toBe('false'); + expect(b2.classList.contains('segmented-btn-selected')).toBeFalse(); + }); + + it('disabled segment following enabled segment keeps solid outline on shared divider', async () => { + const root = env.render(html` +
+ + + +
+ + + `); + await env.waitForStability(); + const b2 = root.querySelector('#b2') as HTMLElement; + const probeEnabled = root.querySelector('#probe-enabled') as HTMLElement; + const probeDisabled = root.querySelector('#probe-disabled') as HTMLElement; + + // Touching enabled b1: borderInlineStartColor must be solid enabled outline + expect(getComputedStyle(b2).borderInlineStartColor).toBe( + getComputedStyle(probeEnabled).color, + ); + // Top and bottom borders must remain disabled outline + expect(getComputedStyle(b2).borderBlockStartColor).toBe( + getComputedStyle(probeDisabled).color, + ); + expect(getComputedStyle(b2).borderBlockEndColor).toBe( + getComputedStyle(probeDisabled).color, + ); + }); + + it('width stability: unselected and selected have identical max-content width for label-only and icon-only', async () => { + const root = env.render(html` +
+ + + + +
+ `); + await env.waitForStability(); + const uLabel = root.querySelector('#unselected-label') as HTMLElement; + const sLabel = root.querySelector('#selected-label') as HTMLElement; + const uIcon = root.querySelector('#unselected-icon') as HTMLElement; + const sIcon = root.querySelector('#selected-icon') as HTMLElement; + + expect(Math.round(uLabel.getBoundingClientRect().width)).toBe( + Math.round(sLabel.getBoundingClientRect().width), + ); + expect(Math.round(uIcon.getBoundingClientRect().width)).toBe( + Math.round(sIcon.getBoundingClientRect().width), + ); + }); +}); + +describe('checkmark and configuration', () => { + const env = new Environment(); + + describe('geometry', () => { + it('label only, unselected: checkmark width 0', async () => { + const root = env.render(html` + + `); + await env.waitForStability(); + const checkmark = root.querySelector( + '.segmented-btn-checkmark', + ) as HTMLElement; + expect(checkmark.getBoundingClientRect().width).toBe(0); + }); + + it('label only, selected: checkmark 18 + 8 + label', async () => { + const root = env.render(html` + + `); + await env.waitForStability(); + const btn = root.querySelector('.segmented-btn') as HTMLElement; + const graphic = root.querySelector( + '.segmented-btn-graphic', + ) as HTMLElement; + const checkmark = root.querySelector( + '.segmented-btn-checkmark', + ) as HTMLElement; + const label = root.querySelector('.segmented-btn-label') as HTMLElement; + + const checkmarkRect = checkmark.getBoundingClientRect(); + expect(Math.round(checkmarkRect.width)).toBe(18); + expect(Math.round(checkmarkRect.height)).toBe(18); + + const graphicRect = graphic.getBoundingClientRect(); + expect(Math.round(graphicRect.width)).toBe(18); + expect(Math.round(graphicRect.height)).toBe(18); + + expect(getComputedStyle(btn).columnGap).toBe('8px'); + expect( + Math.round(label.getBoundingClientRect().left - checkmarkRect.right), + ).toBe(8); + }); + + it('label + icon, unselected: icon opacity 1, checkmark hidden', async () => { + const root = env.render(html` + + `); + await env.waitForStability(); + const checkmark = root.querySelector( + '.segmented-btn-checkmark', + ) as HTMLElement; + const icon = root.querySelector('.segmented-btn-icon') as HTMLElement; + + expect(getComputedStyle(checkmark).opacity).toBe('0'); + expect(getComputedStyle(icon).opacity).toBe('1'); + }); + + it('label + icon, selected: checkmark replaces icon and label does not move', async () => { + const root = env.render(html` +
+ + +
+ `); + await env.waitForStability(); + const unsel = root.querySelector('#unselected') as HTMLElement; + const sel = root.querySelector('#selected') as HTMLElement; + + const selCheckmark = sel.querySelector( + '.segmented-btn-checkmark', + ) as HTMLElement; + const selIcon = sel.querySelector('.segmented-btn-icon') as HTMLElement; + const selGraphic = sel.querySelector( + '.segmented-btn-graphic', + ) as HTMLElement; + + expect(getComputedStyle(selCheckmark).opacity).toBe('1'); + expect(getComputedStyle(selIcon).opacity).toBe('0'); + + const selCheckmarkRect = selCheckmark.getBoundingClientRect(); + expect(Math.round(selCheckmarkRect.width)).toBe(18); + expect(Math.round(selCheckmarkRect.height)).toBe(18); + + const selGraphicRect = selGraphic.getBoundingClientRect(); + expect(Math.round(selGraphicRect.width)).toBe(18); + expect(Math.round(selGraphicRect.height)).toBe(18); + + expect(getComputedStyle(sel).columnGap).toBe('8px'); + + // The label inline-start relative to button inline-start is identical + const unselLabel = unsel.querySelector( + '.segmented-btn-label', + ) as HTMLElement; + const selLabel = sel.querySelector('.segmented-btn-label') as HTMLElement; + const unselDist = + unselLabel.getBoundingClientRect().left - + unsel.getBoundingClientRect().left; + const selDist = + selLabel.getBoundingClientRect().left - + sel.getBoundingClientRect().left; + expect(Math.round(unselDist)).toBe(Math.round(selDist)); + expect( + Math.round( + selLabel.getBoundingClientRect().left - selCheckmarkRect.right, + ), + ).toBe(8); + }); + + it('icon only, unselected: icon centered, checkmark width 0', async () => { + const root = env.render(html` + + `); + await env.waitForStability(); + const checkmark = root.querySelector( + '.segmented-btn-checkmark', + ) as HTMLElement; + expect(checkmark.getBoundingClientRect().width).toBe(0); + }); + + it('icon only, selected: checkmark 18 + 8 + icon 18 at inline-start (LTR and RTL)', async () => { + const root = env.render(html` +
+ +
+ +
+
+ `); + await env.waitForStability(); + const ltrBtn = root.querySelector('#ltr-icon-only') as HTMLElement; + const rtlBtn = root.querySelector('#rtl-icon-only') as HTMLElement; + + const ltrCheck = ltrBtn.querySelector( + '.segmented-btn-checkmark', + ) as HTMLElement; + const ltrIcon = ltrBtn.querySelector( + '.segmented-btn-icon', + ) as HTMLElement; + const ltrCheckRect = ltrCheck.getBoundingClientRect(); + expect(Math.round(ltrCheckRect.width)).toBe(18); + expect(Math.round(ltrCheckRect.height)).toBe(18); + expect(Math.round(ltrIcon.getBoundingClientRect().width)).toBe(18); + // In LTR, checkmark is to the left of icon with 8px gap + expect( + Math.round(ltrIcon.getBoundingClientRect().left - ltrCheckRect.right), + ).toBe(8); + + const rtlCheck = rtlBtn.querySelector( + '.segmented-btn-checkmark', + ) as HTMLElement; + const rtlIcon = rtlBtn.querySelector( + '.segmented-btn-icon', + ) as HTMLElement; + const rtlCheckRect = rtlCheck.getBoundingClientRect(); + expect(Math.round(rtlCheckRect.width)).toBe(18); + expect(Math.round(rtlCheckRect.height)).toBe(18); + expect(Math.round(rtlIcon.getBoundingClientRect().width)).toBe(18); + // In RTL, checkmark is to the right of icon (inline-start) with 8px gap + expect( + Math.round(rtlCheckRect.left - rtlIcon.getBoundingClientRect().right), + ).toBe(8); + }); + + it('the checkmark stroke matches the label color', async () => { + const root = env.render(html` + + `); + await env.waitForStability(); + const checkmark = root.querySelector( + '.segmented-btn-checkmark', + ) as SVGElement; + const label = root.querySelector('.segmented-btn-label') as HTMLElement; + expect(getComputedStyle(checkmark).stroke).toBe( + getComputedStyle(label).color, + ); + }); + }); + + describe('motion and media rules', () => { + it('no CSS animation runs on first render of a selected segment', async () => { + const root = env.render(html` + + `); + await env.waitForStability(); + const btn = root.querySelector('.segmented-btn') as HTMLElement; + expect(btn.getAnimations({subtree: true}).length).toBe(0); + }); + + it('no padding-inline animation runs on initial render of an unselected label + icon segment', async () => { + const root = env.render(html` + + * + Walking + + `); + await env.waitForStability(); + const segment = root.querySelector( + 'md-gb-segmented-button', + ) as HTMLElement; + const button = segment.shadowRoot!.querySelector('button')!; + const animations = button.getAnimations({subtree: true}); + const paddingAnimations = animations.filter((anim) => + (anim as CSSTransition).transitionProperty?.includes('padding-inline'), + ); + expect(paddingAnimations.length).toBe(0); + }); + + it('the stylesheet defines transitions for checkmark stroke-dashoffset and inline-size', async () => { + const root = env.render(html` + + `); + await env.waitForStability(); + const checkmark = root.querySelector( + '.segmented-btn-checkmark', + ) as HTMLElement; + const transitionProperty = getComputedStyle(checkmark).transitionProperty; + expect(transitionProperty).toContain('stroke-dashoffset'); + expect(transitionProperty).toContain('inline-size'); + }); + }); +});