Skip to content

Commit 48124ba

Browse files
zelliottcopybara-github
authored andcommitted
feat(button): add soft-disabled attribute for focusable disabled buttons
PiperOrigin-RevId: 651854744
1 parent 7867674 commit 48124ba

File tree

9 files changed

+146
-21
lines changed

9 files changed

+146
-21
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

+26-5
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.bind(this));
115126
}
116127
}
117128

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

126137
protected override render() {
127138
// Link buttons may not be disabled
128-
const isDisabled = this.disabled && !this.href;
139+
const isRippleDisabled = !this.href && (this.disabled || this.softDisabled);
129140
const buttonOrLink = this.href ? this.renderLink() : this.renderButton();
130141
// TODO(b/310046938): due to a limitation in focus ring/ripple, we can't use
131142
// the same ID for different elements, so we change the ID instead.
@@ -137,7 +148,7 @@ export abstract class Button extends buttonBaseClass implements FormSubmitter {
137148
<md-ripple
138149
part="ripple"
139150
for=${buttonId}
140-
?disabled="${isDisabled}"></md-ripple>
151+
?disabled="${isRippleDisabled}"></md-ripple>
141152
${buttonOrLink}
142153
`;
143154
}
@@ -155,6 +166,7 @@ export abstract class Button extends buttonBaseClass implements FormSubmitter {
155166
id="button"
156167
class="button"
157168
?disabled=${this.disabled}
169+
aria-disabled=${this.softDisabled || nothing}
158170
aria-label="${ariaLabel || nothing}"
159171
aria-haspopup="${ariaHasPopup || nothing}"
160172
aria-expanded="${ariaExpanded || nothing}">
@@ -190,13 +202,22 @@ export abstract class Button extends buttonBaseClass implements FormSubmitter {
190202
`;
191203
}
192204

193-
private readonly handleActivationClick = (event: MouseEvent) => {
205+
private handleClick(event: MouseEvent) {
206+
// If the button is soft-disabled, we need to explicitly prevent the click
207+
// from propagating to other event listeners as well as prevent the default
208+
// action.
209+
if (!this.href && this.softDisabled) {
210+
event.stopImmediatePropagation();
211+
event.preventDefault();
212+
return;
213+
}
214+
194215
if (!isActivationClick(event) || !this.buttonElement) {
195216
return;
196217
}
197218
this.focus();
198219
dispatchActivationClick(this.buttonElement);
199-
};
220+
}
200221

201222
private handleSlotChange() {
202223
this.hasIcon = this.assignedIcons.length > 0;

button/internal/button_test.ts

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

testing/templates.ts

+1
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export enum State {
2424
HOVER = 'Hover',
2525
PRESSED = 'Pressed',
2626
SELECTED = 'Selected',
27+
SOFT_DISABLED = 'Soft disabled',
2728
}
2829

2930
/**

0 commit comments

Comments
 (0)