diff --git a/labs/gb/components/button/_button-tokens.scss b/labs/gb/components/button/_button-tokens.scss index b4e7992ce7..624ecbfea4 100644 --- a/labs/gb/components/button/_button-tokens.scss +++ b/labs/gb/components/button/_button-tokens.scss @@ -87,6 +87,11 @@ --container-color: var(--md-sys-color-inverse-surface); --icon-color: var(--md-sys-color-inverse-on-surface); --label-text-color: var(--md-sys-color-inverse-on-surface); + --outline-color: transparent; +} + +@mixin outlined-disabled { + --container-color: transparent; } @mixin text { diff --git a/labs/gb/components/button/button.scss b/labs/gb/components/button/button.scss index 1ea654f4cd..712c5341de 100644 --- a/labs/gb/components/button/button.scss +++ b/labs/gb/components/button/button.scss @@ -103,6 +103,10 @@ &:is(:disabled, .disabled, [aria-disabled='true']) { @include button-tokens.disabled; --disabled: true; + + &:where(.btn-outlined:not(.btn-selected, [aria-pressed='true'])) { + @include button-tokens.outlined-disabled; + } } & { diff --git a/labs/gb/components/buttongroup/_button-group-tokens.scss b/labs/gb/components/buttongroup/_button-group-tokens.scss index f52aab6b43..e92fa338d9 100644 --- a/labs/gb/components/buttongroup/_button-group-tokens.scss +++ b/labs/gb/components/buttongroup/_button-group-tokens.scss @@ -6,6 +6,7 @@ @mixin root { // LINT.IfChange --between-space: 8px; + --pressed-item-size: size; --pressed-item-width-multiplier: 1.15; --motion-spring-fast-spatial: linear( 0, diff --git a/labs/gb/components/buttongroup/button-group-element.ts b/labs/gb/components/buttongroup/button-group-element.ts index 6dc88e6d2f..bd0cd83614 100644 --- a/labs/gb/components/buttongroup/button-group-element.ts +++ b/labs/gb/components/buttongroup/button-group-element.ts @@ -34,6 +34,7 @@ const baseClass = mixinElementInternals(LitElement); * @fires {InputEvent} input - Fired when selection changes. --bubbles --composed * @fires {Event} change - Fired when selection changes. --bubbles * @cssprop --between-space + * @cssprop --pressed-item-size * @cssprop --pressed-item-width-multiplier * @cssprop --motion-spring-fast-spatial * @cssprop --motion-spring-fast-spatial-duration @@ -50,6 +51,16 @@ export class ButtonGroupElement extends baseClass { super(); this[internals].role = 'toolbar'; + const handlePointerDown = (event: PointerEvent): void => { + const path = event.composedPath(); + const button = this.buttons.find((b) => path.includes(b)); + this.syncPressedButtonSize(button ?? null); + }; + + const handlePointerEnd = (): void => { + this.syncPressedButtonSize(null); + }; + const handleClick = (event: MouseEvent): void => { const path = event.composedPath(); const button = this.buttons.find((b) => path.includes(b)); @@ -98,6 +109,10 @@ export class ButtonGroupElement extends baseClass { this.dispatchEvent(new Event('change', {bubbles: true})); }; + this.addEventListener('pointerdown', handlePointerDown); + this.addEventListener('pointerup', handlePointerEnd); + this.addEventListener('pointercancel', handlePointerEnd); + this.addEventListener('pointerleave', handlePointerEnd); this.addEventListener('click', handleClick); this.addEventListener('input', handleInput); this.addEventListener('change', handleChange); @@ -120,9 +135,13 @@ export class ButtonGroupElement extends baseClass { private readonly groupDisabledButtons = new WeakSet(); + private readonly pressedSizeStyleSheet = new CSSStyleSheet(); + private readonly childObserver = new MutationObserver((mutations) => { for (const mutation of mutations) { - if (mutation.attributeName === 'selected') { + if (mutation.attributeName === 'class') { + this.syncPressedButtonSize(); + } else if (mutation.attributeName === 'selected') { const target = mutation.target as ButtonElement; if (this.selection === 'single') { if (target.selected) { @@ -189,6 +208,7 @@ export class ButtonGroupElement extends baseClass { override connectedCallback(): void { super.connectedCallback(); adoptStyles(this, buttonGroupStyles); + adoptStyles(this.shadowRoot, this.pressedSizeStyleSheet); updateClassList( this, buttonGroupClasses({ @@ -210,6 +230,7 @@ export class ButtonGroupElement extends baseClass { changedProperties: PropertyValues, ): void { super.firstUpdated(changedProperties); + adoptStyles(this.shadowRoot, this.pressedSizeStyleSheet); this.syncButtonsDisabledAndType(); this.normalizeGroupSelection(); this.observeChildren(); @@ -305,12 +326,44 @@ export class ButtonGroupElement extends baseClass { this.childObserver.takeRecords(); } + private syncPressedButtonSize(pressedButton?: ButtonElement | null): void { + if (this.variant === 'connected' || this.disabled) { + this.pressedSizeStyleSheet.replaceSync(''); + return; + } + const activeButton = + pressedButton !== undefined + ? pressedButton + : (this.buttons.find( + (b) => + !b.disabled && + !b.softDisabled && + (b.classList.contains('active') || b.matches(':active')), + ) ?? null); + if (!activeButton || activeButton.disabled || activeButton.softDisabled) { + this.pressedSizeStyleSheet.replaceSync(''); + return; + } + this.pressedSizeStyleSheet.replaceSync( + ':host { --pressed-item-width-multiplier: 1 !important; ' + + '--motion-spring-fast-spatial-duration: 0s !important; }', + ); + const width = activeButton.getBoundingClientRect().width; + if (width > 0) { + this.pressedSizeStyleSheet.replaceSync( + `:host { --pressed-item-size: ${width}px !important; }`, + ); + } else { + this.pressedSizeStyleSheet.replaceSync(''); + } + } + private observeChildren(): void { this.childObserver.disconnect(); for (const button of this.buttons) { this.childObserver.observe(button, { attributes: true, - attributeFilter: ['selected'], + attributeFilter: ['selected', 'class'], }); } } diff --git a/labs/gb/components/buttongroup/button-group-element_test.ts b/labs/gb/components/buttongroup/button-group-element_test.ts index 5db69ef1a4..ed20aab4a3 100644 --- a/labs/gb/components/buttongroup/button-group-element_test.ts +++ b/labs/gb/components/buttongroup/button-group-element_test.ts @@ -27,6 +27,7 @@ describe('ButtonGroupElement', () => { beforeAll(() => { const noTransitions = new CSSStyleSheet(); noTransitions.replaceSync(` + md-gb-button, md-gb-button::part(btn), md-gb-button-group { transition: none !important; @@ -1454,5 +1455,136 @@ describe('ButtonGroupElement', () => { expect(s1.borderBottomLeftRadius).toBe('20px'); expect(s1.borderBottomRightRadius).toBe('20px'); }); + + it('computes transparent background on unselected disabled outlined button and disabled container fill on selected disabled outlined button at button and group levels', async () => { + const root = env.render(html` + + Standalone Unselected + + + Standalone Selected + + + + Group Unselected + + + Group Selected + + + `); + await env.waitForStability(); + + const isOutlineInvisible = (style: CSSStyleDeclaration) => + style.borderTopColor === 'transparent' || + style.borderTopColor === 'rgba(0, 0, 0, 0)'; + + const isOutlineVisible = (style: CSSStyleDeclaration) => + style.borderTopWidth !== '0px' && + style.borderTopColor !== 'transparent' && + style.borderTopColor !== 'rgba(0, 0, 0, 0)'; + + const sStandaloneUnsel = getComputedStyle( + inner(root.querySelector('#standalone-unsel') as ButtonElement), + ); + const sStandaloneSel = getComputedStyle( + inner(root.querySelector('#standalone-sel') as ButtonElement), + ); + const sGrpUnsel = getComputedStyle( + inner(root.querySelector('#grp-unsel') as ButtonElement), + ); + const sGrpSel = getComputedStyle( + inner(root.querySelector('#grp-sel') as ButtonElement), + ); + + // Unselected disabled outlined buttons have transparent background and visible outline + expect(sStandaloneUnsel.backgroundColor).toBe('rgba(0, 0, 0, 0)'); + expect(isOutlineVisible(sStandaloneUnsel)).toBeTrue(); + expect(sGrpUnsel.backgroundColor).toBe('rgba(0, 0, 0, 0)'); + expect(isOutlineVisible(sGrpUnsel)).toBeTrue(); + + // Selected disabled outlined buttons use disabled container fill (not inverse-surface) and invisible outline + expect(sStandaloneSel.backgroundColor).not.toBe('rgba(0, 0, 0, 0)'); + expect(sStandaloneSel.backgroundColor).not.toBe('rgb(50, 47, 53)'); + expect(isOutlineInvisible(sStandaloneSel)).toBeTrue(); + + expect(sGrpSel.backgroundColor).toBe(sStandaloneSel.backgroundColor); + expect(sGrpSel.color).toBe(sStandaloneSel.color); + expect(isOutlineInvisible(sGrpSel)).toBeTrue(); + }); + + it( + 'expands pressed button and shrinks adjacent buttons relative to the ' + + 'pressed button growth in standard variant', + async () => { + if (!CSS.supports('inline-size', 'calc-size(auto, size)')) { + return; + } + const root = env.render(html` + + Short + Much Wider Middle Button + End + + `); + await env.waitForStability(); + + const p1 = root.querySelector('#p1') as ButtonElement; + const p2 = root.querySelector('#p2') as ButtonElement; + const p3 = root.querySelector('#p3') as ButtonElement; + + const w1Base = p1.getBoundingClientRect().width; + const w2Base = p2.getBoundingClientRect().width; + const w3Base = p3.getBoundingClientRect().width; + + // Press middle button (p2): p2 grows by 15% of w2Base, and both + // adjacent buttons (p1, p3) shrink by half of p2's growth. + p2.classList.add('active'); + await env.waitForStability(); + + const p2Growth = w2Base * 0.15; + expect(p2.getBoundingClientRect().width).toBeCloseTo( + w2Base + p2Growth, + 0, + ); + expect(p1.getBoundingClientRect().width).toBeCloseTo( + w1Base - p2Growth / 2, + 0, + ); + expect(p3.getBoundingClientRect().width).toBeCloseTo( + w3Base - p2Growth / 2, + 0, + ); + + p2.classList.remove('active'); + await env.waitForStability(); + + // Press first button (p1): p1 grows by 15% of w1Base, and its single + // adjacent neighbor (p2) shrinks by the full growth of p1. + p1.classList.add('active'); + await env.waitForStability(); + + const p1Growth = w1Base * 0.15; + expect(p1.getBoundingClientRect().width).toBeCloseTo( + w1Base + p1Growth, + 0, + ); + expect(p2.getBoundingClientRect().width).toBeCloseTo( + w2Base - p1Growth, + 0, + ); + expect(p3.getBoundingClientRect().width).toBeCloseTo(w3Base, 0); + + p1.classList.remove('active'); + await env.waitForStability(); + }, + ); }); }); diff --git a/labs/gb/components/buttongroup/button-group.scss b/labs/gb/components/buttongroup/button-group.scss index 3c785d82c0..7e958526c0 100644 --- a/labs/gb/components/buttongroup/button-group.scss +++ b/labs/gb/components/buttongroup/button-group.scss @@ -213,16 +213,6 @@ border-end-end-radius: var(--outer-corner-size); } - // Outlined selected button has no outline in both standard and connected variants - .btn-group > .btn.btn-outlined:is(.btn-selected, [aria-pressed='true']), - .btn-group - > [color='outlined']:is([selected], [aria-pressed='true'])::part(btn) { - --outline-color: transparent; - border-color: transparent; - background-color: var(--md-sys-color-inverse-surface); - color: var(--md-sys-color-inverse-on-surface); - } - // Connected XS and SM 48px min-inline-size .btn-group-connected > .btn:is(.btn-xs, .btn-sm), .btn-group-connected > :is([size='xs'], [size='sm']) { @@ -235,16 +225,79 @@ z-index: 1; } - // Standard press width expansion - @supports (width: calc-size(auto, size)) { - .btn-group:not(.btn-group-connected) > .btn, - .btn-group:not(.btn-group-connected) > * { - transition: width var(--motion-spring-fast-spatial-duration) - var(--motion-spring-fast-spatial); + // Standard press width expansion and adjacent button contraction + @supports (inline-size: calc-size(auto, size)) { + .btn-group:not(.btn-group-connected) { + > *::part(btn) { + min-inline-size: 0; + } + + > .btn, + > * { + min-inline-size: 0; + transition: inline-size var(--motion-spring-fast-spatial-duration) + var(--motion-spring-fast-spatial); + } + + > :not(:active, .active):has( + + :is(:active, .active):not( + :disabled, + .disabled, + [disabled], + [soft-disabled] + ) + ), + > :is(:active, .active):not( + :disabled, + .disabled, + [disabled], + [soft-disabled] + ) + + :not(:active, .active) { + inline-size: calc-size( + auto, + min( + size, + max( + 0px, + size - var(--pressed-item-size) * + (var(--pressed-item-width-multiplier) - 1) / 2 + ) + ) + ); + } + + > :not(:active, .active):has( + + :is(:last-child, :nth-last-child(1 of :not([hidden]))):is( + :active, + .active + ):not(:disabled, .disabled, [disabled], [soft-disabled]) + ), + > :is(:first-child, :nth-child(1 of :not([hidden]))):is( + :active, + .active + ):not(:disabled, .disabled, [disabled], [soft-disabled]) + + :not(:active, .active) { + inline-size: calc-size( + auto, + min( + size, + max( + 0px, + size - var(--pressed-item-size) * + (var(--pressed-item-width-multiplier) - 1) + ) + ) + ); + } - &:active:not(:disabled, .disabled), - &:active:not([disabled], [soft-disabled]) { - width: calc-size( + > :is(:active, .active):not( + :disabled, + .disabled, + [disabled], + [soft-disabled] + ) { + inline-size: calc-size( auto, max(size, size * var(--pressed-item-width-multiplier)) ); diff --git a/labs/gb/components/buttongroup/demo/stories.ts b/labs/gb/components/buttongroup/demo/stories.ts index d13f1e908d..30c92d39e6 100644 --- a/labs/gb/components/buttongroup/demo/stories.ts +++ b/labs/gb/components/buttongroup/demo/stories.ts @@ -132,13 +132,26 @@ const icons: MaterialStoryInit = { variant="connected" selection="single" ?disabled=${disabled}> - + format_align_left - + format_align_center - + format_align_right @@ -149,15 +162,15 @@ const icons: MaterialStoryInit = { variant="connected" selection="single" ?disabled=${disabled}> - + play_arrow Play - + pause Pause - + stop Stop @@ -168,12 +181,14 @@ const icons: MaterialStoryInit = { aria-label="Mixed icon and text actions" variant="connected" ?disabled=${disabled}> - + star Starred - All - + + All + + archive Archive