From bf3d87ca3cd810814593904fc09c05c9b0fa8c19 Mon Sep 17 00:00:00 2001 From: Emily Lin Date: Tue, 29 Sep 2026 08:37:27 -0700 Subject: [PATCH] feat(labs): polish toolbar UX layout and disabled states This change implements UX evaluation refinements for the gBreeze toolbar: - Fix slot spacing and target area gap: enforce 4dp gap between 48px slot target areas (12px visual gap between button containers). - Improve disabled state handling: - Propagate disabled state to all slotted actions, buttons, and form controls, preserving pre-disabled states upon re-enabling. - Apply standard GM3 disabled tokens: 38% opacity for labels and icons, and 12% opacity on-surface for contained buttons. - Completely suppress pointer events, cursors, ripples, and state layers when disabled. - Automatically clear and remove active/pressed/selected state layers upon entering the disabled state. - Fix hideLabels shape transition: suppress cubic-bezier spring overshoot so buttons transition smoothly between pill and circle without becoming square. - Update demo playground: - Add individual slot disabled controls (disabledLeadingSlot, disabledDefaultSlot, disabledTrailingSlot). - Update trailing slot to a labeled button with more_vert icon. - Ensure disabled slot buttons immediately clear selected container styling. - Add comprehensive unit test suite in toolbar_test.ts. PiperOrigin-RevId: 990327342 --- .../components/toolbar/_toolbar-tokens.scss | 24 +- labs/gb/components/toolbar/demo/demo.ts | 4 + labs/gb/components/toolbar/demo/stories.ts | 75 +- labs/gb/components/toolbar/toolbar-element.ts | 283 ++- labs/gb/components/toolbar/toolbar.scss | 671 +++++++- labs/gb/components/toolbar/toolbar.ts | 33 +- labs/gb/components/toolbar/toolbar_test.ts | 1525 +++++++++++++++++ 7 files changed, 2572 insertions(+), 43 deletions(-) create mode 100644 labs/gb/components/toolbar/toolbar_test.ts diff --git a/labs/gb/components/toolbar/_toolbar-tokens.scss b/labs/gb/components/toolbar/_toolbar-tokens.scss index c95f128a00..98c038847e 100644 --- a/labs/gb/components/toolbar/_toolbar-tokens.scss +++ b/labs/gb/components/toolbar/_toolbar-tokens.scss @@ -6,20 +6,22 @@ @mixin root { // LINT.IfChange --button-container-color: var(--md-sys-color-surface-container); + --button-shape: 9999px; --container-color: var(--md-sys-color-surface-container); --container-elevation: var(--md-sys-elevation-shadow-3); --container-leading-space: var(--md-sys-space-100); --container-shape: calc(var(--horizontal-container-height) / 2); - --container-spacing: var(--md-sys-space-50); + --container-spacing: var(--target-area-gap); --container-trailing-space: var(--md-sys-space-100); --docked-container-elevation: var(--md-sys-elevation-shadow-0); --docked-container-height: 64px; --docked-container-leading-space: var(--md-sys-space-200); --docked-container-max-spacing: 32px; - --docked-container-min-spacing: 4px; + --docked-container-min-spacing: var(--target-area-gap); --docked-container-shape: var(--md-sys-shape-corner-none); --docked-container-trailing-space: var(--md-sys-space-200); --horizontal-container-height: 64px; + --icon-button-shape: 50%; --icon-color: var(--md-sys-color-on-surface-variant); --label-text-color: var(--md-sys-color-on-surface-variant); --min-button-size: 40px; @@ -28,6 +30,8 @@ --selected-button-container-color: var(--md-sys-color-secondary-container); --selected-icon-color: var(--md-sys-color-on-secondary-container); --selected-label-text-color: var(--md-sys-color-on-secondary-container); + --target-area-gap: var(--md-sys-space-50); + --touch-target-size: 48px; --vertical-container-width: 64px; // LINT.ThenChange(toolbar-element.ts) keep in sync with `@cssprop` jsdoc tags } @@ -42,6 +46,22 @@ --selected-label-text-color: var(--md-sys-color-on-secondary-container); } +@mixin disabled { + @include standard; + --button-container-color: hsl( + from var(--md-sys-color-on-surface) h s l / 12% + ); + --selected-button-container-color: hsl( + from var(--md-sys-color-on-surface) h s l / 12% + ); + --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%); + --selected-icon-color: hsl(from var(--md-sys-color-on-surface) h s l / 38%); + --selected-label-text-color: hsl( + from var(--md-sys-color-on-surface) h s l / 38% + ); +} + @mixin vibrant { --button-container-color: var(--md-sys-color-primary-container); --container-color: var(--md-sys-color-primary-container); diff --git a/labs/gb/components/toolbar/demo/demo.ts b/labs/gb/components/toolbar/demo/demo.ts index d86204383d..de34c99ccc 100644 --- a/labs/gb/components/toolbar/demo/demo.ts +++ b/labs/gb/components/toolbar/demo/demo.ts @@ -41,6 +41,10 @@ const collection = new MaterialCollection>( }), }), new Knob('docked', {ui: boolInput()}), + new Knob('hideLabels', {ui: boolInput()}), + new Knob('disabledLeadingSlot', {ui: boolInput()}), + new Knob('disabledDefaultSlot', {ui: boolInput()}), + new Knob('disabledTrailingSlot', {ui: boolInput()}), ], ); diff --git a/labs/gb/components/toolbar/demo/stories.ts b/labs/gb/components/toolbar/demo/stories.ts index 2fdaf6e053..7e83b28aad 100644 --- a/labs/gb/components/toolbar/demo/stories.ts +++ b/labs/gb/components/toolbar/demo/stories.ts @@ -15,16 +15,18 @@ import { type ToolbarOrientation, } from '@material/web/labs/gb/components/toolbar/toolbar.js'; import {adoptStyles} from '@material/web/labs/gb/styles/adopt-styles.js'; +import {styles as m3Styles} from '@material/web/labs/gb/styles/m3.cssresult.js'; import {css, html} from 'lit'; -import {styles as m3Styles} from '@material/web/labs/gb/styles/m3.css' with {type: 'css'}; // github-only -// import {styles as m3Styles} from '@material/web/labs/gb/styles/m3.cssresult.js'; // google3-only - /** Knob types for toolbar stories. */ export interface StoryKnobs { color?: ToolbarColor; orientation?: ToolbarOrientation; docked: boolean; + hideLabels?: boolean; + disabledLeadingSlot?: boolean; + disabledDefaultSlot?: boolean; + disabledTrailingSlot?: boolean; } adoptStyles(document, [ @@ -72,21 +74,28 @@ const playground: MaterialStoryInit = { color=${color} orientation=${orientation} ?docked=${docked} + ?hide-labels=${Boolean(knobs.hideLabels)} aria-label="Formatting toolbar"> - + + undo + + edit Edit - - palette - Style + + more_vert + Options - - favorite - - - settings - `; @@ -96,55 +105,61 @@ const playground: MaterialStoryInit = { const allVariants: MaterialStoryInit = { name: 'Expressive Variants', styles, - render() { + render(knobs) { return html`
- - + + star Starred - + visibility Preview - + favorite
- - + + auto_awesome Generate - + palette Theme - + bookmark
- - + + home - + folder Files - + image diff --git a/labs/gb/components/toolbar/toolbar-element.ts b/labs/gb/components/toolbar/toolbar-element.ts index 775446f003..ecceae8498 100644 --- a/labs/gb/components/toolbar/toolbar-element.ts +++ b/labs/gb/components/toolbar/toolbar-element.ts @@ -12,11 +12,24 @@ import { type PropertyValues, } from 'lit'; import {property} from 'lit/decorators.js'; + import {adoptStyles} from '../../styles/adopt-styles.js'; +import {hasSlotted} from '../shared/has-slotted.js'; + +import { + toolbar, + type ToolbarAlign, + type ToolbarColor, + type ToolbarOrientation, +} from './toolbar.js'; import toolbarStyles from './toolbar.css' with {type: 'css'}; // github-only // import {styles as toolbarStyles} from './toolbar.cssresult.js'; // google3-only -import {toolbar, type ToolbarColor, type ToolbarOrientation} from './toolbar.js'; +/** Internal element interface supporting state properties without index signature. */ +declare interface ElementWithState extends Element { + disabled?: boolean; + selected?: boolean; +} /** * A Material Design gBreeze toolbar component. @@ -26,7 +39,10 @@ import {toolbar, type ToolbarColor, type ToolbarOrientation} from './toolbar.js' * @slot leading - Used to display leading toolbar items. * @slot trailing - Used to display trailing toolbar items. * @csspart toolbar - The toolbar's root container. + * + * // LINT.IfChange * @cssprop --button-container-color + * @cssprop --button-shape * @cssprop --container-color * @cssprop --container-elevation * @cssprop --container-leading-space @@ -41,6 +57,7 @@ import {toolbar, type ToolbarColor, type ToolbarOrientation} from './toolbar.js' * @cssprop --docked-container-shape * @cssprop --docked-container-trailing-space * @cssprop --horizontal-container-height + * @cssprop --icon-button-shape * @cssprop --icon-color * @cssprop --label-text-color * @cssprop --min-button-size @@ -49,7 +66,10 @@ import {toolbar, type ToolbarColor, type ToolbarOrientation} from './toolbar.js' * @cssprop --selected-button-container-color * @cssprop --selected-icon-color * @cssprop --selected-label-text-color + * @cssprop --target-area-gap + * @cssprop --touch-target-size * @cssprop --vertical-container-width + * // LINT.ThenChange(_toolbar-tokens.scss) */ export class ToolbarElement extends LitElement { /** @nocollapse */ @@ -87,9 +107,34 @@ export class ToolbarElement extends LitElement { /** Whether the toolbar is docked across full width. */ @property({type: Boolean, reflect: true}) docked = false; + /** Whether the toolbar is disabled. */ + @property({type: Boolean, reflect: true}) disabled = false; + + /** The alignment in vertical orientation. */ + @property({reflect: true}) align?: ToolbarAlign; + + /** Whether to hide text labels. */ + @property({type: Boolean, attribute: 'hide-labels', reflect: true}) + hideLabels = false; + + private itemObserver?: MutationObserver; + override connectedCallback() { super.connectedCallback(); adoptStyles(this, toolbarStyles); + this.itemObserver = new MutationObserver(this.handleItemMutations); + this.itemObserver.observe(this, { + attributes: true, + attributeFilter: ['disabled', 'aria-disabled'], + childList: true, + subtree: true, + }); + } + + override disconnectedCallback() { + this.itemObserver?.disconnect(); + this.itemObserver = undefined; + super.disconnectedCallback(); } protected override willUpdate() { @@ -112,6 +157,15 @@ export class ToolbarElement extends LitElement { `toolbar wrap ${isVertical ? 'block' : 'inline'}`, ); } + + if (changedProperties.has('disabled')) { + this.internals.ariaDisabled = this.disabled ? 'true' : 'false'; + this.updateDisabledPropagation(); + } + + if (changedProperties.has('hideLabels')) { + this.updateLabels(); + } } protected override render() { @@ -122,11 +176,232 @@ export class ToolbarElement extends LitElement { color: this.color, orientation: this.docked ? 'horizontal' : this.orientation, docked: this.docked, + disabled: this.disabled, + align: this.align, + hideLabels: this.hideLabels, })}"> - - - + + +
`; } + + private readonly handleItemMutations = (mutations: MutationRecord[]) => { + for (const mutation of mutations) { + if (mutation.type === 'childList' && this.disabled) { + this.updateDisabledPropagation(); + } + const target = mutation.target; + if (target instanceof Element && target !== this) { + const isElDisabled = + Boolean((target as ElementWithState).disabled) || + target.hasAttribute('disabled') || + target.getAttribute('aria-disabled') === 'true'; + if (isElDisabled) { + this.clearPressedState(target); + for (const desc of Array.from(target.querySelectorAll('*'))) { + this.clearPressedState(desc); + } + } + } + } + }; + + private readonly handleSlotChange = () => { + this.updateDisabledPropagation(); + this.updateLabels(); + const items = this.getSlottedItemsAndDescendants(); + for (const item of items) { + const isElDisabled = + Boolean((item as ElementWithState).disabled) || + item.hasAttribute('disabled') || + item.getAttribute('aria-disabled') === 'true'; + if (isElDisabled) { + this.clearPressedState(item); + } + } + }; + + private getSlottedElements(): Element[] { + const slots = this.shadowRoot?.querySelectorAll('slot'); + if (!slots) return []; + const elements: Element[] = []; + for (const slot of Array.from(slots)) { + elements.push(...slot.assignedElements({flatten: true})); + } + return elements; + } + + private getSlottedItemsAndDescendants(): Element[] { + const slots = this.shadowRoot?.querySelectorAll('slot'); + if (!slots) return []; + const elements: Element[] = []; + const seen = new Set(); + for (const slot of Array.from(slots)) { + for (const assigned of slot.assignedElements({flatten: true})) { + if (!seen.has(assigned)) { + seen.add(assigned); + elements.push(assigned); + } + for (const desc of Array.from(assigned.querySelectorAll('*'))) { + if (!seen.has(desc)) { + seen.add(desc); + elements.push(desc); + } + } + } + } + return elements; + } + + private clearPressedState(item: Element) { + const itemWithState = item as ElementWithState; + if (Boolean(itemWithState.selected)) { + try { + itemWithState.selected = false; + } catch { + // Ignore read-only property accessors on custom elements. + } + } + if (item.hasAttribute('selected')) { + item.removeAttribute('selected'); + } + if (item.getAttribute('aria-pressed') === 'true') { + item.setAttribute('aria-pressed', 'false'); + } + item.classList.remove('active', 'btn-selected', 'icon-btn-selected'); + + if (item.shadowRoot) { + const innerButtons = item.shadowRoot.querySelectorAll( + '[part~="btn"], [part~="icon-btn"], button', + ); + for (const btn of Array.from(innerButtons)) { + btn.classList.remove('active', 'btn-selected', 'icon-btn-selected'); + if (btn.getAttribute('aria-pressed') === 'true') { + btn.setAttribute('aria-pressed', 'false'); + } + } + } + } + + private updateDisabledPropagation() { + const ACTION_SELECTOR = + 'button, a, [role="button"], [role="menuitem"], [role="checkbox"], [role="switch"], [role="radio"], md-gb-button, md-gb-icon-button, .btn, .icon-btn, input, select, textarea'; + if (this.disabled) { + const items = this.getSlottedItemsAndDescendants(); + for (const item of items) { + const itemWithProps = item as ElementWithState; + if ( + item.matches(ACTION_SELECTOR) || + itemWithProps.disabled !== undefined + ) { + const isAlreadyDisabled = + Boolean(itemWithProps.disabled) || + item.hasAttribute('disabled') || + item.getAttribute('aria-disabled') === 'true'; + if (!isAlreadyDisabled) { + item.setAttribute('data-toolbar-disabled', ''); + if (itemWithProps.disabled !== undefined) { + try { + itemWithProps.disabled = true; + } catch { + // Ignore read-only disabled property. + } + } else { + if (item.hasAttribute('tabindex')) { + item.setAttribute( + 'data-toolbar-prev-tabindex', + item.getAttribute('tabindex')!, + ); + } + item.setAttribute('tabindex', '-1'); + } + item.setAttribute('aria-disabled', 'true'); + } + } + this.clearPressedState(item); + } + } else { + const items = new Set([ + ...Array.from(this.querySelectorAll('[data-toolbar-disabled]')), + ...this.getSlottedItemsAndDescendants().filter((el) => + el.hasAttribute('data-toolbar-disabled'), + ), + ]); + for (const item of items) { + const itemWithProps = item as ElementWithState; + item.removeAttribute('data-toolbar-disabled'); + if (itemWithProps.disabled !== undefined) { + try { + itemWithProps.disabled = false; + } catch { + // Ignore read-only disabled property. + } + } + item.removeAttribute('aria-disabled'); + if (item.hasAttribute('data-toolbar-prev-tabindex')) { + item.setAttribute( + 'tabindex', + item.getAttribute('data-toolbar-prev-tabindex')!, + ); + item.removeAttribute('data-toolbar-prev-tabindex'); + } else if (itemWithProps.disabled === undefined) { + item.removeAttribute('tabindex'); + } + } + } + } + + private updateLabels() { + if (this.hideLabels) { + const slotted = this.getSlottedElements(); + for (const el of slotted) { + if ( + el.tagName.toLowerCase() === 'md-gb-button' || + el.classList.contains('btn') + ) { + if (!el.getAttribute('aria-label')) { + const textParts = this.extractLabelTextParts(el); + const labelText = textParts.join(' '); + if (labelText) { + el.setAttribute('aria-label', labelText); + el.setAttribute('data-toolbar-label', ''); + } + } + } + } + } else { + const labeled = this.querySelectorAll('[data-toolbar-label]'); + for (const el of Array.from(labeled)) { + el.removeAttribute('data-toolbar-label'); + el.removeAttribute('aria-label'); + } + } + } + + private extractLabelTextParts(node: Node): string[] { + const textParts: string[] = []; + for (const child of Array.from(node.childNodes)) { + if (child.nodeType === Node.TEXT_NODE) { + const text = child.textContent?.trim(); + if (text) { + textParts.push(text); + } + } else if (child.nodeType === Node.ELEMENT_NODE) { + const element = child as Element; + if (element.matches('md-gb-icon, .icon, [slot="container"]')) { + continue; + } + textParts.push(...this.extractLabelTextParts(element)); + } + } + return textParts; + } } diff --git a/labs/gb/components/toolbar/toolbar.scss b/labs/gb/components/toolbar/toolbar.scss index d2d6fb69d8..c4a50969b0 100644 --- a/labs/gb/components/toolbar/toolbar.scss +++ b/labs/gb/components/toolbar/toolbar.scss @@ -53,6 +53,14 @@ @include toolbar-tokens.vertical; } + :host(md-gb-toolbar[orientation='vertical']:not([docked])[align='start']) { + margin-inline: 0 auto; + } + + :host(md-gb-toolbar[orientation='vertical']:not([docked])[align='end']) { + margin-inline: auto 0; + } + :host(md-gb-toolbar[orientation='vertical']:not([docked])) [part='toolbar'], :host(md-gb-toolbar[orientation='vertical']:not([docked])) .toolbar { @include toolbar-tokens.vertical; @@ -66,6 +74,36 @@ width: fit-content; } + :host(md-gb-toolbar[orientation='vertical']:not([docked])[align='start']) + [part='toolbar'], + :host(md-gb-toolbar[orientation='vertical']:not([docked])[align='start']) + .toolbar { + align-items: flex-start; + } + + :host(md-gb-toolbar[orientation='vertical']:not([docked])[align='end']) + [part='toolbar'], + :host(md-gb-toolbar[orientation='vertical']:not([docked])[align='end']) + .toolbar { + align-items: flex-end; + } + + :host(md-gb-toolbar[disabled]), + :host([disabled]), + :host(md-gb-toolbar[color='vibrant'][disabled]), + :host(md-gb-toolbar[color='standard'][disabled]) { + @include toolbar-tokens.disabled; + + pointer-events: none !important; + cursor: default !important; + --md-icon-fill: 0 !important; + --toolbar-icon-color: var(--icon-color) !important; + --toolbar-label-text-color: var(--label-text-color) !important; + --ripple-hover-opacity: 0 !important; + --ripple-press-opacity: 0 !important; + --state-layer-color: transparent !important; + } + md-gb-toolbar { @include toolbar-tokens.root; @@ -88,6 +126,14 @@ &[orientation='vertical']:not([docked]) { @include toolbar-tokens.vertical; + + &[align='start'] { + margin-inline: 0 auto; + } + + &[align='end'] { + margin-inline: auto 0; + } } &[docked] { @@ -96,6 +142,68 @@ display: flex; width: 100%; } + + &[disabled], + &[color='vibrant'][disabled], + &[color='standard'][disabled] { + @include toolbar-tokens.disabled; + + pointer-events: none !important; + cursor: default !important; + --md-icon-fill: 0 !important; + --toolbar-icon-color: var(--icon-color) !important; + --toolbar-label-text-color: var(--label-text-color) !important; + --ripple-hover-opacity: 0 !important; + --ripple-press-opacity: 0 !important; + --state-layer-color: transparent !important; + } + } + + :host(md-gb-toolbar[disabled]), + :host(md-gb-toolbar[color='vibrant'][disabled]), + md-gb-toolbar[disabled], + md-gb-toolbar[color='vibrant'][disabled], + .toolbar.disabled, + .toolbar.toolbar-vibrant.disabled { + &, + *, + *::before, + *::after { + transition: none !important; + } + } + + [part='toolbar'] > slot, + .toolbar > slot { + display: flex; + align-items: center; + gap: var(--container-spacing); + } + + [part='toolbar'] > slot:not(.has-slotted), + .toolbar > slot:not(.has-slotted) { + display: none; + } + + :host(md-gb-toolbar[orientation='vertical']:not([docked])) + [part='toolbar'] + > slot, + .toolbar.toolbar-vertical:not(.toolbar-docked) > slot { + flex-direction: column; + } + + :host(md-gb-toolbar[orientation='vertical']:not([docked])[align='start']) + [part='toolbar'] + > slot, + .toolbar.toolbar-vertical:not(.toolbar-docked).toolbar-align-start > slot { + align-items: flex-start; + } + + :host(md-gb-toolbar[orientation='vertical']:not([docked])[align='end']) + [part='toolbar'] + > slot, + .toolbar.toolbar-vertical:not(.toolbar-docked).toolbar-align-end > slot { + align-items: flex-end; } .toolbar { @@ -135,6 +243,21 @@ --toolbar-label-text-color: var(--label-text-color); } + &.disabled, + &.toolbar-vibrant.disabled, + &.toolbar-standard.disabled { + @include toolbar-tokens.disabled; + + pointer-events: none !important; + cursor: default !important; + --md-icon-fill: 0 !important; + --toolbar-icon-color: var(--icon-color) !important; + --toolbar-label-text-color: var(--label-text-color) !important; + --ripple-hover-opacity: 0 !important; + --ripple-press-opacity: 0 !important; + --state-layer-color: transparent !important; + } + &.toolbar-docked { @include toolbar-tokens.docked; @@ -165,42 +288,155 @@ var(--container-trailing-space); padding-inline: var(--container-leading-space); width: fit-content; + + &.toolbar-align-start { + align-items: flex-start; + margin-inline: 0 auto; + } + + &.toolbar-align-end { + align-items: flex-end; + margin-inline: auto 0; + } } .btn, ::slotted(.btn) { --container-color: var(--button-container-color); + --container-shape: var(--button-shape); --icon-color: var(--toolbar-icon-color); --label-text-color: var(--toolbar-label-text-color); + transition: + border-radius 0s, + var(--ripple-transition) !important; + + &:is(:active, .active) { + --container-shape: var(--button-shape); + } } .btn:is(.btn-selected, [aria-pressed='true']), ::slotted(.btn:is(.btn-selected, [aria-pressed='true'])) { --container-color: var(--selected-button-container-color); + --container-shape: var(--button-shape); --icon-color: var(--selected-icon-color); --label-text-color: var(--selected-label-text-color); } + .btn:is(:disabled, [disabled]), + ::slotted(.btn:is(:disabled, [disabled])) { + @include toolbar-tokens.disabled; + + --container-color: var(--button-container-color) !important; + --md-icon-fill: 0 !important; + --md-icon-color: var(--icon-color) !important; + } + + .btn:is(:disabled, [disabled]):is(.btn-selected, [aria-pressed='true']), + ::slotted( + .btn:is(:disabled, [disabled]):is(.btn-selected, [aria-pressed='true']) + ) { + @include toolbar-tokens.disabled; + + --container-color: var(--button-container-color) !important; + --md-icon-fill: 0 !important; + --md-icon-color: var(--icon-color) !important; + } + .icon-btn, ::slotted(.icon-btn) { --container-color: var(--button-container-color); + --container-shape: var(--icon-button-shape); --icon-color: var(--toolbar-icon-color); + position: relative; + + &:is(:active, .active) { + --container-shape: var(--icon-button-shape); + } + } + + .icon-btn::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: var(--touch-target-size); + height: var(--touch-target-size); + min-width: var(--touch-target-size); + min-height: var(--touch-target-size); } .icon-btn:is(.icon-btn-selected, [aria-pressed='true']), ::slotted(.icon-btn:is(.icon-btn-selected, [aria-pressed='true'])) { --container-color: var(--selected-button-container-color); + --container-shape: var(--icon-button-shape); --icon-color: var(--selected-icon-color); } + + .icon-btn:is(:disabled, [disabled]), + ::slotted(.icon-btn:is(:disabled, [disabled])) { + @include toolbar-tokens.disabled; + + --container-color: transparent !important; + --md-icon-fill: 0 !important; + --md-icon-color: var(--icon-color) !important; + } + + .icon-btn:is(:disabled, [disabled]):is( + .icon-btn-selected, + [aria-pressed='true'] + ), + ::slotted( + .icon-btn:is(:disabled, [disabled]):is( + .icon-btn-selected, + [aria-pressed='true'] + ) + ) { + @include toolbar-tokens.disabled; + + --container-color: transparent !important; + --md-icon-fill: 0 !important; + --md-icon-color: var(--icon-color) !important; + } } + ::slotted(md-gb-button), md-gb-toolbar md-gb-button, + .toolbar md-gb-button { + --container-shape: var(--button-shape); + --md-icon-color: var(--toolbar-icon-color); + } + + ::slotted(md-gb-button:is([selected], [aria-pressed='true'])), + ::slotted(md-gb-button:is(:active, .active)), + md-gb-toolbar md-gb-button:is([selected], [aria-pressed='true']), + md-gb-toolbar md-gb-button:is(:active, .active), + .toolbar md-gb-button:is([selected], [aria-pressed='true']), + .toolbar md-gb-button:is(:active, .active) { + --container-shape: var(--button-shape); + } + + ::slotted(md-gb-icon-button), md-gb-toolbar md-gb-icon-button, - .toolbar md-gb-button, .toolbar md-gb-icon-button { + --container-shape: var(--icon-button-shape); --md-icon-color: var(--toolbar-icon-color); + inline-size: var(--min-button-size); + block-size: var(--min-button-size); + } + + ::slotted(md-gb-icon-button:is([selected], [aria-pressed='true'])), + ::slotted(md-gb-icon-button:is(:active, .active)), + md-gb-toolbar md-gb-icon-button:is([selected], [aria-pressed='true']), + md-gb-toolbar md-gb-icon-button:is(:active, .active), + .toolbar md-gb-icon-button:is([selected], [aria-pressed='true']), + .toolbar md-gb-icon-button:is(:active, .active) { + --container-shape: var(--icon-button-shape); } + ::slotted(md-gb-button:is([selected], [aria-pressed='true'])), + ::slotted(md-gb-icon-button:is([selected], [aria-pressed='true'])), md-gb-toolbar md-gb-button[selected], md-gb-toolbar md-gb-icon-button[selected], .toolbar md-gb-button[selected], @@ -211,30 +447,455 @@ md-gb-toolbar md-gb-button::part(btn), .toolbar md-gb-button::part(btn) { --container-color: var(--button-container-color); + --container-shape: var(--button-shape); --icon-color: var(--toolbar-icon-color); --label-text-color: var(--toolbar-label-text-color); --md-icon-color: var(--toolbar-icon-color); + position: relative; + transition: + border-radius 0s, + var(--ripple-transition) !important; + + &::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 100%; + height: var(--touch-target-size); + min-height: var(--touch-target-size); + } + } + + md-gb-toolbar md-gb-button:is(:active, .active)::part(btn), + .toolbar md-gb-button:is(:active, .active)::part(btn), + md-gb-toolbar md-gb-button:is([selected], [aria-pressed='true'])::part(btn), + .toolbar md-gb-button:is([selected], [aria-pressed='true'])::part(btn), + md-gb-toolbar + md-gb-button::part(btn):is( + :active, + .active, + .btn-selected, + [aria-pressed='true'] + ), + .toolbar + md-gb-button::part(btn):is( + :active, + .active, + .btn-selected, + [aria-pressed='true'] + ) { + --container-shape: var(--button-shape); } - md-gb-toolbar md-gb-button[selected]::part(btn), - .toolbar md-gb-button[selected]::part(btn) { + md-gb-toolbar md-gb-button:is([selected], [aria-pressed='true'])::part(btn), + .toolbar md-gb-button:is([selected], [aria-pressed='true'])::part(btn), + md-gb-toolbar + md-gb-button::part(btn):is(.btn-selected, [aria-pressed='true']), + .toolbar md-gb-button::part(btn):is(.btn-selected, [aria-pressed='true']) { --container-color: var(--selected-button-container-color); --icon-color: var(--selected-icon-color); --label-text-color: var(--selected-label-text-color); --md-icon-color: var(--selected-icon-color); } + md-gb-toolbar md-gb-button:is(:disabled, [disabled])::part(btn), + .toolbar md-gb-button:is(:disabled, [disabled])::part(btn), + md-gb-toolbar md-gb-button::part(btn):is(:disabled, .disabled), + .toolbar md-gb-button::part(btn):is(:disabled, .disabled) { + @include toolbar-tokens.disabled; + + --container-color: var(--button-container-color) !important; + --md-icon-fill: 0 !important; + --md-icon-color: var(--icon-color) !important; + } + + md-gb-toolbar + md-gb-button:is(:disabled, [disabled]):is( + [selected], + [aria-pressed='true'] + )::part(btn), + .toolbar + md-gb-button:is(:disabled, [disabled]):is( + [selected], + [aria-pressed='true'] + )::part(btn), + md-gb-toolbar + md-gb-button::part(btn):is(:disabled, .disabled):is( + .btn-selected, + [aria-pressed='true'] + ), + .toolbar + md-gb-button::part(btn):is(:disabled, .disabled):is( + .btn-selected, + [aria-pressed='true'] + ) { + @include toolbar-tokens.disabled; + + --container-color: var(--button-container-color) !important; + --md-icon-fill: 0 !important; + --md-icon-color: var(--icon-color) !important; + } + md-gb-toolbar md-gb-icon-button::part(icon-btn), .toolbar md-gb-icon-button::part(icon-btn) { --container-color: var(--button-container-color); + --container-shape: var(--icon-button-shape); --icon-color: var(--toolbar-icon-color); --md-icon-color: var(--toolbar-icon-color); + position: relative; + transition: + border-radius 0s, + var(--ripple-transition) !important; + + &::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: var(--touch-target-size); + height: var(--touch-target-size); + min-width: var(--touch-target-size); + min-height: var(--touch-target-size); + } + } + + md-gb-toolbar md-gb-icon-button:is(:active, .active)::part(icon-btn), + .toolbar md-gb-icon-button:is(:active, .active)::part(icon-btn), + md-gb-toolbar + md-gb-icon-button:is([selected], [aria-pressed='true'])::part(icon-btn), + .toolbar + md-gb-icon-button:is([selected], [aria-pressed='true'])::part(icon-btn), + md-gb-toolbar + md-gb-icon-button::part(icon-btn):is( + :active, + .active, + .icon-btn-selected, + [aria-pressed='true'] + ), + .toolbar + md-gb-icon-button::part(icon-btn):is( + :active, + .active, + .icon-btn-selected, + [aria-pressed='true'] + ) { + --container-shape: var(--icon-button-shape); } - md-gb-toolbar md-gb-icon-button[selected]::part(icon-btn), - .toolbar md-gb-icon-button[selected]::part(icon-btn) { + md-gb-toolbar + md-gb-icon-button:is([selected], [aria-pressed='true'])::part(icon-btn), + .toolbar + md-gb-icon-button:is([selected], [aria-pressed='true'])::part(icon-btn), + md-gb-toolbar + md-gb-icon-button::part(icon-btn):is( + .icon-btn-selected, + [aria-pressed='true'] + ), + .toolbar + md-gb-icon-button::part(icon-btn):is( + .icon-btn-selected, + [aria-pressed='true'] + ) { --container-color: var(--selected-button-container-color); --icon-color: var(--selected-icon-color); --md-icon-color: var(--selected-icon-color); } + + :host(md-gb-toolbar[disabled]), + :host([disabled]), + md-gb-toolbar[disabled], + .toolbar.disabled { + &, + [part='toolbar'], + slot, + ::slotted(*), + * { + pointer-events: none !important; + cursor: default !important; + --ripple-hover-opacity: 0 !important; + --ripple-press-opacity: 0 !important; + --state-layer-color: transparent !important; + } + + &:hover, + &:active, + &.active, + *:hover, + *:active, + *.active, + ::slotted(*:hover), + ::slotted(*:active), + ::slotted(*.active) { + transform: none !important; + --ripple-hover-opacity: 0 !important; + --ripple-press-opacity: 0 !important; + --state-layer-color: transparent !important; + } + + // Contained buttons and contained slot backgrounds + ::slotted(md-gb-button), + ::slotted(.btn), + md-gb-button, + .btn, + [part='toolbar'] > slot, + & > slot { + --container-color: var(--button-container-color) !important; + --icon-color: hsl( + from var(--md-sys-color-on-surface) h s l / 38% + ) !important; + --label-text-color: hsl( + from var(--md-sys-color-on-surface) h s l / 38% + ) !important; + --md-icon-color: hsl( + from var(--md-sys-color-on-surface) h s l / 38% + ) !important; + } + + // Uncontained icon buttons + ::slotted(md-gb-icon-button), + ::slotted(.icon-btn), + md-gb-icon-button, + .icon-btn { + --container-color: transparent !important; + --md-icon-fill: 0 !important; + --icon-color: hsl( + from var(--md-sys-color-on-surface) h s l / 38% + ) !important; + --label-text-color: hsl( + from var(--md-sys-color-on-surface) h s l / 38% + ) !important; + --md-icon-color: hsl( + from var(--md-sys-color-on-surface) h s l / 38% + ) !important; + } + + // All icons and labels apply 38% opacity token + --icon-color: hsl( + from var(--md-sys-color-on-surface) h s l / 38% + ) !important; + --label-text-color: hsl( + from var(--md-sys-color-on-surface) h s l / 38% + ) !important; + --toolbar-icon-color: hsl( + from var(--md-sys-color-on-surface) h s l / 38% + ) !important; + --toolbar-label-text-color: hsl( + from var(--md-sys-color-on-surface) h s l / 38% + ) !important; + --md-icon-color: hsl( + from var(--md-sys-color-on-surface) h s l / 38% + ) !important; + + ::slotted(*), + * { + --icon-color: hsl( + from var(--md-sys-color-on-surface) h s l / 38% + ) !important; + --label-text-color: hsl( + from var(--md-sys-color-on-surface) h s l / 38% + ) !important; + --toolbar-icon-color: hsl( + from var(--md-sys-color-on-surface) h s l / 38% + ) !important; + --toolbar-label-text-color: hsl( + from var(--md-sys-color-on-surface) h s l / 38% + ) !important; + --md-icon-color: hsl( + from var(--md-sys-color-on-surface) h s l / 38% + ) !important; + } + } + + .icon-btn:is(:disabled, [disabled]), + ::slotted(.icon-btn:is(:disabled, [disabled])), + md-gb-icon-button:is(:disabled, [disabled]), + ::slotted(md-gb-icon-button:is(:disabled, [disabled])) { + --container-color: transparent !important; + --md-icon-fill: 0 !important; + } + + .btn:is(:disabled, [disabled]), + ::slotted(.btn:is(:disabled, [disabled])), + md-gb-button:is(:disabled, [disabled]), + ::slotted(md-gb-button:is(:disabled, [disabled])) { + --container-color: var(--button-container-color) !important; + --md-icon-fill: 0 !important; + } + + md-gb-toolbar md-gb-icon-button:is(:disabled, [disabled])::part(icon-btn), + .toolbar md-gb-icon-button:is(:disabled, [disabled])::part(icon-btn), + md-gb-toolbar md-gb-icon-button::part(icon-btn):is(:disabled, .disabled), + .toolbar md-gb-icon-button::part(icon-btn):is(:disabled, .disabled) { + @include toolbar-tokens.disabled; + + --container-color: transparent !important; + --md-icon-fill: 0 !important; + --md-icon-color: var(--icon-color) !important; + } + + md-gb-toolbar + md-gb-icon-button:is(:disabled, [disabled]):is( + [selected], + [aria-pressed='true'] + )::part(icon-btn), + .toolbar + md-gb-icon-button:is(:disabled, [disabled]):is( + [selected], + [aria-pressed='true'] + )::part(icon-btn), + md-gb-toolbar + md-gb-icon-button::part(icon-btn):is(:disabled, .disabled):is( + .icon-btn-selected, + [aria-pressed='true'] + ), + .toolbar + md-gb-icon-button::part(icon-btn):is(:disabled, .disabled):is( + .icon-btn-selected, + [aria-pressed='true'] + ) { + @include toolbar-tokens.disabled; + + --container-color: transparent !important; + --md-icon-fill: 0 !important; + --md-icon-color: var(--icon-color) !important; + } + + :host(md-gb-toolbar[disabled]) md-gb-icon-button::part(icon-btn), + :host([disabled]) md-gb-icon-button::part(icon-btn), + md-gb-toolbar[disabled] md-gb-icon-button::part(icon-btn), + .toolbar.disabled md-gb-icon-button::part(icon-btn), + :host(md-gb-toolbar[disabled]) + md-gb-icon-button:is([selected], [aria-pressed='true'])::part(icon-btn), + :host([disabled]) + md-gb-icon-button:is([selected], [aria-pressed='true'])::part(icon-btn), + md-gb-toolbar[disabled] + md-gb-icon-button:is([selected], [aria-pressed='true'])::part(icon-btn), + .toolbar.disabled + md-gb-icon-button:is([selected], [aria-pressed='true'])::part(icon-btn) { + --container-color: transparent !important; + --md-icon-fill: 0 !important; + --icon-color: hsl( + from var(--md-sys-color-on-surface) h s l / 38% + ) !important; + --label-text-color: hsl( + from var(--md-sys-color-on-surface) h s l / 38% + ) !important; + --md-icon-color: hsl( + from var(--md-sys-color-on-surface) h s l / 38% + ) !important; + } + + :host(md-gb-toolbar[disabled]) md-gb-button::part(btn), + :host([disabled]) md-gb-button::part(btn), + md-gb-toolbar[disabled] md-gb-button::part(btn), + .toolbar.disabled md-gb-button::part(btn), + :host(md-gb-toolbar[disabled]) + md-gb-button:is([selected], [aria-pressed='true'])::part(btn), + :host([disabled]) + md-gb-button:is([selected], [aria-pressed='true'])::part(btn), + md-gb-toolbar[disabled] + md-gb-button:is([selected], [aria-pressed='true'])::part(btn), + .toolbar.disabled + md-gb-button:is([selected], [aria-pressed='true'])::part(btn) { + --container-color: var(--button-container-color) !important; + --icon-color: hsl( + from var(--md-sys-color-on-surface) h s l / 38% + ) !important; + --label-text-color: hsl( + from var(--md-sys-color-on-surface) h s l / 38% + ) !important; + --md-icon-color: hsl( + from var(--md-sys-color-on-surface) h s l / 38% + ) !important; + } + + // Hide labels (R6) + :host(md-gb-toolbar[hide-labels]) ::slotted(md-gb-button), + md-gb-toolbar[hide-labels] md-gb-button, + .toolbar.toolbar-hide-labels md-gb-button, + .toolbar.toolbar-hide-labels .btn, + .toolbar.toolbar-hide-labels ::slotted(.btn) { + --container-shape: var(--button-shape); + --icon-label-space: 0; + --leading-space: 0; + --trailing-space: 0; + aspect-ratio: 1; + block-size: var(--min-button-size); + font-size: 0; + height: var(--min-button-size); + inline-size: var(--min-button-size); + line-height: 0; + overflow: hidden; + padding-inline: 0; + width: var(--min-button-size); + } + + md-gb-toolbar[hide-labels] md-gb-button::part(btn), + .toolbar.toolbar-hide-labels md-gb-button::part(btn) { + --container-shape: var(--button-shape); + --icon-label-space: 0; + --leading-space: 0; + --trailing-space: 0; + aspect-ratio: 1; + block-size: var(--min-button-size); + font-size: 0; + height: var(--min-button-size); + inline-size: var(--min-button-size); + line-height: 0; + overflow: hidden; + padding-inline: 0; + position: relative; + width: var(--min-button-size); + } + + // F4: Touch target for collapsed button in hide-labels mode + md-gb-toolbar[hide-labels] md-gb-button::part(btn)::before, + .toolbar.toolbar-hide-labels md-gb-button::part(btn)::before, + .toolbar.toolbar-hide-labels .btn::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: var(--touch-target-size); + height: var(--touch-target-size); + min-width: var(--touch-target-size); + min-height: var(--touch-target-size); + } + + :host(md-gb-toolbar[hide-labels]) + ::slotted(md-gb-button:is(:active, .active)), + :host(md-gb-toolbar[hide-labels]) + ::slotted(md-gb-button:is([selected], [aria-pressed='true'])), + md-gb-toolbar[hide-labels] md-gb-button:is(:active, .active)::part(btn), + md-gb-toolbar[hide-labels] + md-gb-button:is([selected], [aria-pressed='true'])::part(btn), + .toolbar.toolbar-hide-labels md-gb-button:is(:active, .active)::part(btn), + .toolbar.toolbar-hide-labels + md-gb-button:is([selected], [aria-pressed='true'])::part(btn) { + --container-shape: var(--button-shape); + } + + md-gb-toolbar[hide-labels] + md-gb-button + > :not(md-gb-icon, [slot='container']), + .toolbar.toolbar-hide-labels + md-gb-button + > :not(md-gb-icon, [slot='container']), + .toolbar.toolbar-hide-labels + .btn + > :not(md-gb-icon, .icon, [slot='container']) { + clip: rect(0, 0, 0, 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + white-space: nowrap; + width: 1px; + border: 0; + } } diff --git a/labs/gb/components/toolbar/toolbar.ts b/labs/gb/components/toolbar/toolbar.ts index 76c3e22ae7..9f6084a421 100644 --- a/labs/gb/components/toolbar/toolbar.ts +++ b/labs/gb/components/toolbar/toolbar.ts @@ -5,6 +5,7 @@ */ import {type ClassInfo} from 'lit/directives/class-map.js'; + import {createClassMapDirective} from '../shared/directives.js'; /** Toolbar color configuration types. */ @@ -25,6 +26,15 @@ export const TOOLBAR_ORIENTATIONS = { vertical: 'vertical', } as const; +/** Toolbar alignment types for vertical layout. */ +export type ToolbarAlign = 'start' | 'end'; + +/** Toolbar alignment configurations. */ +export const TOOLBAR_ALIGNS = { + start: 'start', + end: 'end', +} as const; + /** Toolbar classes constants. */ export const TOOLBAR_CLASSES = { toolbar: 'toolbar', @@ -33,6 +43,10 @@ export const TOOLBAR_CLASSES = { toolbarHorizontal: 'toolbar-horizontal', toolbarVertical: 'toolbar-vertical', toolbarDocked: 'toolbar-docked', + toolbarAlignStart: 'toolbar-align-start', + toolbarAlignEnd: 'toolbar-align-end', + toolbarHideLabels: 'toolbar-hide-labels', + disabled: 'disabled', } as const; /** The state provided to the `toolbarClasses()` function. */ @@ -43,6 +57,12 @@ export interface ToolbarClassesState { orientation?: ToolbarOrientation; /** Whether the toolbar is docked across the container width. */ docked?: boolean; + /** Whether the toolbar is disabled. */ + disabled?: boolean; + /** The alignment of items in vertical orientation. */ + align?: ToolbarAlign; + /** Whether text labels on buttons should be hidden. */ + hideLabels?: boolean; } /** @@ -55,11 +75,15 @@ export function toolbarClasses({ color = TOOLBAR_COLORS.standard, orientation = TOOLBAR_ORIENTATIONS.horizontal, docked = false, + disabled = false, + align, + hideLabels = false, }: ToolbarClassesState = {}): ClassInfo { const isDocked = Boolean(docked); const effectiveOrientation = isDocked ? TOOLBAR_ORIENTATIONS.horizontal : orientation; + const isVertical = effectiveOrientation === TOOLBAR_ORIENTATIONS.vertical; return { [TOOLBAR_CLASSES.toolbar]: true, [TOOLBAR_CLASSES.toolbarStandard]: @@ -68,9 +92,14 @@ export function toolbarClasses({ [TOOLBAR_CLASSES.toolbarHorizontal]: effectiveOrientation === TOOLBAR_ORIENTATIONS.horizontal || !effectiveOrientation, - [TOOLBAR_CLASSES.toolbarVertical]: - effectiveOrientation === TOOLBAR_ORIENTATIONS.vertical, + [TOOLBAR_CLASSES.toolbarVertical]: isVertical, [TOOLBAR_CLASSES.toolbarDocked]: isDocked, + [TOOLBAR_CLASSES.toolbarAlignStart]: + isVertical && align === TOOLBAR_ALIGNS.start, + [TOOLBAR_CLASSES.toolbarAlignEnd]: + isVertical && align === TOOLBAR_ALIGNS.end, + [TOOLBAR_CLASSES.toolbarHideLabels]: Boolean(hideLabels), + [TOOLBAR_CLASSES.disabled]: Boolean(disabled), }; } diff --git a/labs/gb/components/toolbar/toolbar_test.ts b/labs/gb/components/toolbar/toolbar_test.ts new file mode 100644 index 0000000000..ffd7f3969c --- /dev/null +++ b/labs/gb/components/toolbar/toolbar_test.ts @@ -0,0 +1,1525 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// import 'jasmine'; (google3-only) + +import '../../styles/icon/md-gb-icon.js'; +import '../button/md-gb-button.js'; +import '../iconbutton/md-gb-icon-button.js'; +import './md-gb-toolbar.js'; + +import {html} from 'lit'; + +import {Environment} from '../../../../testing/environment.js'; +import {adoptStyles} from '../../styles/adopt-styles.js'; +import {styles as m3Styles} from '../../styles/m3.cssresult.js'; +import {ButtonElement} from '../button/button-element.js'; +import {IconButtonElement} from '../iconbutton/icon-button-element.js'; + +import {ToolbarElement} from './toolbar-element.js'; +import {styles as toolbarStyles} from './toolbar.cssresult.js'; + +/** Reads the protected `internals` field of a toolbar for assertions. */ +function getInternals(toolbar: ToolbarElement): ElementInternals { + return (toolbar as unknown as {internals: ElementInternals}).internals; +} + +describe('md-gb-toolbar', () => { + const env = new Environment(); + + beforeAll(() => { + const noTransitions = new CSSStyleSheet(); + noTransitions.replaceSync(` + md-gb-toolbar, + md-gb-button, + md-gb-icon-button, + md-gb-button::part(btn), + md-gb-icon-button::part(icon-btn) { + transition: none !important; + } + `); + adoptStyles(document, [m3Styles, toolbarStyles, noTransitions]); + }); + + describe('R1 & F6: disabled state & marker tracking', () => { + it('reflects disabled property to attribute and sets internals ariaDisabled', async () => { + const root = env.render(html``); + await env.waitForStability(); + const toolbar = root.querySelector('md-gb-toolbar')! as ToolbarElement; + + expect(toolbar.disabled).toBeFalse(); + expect(toolbar.hasAttribute('disabled')).toBeFalse(); + expect(getInternals(toolbar).ariaDisabled).not.toBe('true'); + + toolbar.disabled = true; + await env.waitForStability(); + expect(toolbar.hasAttribute('disabled')).toBeTrue(); + expect(getInternals(toolbar).ariaDisabled).toBe('true'); + + toolbar.disabled = false; + await env.waitForStability(); + expect(toolbar.hasAttribute('disabled')).toBeFalse(); + expect(getInternals(toolbar).ariaDisabled).not.toBe('true'); + }); + + it('propagates disabled to slotted items and preserves individually disabled items on re-enable', async () => { + const root = env.render(html` + + Action 1 + Action 2 + + + `); + await env.waitForStability(); + const toolbar = root.querySelector('md-gb-toolbar')! as ToolbarElement; + const btn1 = root.querySelector('#btn1')! as ButtonElement; + const btn2 = root.querySelector('#btn2')! as ButtonElement; + const icon1 = root.querySelector('#icon1')! as IconButtonElement; + + expect(btn1.disabled).toBeFalse(); + expect(btn2.disabled).toBeTrue(); + expect(icon1.disabled).toBeFalse(); + + toolbar.disabled = true; + await env.waitForStability(); + + expect(btn1.disabled).toBeTrue(); + expect(btn1.hasAttribute('data-toolbar-disabled')).toBeTrue(); + expect(btn2.disabled).toBeTrue(); + expect(btn2.hasAttribute('data-toolbar-disabled')).toBeFalse(); + expect(icon1.disabled).toBeTrue(); + expect(icon1.hasAttribute('data-toolbar-disabled')).toBeTrue(); + + toolbar.disabled = false; + await env.waitForStability(); + + expect(btn1.disabled).toBeFalse(); + expect(btn1.hasAttribute('data-toolbar-disabled')).toBeFalse(); + expect(btn2.disabled).toBeTrue(); + expect(icon1.disabled).toBeFalse(); + expect(icon1.hasAttribute('data-toolbar-disabled')).toBeFalse(); + }); + + it('re-enables items removed while disabled and re-inserted into an enabled toolbar on slotchange', async () => { + const root = env.render(html` +
+ + Movable + + +
+ `); + await env.waitForStability(); + const tb2 = root.querySelector('#tb2')! as ToolbarElement; + const moveBtn = root.querySelector('#moveBtn')! as ButtonElement; + + expect(moveBtn.disabled).toBeTrue(); + expect(moveBtn.hasAttribute('data-toolbar-disabled')).toBeTrue(); + + // Move to enabled toolbar tb2 + tb2.appendChild(moveBtn); + await env.waitForStability(); + + expect(moveBtn.disabled).toBeFalse(); + expect(moveBtn.hasAttribute('data-toolbar-disabled')).toBeFalse(); + }); + + it('applies disabled color tokens to selected buttons and icon buttons when toolbar is disabled without retaining pressed state container token on icon button', async () => { + const root = env.render(html` + + Edit + + + `); + await env.waitForStability(); + const toolbar = root.querySelector('md-gb-toolbar')! as ToolbarElement; + const btn = root.querySelector('#btn')! as ButtonElement; + const icon = root.querySelector('#icon')! as IconButtonElement; + + const innerBtn = btn.shadowRoot!.querySelector( + '[part="btn"]', + ) as HTMLElement; + const innerIcon = icon.shadowRoot!.querySelector( + '[part="icon-btn"]', + ) as HTMLElement; + + toolbar.disabled = true; + await env.waitForStability(); + + const btnStyles = getComputedStyle(innerBtn); + const iconStyles = getComputedStyle(innerIcon); + + // Icon button pressed state is not present with a disabled color token + expect(iconStyles.getPropertyValue('--container-color').trim()).toBe( + 'transparent', + ); + expect(iconStyles.getPropertyValue('--icon-color').trim()).toMatch( + /hsl\(from .* h s l\s*\/\s*38%\)/, + ); + + // Labeled button has standard button container and 38% disabled content opacity + expect(btnStyles.getPropertyValue('--container-color').trim()).toMatch( + /12%|hsl\(from .* \/ 12%\)/, + ); + expect(btnStyles.getPropertyValue('--icon-color').trim()).toMatch( + /hsl\(from .* h s l\s*\/\s*38%\)/, + ); + expect(btnStyles.getPropertyValue('--label-text-color').trim()).toMatch( + /hsl\(from .* h s l\s*\/\s*38%\)/, + ); + }); + + it('applies disabled color tokens to unselected buttons and icon buttons when toolbar is disabled', async () => { + const root = env.render(html` + + Edit + + + `); + await env.waitForStability(); + const toolbar = root.querySelector('md-gb-toolbar')! as ToolbarElement; + const btn = root.querySelector('#btn')! as ButtonElement; + const icon = root.querySelector('#icon')! as IconButtonElement; + + const innerBtn = btn.shadowRoot!.querySelector( + '[part="btn"]', + ) as HTMLElement; + const innerIcon = icon.shadowRoot!.querySelector( + '[part="icon-btn"]', + ) as HTMLElement; + + toolbar.disabled = true; + await env.waitForStability(); + + const btnStyles = getComputedStyle(innerBtn); + const iconStyles = getComputedStyle(innerIcon); + + expect(btnStyles.getPropertyValue('--icon-color').trim()).toMatch( + /hsl\(from .* h s l\s*\/\s*38%\)/, + ); + expect(btnStyles.getPropertyValue('--label-text-color').trim()).toMatch( + /hsl\(from .* h s l\s*\/\s*38%\)/, + ); + expect(iconStyles.getPropertyValue('--icon-color').trim()).toMatch( + /hsl\(from .* h s l\s*\/\s*38%\)/, + ); + }); + + it('triggers standard enabled state and drops content to disabled opacity on vibrant toolbars when disabled', async () => { + const root = env.render(html` + + Action + Selected + + + `); + await env.waitForStability(); + const toolbar = root.querySelector('md-gb-toolbar')! as ToolbarElement; + const unselectedBtn = root.querySelector( + '#unselectedBtn', + )! as ButtonElement; + const selectedBtn = root.querySelector('#selectedBtn')! as ButtonElement; + const icon = root.querySelector('#icon')! as IconButtonElement; + + const innerUnselectedBtn = unselectedBtn.shadowRoot!.querySelector( + '[part="btn"]', + ) as HTMLElement; + const innerSelectedBtn = selectedBtn.shadowRoot!.querySelector( + '[part="btn"]', + ) as HTMLElement; + const innerIcon = icon.shadowRoot!.querySelector( + '[part="icon-btn"]', + ) as HTMLElement; + const toolbarDiv = toolbar.shadowRoot!.querySelector( + '[part="toolbar"]', + ) as HTMLElement; + + toolbar.disabled = true; + await env.waitForStability(); + + const toolbarStyles = getComputedStyle(toolbarDiv); + const unselectedStyles = getComputedStyle(innerUnselectedBtn); + const selectedStyles = getComputedStyle(innerSelectedBtn); + const iconStyles = getComputedStyle(innerIcon); + + // Toolbar container triggers standard enabled container color + expect( + toolbarStyles.getPropertyValue('--container-color').trim(), + ).toMatch( + /var\(--md-sys-color-surface-container\)|surface-container|light-dark/, + ); + + // Transitions are disabled so content drops instantly + expect(toolbarStyles.transitionDuration).toBe('0s'); + + // Unselected items on vibrant toolbar have disabled tokens (38% opacity) + expect(unselectedStyles.getPropertyValue('--icon-color').trim()).toMatch( + /hsl\(from .* h s l\s*\/\s*38%\)/, + ); + expect( + unselectedStyles.getPropertyValue('--label-text-color').trim(), + ).toMatch(/hsl\(from .* h s l\s*\/\s*38%\)/); + + // Previously pressed button has standard button container and 38% disabled content opacity + expect( + selectedStyles.getPropertyValue('--container-color').trim(), + ).toMatch(/12%|hsl\(from .* \/ 12%\)/); + expect(selectedStyles.getPropertyValue('--icon-color').trim()).toMatch( + /hsl\(from .* h s l\s*\/\s*38%\)/, + ); + expect( + selectedStyles.getPropertyValue('--label-text-color').trim(), + ).toMatch(/hsl\(from .* h s l\s*\/\s*38%\)/); + + // Previously pressed icon button has transparent container (no pressed disabled token) and 38% disabled opacity + expect(iconStyles.getPropertyValue('--container-color').trim()).toBe( + 'transparent', + ); + expect(iconStyles.getPropertyValue('--icon-color').trim()).toMatch( + /hsl\(from .* h s l\s*\/\s*38%\)/, + ); + }); + + it('applies standard container and disabled color tokens on .toolbar.toolbar-vibrant.disabled in CSS cascade', () => { + const el = document.createElement('div'); + el.className = 'toolbar toolbar-vibrant disabled'; + document.body.appendChild(el); + + const styles = getComputedStyle(el); + expect(styles.getPropertyValue('--container-color').trim()).toMatch( + /var\(--md-sys-color-surface-container\)|surface-container|light-dark/, + ); + expect(styles.getPropertyValue('--toolbar-icon-color').trim()).toMatch( + /hsl\(from .* h s l\s*\/\s*38%\)/, + ); + expect( + styles.getPropertyValue('--toolbar-label-text-color').trim(), + ).toMatch(/hsl\(from .* h s l\s*\/\s*38%\)/); + + el.remove(); + }); + }); + + describe('R2 & F3 & F5: button shapes & locked states', () => { + it('sets circular shape for icon buttons and full pill shape for labeled buttons across rest, active, and selected', async () => { + const root = env.render(html` + + Edit + + + `); + await env.waitForStability(); + const btn = root.querySelector('#btn')! as ButtonElement; + const icon = root.querySelector('#icon')! as IconButtonElement; + + const innerBtn = btn.shadowRoot!.querySelector( + '[part="btn"]', + ) as HTMLElement; + const innerIcon = icon.shadowRoot!.querySelector( + '[part="icon-btn"]', + ) as HTMLElement; + + // 1. Rest state + const iconRadiusRest = getComputedStyle(innerIcon).borderRadius; + const btnRadiusRest = getComputedStyle(innerBtn).borderRadius; + expect(iconRadiusRest).toBe('50%'); + expect(btnRadiusRest).toBe('9999px'); + + // 2. Active / pressed state + btn.classList.add('active'); + icon.classList.add('active'); + await env.waitForStability(); + + const iconRadiusActive = getComputedStyle(innerIcon).borderRadius; + const btnRadiusActive = getComputedStyle(innerBtn).borderRadius; + expect(iconRadiusActive).toBe('50%'); + expect(btnRadiusActive).toBe('9999px'); + + btn.classList.remove('active'); + icon.classList.remove('active'); + + // 3. Selected / toggled state + btn.selected = true; + icon.selected = true; + await env.waitForStability(); + + const iconRadiusSelected = getComputedStyle(innerIcon).borderRadius; + const btnRadiusSelected = getComputedStyle(innerBtn).borderRadius; + expect(iconRadiusSelected).toBe('50%'); + expect(btnRadiusSelected).toBe('9999px'); + }); + }); + + describe('R3 & F5: vertical alignment & RTL', () => { + it('applies computed align-items and logical margins in LTR and RTL', async () => { + const root = env.render(html` +
+ + + +
+ `); + await env.waitForStability(); + const container = root.querySelector('#container')! as HTMLElement; + const toolbar = root.querySelector('#tb')! as ToolbarElement; + const toolbarDiv = toolbar.shadowRoot!.querySelector( + '[part="toolbar"]', + )! as HTMLElement; + + // LTR - align: start + expect(getComputedStyle(toolbarDiv).alignItems).toBe('flex-start'); + expect(getComputedStyle(toolbar).marginInlineStart).toBe('0px'); + + // LTR - align: end + toolbar.align = 'end'; + await env.waitForStability(); + expect(getComputedStyle(toolbarDiv).alignItems).toBe('flex-end'); + expect(getComputedStyle(toolbar).marginInlineEnd).toBe('0px'); + + // RTL - align: start + container.dir = 'rtl'; + toolbar.align = 'start'; + await env.waitForStability(); + expect(getComputedStyle(toolbarDiv).alignItems).toBe('flex-start'); + expect(getComputedStyle(toolbar).marginInlineStart).toBe('0px'); + + // RTL - align: end + toolbar.align = 'end'; + await env.waitForStability(); + expect(getComputedStyle(toolbarDiv).alignItems).toBe('flex-end'); + expect(getComputedStyle(toolbar).marginInlineEnd).toBe('0px'); + }); + }); + + describe('R4 & F4 & F5: touch target size', () => { + it('creates 48x48 touch target area on icon button and collapsed labeled button in hide-labels mode', async () => { + const root = env.render(html` + + + editEdit + + `); + await env.waitForStability(); + const toolbar = root.querySelector('md-gb-toolbar')! as ToolbarElement; + const icon = root.querySelector('#icon')! as IconButtonElement; + const btn = root.querySelector('#btn')! as ButtonElement; + + expect( + getComputedStyle(toolbar) + .getPropertyValue('--touch-target-size') + .trim(), + ).toBe('48px'); + + // Icon button ::before target + const iconBtn = icon.shadowRoot!.querySelector('[part="icon-btn"]')!; + const iconBeforeStyle = getComputedStyle(iconBtn, '::before'); + expect(iconBeforeStyle.width).toBe('48px'); + expect(iconBeforeStyle.height).toBe('48px'); + + // Collapsed button in hide-labels mode ::before target (F4) + const btnEl = btn.shadowRoot!.querySelector('[part="btn"]')!; + const btnBeforeStyle = getComputedStyle(btnEl, '::before'); + expect(btnBeforeStyle.width).toBe('48px'); + expect(btnBeforeStyle.height).toBe('48px'); + }); + + it('expands vertical touch target to 48px with width 100% on labeled buttons', async () => { + const root = env.render(html` + + Action + + `); + await env.waitForStability(); + const btn = root.querySelector('#btn')! as ButtonElement; + const innerBtn = btn.shadowRoot!.querySelector( + '[part="btn"]', + ) as HTMLElement; + + expect(getComputedStyle(innerBtn).position).toBe('relative'); + + const beforeStyle = getComputedStyle(innerBtn, '::before'); + expect(beforeStyle.height).toBe('48px'); + const beforeWidth = Number(beforeStyle.width.replace('px', '')); + expect(beforeWidth).toBeGreaterThan(0); + expect(beforeWidth).toBeCloseTo(innerBtn.clientWidth, 0); + }); + }); + + describe('R5 & F5: slots and 4dp spacing', () => { + it('applies has-slotted to non-empty slots, hides empty slots, and has 4px computed gap', async () => { + const root = env.render(html` + + + + + `); + await env.waitForStability(); + const toolbar = root.querySelector('md-gb-toolbar')! as ToolbarElement; + const leadingSlot = toolbar.shadowRoot!.querySelector( + 'slot[name="leading"]', + ) as HTMLSlotElement; + const defaultSlot = toolbar.shadowRoot!.querySelector( + 'slot:not([name])', + ) as HTMLSlotElement; + const trailingSlot = toolbar.shadowRoot!.querySelector( + 'slot[name="trailing"]', + ) as HTMLSlotElement; + + expect(leadingSlot.classList.contains('has-slotted')).toBeTrue(); + expect(defaultSlot.classList.contains('has-slotted')).toBeTrue(); + expect(trailingSlot.classList.contains('has-slotted')).toBeFalse(); + + expect(getComputedStyle(trailingSlot).display).toBe('none'); + expect(getComputedStyle(defaultSlot).display).toBe('flex'); + expect(getComputedStyle(defaultSlot).gap).toBe('4px'); + }); + + it('creates 4dp layout gap ensuring 4dp gap between adjacent target areas', async () => { + const root = env.render(html` + + + + + `); + await env.waitForStability(); + const toolbar = root.querySelector('md-gb-toolbar')! as ToolbarElement; + const defaultSlot = toolbar.shadowRoot!.querySelector( + 'slot:not([name])', + ) as HTMLSlotElement; + const icon1 = root.querySelector('#icon1')! as IconButtonElement; + const icon2 = root.querySelector('#icon2')! as IconButtonElement; + + expect(getComputedStyle(defaultSlot).gap).toBe('4px'); + + const rect1 = icon1.getBoundingClientRect(); + const rect2 = icon2.getBoundingClientRect(); + const visualGap = Math.round(rect2.left - rect1.right); + expect(visualGap).toBeGreaterThanOrEqual(3); + expect(visualGap).toBeLessThanOrEqual(5); + + // Distance from the end of one target area to another is 4dp + const targetArea1Right = rect1.right; + const targetArea2Left = rect2.left; + const targetAreaGap = Math.round(targetArea2Left - targetArea1Right); + expect(targetAreaGap).toBeGreaterThanOrEqual(3); + expect(targetAreaGap).toBeLessThanOrEqual(5); + }); + }); + + describe('R6 & F2 & F5: hide-labels without mutating light-DOM', () => { + it('derives aria-label from direct text nodes without mutating DOM, preserves consumer aria-label, and reverts on toggle', async () => { + const root = env.render(html` + + editEdit + starStarred + + `); + await env.waitForStability(); + const toolbar = root.querySelector('md-gb-toolbar')! as ToolbarElement; + const btn = root.querySelector('#btn')! as ButtonElement; + const customBtn = root.querySelector('#customBtn')! as ButtonElement; + + // 1. Initially hideLabels = false + expect(btn.childNodes.length).toBe(2); + expect(btn.childNodes[0].nodeName.toLowerCase()).toBe('md-gb-icon'); + expect(btn.childNodes[1].nodeType).toBe(Node.TEXT_NODE); + expect(btn.hasAttribute('aria-label')).toBeFalse(); + expect(customBtn.getAttribute('aria-label')).toBe('Custom Name'); + + // 2. Toggle hideLabels = true + toolbar.hideLabels = true; + await env.waitForStability(); + + // Child DOM must NOT be mutated (no span wrappers inserted) + expect(btn.childNodes.length).toBe(2); + expect(btn.childNodes[1].nodeType).toBe(Node.TEXT_NODE); + expect(btn.querySelector('.toolbar-label')).toBeNull(); + + // Accessible name derived strictly from direct TEXT_NODE (ignoring + // icon ligatures) + expect(btn.getAttribute('aria-label')).toBe('Edit'); + expect(btn.hasAttribute('data-toolbar-label')).toBeTrue(); + + // Consumer's pre-existing aria-label is preserved + expect(customBtn.getAttribute('aria-label')).toBe('Custom Name'); + expect(customBtn.hasAttribute('data-toolbar-label')).toBeFalse(); + + // Button geometry: 40x40 circle + expect(btn.offsetWidth).toBeGreaterThanOrEqual(36); + expect(btn.offsetWidth).toBeLessThanOrEqual(44); + expect(btn.offsetHeight).toBeGreaterThanOrEqual(36); + expect(btn.offsetHeight).toBeLessThanOrEqual(44); + + // Label is visually hidden + expect(getComputedStyle(btn).fontSize).toBe('0px'); + const innerBtn = btn.shadowRoot!.querySelector( + '[part="btn"]', + ) as HTMLElement; + expect(getComputedStyle(innerBtn).fontSize).toBe('0px'); + const icon = btn.querySelector('md-gb-icon') as HTMLElement; + expect(icon.offsetWidth).toBeGreaterThan(0); + + // 3. Toggle hideLabels = false (revert) + toolbar.hideLabels = false; + await env.waitForStability(); + + // Auto-applied aria-label is removed + expect(btn.hasAttribute('aria-label')).toBeFalse(); + expect(btn.hasAttribute('data-toolbar-label')).toBeFalse(); + + // Consumer aria-label is still preserved + expect(customBtn.getAttribute('aria-label')).toBe('Custom Name'); + + // Label is visible again + expect(getComputedStyle(btn).fontSize).not.toBe('0px'); + }); + + it('extracts aria-label from text inside child elements while excluding md-gb-icon, .icon, and slot=container', async () => { + const root = env.render(html` + + + search + ignored-icon +
ignored container
+ Search +
+ + ignored + Nested Text + +
+ `); + await env.waitForStability(); + const spanBtn = root.querySelector('#spanBtn')! as ButtonElement; + const nestedBtn = root.querySelector('#nestedBtn')! as ButtonElement; + + expect(spanBtn.getAttribute('aria-label')).toBe('Search'); + expect(nestedBtn.getAttribute('aria-label')).toBe('Nested Text'); + }); + }); + + describe('Toolbar pressed state clearance on disabled transition', () => { + it('clears pressed/active state on icon button when entering disabled', async () => { + const root = env.render(html` + + + + `); + await env.waitForStability(); + const toolbar = root.querySelector('md-gb-toolbar')! as ToolbarElement; + const icon = root.querySelector('#icon')! as IconButtonElement; + const innerIcon = icon.shadowRoot!.querySelector( + '[part="icon-btn"]', + ) as HTMLElement; + + expect(icon.selected).toBeTrue(); + expect(icon.classList.contains('active')).toBeTrue(); + expect(icon.getAttribute('aria-pressed')).toBe('true'); + expect( + getComputedStyle(icon).getPropertyValue('--md-icon-fill').trim(), + ).toBe('1'); + + toolbar.disabled = true; + await env.waitForStability(); + + expect(icon.selected).toBeFalse(); + expect(icon.hasAttribute('selected')).toBeFalse(); + expect(icon.classList.contains('active')).toBeFalse(); + expect(icon.getAttribute('aria-pressed')).not.toBe('true'); + expect(icon.getAttribute('aria-pressed')).toBe('false'); + expect( + getComputedStyle(icon).getPropertyValue('--md-icon-fill').trim(), + ).toBe('0'); + expect( + getComputedStyle(innerIcon) + .getPropertyValue('--container-color') + .trim(), + ).toBe('transparent'); + }); + + it('clears pressed/active state on labeled button when entering disabled', async () => { + const root = env.render(html` + + Action + + `); + await env.waitForStability(); + const toolbar = root.querySelector('md-gb-toolbar')! as ToolbarElement; + const btn = root.querySelector('#btn')! as ButtonElement; + + toolbar.disabled = true; + await env.waitForStability(); + + expect(btn.selected).toBeFalse(); + expect(btn.hasAttribute('selected')).toBeFalse(); + expect(btn.classList.contains('active')).toBeFalse(); + expect(btn.classList.contains('btn-selected')).toBeFalse(); + expect(btn.getAttribute('aria-pressed')).not.toBe('true'); + }); + + it('does not resurrect pressed state when toolbar is re-enabled', async () => { + const root = env.render(html` + + + + `); + await env.waitForStability(); + const toolbar = root.querySelector('md-gb-toolbar')! as ToolbarElement; + const icon = root.querySelector('#icon')! as IconButtonElement; + + toolbar.disabled = true; + await env.waitForStability(); + + toolbar.disabled = false; + await env.waitForStability(); + + expect(icon.disabled).toBeFalse(); + expect(icon.selected).toBeFalse(); + expect(icon.hasAttribute('selected')).toBeFalse(); + expect(icon.classList.contains('active')).toBeFalse(); + expect(icon.getAttribute('aria-pressed')).toBe('false'); + }); + + it('clears pressed state on nested slot items and individual disabled transitions', async () => { + const root = env.render(html` + +
+ +
+
+ `); + await env.waitForStability(); + const toolbar = root.querySelector('md-gb-toolbar')! as ToolbarElement; + const nestedIcon = root.querySelector( + '#nestedIcon', + )! as IconButtonElement; + + toolbar.disabled = true; + await env.waitForStability(); + + expect(nestedIcon.selected).toBeFalse(); + expect(nestedIcon.hasAttribute('selected')).toBeFalse(); + expect(nestedIcon.classList.contains('active')).toBeFalse(); + expect(nestedIcon.getAttribute('aria-pressed')).not.toBe('true'); + expect(nestedIcon.getAttribute('aria-pressed')).toBe('false'); + + // Re-enable toolbar and re-activate nested item + toolbar.disabled = false; + await env.waitForStability(); + + nestedIcon.selected = true; + nestedIcon.classList.add('active'); + nestedIcon.setAttribute('aria-pressed', 'true'); + await env.waitForStability(); + + expect(nestedIcon.selected).toBeTrue(); + expect(nestedIcon.classList.contains('active')).toBeTrue(); + + // Dynamically disabling individual item clears pressed state + nestedIcon.disabled = true; + await env.waitForStability(); + + expect(nestedIcon.selected).toBeFalse(); + expect(nestedIcon.hasAttribute('selected')).toBeFalse(); + expect(nestedIcon.classList.contains('active')).toBeFalse(); + expect(nestedIcon.getAttribute('aria-pressed')).not.toBe('true'); + expect(nestedIcon.getAttribute('aria-pressed')).toBe('false'); + }); + + it('clears pressed state on nested buttons when an intermediate container dynamically receives disabled', async () => { + const root = env.render(html` + +
+ + Action +
+
+ `); + await env.waitForStability(); + const container = root.querySelector('#container')! as HTMLDivElement; + const nestedIcon = root.querySelector( + '#nestedIcon', + )! as IconButtonElement; + const nestedBtn = root.querySelector('#nestedBtn')! as ButtonElement; + + expect(nestedIcon.selected).toBeTrue(); + expect(nestedIcon.classList.contains('active')).toBeTrue(); + expect(nestedIcon.getAttribute('aria-pressed')).toBe('true'); + expect(nestedBtn.selected).toBeTrue(); + expect(nestedBtn.classList.contains('active')).toBeTrue(); + expect(nestedBtn.getAttribute('aria-pressed')).toBe('true'); + + // Dynamically disable the intermediate container + container.setAttribute('disabled', ''); + await env.waitForStability(); + + expect(nestedIcon.selected).toBeFalse(); + expect(nestedIcon.hasAttribute('selected')).toBeFalse(); + expect(nestedIcon.classList.contains('active')).toBeFalse(); + expect(nestedIcon.getAttribute('aria-pressed')).toBe('false'); + + expect(nestedBtn.selected).toBeFalse(); + expect(nestedBtn.hasAttribute('selected')).toBeFalse(); + expect(nestedBtn.classList.contains('active')).toBeFalse(); + expect(nestedBtn.getAttribute('aria-pressed')).toBe('false'); + }); + + it('handles custom elements with read-only selected getter gracefully', async () => { + const root = env.render(html` + +
+
+ `); + await env.waitForStability(); + const toolbar = root.querySelector('md-gb-toolbar')! as ToolbarElement; + const customItem = root.querySelector('#customItem')! as HTMLDivElement; + + Object.defineProperty(customItem, 'selected', { + get() { + return true; + }, + configurable: true, + }); + + expect(() => { + toolbar.disabled = true; + }).not.toThrow(); + }); + }); + + describe('Disabled state enhancements (propagation, tokens, suppression)', () => { + it('propagates disabled state to native buttons, custom elements, links, role=button, and form controls', async () => { + const root = env.render(html` + + + Custom Button + + Link Action + Role Button + Role Button No Tabindex + + + + + `); + await env.waitForStability(); + const toolbar = root.querySelector('md-gb-toolbar')! as ToolbarElement; + const nativeBtn = root.querySelector('#nativeBtn')! as HTMLButtonElement; + const customBtn = root.querySelector('#customBtn')! as ButtonElement; + const iconBtn = root.querySelector('#iconBtn')! as IconButtonElement; + const linkBtn = root.querySelector('#linkBtn')! as HTMLAnchorElement; + const roleBtn = root.querySelector('#roleBtn')! as HTMLSpanElement; + const roleBtnNoTabindex = root.querySelector( + '#roleBtnNoTabindex', + )! as HTMLSpanElement; + const inputEl = root.querySelector('#inputEl')! as HTMLInputElement; + const selectEl = root.querySelector('#selectEl')! as HTMLSelectElement; + const textareaEl = root.querySelector( + '#textareaEl', + )! as HTMLTextAreaElement; + + expect(nativeBtn.disabled).toBeFalse(); + expect(customBtn.disabled).toBeFalse(); + expect(iconBtn.disabled).toBeFalse(); + expect(inputEl.disabled).toBeFalse(); + expect(selectEl.disabled).toBeFalse(); + expect(textareaEl.disabled).toBeFalse(); + + toolbar.disabled = true; + await env.waitForStability(); + + // Native button & custom elements + expect(nativeBtn.disabled).toBeTrue(); + expect(nativeBtn.getAttribute('aria-disabled')).toBe('true'); + expect(nativeBtn.hasAttribute('data-toolbar-disabled')).toBeTrue(); + + expect(customBtn.disabled).toBeTrue(); + expect(customBtn.getAttribute('aria-disabled')).toBe('true'); + expect(customBtn.hasAttribute('data-toolbar-disabled')).toBeTrue(); + + expect(iconBtn.disabled).toBeTrue(); + expect(iconBtn.getAttribute('aria-disabled')).toBe('true'); + expect(iconBtn.hasAttribute('data-toolbar-disabled')).toBeTrue(); + + // Form controls + expect(inputEl.disabled).toBeTrue(); + expect(inputEl.getAttribute('aria-disabled')).toBe('true'); + expect(inputEl.hasAttribute('data-toolbar-disabled')).toBeTrue(); + + expect(selectEl.disabled).toBeTrue(); + expect(selectEl.getAttribute('aria-disabled')).toBe('true'); + expect(selectEl.hasAttribute('data-toolbar-disabled')).toBeTrue(); + + expect(textareaEl.disabled).toBeTrue(); + expect(textareaEl.getAttribute('aria-disabled')).toBe('true'); + expect(textareaEl.hasAttribute('data-toolbar-disabled')).toBeTrue(); + + // Links & role="button" elements + expect(linkBtn.getAttribute('aria-disabled')).toBe('true'); + expect(linkBtn.getAttribute('tabindex')).toBe('-1'); + expect(linkBtn.hasAttribute('data-toolbar-disabled')).toBeTrue(); + + expect(roleBtn.getAttribute('aria-disabled')).toBe('true'); + expect(roleBtn.getAttribute('tabindex')).toBe('-1'); + expect(roleBtn.getAttribute('data-toolbar-prev-tabindex')).toBe('0'); + expect(roleBtn.hasAttribute('data-toolbar-disabled')).toBeTrue(); + + expect(roleBtnNoTabindex.getAttribute('aria-disabled')).toBe('true'); + expect(roleBtnNoTabindex.getAttribute('tabindex')).toBe('-1'); + expect( + roleBtnNoTabindex.hasAttribute('data-toolbar-prev-tabindex'), + ).toBeFalse(); + expect( + roleBtnNoTabindex.hasAttribute('data-toolbar-disabled'), + ).toBeTrue(); + }); + + it('restores state on re-enable while preserving pre-disabled states', async () => { + const root = env.render(html` + + + + Custom Button + Pre-disabled Custom + Link Action + Pre-disabled Link + Role Button + Pre-disabled Role + Role No Tab + + `); + await env.waitForStability(); + const toolbar = root.querySelector('md-gb-toolbar')! as ToolbarElement; + const nativeBtn = root.querySelector('#nativeBtn')! as HTMLButtonElement; + const preNative = root.querySelector('#preNative')! as HTMLButtonElement; + const customBtn = root.querySelector('#customBtn')! as ButtonElement; + const preCustom = root.querySelector('#preCustom')! as ButtonElement; + const linkBtn = root.querySelector('#linkBtn')! as HTMLAnchorElement; + const preLink = root.querySelector('#preLink')! as HTMLAnchorElement; + const roleBtn = root.querySelector('#roleBtn')! as HTMLSpanElement; + const preRole = root.querySelector('#preRole')! as HTMLSpanElement; + const roleNoTab = root.querySelector('#roleNoTab')! as HTMLSpanElement; + + toolbar.disabled = true; + await env.waitForStability(); + + // Pre-disabled items must not be marked with data-toolbar-disabled + expect(preNative.hasAttribute('data-toolbar-disabled')).toBeFalse(); + expect(preCustom.hasAttribute('data-toolbar-disabled')).toBeFalse(); + expect(preLink.hasAttribute('data-toolbar-disabled')).toBeFalse(); + expect(preRole.hasAttribute('data-toolbar-disabled')).toBeFalse(); + + // Toolbar is now re-enabled + toolbar.disabled = false; + await env.waitForStability(); + + // Items that were not pre-disabled are restored + expect(nativeBtn.disabled).toBeFalse(); + expect(nativeBtn.hasAttribute('aria-disabled')).toBeFalse(); + expect(nativeBtn.hasAttribute('data-toolbar-disabled')).toBeFalse(); + + expect(customBtn.disabled).toBeFalse(); + expect(customBtn.hasAttribute('aria-disabled')).toBeFalse(); + expect(customBtn.hasAttribute('data-toolbar-disabled')).toBeFalse(); + + expect(linkBtn.hasAttribute('aria-disabled')).toBeFalse(); + expect(linkBtn.hasAttribute('tabindex')).toBeFalse(); + expect(linkBtn.hasAttribute('data-toolbar-disabled')).toBeFalse(); + + expect(roleBtn.hasAttribute('aria-disabled')).toBeFalse(); + expect(roleBtn.getAttribute('tabindex')).toBe('0'); + expect(roleBtn.hasAttribute('data-toolbar-prev-tabindex')).toBeFalse(); + expect(roleBtn.hasAttribute('data-toolbar-disabled')).toBeFalse(); + + expect(roleNoTab.hasAttribute('aria-disabled')).toBeFalse(); + expect(roleNoTab.hasAttribute('tabindex')).toBeFalse(); + expect(roleNoTab.hasAttribute('data-toolbar-disabled')).toBeFalse(); + + // Pre-disabled items retain their disabled state + expect(preNative.disabled).toBeTrue(); + expect(preCustom.disabled).toBeTrue(); + expect(preLink.getAttribute('aria-disabled')).toBe('true'); + expect(preRole.getAttribute('aria-disabled')).toBe('true'); + }); + + it('applies GM3 12% opacity token on contained buttons and 38% opacity token on icons and labels', async () => { + const root = env.render(html` + + Contained Button + + + + + `); + await env.waitForStability(); + const toolbar = root.querySelector('md-gb-toolbar')! as ToolbarElement; + const btn = root.querySelector('#btn')! as ButtonElement; + const iconBtn = root.querySelector('#iconBtn')! as IconButtonElement; + const cssBtn = root.querySelector('#cssBtn')! as HTMLButtonElement; + const cssIconBtn = root.querySelector( + '#cssIconBtn', + )! as HTMLButtonElement; + + toolbar.disabled = true; + await env.waitForStability(); + + const innerBtn = btn.shadowRoot!.querySelector( + '[part="btn"]', + ) as HTMLElement; + const innerIconBtn = iconBtn.shadowRoot!.querySelector( + '[part="icon-btn"]', + ) as HTMLElement; + + const innerBtnStyles = getComputedStyle(innerBtn); + const iconBtnStyles = getComputedStyle(innerIconBtn); + const cssBtnStyles = getComputedStyle(cssBtn); + const cssIconBtnStyles = getComputedStyle(cssIconBtn); + + // Contained buttons have 12% on-surface container token + expect( + innerBtnStyles.getPropertyValue('--container-color').trim(), + ).toMatch(/12%|hsl\(from .* \/ 12%\)/); + expect(cssBtnStyles.getPropertyValue('--container-color').trim()).toMatch( + /12%|hsl\(from .* \/ 12%\)/, + ); + + // Uncontained icon buttons have transparent container and fill: 0 + expect(iconBtnStyles.getPropertyValue('--container-color').trim()).toBe( + 'transparent', + ); + expect(iconBtnStyles.getPropertyValue('--md-icon-fill').trim()).toBe('0'); + expect( + cssIconBtnStyles.getPropertyValue('--container-color').trim(), + ).toBe('transparent'); + expect(cssIconBtnStyles.getPropertyValue('--md-icon-fill').trim()).toBe( + '0', + ); + + // 38% disabled tokens on icons and labels + expect(innerBtnStyles.getPropertyValue('--icon-color').trim()).toMatch( + /hsl\(from .* h s l\s*\/\s*38%\)/, + ); + expect( + innerBtnStyles.getPropertyValue('--label-text-color').trim(), + ).toMatch(/hsl\(from .* h s l\s*\/\s*38%\)/); + }); + + it('suppresses pointer events, cursor, and state layers across host, slots, and descendants', async () => { + const root = env.render(html` + + Action + + + Link + + `); + await env.waitForStability(); + const toolbar = root.querySelector('md-gb-toolbar')! as ToolbarElement; + const toolbarDiv = toolbar.shadowRoot!.querySelector( + '[part="toolbar"]', + )! as HTMLElement; + const slot = toolbar.shadowRoot!.querySelector( + 'slot:not([name])', + )! as HTMLSlotElement; + const btn = root.querySelector('#btn')! as ButtonElement; + const iconBtn = root.querySelector('#iconBtn')! as IconButtonElement; + const nativeBtn = root.querySelector('#nativeBtn')! as HTMLButtonElement; + const linkBtn = root.querySelector('#linkBtn')! as HTMLAnchorElement; + + toolbar.disabled = true; + await env.waitForStability(); + + // Host, toolbar part, slot, and items have pointer-events: none and cursor: default + const tbStyles = getComputedStyle(toolbar); + const partStyles = getComputedStyle(toolbarDiv); + const slotStyles = getComputedStyle(slot); + const btnStyles = getComputedStyle(btn); + const iconBtnStyles = getComputedStyle(iconBtn); + const nativeBtnStyles = getComputedStyle(nativeBtn); + const linkBtnStyles = getComputedStyle(linkBtn); + + expect(tbStyles.pointerEvents).toBe('none'); + expect(tbStyles.cursor).toBe('default'); + expect(partStyles.pointerEvents).toBe('none'); + expect(partStyles.cursor).toBe('default'); + expect(slotStyles.pointerEvents).toBe('none'); + expect(slotStyles.cursor).toBe('default'); + expect(btnStyles.pointerEvents).toBe('none'); + expect(btnStyles.cursor).toBe('default'); + expect(iconBtnStyles.pointerEvents).toBe('none'); + expect(iconBtnStyles.cursor).toBe('default'); + expect(nativeBtnStyles.pointerEvents).toBe('none'); + expect(nativeBtnStyles.cursor).toBe('default'); + expect(linkBtnStyles.pointerEvents).toBe('none'); + expect(linkBtnStyles.cursor).toBe('default'); + + // Ripple / state layer suppression tokens + expect( + tbStyles.getPropertyValue('--ripple-hover-opacity').trim(), + ).toMatch(/^0%?$/); + expect( + tbStyles.getPropertyValue('--ripple-press-opacity').trim(), + ).toMatch(/^0%?$/); + expect(tbStyles.getPropertyValue('--state-layer-color').trim()).toBe( + 'transparent', + ); + + expect( + btnStyles.getPropertyValue('--ripple-hover-opacity').trim(), + ).toMatch(/^0%?$/); + expect( + btnStyles.getPropertyValue('--ripple-press-opacity').trim(), + ).toMatch(/^0%?$/); + expect(btnStyles.getPropertyValue('--state-layer-color').trim()).toBe( + 'transparent', + ); + + // Active / hover state transform suppression + btn.classList.add('active'); + await env.waitForStability(); + expect(getComputedStyle(btn).transform).toBe('none'); + btn.classList.remove('active'); + }); + + it('propagates disabled state to items dynamically appended into a nested container while disabled', async () => { + const root = env.render(html` + +
+ +
+
+ `); + await env.waitForStability(); + const container = root.querySelector( + '#nestedContainer', + )! as HTMLDivElement; + const initialBtn = root.querySelector( + '#initialBtn', + )! as HTMLButtonElement; + + expect(initialBtn.disabled).toBeTrue(); + expect(initialBtn.getAttribute('aria-disabled')).toBe('true'); + + const dynamicBtn = document.createElement('button'); + dynamicBtn.id = 'dynamicBtn'; + dynamicBtn.textContent = 'Dynamic'; + container.appendChild(dynamicBtn); + await env.waitForStability(); + + expect(dynamicBtn.disabled).toBeTrue(); + expect(dynamicBtn.getAttribute('aria-disabled')).toBe('true'); + expect(dynamicBtn.hasAttribute('data-toolbar-disabled')).toBeTrue(); + }); + + it('propagates disabled state and tabindex -1 to composite ARIA roles', async () => { + const root = env.render(html` + +
+ Switch +
+ + Checkbox + Radio + +
+ `); + await env.waitForStability(); + const toolbar = root.querySelector('md-gb-toolbar')! as ToolbarElement; + const switchEl = root.querySelector('#switchEl')! as HTMLDivElement; + const menuItemEl = root.querySelector('#menuItemEl')! as HTMLDivElement; + const checkboxEl = root.querySelector('#checkboxEl')! as HTMLSpanElement; + const radioEl = root.querySelector('#radioEl')! as HTMLSpanElement; + const noTabitem = root.querySelector('#noTabitem')! as HTMLDivElement; + + toolbar.disabled = true; + await env.waitForStability(); + + expect(switchEl.getAttribute('aria-disabled')).toBe('true'); + expect(switchEl.getAttribute('tabindex')).toBe('-1'); + expect(switchEl.getAttribute('data-toolbar-prev-tabindex')).toBe('0'); + expect(switchEl.hasAttribute('data-toolbar-disabled')).toBeTrue(); + + expect(menuItemEl.getAttribute('aria-disabled')).toBe('true'); + expect(menuItemEl.getAttribute('tabindex')).toBe('-1'); + expect(menuItemEl.getAttribute('data-toolbar-prev-tabindex')).toBe('0'); + expect(menuItemEl.hasAttribute('data-toolbar-disabled')).toBeTrue(); + + expect(checkboxEl.getAttribute('aria-disabled')).toBe('true'); + expect(checkboxEl.getAttribute('tabindex')).toBe('-1'); + expect(checkboxEl.getAttribute('data-toolbar-prev-tabindex')).toBe('0'); + expect(checkboxEl.hasAttribute('data-toolbar-disabled')).toBeTrue(); + + expect(radioEl.getAttribute('aria-disabled')).toBe('true'); + expect(radioEl.getAttribute('tabindex')).toBe('-1'); + expect(radioEl.getAttribute('data-toolbar-prev-tabindex')).toBe('-1'); + expect(radioEl.hasAttribute('data-toolbar-disabled')).toBeTrue(); + + expect(noTabitem.getAttribute('aria-disabled')).toBe('true'); + expect(noTabitem.getAttribute('tabindex')).toBe('-1'); + expect(noTabitem.hasAttribute('data-toolbar-prev-tabindex')).toBeFalse(); + expect(noTabitem.hasAttribute('data-toolbar-disabled')).toBeTrue(); + + toolbar.disabled = false; + await env.waitForStability(); + + expect(switchEl.hasAttribute('aria-disabled')).toBeFalse(); + expect(switchEl.getAttribute('tabindex')).toBe('0'); + expect(switchEl.hasAttribute('data-toolbar-disabled')).toBeFalse(); + + expect(menuItemEl.hasAttribute('aria-disabled')).toBeFalse(); + expect(menuItemEl.getAttribute('tabindex')).toBe('0'); + expect(menuItemEl.hasAttribute('data-toolbar-disabled')).toBeFalse(); + + expect(checkboxEl.hasAttribute('aria-disabled')).toBeFalse(); + expect(checkboxEl.getAttribute('tabindex')).toBe('0'); + expect(checkboxEl.hasAttribute('data-toolbar-disabled')).toBeFalse(); + + expect(radioEl.hasAttribute('aria-disabled')).toBeFalse(); + expect(radioEl.getAttribute('tabindex')).toBe('-1'); + expect(radioEl.hasAttribute('data-toolbar-disabled')).toBeFalse(); + + expect(noTabitem.hasAttribute('aria-disabled')).toBeFalse(); + expect(noTabitem.hasAttribute('tabindex')).toBeFalse(); + expect(noTabitem.hasAttribute('data-toolbar-disabled')).toBeFalse(); + }); + }); + + describe('Hide-labels button shape stability and transition (no square overshoot)', () => { + it('preserves --container-shape as var(--button-shape) and circular border-radius when hideLabels is toggled', async () => { + const root = env.render(html` + + editEdit + + `); + await env.waitForStability(); + const toolbar = root.querySelector('md-gb-toolbar')! as ToolbarElement; + const btn = root.querySelector('#btn')! as ButtonElement; + const innerBtn = btn.shadowRoot!.querySelector( + '[part="btn"]', + ) as HTMLElement; + + toolbar.hideLabels = true; + await env.waitForStability(); + + const shape = getComputedStyle(innerBtn) + .getPropertyValue('--container-shape') + .trim(); + expect(shape).toMatch(/var\(--button-shape\)|9999px/); + + const borderRadius = getComputedStyle(innerBtn).borderRadius; + expect(borderRadius).toMatch(/^(9999px|20px)( (9999px|20px))*$/); + expect(borderRadius).not.toBe('0px'); + + toolbar.hideLabels = false; + await env.waitForStability(); + + const revertedShape = getComputedStyle(innerBtn) + .getPropertyValue('--container-shape') + .trim(); + expect(revertedShape).toMatch(/var\(--button-shape\)|9999px/); + expect(getComputedStyle(innerBtn).borderRadius).not.toBe('0px'); + }); + + it('suppresses border-radius transitions on innerBtn to prevent cubic-bezier overshoot', async () => { + const root = env.render(html` + + editEdit + + `); + await env.waitForStability(); + const btn = root.querySelector('#btn')! as ButtonElement; + const innerBtn = btn.shadowRoot!.querySelector( + '[part="btn"]', + ) as HTMLElement; + + const computedStyle = getComputedStyle(innerBtn); + const properties = computedStyle.transitionProperty + .split(',') + .map((s) => s.trim()); + const durations = computedStyle.transitionDuration + .split(',') + .map((s) => s.trim()); + const borderRadiusIndex = properties.indexOf('border-radius'); + + if (borderRadiusIndex !== -1) { + const duration = durations[borderRadiusIndex % durations.length]; + expect(['0s', '0ms']).toContain(duration); + } else { + expect(properties).not.toContain('border-radius'); + } + }); + + it('maintains circular border-radius without square state when button is selected or active and hideLabels is toggled', async () => { + const root = env.render(html` + + + checkDone + + + starStar + + + `); + await env.waitForStability(); + const toolbar = root.querySelector('md-gb-toolbar')! as ToolbarElement; + const toggleBtn = root.querySelector('#toggleBtn')! as ButtonElement; + const activeBtn = root.querySelector('#activeBtn')! as ButtonElement; + const innerToggle = toggleBtn.shadowRoot!.querySelector( + '[part="btn"]', + ) as HTMLElement; + const innerActive = activeBtn.shadowRoot!.querySelector( + '[part="btn"]', + ) as HTMLElement; + + // Toggle hideLabels = true + toolbar.hideLabels = true; + await env.waitForStability(); + + const toggleShape = getComputedStyle(innerToggle) + .getPropertyValue('--container-shape') + .trim(); + expect(toggleShape).toMatch(/var\(--button-shape\)|9999px/); + expect(getComputedStyle(innerToggle).borderRadius).toMatch( + /^(9999px|20px)( (9999px|20px))*$/, + ); + expect(getComputedStyle(innerToggle).borderRadius).not.toBe('0px'); + + const activeShape = getComputedStyle(innerActive) + .getPropertyValue('--container-shape') + .trim(); + expect(activeShape).toMatch(/var\(--button-shape\)|9999px/); + expect(getComputedStyle(innerActive).borderRadius).toMatch( + /^(9999px|20px)( (9999px|20px))*$/, + ); + expect(getComputedStyle(innerActive).borderRadius).not.toBe('0px'); + + // Toggle hideLabels = false + toolbar.hideLabels = false; + await env.waitForStability(); + + expect(getComputedStyle(innerToggle).borderRadius).toMatch( + /^(9999px|20px)( (9999px|20px))*$/, + ); + expect(getComputedStyle(innerToggle).borderRadius).not.toBe('0px'); + expect(getComputedStyle(innerActive).borderRadius).toMatch( + /^(9999px|20px)( (9999px|20px))*$/, + ); + expect(getComputedStyle(innerActive).borderRadius).not.toBe('0px'); + }); + + it('maintains --container-shape as var(--button-shape) on slotted light-DOM .btn elements in hide-labels mode', async () => { + const root = env.render(html` + + + + `); + await env.waitForStability(); + const lightBtn = root.querySelector('#lightBtn') as HTMLElement; + const shape = getComputedStyle(lightBtn) + .getPropertyValue('--container-shape') + .trim(); + expect(shape).toMatch(/var\(--button-shape\)|9999px/); + + const computedStyle = getComputedStyle(lightBtn); + const properties = computedStyle.transitionProperty + .split(',') + .map((s) => s.trim()); + const durations = computedStyle.transitionDuration + .split(',') + .map((s) => s.trim()); + const borderRadiusIndex = properties.indexOf('border-radius'); + + if (borderRadiusIndex !== -1) { + const duration = durations[borderRadiusIndex % durations.length]; + expect(['0s', '0ms']).toContain(duration); + } + }); + }); + + describe('Toolbar slot item disabled state and selected state layer removal', () => { + it('removes selected state layer and clears selected when individual default or trailing slot button becomes disabled', async () => { + const root = env.render(html` + + + undo + + + edit + Edit + + + more_vert + Options + + + `); + await env.waitForStability(); + + const leadingIcon = root.querySelector( + '#leadingIcon', + )! as IconButtonElement; + const defaultBtn = root.querySelector('#defaultBtn')! as ButtonElement; + const trailingBtn = root.querySelector('#trailingBtn')! as ButtonElement; + + expect(leadingIcon.selected).toBeTrue(); + expect(defaultBtn.selected).toBeTrue(); + expect(trailingBtn.selected).toBeTrue(); + + // Disabling default slot button removes its selected state layer + defaultBtn.disabled = true; + await env.waitForStability(); + + expect(defaultBtn.selected).toBeFalse(); + expect(defaultBtn.hasAttribute('selected')).toBeFalse(); + + const innerDefault = defaultBtn.shadowRoot!.querySelector( + '[part="btn"]', + ) as HTMLElement; + const defaultStyles = getComputedStyle(innerDefault); + expect( + defaultStyles.getPropertyValue('--container-color').trim(), + ).toMatch(/12%|hsl\(from .* \/ 12%\)/); + expect(defaultStyles.getPropertyValue('--md-icon-fill').trim()).toBe('0'); + expect(defaultStyles.getPropertyValue('--icon-color').trim()).toMatch( + /hsl\(from .* h s l\s*\/\s*38%\)/, + ); + expect( + defaultStyles.getPropertyValue('--label-text-color').trim(), + ).toMatch(/hsl\(from .* h s l\s*\/\s*38%\)/); + + // Disabling trailing slot button (icon with label) removes its selected state layer + trailingBtn.disabled = true; + await env.waitForStability(); + + expect(trailingBtn.selected).toBeFalse(); + expect(trailingBtn.hasAttribute('selected')).toBeFalse(); + + const innerTrailing = trailingBtn.shadowRoot!.querySelector( + '[part="btn"]', + ) as HTMLElement; + const trailingStyles = getComputedStyle(innerTrailing); + expect( + trailingStyles.getPropertyValue('--container-color').trim(), + ).toMatch(/12%|hsl\(from .* \/ 12%\)/); + expect(trailingStyles.getPropertyValue('--md-icon-fill').trim()).toBe( + '0', + ); + expect(trailingStyles.getPropertyValue('--icon-color').trim()).toMatch( + /hsl\(from .* h s l\s*\/\s*38%\)/, + ); + expect( + trailingStyles.getPropertyValue('--label-text-color').trim(), + ).toMatch(/hsl\(from .* h s l\s*\/\s*38%\)/); + + // Disabling leading icon button also removes its selected state layer + leadingIcon.disabled = true; + await env.waitForStability(); + + expect(leadingIcon.selected).toBeFalse(); + expect(leadingIcon.hasAttribute('selected')).toBeFalse(); + + const innerLeading = leadingIcon.shadowRoot!.querySelector( + '[part="icon-btn"]', + ) as HTMLElement; + const leadingStyles = getComputedStyle(innerLeading); + expect(leadingStyles.getPropertyValue('--container-color').trim()).toBe( + 'transparent', + ); + expect(leadingStyles.getPropertyValue('--md-icon-fill').trim()).toBe('0'); + }); + }); +});