Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions labs/gb/components/buttongroup/_button-group-tokens.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
78 changes: 76 additions & 2 deletions labs/gb/components/buttongroup/button-group-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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));
Expand Down Expand Up @@ -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);
Expand All @@ -120,9 +135,16 @@ export class ButtonGroupElement extends baseClass {

private readonly groupDisabledButtons = new WeakSet<HTMLElement>();

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) {
Expand Down Expand Up @@ -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({
Expand All @@ -210,6 +233,7 @@ export class ButtonGroupElement extends baseClass {
changedProperties: PropertyValues<this>,
): void {
super.firstUpdated(changedProperties);
adoptStyles(this.shadowRoot, this.pressedSizeStyleSheet);
this.syncButtonsDisabledAndType();
this.normalizeGroupSelection();
this.observeChildren();
Expand Down Expand Up @@ -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(
'<md-gb-button-group> does not support color="text" on child ' +
'<md-gb-button> 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';
Expand Down Expand Up @@ -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'],
});
}
}
Expand Down
108 changes: 108 additions & 0 deletions labs/gb/components/buttongroup/button-group-element_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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`
<md-gb-button-group>
<md-gb-button id="default-btn">Default</md-gb-button>
<md-gb-button id="filled-btn" color="filled">Filled</md-gb-button>
<md-gb-button id="outlined-btn" color="outlined"
>Outlined</md-gb-button
>
</md-gb-button-group>
`);
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`
<md-gb-button-group>
<md-gb-button id="text-btn" color="text">Text</md-gb-button>
</md-gb-button-group>
`);
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`
<md-gb-button-group id="group" selection="single">
Expand Down Expand Up @@ -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`
<md-gb-button-group variant="standard">
<md-gb-button id="p1" size="sm">Short</md-gb-button>
<md-gb-button id="p2" size="sm"
>Much Wider Middle Button</md-gb-button
>
<md-gb-button id="p3" size="sm">End</md-gb-button>
</md-gb-button-group>
`);
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();
},
);
});
});
77 changes: 70 additions & 7 deletions labs/gb/components/buttongroup/button-group.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
2 changes: 1 addition & 1 deletion labs/gb/components/buttongroup/demo/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ const collection = new MaterialCollection<KnobTypesToKnobs<StoryKnobs>>(
}),
}),
new Knob<ButtonColor | undefined, 'color'>('color', {
defaultValue: 'filled',
defaultValue: 'tonal',
ui: selectDropdown<ButtonColor>({
options: [
{value: 'filled', label: 'Filled'},
Expand Down
2 changes: 1 addition & 1 deletion labs/gb/components/buttongroup/demo/stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ const playground: MaterialStoryInit<StoryKnobs> = {
: nothing;

const size = knobs.size ?? 'sm';
const color = knobs.color ?? 'filled';
const color = knobs.color ?? 'tonal';
const square = knobs.square ?? false;

return html`
Expand Down
Loading