Skip to content

Commit 1e56236

Browse files
feat: add alwaysFocusable property to button and iconbutton
Aligns with the `alwaysFocusable` property that's currently provided by chip. Fixes #5672. PiperOrigin-RevId: 650632329
1 parent 7867674 commit 1e56236

9 files changed

+184
-26
lines changed

button/internal/button.ts

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

54+
/**
55+
* When true, allows disabled buttons to be focused.
56+
*
57+
* Add this when a button needs increased visibility when disabled. See
58+
* https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_disabled_controls
59+
* for more guidance on when this is needed.
60+
*/
61+
@property({type: Boolean, attribute: 'always-focusable'})
62+
alwaysFocusable = false;
63+
5464
/**
5565
* The URL that the link button points to.
5666
*/
@@ -154,7 +164,8 @@ export abstract class Button extends buttonBaseClass implements FormSubmitter {
154164
return html`<button
155165
id="button"
156166
class="button"
157-
?disabled=${this.disabled}
167+
?disabled=${this.disabled && !this.alwaysFocusable}
168+
aria-disabled=${this.disabled && this.alwaysFocusable ? 'true' : nothing}
158169
aria-label="${ariaLabel || nothing}"
159170
aria-haspopup="${ariaHasPopup || nothing}"
160171
aria-expanded="${ariaExpanded || nothing}">

button/internal/button_test.ts

+59
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
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 disabled and alwaysFocusable', async () => {
40+
const {button} = await setupTest();
41+
button.disabled = true;
42+
button.alwaysFocusable = true;
43+
await env.waitForStability();
44+
45+
button.focus();
46+
expect(document.activeElement).toEqual(button);
47+
});
48+
49+
it('should not activate if clicked when disabled', async () => {
50+
const clickListener = jasmine.createSpy('clickListener');
51+
const {button} = await setupTest();
52+
button.disabled = true;
53+
button.addEventListener('click', clickListener);
54+
await env.waitForStability();
55+
56+
button.click();
57+
expect(clickListener).not.toHaveBeenCalled();
58+
});
59+
});

docs/components/button.md

+7-1
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,12 @@ 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+
Add the `always-focusable` attribute to a disabled button to make it focusable.
240+
241+
```html
242+
<md-elevated-button disabled always-focusable>Add</md-elevated-button>
243+
```
244+
239245
## Elevated button
240246

241247
<!-- go/md-elevated-button -->
@@ -703,7 +709,6 @@ Token | Default value
703709

704710
## API
705711

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

709714
#### Properties
@@ -713,6 +718,7 @@ Token | Default value
713718
| Property | Attribute | Type | Default | Description |
714719
| --- | --- | --- | --- | --- |
715720
| `disabled` | `disabled` | `boolean` | `false` | Whether or not the button is disabled. |
721+
| `alwaysFocusable` | `always-focusable` | `boolean` | `false` | Whether the button is focusable even when disabled. |
716722
| `href` | `href` | `string` | `''` | The URL that the link button points to. |
717723
| `target` | `target` | `string` | `''` | Where to display the linked `href` URL for a link button. Common options include `_blank` to open in a new tab. |
718724
| `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. |

iconbutton/internal/_filled-icon-button.scss

+10-7
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@
5353
color: var(--_pressed-icon-color);
5454
}
5555

56-
&:disabled {
56+
&:disabled,
57+
&[aria-disabled='true'] {
5758
color: var(--_disabled-icon-color);
5859
}
5960

@@ -77,17 +78,19 @@
7778
z-index: -1; // place behind content
7879
}
7980

80-
.icon-button:disabled::before {
81+
.icon-button:disabled::before,
82+
.icon-button[aria-disabled='true']::before {
8183
background-color: var(--_disabled-container-color);
8284
opacity: var(--_disabled-container-opacity);
8385
}
8486

85-
.icon-button:disabled .icon {
87+
.icon-button:disabled .icon,
88+
.icon-button[aria-disabled='true'] .icon {
8689
opacity: var(--_disabled-icon-opacity);
8790
}
8891

8992
.toggle-filled {
90-
&:not(:disabled) {
93+
&:not(:disabled, [aria-disabled='true']) {
9194
color: var(--_toggle-icon-color);
9295

9396
&:hover {
@@ -111,14 +114,14 @@
111114
);
112115
}
113116

114-
.toggle-filled:not(:disabled)::before {
117+
.toggle-filled:not(:disabled, [aria-disabled='true'])::before {
115118
// Note: filled icon buttons have three container colors,
116119
// "container-color" for regular, then selected/unselected for toggle.
117120
background-color: var(--_unselected-container-color);
118121
}
119122

120123
.selected {
121-
&:not(:disabled) {
124+
&:not(:disabled, [aria-disabled='true']) {
122125
color: var(--_toggle-selected-icon-color);
123126

124127
&:hover {
@@ -142,7 +145,7 @@
142145
);
143146
}
144147

145-
.selected:not(:disabled)::before {
148+
.selected:not(:disabled, [aria-disabled='true'])::before {
146149
background-color: var(--_selected-container-color);
147150
}
148151
}

iconbutton/internal/_filled-tonal-icon-button.scss

+10-7
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,8 @@ $_custom-property-prefix: 'filled-tonal-icon-button';
5555
color: var(--_pressed-icon-color);
5656
}
5757

58-
&:disabled {
58+
&:disabled,
59+
&[aria-disabled='true'] {
5960
color: var(--_disabled-icon-color);
6061
}
6162

@@ -79,17 +80,19 @@ $_custom-property-prefix: 'filled-tonal-icon-button';
7980
z-index: -1; // place behind content
8081
}
8182

82-
.icon-button:disabled::before {
83+
.icon-button:disabled::before,
84+
.icon-button[aria-disabled='true']::before {
8385
background-color: var(--_disabled-container-color);
8486
opacity: var(--_disabled-container-opacity);
8587
}
8688

87-
.icon-button:disabled .icon {
89+
.icon-button:disabled .icon,
90+
.icon-button[aria-disabled='true'] .icon {
8891
opacity: var(--_disabled-icon-opacity);
8992
}
9093

9194
.toggle-filled-tonal {
92-
&:not(:disabled) {
95+
&:not(:disabled, [aria-disabled='true']) {
9396
color: var(--_toggle-icon-color);
9497

9598
&:hover {
@@ -113,14 +116,14 @@ $_custom-property-prefix: 'filled-tonal-icon-button';
113116
);
114117
}
115118

116-
.toggle-filled-tonal:not(:disabled)::before {
119+
.toggle-filled-tonal:not(:disabled, [aria-disabled='true'])::before {
117120
// Note: filled tonal icon buttons have three container colors,
118121
// "container-color" for regular, then selected/unselected for toggle.
119122
background-color: var(--_unselected-container-color);
120123
}
121124

122125
.selected {
123-
&:not(:disabled) {
126+
&:not(:disabled, [aria-disabled='true']) {
124127
color: var(--_toggle-selected-icon-color);
125128

126129
&:hover {
@@ -144,7 +147,7 @@ $_custom-property-prefix: 'filled-tonal-icon-button';
144147
);
145148
}
146149

147-
.selected:not(:disabled)::before {
150+
.selected:not(:disabled, [aria-disabled='true'])::before {
148151
background-color: var(--_selected-container-color);
149152
}
150153
}

iconbutton/internal/_icon-button.scss

+5-3
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,8 @@
9191
color: var(--_pressed-icon-color);
9292
}
9393

94-
&:disabled {
94+
&:disabled,
95+
&[aria-disabled='true'] {
9596
color: var(--_disabled-icon-color);
9697
}
9798
}
@@ -100,12 +101,13 @@
100101
border-radius: var(--_state-layer-shape);
101102
}
102103

103-
.standard:disabled .icon {
104+
.standard:disabled .icon,
105+
.standard[aria-disabled='true'] .icon {
104106
opacity: var(--_disabled-icon-opacity);
105107
}
106108

107109
.selected {
108-
&:not(:disabled) {
110+
&:not(:disabled, [aria-disabled='true']) {
109111
color: var(--_selected-icon-color);
110112

111113
&:hover {

iconbutton/internal/_outlined-icon-button.scss

+10-6
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,8 @@
6969
color: var(--_pressed-icon-color);
7070
}
7171

72-
&:disabled {
72+
&:disabled,
73+
&[aria-disabled='true'] {
7374
color: var(--_disabled-icon-color);
7475

7576
&::before {
@@ -79,7 +80,8 @@
7980
}
8081
}
8182

82-
.outlined:disabled .icon {
83+
.outlined:disabled .icon,
84+
.outlined[aria-disabled='true'] .icon {
8385
opacity: var(--_disabled-icon-opacity);
8486
}
8587

@@ -103,7 +105,7 @@
103105

104106
// Selected icon button toggle.
105107
.selected {
106-
&:not(:disabled) {
108+
&:not(:disabled, [aria-disabled='true']) {
107109
color: var(--_selected-icon-color);
108110

109111
&:hover {
@@ -129,11 +131,12 @@
129131
);
130132
}
131133

132-
.selected:not(:disabled)::before {
134+
.selected:not(:disabled, [aria-disabled='true'])::before {
133135
background-color: var(--_selected-container-color);
134136
}
135137

136-
.selected:disabled::before {
138+
.selected:disabled::before,
139+
.selected[aria-disabled='true']::before {
137140
background-color: var(--_disabled-selected-container-color);
138141
opacity: var(--_disabled-selected-container-opacity);
139142
}
@@ -150,7 +153,8 @@
150153
border-width: var(--_outline-width);
151154
}
152155

153-
&:disabled::before {
156+
&:disabled::before,
157+
&[aria-disabled='true']::before {
154158
border-color: GrayText;
155159
opacity: 1;
156160
}

iconbutton/internal/icon-button.ts

+12-1
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,16 @@ export class IconButton extends iconButtonBaseClass implements FormSubmitter {
5858
*/
5959
@property({type: Boolean, reflect: true}) disabled = false;
6060

61+
/**
62+
* When true, allows disabled buttons to be focused.
63+
*
64+
* Add this when a button needs increased visibility when disabled. See
65+
* https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_disabled_controls
66+
* for more guidance on when this is needed.
67+
*/
68+
@property({type: Boolean, attribute: 'always-focusable'})
69+
alwaysFocusable = false;
70+
6171
/**
6272
* Flips the icon if it is in an RTL context at startup.
6373
*/
@@ -156,7 +166,8 @@ export class IconButton extends iconButtonBaseClass implements FormSubmitter {
156166
aria-haspopup="${(!this.href && ariaHasPopup) || nothing}"
157167
aria-expanded="${(!this.href && ariaExpanded) || nothing}"
158168
aria-pressed="${ariaPressedValue}"
159-
?disabled="${!this.href && this.disabled}"
169+
aria-disabled=${!this.href && this.disabled && this.alwaysFocusable ? 'true' : nothing}
170+
?disabled=${!this.href && this.disabled && !this.alwaysFocusable}
160171
@click="${this.handleClick}">
161172
${this.renderFocusRing()}
162173
${this.renderRipple()}

0 commit comments

Comments
 (0)