diff --git a/labs/gb/components/toolbar/_toolbar-tokens.scss b/labs/gb/components/toolbar/_toolbar-tokens.scss index c95f128a00..e9eb9fbc50 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 } diff --git a/labs/gb/components/toolbar/demo/demo.ts b/labs/gb/components/toolbar/demo/demo.ts index d86204383d..6ccef054cc 100644 --- a/labs/gb/components/toolbar/demo/demo.ts +++ b/labs/gb/components/toolbar/demo/demo.ts @@ -8,7 +8,7 @@ import './material-collection.js'; import './index.js'; import { - KnobTypesToKnobs, + type KnobTypesToKnobs, MaterialCollection, materialInitsToStoryInits, setUpDemo, @@ -19,7 +19,7 @@ import { } from '@material/web/labs/gb/components/toolbar/toolbar.js'; import {boolInput, Knob, selectDropdown} from './index.js'; -import {stories, StoryKnobs} from './stories.js'; +import {stories, type StoryKnobs} from './stories.js'; const collection = new MaterialCollection>( 'Toolbar', @@ -41,6 +41,10 @@ const collection = new MaterialCollection>( }), }), new Knob('docked', {ui: boolInput()}), + new Knob('hideLabels', {ui: boolInput()}), + new Knob('disableUndo', {ui: boolInput()}), + new Knob('disableEdit', {ui: boolInput()}), + new Knob('disableOptions', {ui: boolInput()}), ], ); diff --git a/labs/gb/components/toolbar/demo/stories.ts b/labs/gb/components/toolbar/demo/stories.ts index 2fdaf6e053..42e0753cea 100644 --- a/labs/gb/components/toolbar/demo/stories.ts +++ b/labs/gb/components/toolbar/demo/stories.ts @@ -9,22 +9,25 @@ import '@material/web/labs/gb/components/iconbutton/md-gb-icon-button.js'; import '@material/web/labs/gb/components/toolbar/md-gb-toolbar.js'; import '@material/web/labs/gb/styles/icon/md-gb-icon.js'; -import {MaterialStoryInit} from './material-collection.js'; +import {type MaterialStoryInit} from './material-collection.js'; import { type ToolbarColor, type ToolbarOrientation, } from '@material/web/labs/gb/components/toolbar/toolbar.js'; import {adoptStyles} from '@material/web/labs/gb/styles/adopt-styles.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 +import {css, html} from 'lit'; /** Knob types for toolbar stories. */ export interface StoryKnobs { color?: ToolbarColor; orientation?: ToolbarOrientation; docked: boolean; + hideLabels?: boolean; + disableUndo?: boolean; + disableEdit?: boolean; + disableOptions?: boolean; } adoptStyles(document, [ @@ -72,21 +75,26 @@ 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,59 +104,123 @@ 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
+ +
+ + + star + Starred + + + visibility + Preview + + + bookmark + + + favorite + + + share + + +
+ +
+ + + auto_awesome + Generate + + + palette + Theme + + + bookmark + + + favorite + + + share + + +
`; }, diff --git a/labs/gb/components/toolbar/toolbar-element.ts b/labs/gb/components/toolbar/toolbar-element.ts index 775446f003..e774ae7298 100644 --- a/labs/gb/components/toolbar/toolbar-element.ts +++ b/labs/gb/components/toolbar/toolbar-element.ts @@ -12,12 +12,19 @@ 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'; - /** * A Material Design gBreeze toolbar component. * @@ -26,7 +33,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 +51,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 +60,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,6 +101,13 @@ export class ToolbarElement extends LitElement { /** Whether the toolbar is docked across full width. */ @property({type: Boolean, reflect: true}) docked = 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; + override connectedCallback() { super.connectedCallback(); adoptStyles(this, toolbarStyles); @@ -112,6 +133,10 @@ export class ToolbarElement extends LitElement { `toolbar wrap ${isVertical ? 'block' : 'inline'}`, ); } + + if (changedProperties.has('hideLabels')) { + this.updateLabels(); + } } protected override render() { @@ -122,11 +147,79 @@ export class ToolbarElement extends LitElement { color: this.color, orientation: this.docked ? 'horizontal' : this.orientation, docked: this.docked, + align: this.align, + hideLabels: this.hideLabels, })}"> - - - + + + `; } + + private readonly handleSlotChange = () => { + this.updateLabels(); + }; + + 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 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..300d22f375 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,20 @@ 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; + } + md-gb-toolbar { @include toolbar-tokens.root; @@ -88,6 +110,14 @@ &[orientation='vertical']:not([docked]) { @include toolbar-tokens.vertical; + + &[align='start'] { + margin-inline: 0 auto; + } + + &[align='end'] { + margin-inline: auto 0; + } } &[docked] { @@ -98,6 +128,39 @@ } } + [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 { @include toolbar-tokens.root; @@ -165,18 +228,37 @@ 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); + + &: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); } @@ -184,23 +266,71 @@ .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%); + inline-size: var(--touch-target-size); + block-size: var(--touch-target-size); + min-inline-size: var(--touch-target-size); + min-block-size: 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); } } + ::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], @@ -210,14 +340,76 @@ md-gb-toolbar md-gb-button::part(btn), .toolbar md-gb-button::part(btn) { + --container-shape: var(--button-shape); + position: relative; + transition: + border-radius 0s, + var(--ripple-transition); + + &::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + inline-size: 100%; + block-size: var(--touch-target-size); + min-block-size: var(--touch-target-size); + } + } + + // Toolbar colors only apply to enabled buttons. `::part()` rules from the + // outer tree win over the button's own `:disabled` styles, so they must not + // match disabled buttons for the button's disabled tokens to show through. + md-gb-toolbar md-gb-button:not([disabled], [soft-disabled])::part(btn), + .toolbar md-gb-button:not([disabled], [soft-disabled])::part(btn) { --container-color: var(--button-container-color); --icon-color: var(--toolbar-icon-color); --label-text-color: var(--toolbar-label-text-color); --md-icon-color: var(--toolbar-icon-color); } - md-gb-toolbar md-gb-button[selected]::part(btn), - .toolbar md-gb-button[selected]::part(btn) { + 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:not([disabled], [soft-disabled]):is( + [selected], + [aria-pressed='true'] + )::part(btn), + .toolbar + md-gb-button:not([disabled], [soft-disabled]):is( + [selected], + [aria-pressed='true'] + )::part(btn), + md-gb-toolbar + md-gb-button:not([disabled], [soft-disabled])::part(btn):is( + .btn-selected, + [aria-pressed='true'] + ), + .toolbar + md-gb-button:not([disabled], [soft-disabled])::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); @@ -226,15 +418,176 @@ md-gb-toolbar md-gb-icon-button::part(icon-btn), .toolbar md-gb-icon-button::part(icon-btn) { + --container-shape: var(--icon-button-shape); + position: relative; + transition: + border-radius 0s, + var(--ripple-transition); + + &::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + inline-size: var(--touch-target-size); + block-size: var(--touch-target-size); + min-inline-size: var(--touch-target-size); + min-block-size: var(--touch-target-size); + } + } + + md-gb-toolbar + md-gb-icon-button:not([disabled], [soft-disabled])::part(icon-btn), + .toolbar md-gb-icon-button:not([disabled], [soft-disabled])::part(icon-btn) { --container-color: var(--button-container-color); --icon-color: var(--toolbar-icon-color); --md-icon-color: var(--toolbar-icon-color); } - md-gb-toolbar md-gb-icon-button[selected]::part(icon-btn), - .toolbar md-gb-icon-button[selected]::part(icon-btn) { + // Disabled toolbar buttons have no container, selected or not; their + // content color comes from the button's own disabled tokens. + md-gb-toolbar md-gb-button:is([disabled], [soft-disabled])::part(btn), + .toolbar md-gb-button:is([disabled], [soft-disabled])::part(btn), + md-gb-toolbar + md-gb-icon-button:is([disabled], [soft-disabled])::part(icon-btn), + .toolbar md-gb-icon-button:is([disabled], [soft-disabled])::part(icon-btn) { + --container-color: transparent; + } + + 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:not([disabled], [soft-disabled]):is( + [selected], + [aria-pressed='true'] + )::part(icon-btn), + .toolbar + md-gb-icon-button:not([disabled], [soft-disabled]):is( + [selected], + [aria-pressed='true'] + )::part(icon-btn), + md-gb-toolbar + md-gb-icon-button:not([disabled], [soft-disabled])::part(icon-btn):is( + .icon-btn-selected, + [aria-pressed='true'] + ), + .toolbar + md-gb-icon-button:not([disabled], [soft-disabled])::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); } + + // Hide labels (R6) + :host(md-gb-toolbar[hide-labels]) ::slotted(md-gb-button), + :host(md-gb-toolbar[hide-labels]) ::slotted(.btn), + md-gb-toolbar[hide-labels] md-gb-button, + md-gb-toolbar[hide-labels] .btn, + .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; + inline-size: var(--min-button-size); + line-height: 0; + overflow: hidden; + padding-inline: 0; + } + + 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; + inline-size: var(--min-button-size); + line-height: 0; + overflow: hidden; + padding-inline: 0; + position: relative; + } + + // F4: Touch target for collapsed button in hide-labels mode + :host(md-gb-toolbar[hide-labels]) ::slotted(.btn)::before, + md-gb-toolbar[hide-labels] md-gb-button::part(btn)::before, + md-gb-toolbar[hide-labels] .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%); + inline-size: var(--touch-target-size); + block-size: var(--touch-target-size); + min-inline-size: var(--touch-target-size); + min-block-size: 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']), + md-gb-toolbar[hide-labels] .btn > :not(md-gb-icon, .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); + block-size: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + white-space: nowrap; + inline-size: 1px; + border: 0; + } } diff --git a/labs/gb/components/toolbar/toolbar.ts b/labs/gb/components/toolbar/toolbar.ts index 76c3e22ae7..4b76382171 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,9 @@ 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', } as const; /** The state provided to the `toolbarClasses()` function. */ @@ -43,6 +56,10 @@ export interface ToolbarClassesState { orientation?: ToolbarOrientation; /** Whether the toolbar is docked across the container width. */ docked?: boolean; + /** The alignment of items in vertical orientation. */ + align?: ToolbarAlign; + /** Whether text labels on buttons should be hidden. */ + hideLabels?: boolean; } /** @@ -55,11 +72,14 @@ export function toolbarClasses({ color = TOOLBAR_COLORS.standard, orientation = TOOLBAR_ORIENTATIONS.horizontal, docked = 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 +88,13 @@ 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), }; } diff --git a/labs/gb/components/toolbar/toolbar_test.ts b/labs/gb/components/toolbar/toolbar_test.ts new file mode 100644 index 0000000000..b32dfe91d5 --- /dev/null +++ b/labs/gb/components/toolbar/toolbar_test.ts @@ -0,0 +1,823 @@ +/** + * @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 {Harness} from '../../../../testing/harness.js'; +import {adoptStyles} from '../../styles/adopt-styles.js'; +import m3Styles from '../../styles/m3.css' with {type: 'css'}; // github-only +// import {styles as m3Styles} from '../../styles/m3.cssresult.js'; // google3-only +import {ButtonElement} from '../button/button-element.js'; +import {IconButtonElement} from '../iconbutton/icon-button-element.js'; + +import {ToolbarElement} from './toolbar-element.js'; +import toolbarStyles from './toolbar.css' with {type: 'css'}; // github-only +// import {styles as toolbarStyles} from './toolbar.cssresult.js'; // google3-only + +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('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('places 40px buttons 4px apart in a floating toolbar', async () => { + const root = env.render(html` + + + + + `); + await env.waitForStability(); + const icon1 = root.querySelector('#icon1')! as IconButtonElement; + const icon2 = root.querySelector('#icon2')! as IconButtonElement; + + const rect1 = icon1.getBoundingClientRect(); + const rect2 = icon2.getBoundingClientRect(); + expect(Math.round(rect1.width)).toBe(40); + expect(Math.round(rect2.left - rect1.right)).toBe(4); + }); + + it('has 8px inline padding between the floating container edge and its buttons', async () => { + const root = env.render(html` + + + + + `); + await env.waitForStability(); + const toolbar = root.querySelector('md-gb-toolbar')! as ToolbarElement; + const container = toolbar.shadowRoot!.querySelector( + '[part="toolbar"]', + ) as HTMLElement; + const containerRect = container.getBoundingClientRect(); + const rect1 = root.querySelector('#icon1')!.getBoundingClientRect(); + const rect2 = root.querySelector('#icon2')!.getBoundingClientRect(); + + expect(Math.round(rect1.left - containerRect.left)).toBe(8); + expect(Math.round(containerRect.right - rect2.right)).toBe(8); + }); + + it('keeps at least a 4px gap between buttons in a docked toolbar', async () => { + // A 0px max forces clamp(min, 4vw, max) to its minimum, as on a narrow + // screen where 4vw falls below the minimum spacing. The override targets + // the inner container because it re-declares the toolbar tokens. + const root = env.render(html` + + + + + + `); + await env.waitForStability(); + const rect1 = root.querySelector('#icon1')!.getBoundingClientRect(); + const rect2 = root.querySelector('#icon2')!.getBoundingClientRect(); + + expect(Math.round(rect2.left - rect1.right)).toBe(4); + }); + }); + + 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'); + }); + + it('collapses slotted light-DOM .btn dimensions, sets font-size: 0px, and provides ::before touch target', async () => { + const root = env.render(html` + + + + `); + await env.waitForStability(); + const lightBtn = root.querySelector('#lightBtn')! as HTMLButtonElement; + + // Verify dimensions collapsed to ~40x40px + expect(lightBtn.offsetWidth).toBeGreaterThanOrEqual(36); + expect(lightBtn.offsetWidth).toBeLessThanOrEqual(44); + expect(lightBtn.offsetHeight).toBeGreaterThanOrEqual(36); + expect(lightBtn.offsetHeight).toBeLessThanOrEqual(44); + + // Verify font-size: 0px + expect(getComputedStyle(lightBtn).fontSize).toBe('0px'); + + // Verify ::before touch target pseudo-element computed sizing + const beforeStyle = getComputedStyle(lightBtn, '::before'); + expect(beforeStyle.content).not.toBe('none'); + expect(beforeStyle.position).toBe('absolute'); + }); + }); + + 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('individually disabled buttons', () => { + const DISABLED_CONTENT = + 'hsl(from var(--md-sys-color-on-surface) h s l / 38%)'; + const TRANSPARENT = 'rgba(0, 0, 0, 0)'; + + /** Resolves a CSS color expression to its computed `color` value. */ + function resolveColor(root: HTMLElement, value: string): string { + const probe = document.createElement('div'); + probe.style.color = value; + root.appendChild(probe); + const resolved = getComputedStyle(probe).color; + probe.remove(); + return resolved; + } + + /** Resolves a CSS color expression to its computed `background-color`. */ + function resolveBackground(root: HTMLElement, value: string): string { + const probe = document.createElement('div'); + probe.style.backgroundColor = value; + root.appendChild(probe); + const resolved = getComputedStyle(probe).backgroundColor; + probe.remove(); + return resolved; + } + + function innerIconButton(icon: IconButtonElement): HTMLElement { + return icon.shadowRoot!.querySelector('[part="icon-btn"]')!; + } + + function innerButton(btn: ButtonElement): HTMLElement { + return btn.shadowRoot!.querySelector('[part="btn"]')!; + } + + for (const attr of ['disabled', 'soft-disabled'] as const) { + for (const color of ['standard', 'vibrant'] as const) { + for (const selected of [false, true]) { + const state = selected ? 'selected' : 'unselected'; + + it(`renders a ${attr} ${state} icon button in a ${color} toolbar with on-surface 38% icon color`, async () => { + const root = env.render(html` + + + favorite + + + `); + await env.waitForStability(); + const icon = root.querySelector('#icon')! as IconButtonElement; + const glyph = icon.querySelector('md-gb-icon')! as HTMLElement; + + const expected = resolveColor(root, DISABLED_CONTENT); + expect(getComputedStyle(innerIconButton(icon)).color).toBe( + expected, + ); + expect(getComputedStyle(glyph).color).toBe(expected); + }); + + it(`renders a ${attr} ${state} icon button in a ${color} toolbar with a transparent container`, async () => { + const root = env.render(html` + + + favorite + + + `); + await env.waitForStability(); + const icon = root.querySelector('#icon')! as IconButtonElement; + + expect( + getComputedStyle(innerIconButton(icon)).backgroundColor, + ).toBe(TRANSPARENT); + }); + + it(`renders a ${attr} ${state} labeled button in a ${color} toolbar with on-surface 38% label and icon colors`, async () => { + const root = env.render(html` + + + edit + Edit + + + `); + await env.waitForStability(); + const btn = root.querySelector('#btn')! as ButtonElement; + const glyph = btn.querySelector('md-gb-icon')! as HTMLElement; + + const expected = resolveColor(root, DISABLED_CONTENT); + expect(getComputedStyle(innerButton(btn)).color).toBe(expected); + expect(getComputedStyle(glyph).color).toBe(expected); + }); + + it(`renders a ${attr} ${state} labeled button in a ${color} toolbar with a transparent container`, async () => { + const root = env.render(html` + + + edit + Edit + + + `); + await env.waitForStability(); + const btn = root.querySelector('#btn')! as ButtonElement; + + expect(getComputedStyle(innerButton(btn)).backgroundColor).toBe( + TRANSPARENT, + ); + }); + } + } + } + + it('removes the state layer from disabled buttons', async () => { + const root = env.render(html` + + + favorite + + Edit + + `); + await env.waitForStability(); + const icon = root.querySelector('#icon')! as IconButtonElement; + const btn = root.querySelector('#btn')! as ButtonElement; + + expect(getComputedStyle(innerIconButton(icon)).backgroundImage).toBe( + 'none', + ); + expect(getComputedStyle(innerButton(btn)).backgroundImage).toBe('none'); + }); + + it('does not toggle disabled toggle buttons when clicked', async () => { + const root = env.render(html` + + + favorite + + Edit + + `); + await env.waitForStability(); + const icon = root.querySelector('#icon')! as IconButtonElement; + const btn = root.querySelector('#btn')! as ButtonElement; + + await new Harness(innerIconButton(icon)).clickWithMouse(); + await new Harness(innerButton(btn)).clickWithMouse(); + await env.waitForStability(); + + expect(icon.selected).toBeFalse(); + expect(btn.selected).toBeFalse(); + }); + + it('toggles an enabled sibling of a disabled button when clicked', async () => { + const root = env.render(html` + + Edit + Share + + `); + await env.waitForStability(); + const enabled = root.querySelector('#enabled')! as ButtonElement; + + await new Harness(innerButton(enabled)).clickWithMouse(); + await env.waitForStability(); + + expect(enabled.selected).toBeTrue(); + }); + + it('keeps toolbar colors on an enabled sibling of a disabled button', async () => { + const root = env.render(html` + + Edit + Share + + `); + await env.waitForStability(); + const enabled = root.querySelector('#enabled')! as ButtonElement; + const inner = getComputedStyle(innerButton(enabled)); + + expect(inner.color).toBe( + resolveColor(root, 'var(--md-sys-color-on-surface-variant)'), + ); + expect(inner.backgroundColor).toBe( + resolveBackground(root, 'var(--md-sys-color-surface-container)'), + ); + }); + + it('restores toolbar colors when a disabled button is re-enabled', async () => { + const root = env.render(html` + + + favorite + + Edit + + `); + await env.waitForStability(); + const icon = root.querySelector('#icon')! as IconButtonElement; + const btn = root.querySelector('#btn')! as ButtonElement; + + icon.disabled = false; + btn.disabled = false; + await env.waitForStability(); + + const primary = resolveColor(root, 'var(--md-sys-color-primary)'); + const container = resolveBackground( + root, + 'var(--md-sys-color-primary-container)', + ); + expect(getComputedStyle(innerIconButton(icon)).color).toBe(primary); + expect(getComputedStyle(innerIconButton(icon)).backgroundColor).toBe( + container, + ); + expect(getComputedStyle(innerButton(btn)).color).toBe(primary); + expect(getComputedStyle(innerButton(btn)).backgroundColor).toBe( + container, + ); + }); + }); +});