From 3acb1610ed7bc996aaf4636a6c08a39bfda8d59d Mon Sep 17 00:00:00 2001 From: Zack Elliott Date: Thu, 11 Jul 2024 12:10:29 -0700 Subject: [PATCH] feat(iconbutton): add `soft-disabled` attribute for focusable disabled icon buttons PiperOrigin-RevId: 651491983 --- button/demo/demo.ts | 1 + button/demo/stories.ts | 26 ++++-- button/internal/_elevation.scss | 4 +- button/internal/_icon.scss | 2 +- button/internal/_outlined-button.scss | 6 +- button/internal/_shared.scss | 8 +- button/internal/button.ts | 37 +++++++- button/internal/button_test.ts | 89 +++++++++++++++++++ iconbutton/demo/demo.ts | 1 + iconbutton/demo/stories.ts | 35 +++++--- iconbutton/icon-button_test.ts | 60 +++++++++++++ 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 | 18 ++-- iconbutton/internal/_shared.scss | 4 +- iconbutton/internal/icon-button.ts | 55 ++++++++++-- testing/templates.ts | 1 + 18 files changed, 325 insertions(+), 66 deletions(-) create mode 100644 button/internal/button_test.ts diff --git a/button/demo/demo.ts b/button/demo/demo.ts index 33295e2ddd..1197673aae 100644 --- a/button/demo/demo.ts +++ b/button/demo/demo.ts @@ -22,6 +22,7 @@ const collection = new MaterialCollection>( [ new Knob('label', {ui: textInput(), defaultValue: ''}), new Knob('disabled', {ui: boolInput(), defaultValue: false}), + new Knob('softDisabled', {ui: boolInput(), defaultValue: false}), ], ); diff --git a/button/demo/stories.ts b/button/demo/stories.ts index 81474df329..df5150479b 100644 --- a/button/demo/stories.ts +++ b/button/demo/stories.ts @@ -18,6 +18,7 @@ import {css, html} from 'lit'; export interface StoryKnobs { label: string; disabled: boolean; + softDisabled: boolean; } const styles = css` @@ -38,33 +39,42 @@ const styles = css` const buttons: MaterialStoryInit = { name: 'Button variants', styles, - render({label, disabled}) { + render({label, disabled, softDisabled}) { return html`
- + ${label || 'Filled'} - + ${label || 'Outlined'} - + ${label || 'Elevated'} - + ${label || 'Tonal'} - + ${label || 'Text'}
upload ${label || 'Filled'} @@ -72,6 +82,7 @@ const buttons: MaterialStoryInit = { upload ${label || 'Outlined'} @@ -79,6 +90,7 @@ const buttons: MaterialStoryInit = { upload ${label || 'Elevated'} @@ -86,6 +98,7 @@ const buttons: MaterialStoryInit = { upload ${label || 'Tonal'} @@ -93,6 +106,7 @@ const buttons: MaterialStoryInit = { upload ${label || 'Text'} diff --git a/button/internal/_elevation.scss b/button/internal/_elevation.scss index 1899999e44..758305c991 100644 --- a/button/internal/_elevation.scss +++ b/button/internal/_elevation.scss @@ -20,7 +20,7 @@ $_md-sys-motion: tokens.md-sys-motion-values(); transition-timing-function: map.get($_md-sys-motion, 'emphasized-easing'); } - :host([disabled]) md-elevation { + :host(:is([disabled], [soft-disabled])) md-elevation { transition: none; } @@ -59,7 +59,7 @@ $_md-sys-motion: tokens.md-sys-motion-values(); ); } - :host([disabled]) md-elevation { + :host(:is([disabled], [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..10653cac8c 100644 --- a/button/internal/_icon.scss +++ b/button/internal/_icon.scss @@ -31,7 +31,7 @@ color: var(--_pressed-icon-color); } - :host([disabled]) ::slotted([slot='icon']) { + :host(:is([disabled], [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..08e88ca938 100644 --- a/button/internal/_outlined-button.scss +++ b/button/internal/_outlined-button.scss @@ -57,20 +57,20 @@ border-color: var(--_pressed-outline-color); } - :host([disabled]) .outline { + :host(:is([disabled], [soft-disabled])) .outline { border-color: var(--_disabled-outline-color); opacity: var(--_disabled-outline-opacity); } @media (forced-colors: active) { - :host([disabled]) .background { + :host(:is([disabled], [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(:is([disabled], [soft-disabled])) .outline { opacity: 1; } } diff --git a/button/internal/_shared.scss b/button/internal/_shared.scss index 40e6b88899..bcb6ae268f 100644 --- a/button/internal/_shared.scss +++ b/button/internal/_shared.scss @@ -71,7 +71,7 @@ ); } - :host([disabled]) { + :host(:is([disabled], [soft-disabled])) { cursor: default; pointer-events: none; } @@ -139,12 +139,12 @@ text-overflow: inherit; } - :host([disabled]) .label { + :host(:is([disabled], [soft-disabled])) .label { color: var(--_disabled-label-text-color); opacity: var(--_disabled-label-text-opacity); } - :host([disabled]) .background { + :host(:is([disabled], [soft-disabled])) .background { background-color: var(--_disabled-container-color); opacity: var(--_disabled-container-opacity); } @@ -157,7 +157,7 @@ border: 1px solid CanvasText; } - :host([disabled]) { + :host(:is([disabled], [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..ebe88e084e 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,17 @@ export abstract class Button extends buttonBaseClass implements FormSubmitter { this.buttonElement?.blur(); } + protected override willUpdate() { + // Link buttons cannot be disabled or soft-disabled. + 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 +156,7 @@ export abstract class Button extends buttonBaseClass implements FormSubmitter { + ?disabled="${isRippleDisabled}"> ${buttonOrLink} `; } @@ -155,6 +174,7 @@ export abstract class Button extends buttonBaseClass implements FormSubmitter { id="button" class="button" ?disabled=${this.disabled} + aria-disabled=${this.softDisabled || nothing} aria-label="${ariaLabel || nothing}" aria-haspopup="${ariaHasPopup || nothing}" aria-expanded="${ariaExpanded || nothing}"> @@ -190,7 +210,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..8f3b4c838c --- /dev/null +++ b/button/internal/button_test.ts @@ -0,0 +1,89 @@ +/** + * @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 () => { + // Arrange + const {button} = await setupTest(); + button.disabled = true; + await env.waitForStability(); + + // Act + button.focus(); + + // Assert + expect(document.activeElement) + .withContext('disabled button should not be focused') + .not.toBe(button); + }); + + it('should be focusable when soft-disabled', async () => { + // Arrange + const {button} = await setupTest(); + button.softDisabled = true; + await env.waitForStability(); + + // Act + button.focus(); + + // Assert + expect(document.activeElement) + .withContext('soft-disabled button should be focused') + .toBe(button); + }); + + it('should not be clickable when disabled', async () => { + // Arrange + const clickListener = jasmine.createSpy('clickListener'); + const {button} = await setupTest(); + button.disabled = true; + button.addEventListener('click', clickListener); + await env.waitForStability(); + + // Act + button.click(); + + // Assert + expect(clickListener).not.toHaveBeenCalled(); + }); + + it('should not be clickable when soft-disabled', async () => { + // Arrange + const clickListener = jasmine.createSpy('clickListener'); + const {button} = await setupTest(); + button.softDisabled = true; + button.addEventListener('click', clickListener); + await env.waitForStability(); + + // Act + button.click(); + + // Assert + expect(clickListener).not.toHaveBeenCalled(); + }); +}); 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..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..7445d34a34 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,18 @@ ); } - .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(:is([disabled], [soft-disabled])) { --_disabled-outline-opacity: 1; } @@ -150,7 +153,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..b59fe7226e 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,7 @@ } @media (forced-colors: active) { - :host([disabled]) { + :host(:is([disabled], [soft-disabled])) { --_disabled-icon-opacity: 1; } } diff --git a/iconbutton/internal/icon-button.ts b/iconbutton/internal/icon-button.ts index 9a23674922..a2ddd0dc5c 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); + } + } + 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 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