Skip to content

Commit 766feae

Browse files
zelliottcopybara-github
authored andcommitted
feat: add soft-disabled property to button and iconbutton
Fixes #5672. PiperOrigin-RevId: 651409230
1 parent 7867674 commit 766feae

15 files changed

+300
-52
lines changed

button/internal/_elevation.scss

+4-2
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ $_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([disabled]) md-elevation,
24+
:host([soft-disabled]) md-elevation {
2425
transition: none;
2526
}
2627

@@ -59,7 +60,8 @@ $_md-sys-motion: tokens.md-sys-motion-values();
5960
);
6061
}
6162

62-
:host([disabled]) md-elevation {
63+
:host([disabled]) md-elevation,
64+
:host([soft-disabled]) md-elevation {
6365
@include elevation.theme(
6466
(
6567
'level': var(--_disabled-container-elevation),

button/internal/_icon.scss

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

34-
:host([disabled]) ::slotted([slot='icon']) {
34+
:host([disabled]) ::slotted([slot='icon']),
35+
:host([soft-disabled]) ::slotted([slot='icon']) {
3536
color: var(--_disabled-icon-color);
3637
opacity: var(--_disabled-icon-opacity);
3738
}

button/internal/_outlined-button.scss

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

60-
:host([disabled]) .outline {
60+
:host([disabled]) .outline,
61+
:host([soft-disabled]) .outline {
6162
border-color: var(--_disabled-outline-color);
6263
opacity: var(--_disabled-outline-opacity);
6364
}
6465

6566
@media (forced-colors: active) {
66-
:host([disabled]) .background {
67+
:host([disabled]) .background,
68+
:host([soft-disabled]) .background {
6769
// Only outlined buttons change their border when disabled to distinguish
6870
// them from other buttons that add a border for increased visibility in
6971
// HCM.
7072
border-color: GrayText;
7173
}
7274

73-
:host([disabled]) .outline {
75+
:host([disabled]) .outline,
76+
:host([soft-disabled]) .outline {
7477
opacity: 1;
7578
}
7679
}

button/internal/_shared.scss

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

74-
:host([disabled]) {
74+
:host([disabled]),
75+
:host([soft-disabled]) {
7576
cursor: default;
7677
pointer-events: none;
7778
}
@@ -139,12 +140,14 @@
139140
text-overflow: inherit;
140141
}
141142

142-
:host([disabled]) .label {
143+
:host([disabled]) .label,
144+
:host([soft-disabled]) .label {
143145
color: var(--_disabled-label-text-color);
144146
opacity: var(--_disabled-label-text-opacity);
145147
}
146148

147-
:host([disabled]) .background {
149+
:host([disabled]) .background,
150+
:host([soft-disabled]) .background {
148151
background-color: var(--_disabled-container-color);
149152
opacity: var(--_disabled-container-opacity);
150153
}
@@ -157,7 +160,8 @@
157160
border: 1px solid CanvasText;
158161
}
159162

160-
:host([disabled]) {
163+
:host([disabled]),
164+
:host([soft-disabled]) {
161165
--_disabled-icon-color: GrayText;
162166
--_disabled-icon-opacity: 1;
163167
--_disabled-container-opacity: 1;

button/internal/button.ts

+35-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,19 @@ export abstract class Button extends buttonBaseClass implements FormSubmitter {
123134
this.buttonElement?.blur();
124135
}
125136

137+
/**
138+
* Link buttons cannot be disabled or soft-disabled.
139+
*/
140+
protected override willUpdate() {
141+
if (this.href) {
142+
this.disabled = false;
143+
this.softDisabled = false;
144+
}
145+
}
146+
126147
protected override render() {
127148
// Link buttons may not be disabled
128-
const isDisabled = this.disabled && !this.href;
149+
const isRippleDisabled = !this.href && (this.disabled || this.softDisabled);
129150
const buttonOrLink = this.href ? this.renderLink() : this.renderButton();
130151
// TODO(b/310046938): due to a limitation in focus ring/ripple, we can't use
131152
// the same ID for different elements, so we change the ID instead.
@@ -137,7 +158,7 @@ export abstract class Button extends buttonBaseClass implements FormSubmitter {
137158
<md-ripple
138159
part="ripple"
139160
for=${buttonId}
140-
?disabled="${isDisabled}"></md-ripple>
161+
?disabled="${isRippleDisabled}"></md-ripple>
141162
${buttonOrLink}
142163
`;
143164
}
@@ -155,6 +176,7 @@ export abstract class Button extends buttonBaseClass implements FormSubmitter {
155176
id="button"
156177
class="button"
157178
?disabled=${this.disabled}
179+
aria-disabled=${this.softDisabled ? 'true' : nothing}
158180
aria-label="${ariaLabel || nothing}"
159181
aria-haspopup="${ariaHasPopup || nothing}"
160182
aria-expanded="${ariaExpanded || nothing}">
@@ -190,7 +212,16 @@ export abstract class Button extends buttonBaseClass implements FormSubmitter {
190212
`;
191213
}
192214

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

button/internal/button_test.ts

+69
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
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+
const {button} = await setupTest();
32+
button.disabled = true;
33+
await env.waitForStability();
34+
35+
button.focus();
36+
expect(document.activeElement).toEqual(document.body);
37+
});
38+
39+
it('should be focusable when soft-disabled', async () => {
40+
const {button} = await setupTest();
41+
button.softDisabled = true;
42+
await env.waitForStability();
43+
44+
button.focus();
45+
expect(document.activeElement).toEqual(button);
46+
});
47+
48+
it('should not be clickable when disabled', async () => {
49+
const clickListener = jasmine.createSpy('clickListener');
50+
const {button} = await setupTest();
51+
button.disabled = true;
52+
button.addEventListener('click', clickListener);
53+
await env.waitForStability();
54+
55+
button.click();
56+
expect(clickListener).not.toHaveBeenCalled();
57+
});
58+
59+
it('should not be clickable when soft-disabled', async () => {
60+
const clickListener = jasmine.createSpy('clickListener');
61+
const {button} = await setupTest();
62+
button.softDisabled = true;
63+
button.addEventListener('click', clickListener);
64+
await env.waitForStability();
65+
66+
button.click();
67+
expect(clickListener).not.toHaveBeenCalled();
68+
});
69+
});

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. |

docs/components/icon-button.md

+25-3
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,28 @@ attribute to icon buttons whose labels need a more descriptive label.
182182
</md-icon-button>
183183
```
184184

185+
### Focusable and disabled
186+
187+
By default, disabled icon buttons are not focusable with the keyboard, while
188+
soft-disabled icon buttons are. Some use cases encourage focusability of
189+
disabled toolbar items to increase their discoverability.
190+
191+
See the
192+
[ARIA guidelines on focusability of disabled controls](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_disabled_controls)<!-- {.external} -->
193+
for guidance on when this is recommended.
194+
195+
```html
196+
<div role="toolbar">
197+
<md-icon-button><md-icon>copy</md-icon></md-icon-button>
198+
<md-icon-button><md-icon>cut</md-icon></md-icon-button>
199+
<!--
200+
This icon button is disabled but kept focusable to improve its
201+
discoverability in the toolbar.
202+
-->
203+
<md-icon-button soft-disabled><md-icon>paste</md-icon></md-icon-button>
204+
</div>
205+
```
206+
185207
### Toggle
186208

187209
Add an `aria-label-selected` attribute to toggle buttons whose labels need a
@@ -319,7 +341,7 @@ Token | Default value
319341
### Filled Icon Button tokens
320342

321343
Token | Default value
322-
-------------------------------------------------- | ------------------------
344+
-------------------------------------------------- | -------------
323345
`--md-filled-icon-button-selected-container-color` | `--md-sys-color-primary`
324346
`--md-filled-icon-button-container-shape` | `--md-sys-shape-corner-full`
325347
`--md-filled-icon-button-container-width` | `40px`
@@ -391,7 +413,7 @@ Token | Default value
391413
### Outlined Icon Button tokens
392414

393415
Token | Default value
394-
-------------------------------------------- | ------------------------
416+
-------------------------------------------- | ----------------------------
395417
`--md-outlined-icon-button-outline-color` | `--md-sys-color-outline`
396418
`--md-outlined-icon-button-outline-width` | `1px`
397419
`--md-outlined-icon-button-container-shape` | `--md-sys-shape-corner-full`
@@ -428,7 +450,6 @@ Token | Default value
428450

429451
## API
430452

431-
432453
### MdIconButton <code>&lt;md-icon-button&gt;</code>
433454

434455
#### Properties
@@ -472,6 +493,7 @@ Token | Default value
472493
| Property | Attribute | Type | Default | Description |
473494
| --- | --- | --- | --- | --- |
474495
| `disabled` | `disabled` | `boolean` | `false` | Disables the icon button and makes it non-interactive. |
496+
| `softDisabled` | `soft-disabled` | `boolean` | `false` | "Soft-disables" the icon button (disabled but still focusable). |
475497
| `flipIconInRtl` | `flip-icon-in-rtl` | `boolean` | `false` | Flips the icon if it is in an RTL context at startup. |
476498
| `href` | `href` | `string` | `''` | Sets the underlying `HTMLAnchorElement`'s `href` resource attribute. |
477499
| `target` | `target` | `string` | `''` | Sets the underlying `HTMLAnchorElement`'s `target` attribute. |

0 commit comments

Comments
 (0)