From 281c092d061f77b812e57f473e77cfd123016471 Mon Sep 17 00:00:00 2001 From: Zack Elliott Date: Fri, 12 Jul 2024 12:43:55 -0700 Subject: [PATCH] feat(iconbutton): add `soft-disabled` attribute for focusable disabled icon buttons PiperOrigin-RevId: 651858380 --- iconbutton/demo/demo.ts | 1 + iconbutton/demo/stories.ts | 35 +++++++---- iconbutton/icon-button_test.ts | 60 +++++++++++++++++++ iconbutton/internal/_filled-icon-button.scss | 14 ++--- .../internal/_filled-tonal-icon-button.scss | 16 +++-- iconbutton/internal/_icon-button.scss | 6 +- .../internal/_outlined-icon-button.scss | 14 ++--- iconbutton/internal/_shared.scss | 5 +- iconbutton/internal/icon-button.ts | 55 ++++++++++++++--- 9 files changed, 160 insertions(+), 46 deletions(-) diff --git a/iconbutton/demo/demo.ts b/iconbutton/demo/demo.ts index 704b85c703..cb80be8198 100644 --- a/iconbutton/demo/demo.ts +++ b/iconbutton/demo/demo.ts @@ -23,6 +23,7 @@ const collection = new MaterialCollection>( new Knob('disabled', {ui: boolInput(), defaultValue: false}), new Knob('icon', {ui: textInput(), defaultValue: ''}), new Knob('selectedIcon', {ui: textInput(), defaultValue: ''}), + new Knob('softDisabled', {ui: boolInput(), defaultValue: false}), ], ); diff --git a/iconbutton/demo/stories.ts b/iconbutton/demo/stories.ts index 09ae67a06d..4a3e9b79ac 100644 --- a/iconbutton/demo/stories.ts +++ b/iconbutton/demo/stories.ts @@ -19,6 +19,7 @@ export interface StoryKnobs { icon: string; selectedIcon: string; disabled: boolean; + softDisabled: boolean; } const styles = [ @@ -44,26 +45,35 @@ const styles = [ const buttons: MaterialStoryInit = { name: 'Icon button variants', styles, - render({icon, disabled}) { + render({icon, disabled, softDisabled}) { return html`

Standard

- + ${icon || 'settings'}

Outlined

- + ${icon || 'search'}

Filled

- + ${icon || 'done'}
@@ -72,7 +82,8 @@ const buttons: MaterialStoryInit = {

Filled tonal

+ ?disabled=${disabled} + ?soft-disabled=${softDisabled}> ${icon || 'add'}
@@ -84,7 +95,7 @@ const buttons: MaterialStoryInit = { const toggles: MaterialStoryInit = { name: 'Toggle icon buttons', styles, - render({icon, selectedIcon, disabled}) { + render({icon, selectedIcon, disabled, softDisabled}) { return html`
@@ -93,7 +104,8 @@ const toggles: MaterialStoryInit = { aria-label="Show password" aria-label-selected="Hide password" toggle - ?disabled=${disabled}> + ?disabled=${disabled} + ?soft-disabled=${softDisabled}> ${icon || 'visibility'} ${selectedIcon || 'visibility_off'} @@ -107,7 +119,8 @@ const toggles: MaterialStoryInit = { aria-label="Play" aria-label-selected="Pause" toggle - ?disabled=${disabled}> + ?disabled=${disabled} + ?soft-disabled=${softDisabled}> ${icon || 'play_arrow'} ${selectedIcon || 'pause'} @@ -119,7 +132,8 @@ const toggles: MaterialStoryInit = { aria-label="Show more" aria-label-selected="Show less" toggle - ?disabled=${disabled}> + ?disabled=${disabled} + ?soft-disabled=${softDisabled}> ${icon || 'expand_more'} ${selectedIcon || 'expand_less'} @@ -131,7 +145,8 @@ const toggles: MaterialStoryInit = { aria-label="Open menu" aria-label-selected="Close menu" toggle - ?disabled=${disabled}> + ?disabled=${disabled} + ?soft-disabled=${softDisabled}> ${icon || 'menu'} ${selectedIcon || 'close'} diff --git a/iconbutton/icon-button_test.ts b/iconbutton/icon-button_test.ts index ffd4a81e87..64281926b8 100644 --- a/iconbutton/icon-button_test.ts +++ b/iconbutton/icon-button_test.ts @@ -61,6 +61,66 @@ describe('icon button tests', () => { }, ); + it('should not be focusable when disabled', async () => { + // Arrange + const {element} = await setUpTest('button'); + element.disabled = true; + await element.updateComplete; + + // Act + element.focus(); + + // Assert + expect(document.activeElement) + .withContext('disabled button should not be focused') + .not.toBe(element); + }); + + it('should be focusable when soft-disabled', async () => { + // Arrange + const {element} = await setUpTest('button'); + element.softDisabled = true; + await element.updateComplete; + + // Act + element.focus(); + + // Assert + expect(document.activeElement) + .withContext('soft-disabled button should be focused') + .toBe(element); + }); + + it('should not be clickable when disabled', async () => { + // Arrange + const clickListener = jasmine.createSpy('clickListener'); + const {element} = await setUpTest('button'); + element.disabled = true; + element.addEventListener('click', clickListener); + await element.updateComplete; + + // Act + element.click(); + + // Assert + expect(clickListener).not.toHaveBeenCalled(); + }); + + it('should not be clickable when soft-disabled', async () => { + // Arrange + const clickListener = jasmine.createSpy('clickListener'); + const {element} = await setUpTest('button'); + element.softDisabled = true; + element.addEventListener('click', clickListener); + await element.updateComplete; + + // Act + element.click(); + + // Assert + 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..3332e3f84b 100644 --- a/iconbutton/internal/_filled-icon-button.scss +++ b/iconbutton/internal/_filled-icon-button.scss @@ -53,7 +53,7 @@ color: var(--_pressed-icon-color); } - &:disabled { + &:is(:disabled, [aria-disabled='true']) { color: var(--_disabled-icon-color); } @@ -77,17 +77,17 @@ z-index: -1; // place behind content } - .icon-button:disabled::before { + .icon-button:is(:disabled, [aria-disabled='true'])::before { background-color: var(--_disabled-container-color); opacity: var(--_disabled-container-opacity); } - .icon-button:disabled .icon { + .icon-button:is(:disabled, [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 +111,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 +142,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..8de926a1ad 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,7 @@ $_custom-property-prefix: 'filled-tonal-icon-button'; color: var(--_pressed-icon-color); } - &:disabled { + &:is(:disabled, [aria-disabled='true']) { color: var(--_disabled-icon-color); } @@ -79,17 +77,17 @@ $_custom-property-prefix: 'filled-tonal-icon-button'; z-index: -1; // place behind content } - .icon-button:disabled::before { + .icon-button:is(:disabled, [aria-disabled='true'])::before { background-color: var(--_disabled-container-color); opacity: var(--_disabled-container-opacity); } - .icon-button:disabled .icon { + .icon-button:is(:disabled, [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 +111,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 +142,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..96b3eaba76 100644 --- a/iconbutton/internal/_icon-button.scss +++ b/iconbutton/internal/_icon-button.scss @@ -91,7 +91,7 @@ color: var(--_pressed-icon-color); } - &:disabled { + &:is(:disabled, [aria-disabled='true']) { color: var(--_disabled-icon-color); } } @@ -100,12 +100,12 @@ border-radius: var(--_state-layer-shape); } - .standard:disabled .icon { + .standard:is(:disabled, [aria-disabled='true']) { 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..819521eb1f 100644 --- a/iconbutton/internal/_outlined-icon-button.scss +++ b/iconbutton/internal/_outlined-icon-button.scss @@ -69,7 +69,7 @@ color: var(--_pressed-icon-color); } - &:disabled { + &:is(:disabled, [aria-disabled='true']) { color: var(--_disabled-icon-color); &::before { @@ -79,7 +79,7 @@ } } - .outlined:disabled .icon { + .outlined:is(:disabled, [aria-disabled='true']) .icon { opacity: var(--_disabled-icon-opacity); } @@ -103,7 +103,7 @@ // Selected icon button toggle. .selected { - &:not(:disabled) { + &:not(:disabled, [aria-disabled='true']) { color: var(--_selected-icon-color); &:hover { @@ -129,17 +129,17 @@ ); } - .selected:not(:disabled)::before { + .selected:not(:disabled, [aria-disabled='true'])::before { background-color: var(--_selected-container-color); } - .selected:disabled::before { + .selected:is(:disabled, [aria-disabled='true'])::before { background-color: var(--_disabled-selected-container-color); opacity: var(--_disabled-selected-container-opacity); } @media (forced-colors: active) { - :host([disabled]) { + :host(:is([disabled], [soft-disabled])) { --_disabled-outline-opacity: 1; } @@ -150,7 +150,7 @@ border-width: var(--_outline-width); } - &:disabled::before { + &:is(:disabled, [aria-disabled='true'])::before { border-color: GrayText; opacity: 1; } diff --git a/iconbutton/internal/_shared.scss b/iconbutton/internal/_shared.scss index eea1b05aae..b1275f5ba5 100644 --- a/iconbutton/internal/_shared.scss +++ b/iconbutton/internal/_shared.scss @@ -41,7 +41,7 @@ ); } - :host([disabled]) { + :host(:is([disabled], [soft-disabled])) { pointer-events: none; } @@ -109,7 +109,8 @@ } @media (forced-colors: active) { - :host([disabled]) { + :host(:is([disabled], [soft-disabled])) { + --_disabled-icon-color: GrayText; --_disabled-icon-opacity: 1; } } diff --git a/iconbutton/internal/icon-button.ts b/iconbutton/internal/icon-button.ts index 9a23674922..a74f70c3c2 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,18 @@ export class IconButton extends iconButtonBaseClass implements FormSubmitter { @state() private flipIcon = isRtl(this, this.flipIconInRtl); - /** - * Link buttons cannot be disabled. - */ + constructor() { + super(); + if (!isServer) { + this.addEventListener('click', this.handleClick.bind(this)); + } + } + protected override willUpdate() { + // Link buttons cannot be disabled or soft-disabled. if (this.href) { this.disabled = false; + this.softDisabled = false; } } @@ -156,8 +172,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) || nothing} ?disabled="${!this.href && this.disabled}" - @click="${this.handleClick}"> + @click="${this.handleClickOnChild}"> ${this.renderFocusRing()} ${this.renderRipple()} ${!this.selected ? this.renderIcon() : nothing} @@ -210,10 +227,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 +239,31 @@ export class IconButton extends iconButtonBaseClass implements FormSubmitter { super.connectedCallback(); } - private async handleClick(event: Event) { + /** Handles a click on this element. */ + private 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