Merge 24da379ea657f20b24f2fcaaf25b8953b3555824 into 78676746041b39f014fe9568efbf5ce2e9d7d46a

This commit is contained in:
copybara-service[bot] 2024-07-10 15:27:09 +00:00 committed by GitHub
commit 531b420210
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 224 additions and 26 deletions

View File

@ -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.
*/
@ -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}">

View File

@ -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();
});
});

View File

@ -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 -->
@ -703,7 +725,6 @@ Token | Default value
## API
### MdElevatedButton <code>&lt;md-elevated-button&gt;</code>
#### Properties
@ -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. |

View File

@ -53,7 +53,8 @@
color: var(--_pressed-icon-color);
}
&:disabled {
&:disabled,
&[aria-disabled='true'] {
color: var(--_disabled-icon-color);
}
@ -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 {
@ -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 {
@ -142,7 +145,7 @@
);
}
.selected:not(:disabled)::before {
.selected:not(:disabled, [aria-disabled='true'])::before {
background-color: var(--_selected-container-color);
}
}

View File

@ -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);
}
@ -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 {
@ -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 {
@ -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);
}
}

View File

@ -91,7 +91,8 @@
color: var(--_pressed-icon-color);
}
&:disabled {
&:disabled,
&[aria-disabled='true'] {
color: var(--_disabled-icon-color);
}
}
@ -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 {

View File

@ -69,7 +69,8 @@
color: var(--_pressed-icon-color);
}
&:disabled {
&:disabled,
&[aria-disabled='true'] {
color: var(--_disabled-icon-color);
&::before {
@ -79,7 +80,8 @@
}
}
.outlined:disabled .icon {
.outlined:disabled .icon,
.outlined[aria-disabled='true'] .icon {
opacity: var(--_disabled-icon-opacity);
}
@ -103,7 +105,7 @@
// Selected icon button toggle.
.selected {
&:not(:disabled) {
&:not(:disabled, [aria-disabled='true']) {
color: var(--_selected-icon-color);
&:hover {
@ -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);
}
@ -150,7 +153,8 @@
border-width: var(--_outline-width);
}
&:disabled::before {
&:disabled::before,
&[aria-disabled='true']::before {
border-color: GrayText;
opacity: 1;
}

View File

@ -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.
*/
@ -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()}

View File

@ -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 {IconButtonHarness} from '../harness.js';
import {IconButton} from './icon-button.js';
@customElement('test-icon-button')
class TestIconButton extends IconButton {}
describe('IconButton', () => {
const env = new Environment();
async function setupTest() {
const iconButton = new TestIconButton();
env.render(html`${iconButton}`);
await env.waitForStability();
return {iconButton, harness: new IconButtonHarness(iconButton)};
}
it('should not be focusable when disabled', async () => {
const {iconButton} = await setupTest();
iconButton.disabled = true;
await env.waitForStability();
iconButton.focus();
expect(document.activeElement).toEqual(document.body);
});
it('should be focusable when disabled and alwaysFocusable', async () => {
const {iconButton} = await setupTest();
iconButton.disabled = true;
iconButton.alwaysFocusable = true;
await env.waitForStability();
iconButton.focus();
expect(document.activeElement).toEqual(iconButton);
});
it('should not be clickable when disabled', async () => {
const clickListener = jasmine.createSpy('clickListener');
const {iconButton} = await setupTest();
iconButton.disabled = true;
iconButton.addEventListener('click', clickListener);
await env.waitForStability();
iconButton.click();
expect(clickListener).not.toHaveBeenCalled();
});
it('should not be clickable when disabled and alwaysFocusable', async () => {
const clickListener = jasmine.createSpy('clickListener');
const {iconButton} = await setupTest();
iconButton.disabled = true;
iconButton.alwaysFocusable = true;
iconButton.addEventListener('click', clickListener);
await env.waitForStability();
iconButton.click();
expect(clickListener).not.toHaveBeenCalled();
});
});