Skip to content

Commit

Permalink
feat: add alwaysFocusable property to button and iconbutton
Browse files Browse the repository at this point in the history
Aligns with the `alwaysFocusable` property that's currently provided by chip. Fixes #5672.

PiperOrigin-RevId: 650632329
  • Loading branch information
material-web-copybara authored and copybara-github committed Jul 10, 2024
1 parent 7867674 commit 1e56236
Show file tree
Hide file tree
Showing 9 changed files with 184 additions and 26 deletions.
13 changes: 12 additions & 1 deletion button/internal/button.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,16 @@ export abstract class Button extends buttonBaseClass implements FormSubmitter {
*/
@property({type: Boolean, reflect: true}) disabled = false;

/**
* When true, allows disabled buttons to be focused.
*
* Add 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: 'always-focusable'})
alwaysFocusable = false;

/**
* The URL that the link button points to.
*/
Expand Down Expand Up @@ -154,7 +164,8 @@ export abstract class Button extends buttonBaseClass implements FormSubmitter {
return html`<button
id="button"
class="button"
?disabled=${this.disabled}
?disabled=${this.disabled && !this.alwaysFocusable}
aria-disabled=${this.disabled && this.alwaysFocusable ? 'true' : nothing}
aria-label="${ariaLabel || nothing}"
aria-haspopup="${ariaHasPopup || nothing}"
aria-expanded="${ariaExpanded || nothing}">
Expand Down
59 changes: 59 additions & 0 deletions button/internal/button_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* @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 () => {
const {button} = await setupTest();
button.disabled = true;
await env.waitForStability();

button.focus();
expect(document.activeElement).toEqual(document.body);
});

it('should be focusable when disabled and alwaysFocusable', async () => {
const {button} = await setupTest();
button.disabled = true;
button.alwaysFocusable = true;
await env.waitForStability();

button.focus();
expect(document.activeElement).toEqual(button);
});

it('should not activate if clicked when disabled', async () => {
const clickListener = jasmine.createSpy('clickListener');
const {button} = await setupTest();
button.disabled = true;
button.addEventListener('click', clickListener);
await env.waitForStability();

button.click();
expect(clickListener).not.toHaveBeenCalled();
});
});
8 changes: 7 additions & 1 deletion docs/components/button.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,12 @@ attribute to buttons whose labels need a more descriptive label.
<md-elevated-button aria-label="Add a new contact">Add</md-elevated-button>
```

Add the `always-focusable` attribute to a disabled button to make it focusable.

```html
<md-elevated-button disabled always-focusable>Add</md-elevated-button>
```

## Elevated button

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

## API


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

#### Properties
Expand All @@ -713,6 +718,7 @@ Token | Default value
| Property | Attribute | Type | Default | Description |
| --- | --- | --- | --- | --- |
| `disabled` | `disabled` | `boolean` | `false` | Whether or not the button is disabled. |
| `alwaysFocusable` | `always-focusable` | `boolean` | `false` | Whether the button is focusable even when disabled. |
| `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
17 changes: 10 additions & 7 deletions iconbutton/internal/_filled-icon-button.scss
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@
color: var(--_pressed-icon-color);
}

&:disabled {
&:disabled,
&[aria-disabled='true'] {
color: var(--_disabled-icon-color);
}

Expand All @@ -77,17 +78,19 @@
z-index: -1; // place behind content
}

.icon-button:disabled::before {
.icon-button:disabled::before,
.icon-button[aria-disabled='true']::before {
background-color: var(--_disabled-container-color);
opacity: var(--_disabled-container-opacity);
}

.icon-button:disabled .icon {
.icon-button:disabled .icon,
.icon-button[aria-disabled='true'] .icon {
opacity: var(--_disabled-icon-opacity);
}

.toggle-filled {
&:not(:disabled) {
&:not(:disabled, [aria-disabled='true']) {
color: var(--_toggle-icon-color);

&:hover {
Expand All @@ -111,14 +114,14 @@
);
}

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

.selected {
&:not(:disabled) {
&:not(:disabled, [aria-disabled='true']) {
color: var(--_toggle-selected-icon-color);

&:hover {
Expand All @@ -142,7 +145,7 @@
);
}

.selected:not(:disabled)::before {
.selected:not(:disabled, [aria-disabled='true'])::before {
background-color: var(--_selected-container-color);
}
}
17 changes: 10 additions & 7 deletions iconbutton/internal/_filled-tonal-icon-button.scss
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ $_custom-property-prefix: 'filled-tonal-icon-button';
color: var(--_pressed-icon-color);
}

&:disabled {
&:disabled,
&[aria-disabled='true'] {
color: var(--_disabled-icon-color);
}

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

.icon-button:disabled::before {
.icon-button:disabled::before,
.icon-button[aria-disabled='true']::before {
background-color: var(--_disabled-container-color);
opacity: var(--_disabled-container-opacity);
}

.icon-button:disabled .icon {
.icon-button:disabled .icon,
.icon-button[aria-disabled='true'] .icon {
opacity: var(--_disabled-icon-opacity);
}

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

&:hover {
Expand All @@ -113,14 +116,14 @@ $_custom-property-prefix: 'filled-tonal-icon-button';
);
}

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

.selected {
&:not(:disabled) {
&:not(:disabled, [aria-disabled='true']) {
color: var(--_toggle-selected-icon-color);

&:hover {
Expand All @@ -144,7 +147,7 @@ $_custom-property-prefix: 'filled-tonal-icon-button';
);
}

.selected:not(:disabled)::before {
.selected:not(:disabled, [aria-disabled='true'])::before {
background-color: var(--_selected-container-color);
}
}
8 changes: 5 additions & 3 deletions iconbutton/internal/_icon-button.scss
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,8 @@
color: var(--_pressed-icon-color);
}

&:disabled {
&:disabled,
&[aria-disabled='true'] {
color: var(--_disabled-icon-color);
}
}
Expand All @@ -100,12 +101,13 @@
border-radius: var(--_state-layer-shape);
}

.standard:disabled .icon {
.standard:disabled .icon,
.standard[aria-disabled='true'] .icon {
opacity: var(--_disabled-icon-opacity);
}

.selected {
&:not(:disabled) {
&:not(:disabled, [aria-disabled='true']) {
color: var(--_selected-icon-color);

&:hover {
Expand Down
16 changes: 10 additions & 6 deletions iconbutton/internal/_outlined-icon-button.scss
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@
color: var(--_pressed-icon-color);
}

&:disabled {
&:disabled,
&[aria-disabled='true'] {
color: var(--_disabled-icon-color);

&::before {
Expand All @@ -79,7 +80,8 @@
}
}

.outlined:disabled .icon {
.outlined:disabled .icon,
.outlined[aria-disabled='true'] .icon {
opacity: var(--_disabled-icon-opacity);
}

Expand All @@ -103,7 +105,7 @@

// Selected icon button toggle.
.selected {
&:not(:disabled) {
&:not(:disabled, [aria-disabled='true']) {
color: var(--_selected-icon-color);

&:hover {
Expand All @@ -129,11 +131,12 @@
);
}

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

.selected:disabled::before {
.selected:disabled::before,
.selected[aria-disabled='true']::before {
background-color: var(--_disabled-selected-container-color);
opacity: var(--_disabled-selected-container-opacity);
}
Expand All @@ -150,7 +153,8 @@
border-width: var(--_outline-width);
}

&:disabled::before {
&:disabled::before,
&[aria-disabled='true']::before {
border-color: GrayText;
opacity: 1;
}
Expand Down
13 changes: 12 additions & 1 deletion iconbutton/internal/icon-button.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,16 @@ export class IconButton extends iconButtonBaseClass implements FormSubmitter {
*/
@property({type: Boolean, reflect: true}) disabled = false;

/**
* When true, allows disabled buttons to be focused.
*
* Add 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: 'always-focusable'})
alwaysFocusable = false;

/**
* Flips the icon if it is in an RTL context at startup.
*/
Expand Down Expand Up @@ -156,7 +166,8 @@ export class IconButton extends iconButtonBaseClass implements FormSubmitter {
aria-haspopup="${(!this.href && ariaHasPopup) || nothing}"
aria-expanded="${(!this.href && ariaExpanded) || nothing}"
aria-pressed="${ariaPressedValue}"
?disabled="${!this.href && this.disabled}"
aria-disabled=${!this.href && this.disabled && this.alwaysFocusable ? 'true' : nothing}
?disabled=${!this.href && this.disabled && !this.alwaysFocusable}
@click="${this.handleClick}">
${this.renderFocusRing()}
${this.renderRipple()}
Expand Down
Loading

0 comments on commit 1e56236

Please sign in to comment.