Skip to content

Commit 0901384

Browse files
zelliottcopybara-github
authored andcommitted
docs(button,iconbutton): add docs for soft-disabled attribute
Fixes #5672 PiperOrigin-RevId: 651409230
1 parent 7867674 commit 0901384

20 files changed

+373
-70
lines changed

button/demo/demo.ts

+1
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const collection = new MaterialCollection<KnobTypesToKnobs<StoryKnobs>>(
2222
[
2323
new Knob('label', {ui: textInput(), defaultValue: ''}),
2424
new Knob('disabled', {ui: boolInput(), defaultValue: false}),
25+
new Knob('softDisabled', {ui: boolInput(), defaultValue: false}),
2526
],
2627
);
2728

button/demo/stories.ts

+20-6
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {css, html} from 'lit';
1818
export interface StoryKnobs {
1919
label: string;
2020
disabled: boolean;
21+
softDisabled: boolean;
2122
}
2223

2324
const styles = css`
@@ -38,61 +39,74 @@ const styles = css`
3839
const buttons: MaterialStoryInit<StoryKnobs> = {
3940
name: 'Button variants',
4041
styles,
41-
render({label, disabled}) {
42+
render({label, disabled, softDisabled}) {
4243
return html`
4344
<div class="column">
4445
<div class="row">
45-
<md-filled-button ?disabled=${disabled}>
46+
<md-filled-button
47+
?disabled=${disabled}
48+
?soft-disabled=${softDisabled}>
4649
${label || 'Filled'}
4750
</md-filled-button>
4851
49-
<md-outlined-button ?disabled=${disabled}>
52+
<md-outlined-button
53+
?disabled=${disabled}
54+
?soft-disabled=${softDisabled}>
5055
${label || 'Outlined'}
5156
</md-outlined-button>
5257
53-
<md-elevated-button ?disabled=${disabled}>
58+
<md-elevated-button
59+
?disabled=${disabled}
60+
?soft-disabled=${softDisabled}>
5461
${label || 'Elevated'}
5562
</md-elevated-button>
5663
57-
<md-filled-tonal-button ?disabled=${disabled}>
64+
<md-filled-tonal-button
65+
?disabled=${disabled}
66+
?soft-disabled=${softDisabled}>
5867
${label || 'Tonal'}
5968
</md-filled-tonal-button>
6069
61-
<md-text-button ?disabled=${disabled}>
70+
<md-text-button ?disabled=${disabled} ?soft-disabled=${softDisabled}>
6271
${label || 'Text'}
6372
</md-text-button>
6473
</div>
6574
<div class="row">
6675
<md-filled-button
6776
?disabled=${disabled}
77+
?soft-disabled=${softDisabled}
6878
aria-label="${label || 'Filled'} button with icon">
6979
<md-icon slot="icon">upload</md-icon>
7080
${label || 'Filled'}
7181
</md-filled-button>
7282
7383
<md-outlined-button
7484
?disabled=${disabled}
85+
?soft-disabled=${softDisabled}
7586
aria-label="${label || 'Outlined'} button with icon">
7687
<md-icon slot="icon">upload</md-icon>
7788
${label || 'Outlined'}
7889
</md-outlined-button>
7990
8091
<md-elevated-button
8192
?disabled=${disabled}
93+
?soft-disabled=${softDisabled}
8294
aria-label="${label || 'Elevated'} button with icon">
8395
<md-icon slot="icon">upload</md-icon>
8496
${label || 'Elevated'}
8597
</md-elevated-button>
8698
8799
<md-filled-tonal-button
88100
?disabled=${disabled}
101+
?soft-disabled=${softDisabled}
89102
aria-label="${label || 'Tonal'} button with icon">
90103
<md-icon slot="icon">upload</md-icon>
91104
${label || 'Tonal'}
92105
</md-filled-tonal-button>
93106
94107
<md-text-button
95108
?disabled=${disabled}
109+
?soft-disabled=${softDisabled}
96110
aria-label="${label || 'Text'} button with icon">
97111
<md-icon slot="icon">upload</md-icon>
98112
${label || 'Text'}

button/internal/_elevation.scss

+2-2
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ $_md-sys-motion: tokens.md-sys-motion-values();
2020
transition-timing-function: map.get($_md-sys-motion, 'emphasized-easing');
2121
}
2222

23-
:host([disabled]) md-elevation {
23+
:host(:is([disabled], [soft-disabled])) md-elevation {
2424
transition: none;
2525
}
2626

@@ -59,7 +59,7 @@ $_md-sys-motion: tokens.md-sys-motion-values();
5959
);
6060
}
6161

62-
:host([disabled]) md-elevation {
62+
:host(:is([disabled], [soft-disabled])) md-elevation {
6363
@include elevation.theme(
6464
(
6565
'level': var(--_disabled-container-elevation),

button/internal/_icon.scss

+1-1
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
color: var(--_pressed-icon-color);
3232
}
3333

34-
:host([disabled]) ::slotted([slot='icon']) {
34+
:host(:is([disabled], [soft-disabled])) ::slotted([slot='icon']) {
3535
color: var(--_disabled-icon-color);
3636
opacity: var(--_disabled-icon-opacity);
3737
}

button/internal/_outlined-button.scss

+3-3
Original file line numberDiff line numberDiff line change
@@ -57,20 +57,20 @@
5757
border-color: var(--_pressed-outline-color);
5858
}
5959

60-
:host([disabled]) .outline {
60+
:host(:is([disabled], [soft-disabled])) .outline {
6161
border-color: var(--_disabled-outline-color);
6262
opacity: var(--_disabled-outline-opacity);
6363
}
6464

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

73-
:host([disabled]) .outline {
73+
:host(:is([disabled], [soft-disabled])) .outline {
7474
opacity: 1;
7575
}
7676
}

button/internal/_shared.scss

+4-4
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@
7171
);
7272
}
7373

74-
:host([disabled]) {
74+
:host(:is([disabled], [soft-disabled])) {
7575
cursor: default;
7676
pointer-events: none;
7777
}
@@ -139,12 +139,12 @@
139139
text-overflow: inherit;
140140
}
141141

142-
:host([disabled]) .label {
142+
:host(:is([disabled], [soft-disabled])) .label {
143143
color: var(--_disabled-label-text-color);
144144
opacity: var(--_disabled-label-text-opacity);
145145
}
146146

147-
:host([disabled]) .background {
147+
:host(:is([disabled], [soft-disabled])) .background {
148148
background-color: var(--_disabled-container-color);
149149
opacity: var(--_disabled-container-opacity);
150150
}
@@ -157,7 +157,7 @@
157157
border: 1px solid CanvasText;
158158
}
159159

160-
:host([disabled]) {
160+
:host(:is([disabled], [soft-disabled])) {
161161
--_disabled-icon-color: GrayText;
162162
--_disabled-icon-opacity: 1;
163163
--_disabled-container-opacity: 1;

button/internal/button.ts

+33-4
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,17 @@ export abstract class Button extends buttonBaseClass implements FormSubmitter {
5151
*/
5252
@property({type: Boolean, reflect: true}) disabled = false;
5353

54+
/**
55+
* Whether or not the button is "soft-disabled" (disabled but still
56+
* focusable).
57+
*
58+
* Use this when a button needs increased visibility when disabled. See
59+
* https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_disabled_controls
60+
* for more guidance on when this is needed.
61+
*/
62+
@property({type: Boolean, attribute: 'soft-disabled', reflect: true})
63+
softDisabled = false;
64+
5465
/**
5566
* The URL that the link button points to.
5667
*/
@@ -111,7 +122,7 @@ export abstract class Button extends buttonBaseClass implements FormSubmitter {
111122
constructor() {
112123
super();
113124
if (!isServer) {
114-
this.addEventListener('click', this.handleActivationClick);
125+
this.addEventListener('click', this.handleClick);
115126
}
116127
}
117128

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

137+
protected override willUpdate() {
138+
// Link buttons cannot be disabled or soft-disabled.
139+
if (this.href) {
140+
this.disabled = false;
141+
this.softDisabled = false;
142+
}
143+
}
144+
126145
protected override render() {
127146
// Link buttons may not be disabled
128-
const isDisabled = this.disabled && !this.href;
147+
const isRippleDisabled = !this.href && (this.disabled || this.softDisabled);
129148
const buttonOrLink = this.href ? this.renderLink() : this.renderButton();
130149
// TODO(b/310046938): due to a limitation in focus ring/ripple, we can't use
131150
// the same ID for different elements, so we change the ID instead.
@@ -137,7 +156,7 @@ export abstract class Button extends buttonBaseClass implements FormSubmitter {
137156
<md-ripple
138157
part="ripple"
139158
for=${buttonId}
140-
?disabled="${isDisabled}"></md-ripple>
159+
?disabled="${isRippleDisabled}"></md-ripple>
141160
${buttonOrLink}
142161
`;
143162
}
@@ -155,6 +174,7 @@ export abstract class Button extends buttonBaseClass implements FormSubmitter {
155174
id="button"
156175
class="button"
157176
?disabled=${this.disabled}
177+
aria-disabled=${this.softDisabled || nothing}
158178
aria-label="${ariaLabel || nothing}"
159179
aria-haspopup="${ariaHasPopup || nothing}"
160180
aria-expanded="${ariaExpanded || nothing}">
@@ -190,7 +210,16 @@ export abstract class Button extends buttonBaseClass implements FormSubmitter {
190210
`;
191211
}
192212

193-
private readonly handleActivationClick = (event: MouseEvent) => {
213+
private readonly handleClick = (event: MouseEvent) => {
214+
// If the button is soft-disabled, we need to explicitly prevent the click
215+
// from propagating to other event listeners as well as prevent the default
216+
// action.
217+
if (!this.href && this.softDisabled) {
218+
event.stopImmediatePropagation();
219+
event.preventDefault();
220+
return;
221+
}
222+
194223
if (!isActivationClick(event) || !this.buttonElement) {
195224
return;
196225
}

button/internal/button_test.ts

+89
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/**
2+
* @license
3+
* Copyright 2023 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
// import 'jasmine'; (google3-only)
8+
9+
import {html} from 'lit';
10+
import {customElement} from 'lit/decorators.js';
11+
12+
import {Environment} from '../../testing/environment.js';
13+
import {ButtonHarness} from '../harness.js';
14+
15+
import {Button} from './button.js';
16+
17+
@customElement('test-button')
18+
class TestButton extends Button {}
19+
20+
describe('Button', () => {
21+
const env = new Environment();
22+
23+
async function setupTest() {
24+
const button = new TestButton();
25+
env.render(html`${button}`);
26+
await env.waitForStability();
27+
return {button, harness: new ButtonHarness(button)};
28+
}
29+
30+
it('should not be focusable when disabled', async () => {
31+
// Arrange
32+
const {button} = await setupTest();
33+
button.disabled = true;
34+
await env.waitForStability();
35+
36+
// Act
37+
button.focus();
38+
39+
// Assert
40+
expect(document.activeElement)
41+
.withContext('disabled button should not be focused')
42+
.not.toBe(button);
43+
});
44+
45+
it('should be focusable when soft-disabled', async () => {
46+
// Arrange
47+
const {button} = await setupTest();
48+
button.softDisabled = true;
49+
await env.waitForStability();
50+
51+
// Act
52+
button.focus();
53+
54+
// Assert
55+
expect(document.activeElement)
56+
.withContext('soft-disabled button should be focused')
57+
.toBe(button);
58+
});
59+
60+
it('should not be clickable when disabled', async () => {
61+
// Arrange
62+
const clickListener = jasmine.createSpy('clickListener');
63+
const {button} = await setupTest();
64+
button.disabled = true;
65+
button.addEventListener('click', clickListener);
66+
await env.waitForStability();
67+
68+
// Act
69+
button.click();
70+
71+
// Assert
72+
expect(clickListener).not.toHaveBeenCalled();
73+
});
74+
75+
it('should not be clickable when soft-disabled', async () => {
76+
// Arrange
77+
const clickListener = jasmine.createSpy('clickListener');
78+
const {button} = await setupTest();
79+
button.softDisabled = true;
80+
button.addEventListener('click', clickListener);
81+
await env.waitForStability();
82+
83+
// Act
84+
button.click();
85+
86+
// Assert
87+
expect(clickListener).not.toHaveBeenCalled();
88+
});
89+
});

docs/components/button.md

+23-1
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,28 @@ attribute to buttons whose labels need a more descriptive label.
236236
<md-elevated-button aria-label="Add a new contact">Add</md-elevated-button>
237237
```
238238

239+
### Focusable and disabled
240+
241+
By default, disabled buttons are not focusable with the keyboard, while
242+
soft-disabled buttons are. Some use cases encourage focusability of disabled
243+
toolbar items to increase their discoverability.
244+
245+
See the
246+
[ARIA guidelines on focusability of disabled controls](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_disabled_controls)<!-- {.external} -->
247+
for guidance on when this is recommended.
248+
249+
```html
250+
<div role="toolbar">
251+
<md-text-button>Copy</md-text-button>
252+
<md-text-button>Cut</md-text-button>
253+
<!--
254+
This button is disabled but kept focusable to improve its discoverability
255+
in the toolbar.
256+
-->
257+
<md-text-button soft-disabled>Paste</md-text-button>
258+
</div>
259+
```
260+
239261
## Elevated button
240262

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

704726
## API
705727

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

709730
#### Properties
@@ -713,6 +734,7 @@ Token | Default value
713734
| Property | Attribute | Type | Default | Description |
714735
| --- | --- | --- | --- | --- |
715736
| `disabled` | `disabled` | `boolean` | `false` | Whether or not the button is disabled. |
737+
| `softDisabled` | `soft-disabled` | `boolean` | `false` | Whether the button is "soft-disabled" (disabled but still focusable). |
716738
| `href` | `href` | `string` | `''` | The URL that the link button points to. |
717739
| `target` | `target` | `string` | `''` | Where to display the linked `href` URL for a link button. Common options include `_blank` to open in a new tab. |
718740
| `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. |

0 commit comments

Comments
 (0)