From 766feae322063440436417f91b670e2e34f9bb08 Mon Sep 17 00:00:00 2001 From: Zack Elliott Date: Thu, 11 Jul 2024 07:53:59 -0700 Subject: [PATCH] feat: add `soft-disabled` property to button and iconbutton Fixes #5672. PiperOrigin-RevId: 651409230 --- button/internal/_elevation.scss | 6 +- button/internal/_icon.scss | 3 +- button/internal/_outlined-button.scss | 9 ++- button/internal/_shared.scss | 12 ++-- button/internal/button.ts | 39 +++++++++-- button/internal/button_test.ts | 69 +++++++++++++++++++ docs/components/button.md | 24 ++++++- docs/components/icon-button.md | 28 +++++++- iconbutton/icon-button_test.ts | 40 +++++++++++ iconbutton/internal/_filled-icon-button.scss | 17 +++-- .../internal/_filled-tonal-icon-button.scss | 19 ++--- iconbutton/internal/_icon-button.scss | 8 ++- .../internal/_outlined-icon-button.scss | 19 +++-- iconbutton/internal/_shared.scss | 6 +- iconbutton/internal/icon-button.ts | 53 ++++++++++++-- 15 files changed, 300 insertions(+), 52 deletions(-) create mode 100644 button/internal/button_test.ts diff --git a/button/internal/_elevation.scss b/button/internal/_elevation.scss index 1899999e44..9ce7bca6d5 100644 --- a/button/internal/_elevation.scss +++ b/button/internal/_elevation.scss @@ -20,7 +20,8 @@ $_md-sys-motion: tokens.md-sys-motion-values(); transition-timing-function: map.get($_md-sys-motion, 'emphasized-easing'); } - :host([disabled]) md-elevation { + :host([disabled]) md-elevation, + :host([soft-disabled]) md-elevation { transition: none; } @@ -59,7 +60,8 @@ $_md-sys-motion: tokens.md-sys-motion-values(); ); } - :host([disabled]) md-elevation { + :host([disabled]) md-elevation, + :host([soft-disabled]) md-elevation { @include elevation.theme( ( 'level': var(--_disabled-container-elevation), diff --git a/button/internal/_icon.scss b/button/internal/_icon.scss index e2156e5be5..38e8abce44 100644 --- a/button/internal/_icon.scss +++ b/button/internal/_icon.scss @@ -31,7 +31,8 @@ color: var(--_pressed-icon-color); } - :host([disabled]) ::slotted([slot='icon']) { + :host([disabled]) ::slotted([slot='icon']), + :host([soft-disabled]) ::slotted([slot='icon']) { color: var(--_disabled-icon-color); opacity: var(--_disabled-icon-opacity); } diff --git a/button/internal/_outlined-button.scss b/button/internal/_outlined-button.scss index 0cf7e0bcc9..2ddec7fd5b 100644 --- a/button/internal/_outlined-button.scss +++ b/button/internal/_outlined-button.scss @@ -57,20 +57,23 @@ border-color: var(--_pressed-outline-color); } - :host([disabled]) .outline { + :host([disabled]) .outline, + :host([soft-disabled]) .outline { border-color: var(--_disabled-outline-color); opacity: var(--_disabled-outline-opacity); } @media (forced-colors: active) { - :host([disabled]) .background { + :host([disabled]) .background, + :host([soft-disabled]) .background { // Only outlined buttons change their border when disabled to distinguish // them from other buttons that add a border for increased visibility in // HCM. border-color: GrayText; } - :host([disabled]) .outline { + :host([disabled]) .outline, + :host([soft-disabled]) .outline { opacity: 1; } } diff --git a/button/internal/_shared.scss b/button/internal/_shared.scss index 40e6b88899..62d46be4d1 100644 --- a/button/internal/_shared.scss +++ b/button/internal/_shared.scss @@ -71,7 +71,8 @@ ); } - :host([disabled]) { + :host([disabled]), + :host([soft-disabled]) { cursor: default; pointer-events: none; } @@ -139,12 +140,14 @@ text-overflow: inherit; } - :host([disabled]) .label { + :host([disabled]) .label, + :host([soft-disabled]) .label { color: var(--_disabled-label-text-color); opacity: var(--_disabled-label-text-opacity); } - :host([disabled]) .background { + :host([disabled]) .background, + :host([soft-disabled]) .background { background-color: var(--_disabled-container-color); opacity: var(--_disabled-container-opacity); } @@ -157,7 +160,8 @@ border: 1px solid CanvasText; } - :host([disabled]) { + :host([disabled]), + :host([soft-disabled]) { --_disabled-icon-color: GrayText; --_disabled-icon-opacity: 1; --_disabled-container-opacity: 1; diff --git a/button/internal/button.ts b/button/internal/button.ts index 7037f80b92..933d175705 100644 --- a/button/internal/button.ts +++ b/button/internal/button.ts @@ -51,6 +51,17 @@ export abstract class Button extends buttonBaseClass implements FormSubmitter { */ @property({type: Boolean, reflect: true}) disabled = false; + /** + * Whether or not the button is "soft-disabled" (disabled but still + * focusable). + * + * Use 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: 'soft-disabled', reflect: true}) + softDisabled = false; + /** * The URL that the link button points to. */ @@ -111,7 +122,7 @@ export abstract class Button extends buttonBaseClass implements FormSubmitter { constructor() { super(); if (!isServer) { - this.addEventListener('click', this.handleActivationClick); + this.addEventListener('click', this.handleClick); } } @@ -123,9 +134,19 @@ export abstract class Button extends buttonBaseClass implements FormSubmitter { this.buttonElement?.blur(); } + /** + * Link buttons cannot be disabled or soft-disabled. + */ + protected override willUpdate() { + if (this.href) { + this.disabled = false; + this.softDisabled = false; + } + } + protected override render() { // Link buttons may not be disabled - const isDisabled = this.disabled && !this.href; + const isRippleDisabled = !this.href && (this.disabled || this.softDisabled); const buttonOrLink = this.href ? this.renderLink() : this.renderButton(); // TODO(b/310046938): due to a limitation in focus ring/ripple, we can't use // the same ID for different elements, so we change the ID instead. @@ -137,7 +158,7 @@ export abstract class Button extends buttonBaseClass implements FormSubmitter { + ?disabled="${isRippleDisabled}"> ${buttonOrLink} `; } @@ -155,6 +176,7 @@ export abstract class Button extends buttonBaseClass implements FormSubmitter { id="button" class="button" ?disabled=${this.disabled} + aria-disabled=${this.softDisabled ? 'true' : nothing} aria-label="${ariaLabel || nothing}" aria-haspopup="${ariaHasPopup || nothing}" aria-expanded="${ariaExpanded || nothing}"> @@ -190,7 +212,16 @@ export abstract class Button extends buttonBaseClass implements FormSubmitter { `; } - private readonly handleActivationClick = (event: MouseEvent) => { + private readonly handleClick = (event: MouseEvent) => { + // If the button is soft-disabled, we need to explicitly prevent the click + // from propagating to other event listeners as well as prevent the default + // action. + if (!this.href && this.softDisabled) { + event.stopImmediatePropagation(); + event.preventDefault(); + return; + } + if (!isActivationClick(event) || !this.buttonElement) { return; } diff --git a/button/internal/button_test.ts b/button/internal/button_test.ts new file mode 100644 index 0000000000..c287b6deaa --- /dev/null +++ b/button/internal/button_test.ts @@ -0,0 +1,69 @@ +/** + * @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 soft-disabled', async () => { + const {button} = await setupTest(); + button.softDisabled = 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 soft-disabled', async () => { + const clickListener = jasmine.createSpy('clickListener'); + const {button} = await setupTest(); + button.softDisabled = true; + button.addEventListener('click', clickListener); + await env.waitForStability(); + + button.click(); + expect(clickListener).not.toHaveBeenCalled(); + }); +}); diff --git a/docs/components/button.md b/docs/components/button.md index 2db15a1c2a..a396d41726 100644 --- a/docs/components/button.md +++ b/docs/components/button.md @@ -236,6 +236,28 @@ attribute to buttons whose labels need a more descriptive label. Add ``` +### Focusable and disabled + +By default, disabled buttons are not focusable with the keyboard, while +soft-disabled buttons are. 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) +for guidance on when this is recommended. + +```html +
+ Copy + Cut + + Paste +
+``` + ## Elevated button @@ -703,7 +725,6 @@ Token | Default value ## API - ### MdElevatedButton <md-elevated-button> #### Properties @@ -713,6 +734,7 @@ Token | Default value | Property | Attribute | Type | Default | Description | | --- | --- | --- | --- | --- | | `disabled` | `disabled` | `boolean` | `false` | Whether or not the button is disabled. | +| `softDisabled` | `soft-disabled` | `boolean` | `false` | Whether the button is "soft-disabled" (disabled but still focusable). | | `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.
_Note:_ Link buttons cannot have trailing icons. | diff --git a/docs/components/icon-button.md b/docs/components/icon-button.md index e0196cdf03..a2872e4546 100644 --- a/docs/components/icon-button.md +++ b/docs/components/icon-button.md @@ -182,6 +182,28 @@ attribute to icon buttons whose labels need a more descriptive label. ``` +### Focusable and disabled + +By default, disabled icon buttons are not focusable with the keyboard, while +soft-disabled icon buttons are. 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) +for guidance on when this is recommended. + +```html +
+ copy + cut + + paste +
+``` + ### Toggle Add an `aria-label-selected` attribute to toggle buttons whose labels need a @@ -319,7 +341,7 @@ Token | Default value ### Filled Icon Button tokens Token | Default value --------------------------------------------------- | ------------------------ +-------------------------------------------------- | ------------- `--md-filled-icon-button-selected-container-color` | `--md-sys-color-primary` `--md-filled-icon-button-container-shape` | `--md-sys-shape-corner-full` `--md-filled-icon-button-container-width` | `40px` @@ -391,7 +413,7 @@ Token | Default value ### Outlined Icon Button tokens Token | Default value --------------------------------------------- | ------------------------ +-------------------------------------------- | ---------------------------- `--md-outlined-icon-button-outline-color` | `--md-sys-color-outline` `--md-outlined-icon-button-outline-width` | `1px` `--md-outlined-icon-button-container-shape` | `--md-sys-shape-corner-full` @@ -428,7 +450,6 @@ Token | Default value ## API - ### MdIconButton <md-icon-button> #### Properties @@ -472,6 +493,7 @@ Token | Default value | Property | Attribute | Type | Default | Description | | --- | --- | --- | --- | --- | | `disabled` | `disabled` | `boolean` | `false` | Disables the icon button and makes it non-interactive. | +| `softDisabled` | `soft-disabled` | `boolean` | `false` | "Soft-disables" the icon button (disabled but still focusable). | | `flipIconInRtl` | `flip-icon-in-rtl` | `boolean` | `false` | Flips the icon if it is in an RTL context at startup. | | `href` | `href` | `string` | `''` | Sets the underlying `HTMLAnchorElement`'s `href` resource attribute. | | `target` | `target` | `string` | `''` | Sets the underlying `HTMLAnchorElement`'s `target` attribute. | diff --git a/iconbutton/icon-button_test.ts b/iconbutton/icon-button_test.ts index ffd4a81e87..34772a31fe 100644 --- a/iconbutton/icon-button_test.ts +++ b/iconbutton/icon-button_test.ts @@ -61,6 +61,46 @@ describe('icon button tests', () => { }, ); + it('should not be focusable when disabled', async () => { + const {element} = await setUpTest('button'); + element.disabled = true; + await element.updateComplete; + + element.focus(); + expect(document.activeElement).toEqual(document.body); + }); + + it('should be focusable when soft-disabled', async () => { + const {element} = await setUpTest('button'); + element.softDisabled = true; + await element.updateComplete; + + element.focus(); + expect(document.activeElement).toEqual(element); + }); + + it('should not be clickable when disabled', async () => { + const clickListener = jasmine.createSpy('clickListener'); + const {element} = await setUpTest('button'); + element.disabled = true; + element.addEventListener('click', clickListener); + await element.updateComplete; + + element.click(); + expect(clickListener).not.toHaveBeenCalled(); + }); + + it('should not be clickable when soft-disabled', async () => { + const clickListener = jasmine.createSpy('clickListener'); + const {element} = await setUpTest('button'); + element.softDisabled = true; + element.addEventListener('click', clickListener); + await element.updateComplete; + + element.click(); + expect(clickListener).not.toHaveBeenCalled(); + }); + it( 'setting `ariaLabel` updates the aria-label attribute on the native ' + 'button element', diff --git a/iconbutton/internal/_filled-icon-button.scss b/iconbutton/internal/_filled-icon-button.scss index 7293798c27..f8737a3b4a 100644 --- a/iconbutton/internal/_filled-icon-button.scss +++ b/iconbutton/internal/_filled-icon-button.scss @@ -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); } } diff --git a/iconbutton/internal/_filled-tonal-icon-button.scss b/iconbutton/internal/_filled-tonal-icon-button.scss index e50c65b065..8ed24efae7 100644 --- a/iconbutton/internal/_filled-tonal-icon-button.scss +++ b/iconbutton/internal/_filled-tonal-icon-button.scss @@ -12,8 +12,6 @@ @use '../../tokens'; // go/keep-sorted end -$_custom-property-prefix: 'filled-tonal-icon-button'; - @mixin theme($tokens) { $supported-tokens: tokens.$md-comp-filled-tonal-icon-button-supported-tokens; @each $token, $value in $tokens { @@ -55,7 +53,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 +78,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 +114,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 +145,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); } } diff --git a/iconbutton/internal/_icon-button.scss b/iconbutton/internal/_icon-button.scss index 433a11de27..fe24869bea 100644 --- a/iconbutton/internal/_icon-button.scss +++ b/iconbutton/internal/_icon-button.scss @@ -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 { diff --git a/iconbutton/internal/_outlined-icon-button.scss b/iconbutton/internal/_outlined-icon-button.scss index 0ecc5f4c59..d177db12f9 100644 --- a/iconbutton/internal/_outlined-icon-button.scss +++ b/iconbutton/internal/_outlined-icon-button.scss @@ -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,17 +131,19 @@ ); } - .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); } @media (forced-colors: active) { - :host([disabled]) { + :host([disabled]), + :host([soft-disabled]) { --_disabled-outline-opacity: 1; } @@ -150,7 +154,8 @@ border-width: var(--_outline-width); } - &:disabled::before { + &:disabled::before, + &[aria-disabled='true']::before { border-color: GrayText; opacity: 1; } diff --git a/iconbutton/internal/_shared.scss b/iconbutton/internal/_shared.scss index eea1b05aae..adf04ba4cd 100644 --- a/iconbutton/internal/_shared.scss +++ b/iconbutton/internal/_shared.scss @@ -41,7 +41,8 @@ ); } - :host([disabled]) { + :host([disabled]), + :host([soft-disabled]) { pointer-events: none; } @@ -109,7 +110,8 @@ } @media (forced-colors: active) { - :host([disabled]) { + :host([disabled]), + :host([soft-disabled]) { --_disabled-icon-opacity: 1; } } diff --git a/iconbutton/internal/icon-button.ts b/iconbutton/internal/icon-button.ts index 9a23674922..f97458906d 100644 --- a/iconbutton/internal/icon-button.ts +++ b/iconbutton/internal/icon-button.ts @@ -7,7 +7,7 @@ import '../../focus/md-focus-ring.js'; import '../../ripple/ripple.js'; -import {html, LitElement, nothing} from 'lit'; +import {html, isServer, LitElement, nothing} from 'lit'; import {property, state} from 'lit/decorators.js'; import {classMap} from 'lit/directives/class-map.js'; import {literal, html as staticHtml} from 'lit/static-html.js'; @@ -58,6 +58,16 @@ export class IconButton extends iconButtonBaseClass implements FormSubmitter { */ @property({type: Boolean, reflect: true}) disabled = false; + /** + * "Soft-disables" the icon button (disabled but still focusable). + * + * Use this when an icon 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: 'soft-disabled', reflect: true}) + softDisabled = false; + /** * Flips the icon if it is in an RTL context at startup. */ @@ -127,12 +137,20 @@ export class IconButton extends iconButtonBaseClass implements FormSubmitter { @state() private flipIcon = isRtl(this, this.flipIconInRtl); + constructor() { + super(); + if (!isServer) { + this.addEventListener('click', this.handleClick); + } + } + /** - * Link buttons cannot be disabled. + * Link buttons cannot be disabled or soft-disabled. */ protected override willUpdate() { if (this.href) { this.disabled = false; + this.softDisabled = false; } } @@ -156,8 +174,9 @@ export class IconButton extends iconButtonBaseClass implements FormSubmitter { aria-haspopup="${(!this.href && ariaHasPopup) || nothing}" aria-expanded="${(!this.href && ariaExpanded) || nothing}" aria-pressed="${ariaPressedValue}" + aria-disabled=${!this.href && this.softDisabled ? 'true' : nothing} ?disabled="${!this.href && this.disabled}" - @click="${this.handleClick}"> + @click="${this.handleClickOnChild}"> ${this.renderFocusRing()} ${this.renderRipple()} ${!this.selected ? this.renderIcon() : nothing} @@ -210,10 +229,11 @@ export class IconButton extends iconButtonBaseClass implements FormSubmitter { } private renderRipple() { + const isRippleDisabled = !this.href && (this.disabled || this.softDisabled); // TODO(b/310046938): use the same id for both elements return html``; + ?disabled="${isRippleDisabled}">`; } override connectedCallback() { @@ -221,10 +241,31 @@ export class IconButton extends iconButtonBaseClass implements FormSubmitter { super.connectedCallback(); } - private async handleClick(event: Event) { + /** Handles a click on this element. */ + private readonly handleClick = (event: MouseEvent) => { + // If the icon button is soft-disabled, we need to explicitly prevent the + // click from propagating to other event listeners as well as prevent the + // default action. + if (!this.href && this.softDisabled) { + event.stopImmediatePropagation(); + event.preventDefault(); + return; + } + }; + + /** + * Handles a click on the child
or