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..f605e236d0 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.bind(this)); } } @@ -125,7 +136,7 @@ export abstract class Button extends buttonBaseClass implements FormSubmitter { 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 +148,7 @@ export abstract class Button extends buttonBaseClass implements FormSubmitter { + ?disabled="${isRippleDisabled}"> ${buttonOrLink} `; } @@ -155,6 +166,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,13 +202,22 @@ export abstract class Button extends buttonBaseClass implements FormSubmitter { `; } - private readonly handleActivationClick = (event: MouseEvent) => { + private 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; } this.focus(); dispatchActivationClick(this.buttonElement); - }; + } private handleSlotChange() { this.hasIcon = this.assignedIcons.length > 0; diff --git a/button/internal/button_test.ts b/button/internal/button_test.ts new file mode 100644 index 0000000000..9f456e7232 --- /dev/null +++ b/button/internal/button_test.ts @@ -0,0 +1,88 @@ +/** + * @license + * Copyright 2024 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/testing/templates.ts b/testing/templates.ts index 5daeb44458..769489ead4 100644 --- a/testing/templates.ts +++ b/testing/templates.ts @@ -24,6 +24,7 @@ export enum State { HOVER = 'Hover', PRESSED = 'Pressed', SELECTED = 'Selected', + SOFT_DISABLED = 'Soft disabled', } /**