Skip to content

Commit

Permalink
docs(button,iconbutton): add docs for soft-disabled attribute
Browse files Browse the repository at this point in the history
Fixes #5672

PiperOrigin-RevId: 651409230
  • Loading branch information
zelliott authored and copybara-github committed Jul 11, 2024
1 parent 7867674 commit 07f2532
Show file tree
Hide file tree
Showing 20 changed files with 373 additions and 70 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
37 changes: 33 additions & 4 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);
}
}

Expand All @@ -123,9 +134,17 @@ export abstract class Button extends buttonBaseClass implements FormSubmitter {
this.buttonElement?.blur();
}

protected override willUpdate() {
// Link buttons cannot be disabled or soft-disabled.
if (this.href) {
this.disabled = false;
this.softDisabled = false;
}
}

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 +156,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 +174,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,7 +210,16 @@ export abstract class Button extends buttonBaseClass implements FormSubmitter {
`;
}

private readonly handleActivationClick = (event: MouseEvent) => {
private readonly 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;
}
Expand Down
89 changes: 89 additions & 0 deletions button/internal/button_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* @license
* Copyright 2023 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();
});
});
24 changes: 23 additions & 1 deletion docs/components/button.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,28 @@ attribute to buttons whose labels need a more descriptive label.
<md-elevated-button aria-label="Add a new contact">Add</md-elevated-button>
```

### Focusable and disabled

By default, disabled buttons are not focusable with the keyboard, while
"soft-disabled" buttons are. Some use cases encourage focusability of disabled
toolbar items to increase their discoverability.

See the
[ARIA guidelines on focusability of disabled controls](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_disabled_controls)<!-- {.external} -->
for guidance on when this is recommended.

```html
<div role="toolbar">
<md-text-button>Copy</md-text-button>
<md-text-button>Cut</md-text-button>
<!--
This button is disabled but kept focusable to improve its discoverability
in the toolbar.
-->
<md-text-button soft-disabled>Paste</md-text-button>
</div>
```

## Elevated button

<!-- go/md-elevated-button -->
Expand Down Expand Up @@ -703,7 +725,6 @@ Token | Default value

## API


### MdElevatedButton <code>&lt;md-elevated-button&gt;</code>

#### Properties
Expand All @@ -713,6 +734,7 @@ Token | Default value
| Property | Attribute | Type | Default | Description |
| --- | --- | --- | --- | --- |
| `disabled` | `disabled` | `boolean` | `false` | Whether or not the button is disabled. |
| `softDisabled` | `soft-disabled` | `boolean` | `false` | Whether the button is "soft-disabled" (disabled but still focusable). |
| `href` | `href` | `string` | `''` | The URL that the link button points to. |
| `target` | `target` | `string` | `''` | Where to display the linked `href` URL for a link button. Common options include `_blank` to open in a new tab. |
| `trailingIcon` | `trailing-icon` | `boolean` | `false` | Whether to render the icon at the inline end of the label rather than the inline start.<br>_Note:_ Link buttons cannot have trailing icons. |
Expand Down
Loading

0 comments on commit 07f2532

Please sign in to comment.