Skip to content

Commit

Permalink
feat(button): add soft-disabled attribute for focusable disabled bu…
Browse files Browse the repository at this point in the history
…ttons

PiperOrigin-RevId: 651854744
  • Loading branch information
zelliott authored and copybara-github committed Jul 12, 2024
1 parent 7867674 commit 48124ba
Show file tree
Hide file tree
Showing 9 changed files with 146 additions and 21 deletions.
1 change: 1 addition & 0 deletions button/demo/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const collection = new MaterialCollection<KnobTypesToKnobs<StoryKnobs>>(
[
new Knob('label', {ui: textInput(), defaultValue: ''}),
new Knob('disabled', {ui: boolInput(), defaultValue: false}),
new Knob('softDisabled', {ui: boolInput(), defaultValue: false}),
],
);

Expand Down
26 changes: 20 additions & 6 deletions button/demo/stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {css, html} from 'lit';
export interface StoryKnobs {
label: string;
disabled: boolean;
softDisabled: boolean;
}

const styles = css`
Expand All @@ -38,61 +39,74 @@ const styles = css`
const buttons: MaterialStoryInit<StoryKnobs> = {
name: 'Button variants',
styles,
render({label, disabled}) {
render({label, disabled, softDisabled}) {
return html`
<div class="column">
<div class="row">
<md-filled-button ?disabled=${disabled}>
<md-filled-button
?disabled=${disabled}
?soft-disabled=${softDisabled}>
${label || 'Filled'}
</md-filled-button>
<md-outlined-button ?disabled=${disabled}>
<md-outlined-button
?disabled=${disabled}
?soft-disabled=${softDisabled}>
${label || 'Outlined'}
</md-outlined-button>
<md-elevated-button ?disabled=${disabled}>
<md-elevated-button
?disabled=${disabled}
?soft-disabled=${softDisabled}>
${label || 'Elevated'}
</md-elevated-button>
<md-filled-tonal-button ?disabled=${disabled}>
<md-filled-tonal-button
?disabled=${disabled}
?soft-disabled=${softDisabled}>
${label || 'Tonal'}
</md-filled-tonal-button>
<md-text-button ?disabled=${disabled}>
<md-text-button ?disabled=${disabled} ?soft-disabled=${softDisabled}>
${label || 'Text'}
</md-text-button>
</div>
<div class="row">
<md-filled-button
?disabled=${disabled}
?soft-disabled=${softDisabled}
aria-label="${label || 'Filled'} button with icon">
<md-icon slot="icon">upload</md-icon>
${label || 'Filled'}
</md-filled-button>
<md-outlined-button
?disabled=${disabled}
?soft-disabled=${softDisabled}
aria-label="${label || 'Outlined'} button with icon">
<md-icon slot="icon">upload</md-icon>
${label || 'Outlined'}
</md-outlined-button>
<md-elevated-button
?disabled=${disabled}
?soft-disabled=${softDisabled}
aria-label="${label || 'Elevated'} button with icon">
<md-icon slot="icon">upload</md-icon>
${label || 'Elevated'}
</md-elevated-button>
<md-filled-tonal-button
?disabled=${disabled}
?soft-disabled=${softDisabled}
aria-label="${label || 'Tonal'} button with icon">
<md-icon slot="icon">upload</md-icon>
${label || 'Tonal'}
</md-filled-tonal-button>
<md-text-button
?disabled=${disabled}
?soft-disabled=${softDisabled}
aria-label="${label || 'Text'} button with icon">
<md-icon slot="icon">upload</md-icon>
${label || 'Text'}
Expand Down
4 changes: 2 additions & 2 deletions button/internal/_elevation.scss
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ $_md-sys-motion: tokens.md-sys-motion-values();
transition-timing-function: map.get($_md-sys-motion, 'emphasized-easing');
}

:host([disabled]) md-elevation {
:host(:is([disabled], [soft-disabled])) md-elevation {
transition: none;
}

Expand Down Expand Up @@ -59,7 +59,7 @@ $_md-sys-motion: tokens.md-sys-motion-values();
);
}

:host([disabled]) md-elevation {
:host(:is([disabled], [soft-disabled])) md-elevation {
@include elevation.theme(
(
'level': var(--_disabled-container-elevation),
Expand Down
2 changes: 1 addition & 1 deletion button/internal/_icon.scss
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
color: var(--_pressed-icon-color);
}

:host([disabled]) ::slotted([slot='icon']) {
:host(:is([disabled], [soft-disabled])) ::slotted([slot='icon']) {
color: var(--_disabled-icon-color);
opacity: var(--_disabled-icon-opacity);
}
Expand Down
6 changes: 3 additions & 3 deletions button/internal/_outlined-button.scss
Original file line number Diff line number Diff line change
Expand Up @@ -57,20 +57,20 @@
border-color: var(--_pressed-outline-color);
}

:host([disabled]) .outline {
:host(:is([disabled], [soft-disabled])) .outline {
border-color: var(--_disabled-outline-color);
opacity: var(--_disabled-outline-opacity);
}

@media (forced-colors: active) {
:host([disabled]) .background {
:host(:is([disabled], [soft-disabled])) .background {
// Only outlined buttons change their border when disabled to distinguish
// them from other buttons that add a border for increased visibility in
// HCM.
border-color: GrayText;
}

:host([disabled]) .outline {
:host(:is([disabled], [soft-disabled])) .outline {
opacity: 1;
}
}
Expand Down
8 changes: 4 additions & 4 deletions button/internal/_shared.scss
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@
);
}

:host([disabled]) {
:host(:is([disabled], [soft-disabled])) {
cursor: default;
pointer-events: none;
}
Expand Down Expand Up @@ -139,12 +139,12 @@
text-overflow: inherit;
}

:host([disabled]) .label {
:host(:is([disabled], [soft-disabled])) .label {
color: var(--_disabled-label-text-color);
opacity: var(--_disabled-label-text-opacity);
}

:host([disabled]) .background {
:host(:is([disabled], [soft-disabled])) .background {
background-color: var(--_disabled-container-color);
opacity: var(--_disabled-container-opacity);
}
Expand All @@ -157,7 +157,7 @@
border: 1px solid CanvasText;
}

:host([disabled]) {
:host(:is([disabled], [soft-disabled])) {
--_disabled-icon-color: GrayText;
--_disabled-icon-opacity: 1;
--_disabled-container-opacity: 1;
Expand Down
31 changes: 26 additions & 5 deletions button/internal/button.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,17 @@ export abstract class Button extends buttonBaseClass implements FormSubmitter {
*/
@property({type: Boolean, reflect: true}) disabled = false;

/**
* Whether or not the button is "soft-disabled" (disabled but still
* focusable).
*
* Use this when a button needs increased visibility when disabled. See
* https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_disabled_controls
* for more guidance on when this is needed.
*/
@property({type: Boolean, attribute: 'soft-disabled', reflect: true})
softDisabled = false;

/**
* The URL that the link button points to.
*/
Expand Down Expand Up @@ -111,7 +122,7 @@ export abstract class Button extends buttonBaseClass implements FormSubmitter {
constructor() {
super();
if (!isServer) {
this.addEventListener('click', this.handleActivationClick);
this.addEventListener('click', this.handleClick.bind(this));
}
}

Expand All @@ -125,7 +136,7 @@ export abstract class Button extends buttonBaseClass implements FormSubmitter {

protected override render() {
// Link buttons may not be disabled
const isDisabled = this.disabled && !this.href;
const isRippleDisabled = !this.href && (this.disabled || this.softDisabled);
const buttonOrLink = this.href ? this.renderLink() : this.renderButton();
// TODO(b/310046938): due to a limitation in focus ring/ripple, we can't use
// the same ID for different elements, so we change the ID instead.
Expand All @@ -137,7 +148,7 @@ export abstract class Button extends buttonBaseClass implements FormSubmitter {
<md-ripple
part="ripple"
for=${buttonId}
?disabled="${isDisabled}"></md-ripple>
?disabled="${isRippleDisabled}"></md-ripple>
${buttonOrLink}
`;
}
Expand All @@ -155,6 +166,7 @@ export abstract class Button extends buttonBaseClass implements FormSubmitter {
id="button"
class="button"
?disabled=${this.disabled}
aria-disabled=${this.softDisabled || nothing}
aria-label="${ariaLabel || nothing}"
aria-haspopup="${ariaHasPopup || nothing}"
aria-expanded="${ariaExpanded || nothing}">
Expand Down Expand Up @@ -190,13 +202,22 @@ export abstract class Button extends buttonBaseClass implements FormSubmitter {
`;
}

private readonly handleActivationClick = (event: MouseEvent) => {
private handleClick(event: MouseEvent) {
// If the button is soft-disabled, we need to explicitly prevent the click
// from propagating to other event listeners as well as prevent the default
// action.
if (!this.href && this.softDisabled) {
event.stopImmediatePropagation();
event.preventDefault();
return;
}

if (!isActivationClick(event) || !this.buttonElement) {
return;
}
this.focus();
dispatchActivationClick(this.buttonElement);
};
}

private handleSlotChange() {
this.hasIcon = this.assignedIcons.length > 0;
Expand Down
88 changes: 88 additions & 0 deletions button/internal/button_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* @license
* Copyright 2024 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

// import 'jasmine'; (google3-only)

import {html} from 'lit';
import {customElement} from 'lit/decorators.js';

import {Environment} from '../../testing/environment.js';
import {ButtonHarness} from '../harness.js';
import {Button} from './button.js';

@customElement('test-button')
class TestButton extends Button {}

describe('Button', () => {
const env = new Environment();

async function setupTest() {
const button = new TestButton();
env.render(html`${button}`);
await env.waitForStability();
return {button, harness: new ButtonHarness(button)};
}

it('should not be focusable when disabled', async () => {
// Arrange
const {button} = await setupTest();
button.disabled = true;
await env.waitForStability();

// Act
button.focus();

// Assert
expect(document.activeElement)
.withContext('disabled button should not be focused')
.not.toBe(button);
});

it('should be focusable when soft-disabled', async () => {
// Arrange
const {button} = await setupTest();
button.softDisabled = true;
await env.waitForStability();

// Act
button.focus();

// Assert
expect(document.activeElement)
.withContext('soft-disabled button should be focused')
.toBe(button);
});

it('should not be clickable when disabled', async () => {
// Arrange
const clickListener = jasmine.createSpy('clickListener');
const {button} = await setupTest();
button.disabled = true;
button.addEventListener('click', clickListener);
await env.waitForStability();

// Act
button.click();

// Assert
expect(clickListener).not.toHaveBeenCalled();
});

it('should not be clickable when soft-disabled', async () => {
// Arrange
const clickListener = jasmine.createSpy('clickListener');
const {button} = await setupTest();
button.softDisabled = true;
button.addEventListener('click', clickListener);
await env.waitForStability();

// Act
button.click();

// Assert
expect(clickListener).not.toHaveBeenCalled();
});
});
1 change: 1 addition & 0 deletions testing/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export enum State {
HOVER = 'Hover',
PRESSED = 'Pressed',
SELECTED = 'Selected',
SOFT_DISABLED = 'Soft disabled',
}

/**
Expand Down

0 comments on commit 48124ba

Please sign in to comment.