Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add alwaysFocusable property to button and iconbutton #5673

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
71 changes: 71 additions & 0 deletions button/internal/button_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* @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 be clickable 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();
});

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

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

### Focusable and disabled

By default, disabled buttons are not focusable with the keyboard. Some use cases
encourage focusability of disabled toolbar items to increase their
discoverability.

See the
[ARIA guidelines on focusability of disabled controls](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_disabled_controls)<!-- {.external} -->
for guidance on when this is recommended.

```html
<div role="toolbar">
<md-text-button>Copy</md-text-button>
<md-text-button>Cut</md-text-button>
<!--
This button is disabled, but kept focusable to improve its discoverability
in the toolbar.
-->
<md-text-button disabled always-focusable>Paste</md-text-button>
</div>
```

## Elevated button

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

## API


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

#### Properties
Expand All @@ -713,6 +734,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 still focusable 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
Loading