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..a9818371bb 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,16 @@ 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 === 'color') { + this.syncButtonsDisabledAndType(); + this.childObserver.takeRecords(); + } else 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 +211,7 @@ export class ButtonGroupElement extends baseClass { override connectedCallback(): void { super.connectedCallback(); adoptStyles(this, buttonGroupStyles); + adoptStyles(this.shadowRoot, this.pressedSizeStyleSheet); updateClassList( this, buttonGroupClasses({ @@ -210,6 +233,7 @@ export class ButtonGroupElement extends baseClass { changedProperties: PropertyValues, ): void { super.firstUpdated(changedProperties); + adoptStyles(this.shadowRoot, this.pressedSizeStyleSheet); this.syncButtonsDisabledAndType(); this.normalizeGroupSelection(); this.observeChildren(); @@ -274,6 +298,24 @@ export class ButtonGroupElement extends baseClass { private syncButtonsDisabledAndType(): void { const buttons = this.buttons; for (const button of buttons) { + const isExplicitText = + button.getAttribute('color') === 'text' || + (button.hasAttribute('color') && button.color === 'text'); + if (isExplicitText) { + console.warn( + ' does not support color="text" on child ' + + ' elements. Falling back to color="tonal".', + ); + } + if ( + !button.hasAttribute('color') || + button.getAttribute('color') === 'text' || + button.color === 'text' + ) { + button.setAttribute('color', 'tonal'); + button.color = 'tonal'; + } + const hasHref = button.hasAttribute('href') || Boolean(button.href); if (!hasHref) { const targetType = this.selection === 'none' ? 'button' : 'toggle'; @@ -305,12 +347,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', 'color', '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..ae8508973a 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; @@ -193,6 +194,44 @@ describe('ButtonGroupElement', () => { expect(actionBtn.type).toBe('toggle'); }); + it('defaults uncolored child buttons to tonal and preserves explicit color', async () => { + const root = env.render(html` + + Default + Filled + Outlined + + `); + await env.waitForStability(); + const defaultBtn = root.querySelector('#default-btn') as ButtonElement; + const filledBtn = root.querySelector('#filled-btn') as ButtonElement; + const outlinedBtn = root.querySelector('#outlined-btn') as ButtonElement; + + expect(defaultBtn.getAttribute('color')).toBe('tonal'); + expect(defaultBtn.color).toBe('tonal'); + expect(filledBtn.getAttribute('color')).toBe('filled'); + expect(filledBtn.color).toBe('filled'); + expect(outlinedBtn.getAttribute('color')).toBe('outlined'); + expect(outlinedBtn.color).toBe('outlined'); + }); + + it('coerces explicit color="text" to tonal and logs a console warning', async () => { + const warnSpy = spyOn(console, 'warn'); + const root = env.render(html` + + Text + + `); + await env.waitForStability(); + const textBtn = root.querySelector('#text-btn') as ButtonElement; + + expect(textBtn.getAttribute('color')).toBe('tonal'); + expect(textBtn.color).toBe('tonal'); + expect(warnSpy).toHaveBeenCalled(); + }); + it('cascades selection type to newly appended children', async () => { const root = env.render(html` @@ -1454,5 +1493,74 @@ describe('ButtonGroupElement', () => { expect(s1.borderBottomLeftRadius).toBe('20px'); expect(s1.borderBottomRightRadius).toBe('20px'); }); + + it( + 'expands pressed button and shrinks adjacent buttons relative to the ' + + 'pressed button growth in standard variant', + async () => { + if (!CSS.supports('width', '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..69d6521464 100644 --- a/labs/gb/components/buttongroup/button-group.scss +++ b/labs/gb/components/buttongroup/button-group.scss @@ -235,15 +235,78 @@ z-index: 1; } - // Standard press width expansion + // Standard press width expansion and adjacent button contraction @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); + .btn-group:not(.btn-group-connected) { + > *::part(btn) { + min-inline-size: 0; + } + + > .btn, + > * { + min-inline-size: 0; + transition: width 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) { + width: 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) { + width: 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]) { + > :is(:active, .active):not( + :disabled, + .disabled, + [disabled], + [soft-disabled] + ) { width: calc-size( auto, max(size, size * var(--pressed-item-width-multiplier)) diff --git a/labs/gb/components/buttongroup/demo/demo.ts b/labs/gb/components/buttongroup/demo/demo.ts index 81d2812760..55d0af0e32 100644 --- a/labs/gb/components/buttongroup/demo/demo.ts +++ b/labs/gb/components/buttongroup/demo/demo.ts @@ -55,7 +55,7 @@ const collection = new MaterialCollection>( }), }), new Knob('color', { - defaultValue: 'filled', + defaultValue: 'tonal', ui: selectDropdown({ options: [ {value: 'filled', label: 'Filled'}, diff --git a/labs/gb/components/buttongroup/demo/stories.ts b/labs/gb/components/buttongroup/demo/stories.ts index d13f1e908d..218a15566f 100644 --- a/labs/gb/components/buttongroup/demo/stories.ts +++ b/labs/gb/components/buttongroup/demo/stories.ts @@ -74,7 +74,7 @@ const playground: MaterialStoryInit = { : nothing; const size = knobs.size ?? 'sm'; - const color = knobs.color ?? 'filled'; + const color = knobs.color ?? 'tonal'; const square = knobs.square ?? false; return html`