Skip to content
Merged
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
5 changes: 5 additions & 0 deletions labs/gb/components/button/_button-tokens.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions labs/gb/components/button/button.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

& {
Expand Down
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
57 changes: 55 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,13 @@ 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 === '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 +208,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 +230,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 @@ -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'],
});
}
}
Expand Down
132 changes: 132 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 @@ -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`
<md-gb-button id="standalone-unsel" color="outlined" disabled>
Standalone Unselected
</md-gb-button>
<md-gb-button
id="standalone-sel"
type="toggle"
color="outlined"
selected
disabled>
Standalone Selected
</md-gb-button>
<md-gb-button-group variant="standard" selection="multiple" disabled>
<md-gb-button id="grp-unsel" color="outlined">
Group Unselected
</md-gb-button>
<md-gb-button id="grp-sel" color="outlined" selected>
Group Selected
</md-gb-button>
</md-gb-button-group>
`);
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`
<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();
},
);
});
});
91 changes: 72 additions & 19 deletions labs/gb/components/buttongroup/button-group.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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']) {
Expand All @@ -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))
);
Expand Down
Loading
Loading