From f90c6b59c6b7a94e2f4990c08cb5b62470e73bc3 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Thu, 7 Dec 2023 12:48:14 +0100 Subject: [PATCH] feat(material/button): add the ability to interact with disabled buttons Native disabled buttons don't allow focus and prevent the button from dispatching events. In some cases this can be problematic, because it prevents the app from showing to the user why the button is disabled. These changes introduce a new opt-in input that will style buttons as disabled and set `aria-disabled="true"`, but not set the native `disabled` attribute, allowing them to be interactive. --- .../button-disabled-interactive-example.css | 3 + .../button-disabled-interactive-example.html | 10 ++ .../button-disabled-interactive-example.ts | 15 ++ .../material/button/index.ts | 1 + src/dev-app/button/BUILD.bazel | 2 + src/dev-app/button/button-demo.html | 169 +++++++++++++++--- src/dev-app/button/button-demo.ts | 39 +++- src/material/button/_button-base.scss | 13 +- src/material/button/button-base.ts | 58 ++++-- src/material/button/button.md | 14 ++ src/material/button/button.scss | 2 +- src/material/button/button.spec.ts | 68 ++++++- src/material/button/fab.scss | 5 + src/material/button/public-api.ts | 1 + src/material/button/testing/button-harness.ts | 4 +- src/material/core/tokens/m2/mat/_fab.scss | 3 + .../core/tokens/m2/mat/_filled-button.scss | 3 + .../core/tokens/m2/mat/_icon-button.scss | 3 + .../core/tokens/m2/mat/_outlined-button.scss | 3 + .../core/tokens/m2/mat/_protected-button.scss | 3 + .../core/tokens/m2/mat/_text-button.scss | 3 + tools/public_api_guard/material/button.md | 8 + 22 files changed, 373 insertions(+), 57 deletions(-) create mode 100644 src/components-examples/material/button/button-disabled-interactive/button-disabled-interactive-example.css create mode 100644 src/components-examples/material/button/button-disabled-interactive/button-disabled-interactive-example.html create mode 100644 src/components-examples/material/button/button-disabled-interactive/button-disabled-interactive-example.ts diff --git a/src/components-examples/material/button/button-disabled-interactive/button-disabled-interactive-example.css b/src/components-examples/material/button/button-disabled-interactive/button-disabled-interactive-example.css new file mode 100644 index 000000000000..83117bf83c68 --- /dev/null +++ b/src/components-examples/material/button/button-disabled-interactive/button-disabled-interactive-example.css @@ -0,0 +1,3 @@ +button { + margin-right: 8px; +} diff --git a/src/components-examples/material/button/button-disabled-interactive/button-disabled-interactive-example.html b/src/components-examples/material/button/button-disabled-interactive/button-disabled-interactive-example.html new file mode 100644 index 000000000000..23b52d5c661f --- /dev/null +++ b/src/components-examples/material/button/button-disabled-interactive/button-disabled-interactive-example.html @@ -0,0 +1,10 @@ + + + diff --git a/src/components-examples/material/button/button-disabled-interactive/button-disabled-interactive-example.ts b/src/components-examples/material/button/button-disabled-interactive/button-disabled-interactive-example.ts new file mode 100644 index 000000000000..304ae4fea792 --- /dev/null +++ b/src/components-examples/material/button/button-disabled-interactive/button-disabled-interactive-example.ts @@ -0,0 +1,15 @@ +import {Component} from '@angular/core'; +import {MatButton} from '@angular/material/button'; +import {MatTooltip} from '@angular/material/tooltip'; + +/** + * @title Interactive disabled buttons + */ +@Component({ + selector: 'button-disabled-interactive-example', + templateUrl: 'button-disabled-interactive-example.html', + styleUrls: ['button-disabled-interactive-example.css'], + standalone: true, + imports: [MatButton, MatTooltip], +}) +export class ButtonDisabledInteractiveExample {} diff --git a/src/components-examples/material/button/index.ts b/src/components-examples/material/button/index.ts index 05a679de82b5..73a5072bd973 100644 --- a/src/components-examples/material/button/index.ts +++ b/src/components-examples/material/button/index.ts @@ -1,3 +1,4 @@ export {ButtonOverviewExample} from './button-overview/button-overview-example'; export {ButtonTypesExample} from './button-types/button-types-example'; +export {ButtonDisabledInteractiveExample} from './button-disabled-interactive/button-disabled-interactive-example'; export {ButtonHarnessExample} from './button-harness/button-harness-example'; diff --git a/src/dev-app/button/BUILD.bazel b/src/dev-app/button/BUILD.bazel index 2ab46182d0e8..9db19dfd6c77 100644 --- a/src/dev-app/button/BUILD.bazel +++ b/src/dev-app/button/BUILD.bazel @@ -11,7 +11,9 @@ ng_module( ], deps = [ "//src/material/button", + "//src/material/checkbox", "//src/material/icon", + "//src/material/tooltip", ], ) diff --git a/src/dev-app/button/button-demo.html b/src/dev-app/button/button-demo.html index a0c78a616a56..cb709018edb4 100644 --- a/src/dev-app/button/button-demo.html +++ b/src/dev-app/button/button-demo.html @@ -19,18 +19,52 @@

Buttons

- - - - - + + + + - - - +
- SEARCH - SEARCH - SEARCH - SEARCH - + SEARCH + SEARCH + SEARCH + SEARCH + check - + check - Search - + Search + check Search check @@ -81,7 +158,11 @@

Text Buttons [mat-button]

- + - + - + - + -
-

Icon Button Anchors [mat-icon-button]

+

Icon Button Anchors [mat-icon-button]

cached @@ -164,7 +261,12 @@

Icon Button Anchors [mat-icon-button]

trending_up - + visibility
@@ -183,7 +285,11 @@

Fab Buttons [mat-fab]

- @@ -202,7 +308,11 @@

Mini Fab Buttons [mat-mini-fab]

- @@ -212,9 +322,12 @@

Interaction/State

isDisabled: {{isDisabled}}

Button 1 as been clicked {{clickCounter}} times

- +

+ Allow disabled button interactivity +

+

+ All disabled +

diff --git a/src/dev-app/button/button-demo.ts b/src/dev-app/button/button-demo.ts index 0f2a7b548c84..528ed0617da9 100644 --- a/src/dev-app/button/button-demo.ts +++ b/src/dev-app/button/button-demo.ts @@ -7,18 +7,45 @@ */ import {Component} from '@angular/core'; -import {MatButtonModule} from '@angular/material/button'; -import {MatIconModule} from '@angular/material/icon'; +import {FormsModule} from '@angular/forms'; +import { + MatButton, + MatAnchor, + MatFabButton, + MatFabAnchor, + MatIconButton, + MatIconAnchor, + MatMiniFabButton, + MatMiniFabAnchor, +} from '@angular/material/button'; +import {MatIcon} from '@angular/material/icon'; +import {MatTooltip} from '@angular/material/tooltip'; +import {MatCheckbox} from '@angular/material/checkbox'; @Component({ selector: 'button-demo', templateUrl: 'button-demo.html', styleUrls: ['button-demo.css'], standalone: true, - imports: [MatButtonModule, MatIconModule], + imports: [ + MatButton, + MatAnchor, + MatFabButton, + MatFabAnchor, + MatMiniFabButton, + MatMiniFabAnchor, + MatIconButton, + MatIconAnchor, + MatIcon, + MatTooltip, + MatCheckbox, + FormsModule, + ], }) export class ButtonDemo { - isDisabled: boolean = false; - clickCounter: number = 0; - toggleDisable: boolean = false; + isDisabled = false; + clickCounter = 0; + toggleDisable = false; + tooltipText = 'This is a button tooltip!'; + disabledInteractive = false; } diff --git a/src/material/button/_button-base.scss b/src/material/button/_button-base.scss index c2443f93ffe0..b93beb56b460 100644 --- a/src/material/button/_button-base.scss +++ b/src/material/button/_button-base.scss @@ -68,12 +68,17 @@ @include token-utils.create-token-slot(background-color, state-layer-color); } + &.mat-mdc-button-disabled .mat-mdc-button-persistent-ripple::before { + @include token-utils.create-token-slot(background-color, disabled-state-layer-color); + } + &:hover .mat-mdc-button-persistent-ripple::before { @include token-utils.create-token-slot(opacity, hover-state-layer-opacity); } &.cdk-program-focused, - &.cdk-keyboard-focused { + &.cdk-keyboard-focused, + &.mat-mdc-button-disabled-interactive:focus { .mat-mdc-button-persistent-ripple::before { @include token-utils.create-token-slot(opacity, focus-state-layer-opacity); } @@ -91,11 +96,15 @@ // and note that having pointer-events may have unintended side-effects, e.g. allowing the user // to click the target underneath the button. @mixin mat-private-button-disabled() { - &[disabled] { + &.mat-mdc-button-disabled { cursor: default; pointer-events: none; @content; } + + &.mat-mdc-button-disabled-interactive { + pointer-events: auto; + } } // Hides the touch target on lower densities. diff --git a/src/material/button/button-base.ts b/src/material/button/button-base.ts index 656533c3a5d4..a6fcb62b6633 100644 --- a/src/material/button/button-base.ts +++ b/src/material/button/button-base.ts @@ -14,6 +14,7 @@ import { Directive, ElementRef, inject, + InjectionToken, Input, NgZone, numberAttribute, @@ -22,9 +23,21 @@ import { } from '@angular/core'; import {MatRipple, MatRippleLoader} from '@angular/material/core'; +/** Object that can be used to configure the default options for the button component. */ +export interface MatButtonConfig { + /** Whether disabled buttons should be interactive. */ + disabledInteractive?: boolean; +} + +/** Injection token that can be used to provide the default options the button component. */ +export const MAT_BUTTON_CONFIG = new InjectionToken('MAT_BUTTON_CONFIG'); + /** Shared host configuration for all buttons */ export const MAT_BUTTON_HOST = { - '[attr.disabled]': 'disabled || null', + '[attr.disabled]': '_getDisabledAttribute()', + '[attr.aria-disabled]': 'disabled && disabledInteractive ? "true" : null', + '[class.mat-mdc-button-disabled]': 'disabled', + '[class.mat-mdc-button-disabled-interactive]': 'disabledInteractive', '[class._mat-animation-noopable]': '_animationMode === "NoopAnimations"', // MDC automatically applies the primary theme color to the button, but we want to support // an unthemed version. If color is undefined, apply a CSS class that makes it easy to @@ -108,6 +121,7 @@ export class MatButtonBase implements AfterViewInit, OnDestroy { } private _disableRipple: boolean = false; + /** Whether the button is disabled. */ @Input({transform: booleanAttribute}) get disabled(): boolean { return this._disabled; @@ -118,19 +132,33 @@ export class MatButtonBase implements AfterViewInit, OnDestroy { } private _disabled: boolean = false; + /** + * Natively disabled buttons prevent focus and any pointer events from reaching the button. + * In some scenarios this might not be desirable, because it can prevent users from finding out + * why the button is disabled (e.g. via tooltip). + * + * Enabling this input will change the button so that it is styled to be disabled and will be + * marked as `aria-disabled`, but it will allow the button to receive events and focus. + * + * Note that by enabling this, you need to set the `tabindex` yourself if the button isn't + * meant to be tabbable and you have to prevent the button action (e.g. form submissions). + */ + @Input({transform: booleanAttribute}) + disabledInteractive: boolean; + constructor( public _elementRef: ElementRef, public _platform: Platform, public _ngZone: NgZone, public _animationMode?: string, ) { - this._rippleLoader?.configureRipple(this._elementRef.nativeElement, { - className: 'mat-mdc-button-ripple', - }); - - const element = this._elementRef.nativeElement; + const config = inject(MAT_BUTTON_CONFIG, {optional: true}); + const element = _elementRef.nativeElement; const classList = (element as HTMLElement).classList; + this.disabledInteractive = config?.disabledInteractive ?? false; + this._rippleLoader?.configureRipple(element, {className: 'mat-mdc-button-ripple'}); + // For each of the variant selectors that is present in the button's host // attributes, add the correct corresponding MDC classes. for (const {attribute, mdcClasses} of HOST_SELECTOR_MDC_CLASS_PAIR) { @@ -149,14 +177,18 @@ export class MatButtonBase implements AfterViewInit, OnDestroy { } /** Focuses the button. */ - focus(_origin: FocusOrigin = 'program', options?: FocusOptions): void { - if (_origin) { - this._focusMonitor.focusVia(this._elementRef.nativeElement, _origin, options); + focus(origin: FocusOrigin = 'program', options?: FocusOptions): void { + if (origin) { + this._focusMonitor.focusVia(this._elementRef.nativeElement, origin, options); } else { this._elementRef.nativeElement.focus(options); } } + protected _getDisabledAttribute() { + return this.disabledInteractive || !this.disabled ? null : true; + } + private _updateRippleDisabled(): void { this._rippleLoader?.setDisabled( this._elementRef.nativeElement, @@ -167,14 +199,16 @@ export class MatButtonBase implements AfterViewInit, OnDestroy { /** Shared host configuration for buttons using the `` tag. */ export const MAT_ANCHOR_HOST = { - '[attr.disabled]': 'disabled || null', + '[attr.disabled]': '_getDisabledAttribute()', + '[class.mat-mdc-button-disabled]': 'disabled', + '[class.mat-mdc-button-disabled-interactive]': 'disabledInteractive', '[class._mat-animation-noopable]': '_animationMode === "NoopAnimations"', // Note that we ignore the user-specified tabindex when it's disabled for // consistency with the `mat-button` applied on native buttons where even // though they have an index, they're not tabbable. - '[attr.tabindex]': 'disabled ? -1 : tabIndex', - '[attr.aria-disabled]': 'disabled.toString()', + '[attr.tabindex]': 'disabled && !disabledInteractive ? -1 : tabIndex', + '[attr.aria-disabled]': 'disabled', // MDC automatically applies the primary theme color to the button, but we want to support // an unthemed version. If color is undefined, apply a CSS class that makes it easy to // select and style this "theme". diff --git a/src/material/button/button.md b/src/material/button/button.md index e922733ec9f9..b2c39ebb8bed 100644 --- a/src/material/button/button.md +++ b/src/material/button/button.md @@ -45,6 +45,20 @@ not. ``` +### Interactive disabled buttons +Native disabled ` + [color]="buttonColor" [disabledInteractive]="disabledInteractive"> Link @@ -398,12 +453,13 @@ describe('MatFabDefaultOptions', () => { imports: [MatButtonModule], }) class TestApp { - clickCount: number = 0; - isDisabled: boolean = false; - rippleDisabled: boolean = false; + clickCount = 0; + isDisabled = false; + rippleDisabled = false; buttonColor: ThemePalette; tabIndex: number; - extended: boolean = false; + extended = false; + disabledInteractive = false; increment() { this.clickCount++; diff --git a/src/material/button/fab.scss b/src/material/button/fab.scss index 8c07e851e388..b980cf355a6d 100644 --- a/src/material/button/fab.scss +++ b/src/material/button/fab.scss @@ -58,6 +58,11 @@ @include button-base.mat-private-button-disabled { @include elevation.elevation(0); + // Necessary for interactive disabled buttons. + &:focus { + @include elevation.elevation(0); + } + @include token-utils.use-tokens(tokens-mat-fab.$prefix, tokens-mat-fab.get-token-slots()) { @include token-utils.create-token-slot(color, disabled-state-foreground-color); @include token-utils.create-token-slot(background-color, disabled-state-container-color); diff --git a/src/material/button/public-api.ts b/src/material/button/public-api.ts index bf707709d4e3..9dfc0a4434af 100644 --- a/src/material/button/public-api.ts +++ b/src/material/button/public-api.ts @@ -10,3 +10,4 @@ export * from './button'; export * from './fab'; export * from './icon-button'; export * from './module'; +export {MAT_BUTTON_CONFIG, MatButtonConfig} from './button-base'; diff --git a/src/material/button/testing/button-harness.ts b/src/material/button/testing/button-harness.ts index c2ed97d09a82..afc880780644 100644 --- a/src/material/button/testing/button-harness.ts +++ b/src/material/button/testing/button-harness.ts @@ -60,8 +60,8 @@ export class MatButtonHarness extends ContentContainerComponentHarness { /** Gets a boolean promise indicating if the button is disabled. */ async isDisabled(): Promise { - const disabled = (await this.host()).getAttribute('disabled'); - return booleanAttribute(await disabled); + const host = await this.host(); + return host.hasClass('mat-mdc-button-disabled'); } /** Gets a promise for the button's label text. */ diff --git a/src/material/core/tokens/m2/mat/_fab.scss b/src/material/core/tokens/m2/mat/_fab.scss index 22da2e566fba..25a6eb009944 100644 --- a/src/material/core/tokens/m2/mat/_fab.scss +++ b/src/material/core/tokens/m2/mat/_fab.scss @@ -30,6 +30,9 @@ $prefix: (mat, fab); // Color of the element that shows the hover, focus and pressed states. state-layer-color: $on-surface, + // Color of the element that shows the hover, focus and pressed states while disabled. + disabled-state-layer-color: $on-surface, + // Color of the ripple element. ripple-color: rgba($on-surface, 0.1), diff --git a/src/material/core/tokens/m2/mat/_filled-button.scss b/src/material/core/tokens/m2/mat/_filled-button.scss index 13cb3fdb3e2a..721668f5b68f 100644 --- a/src/material/core/tokens/m2/mat/_filled-button.scss +++ b/src/material/core/tokens/m2/mat/_filled-button.scss @@ -27,6 +27,9 @@ $prefix: (mat, filled-button); // Color of the element that shows the hover, focus and pressed states. state-layer-color: $on-surface, + // Color of the element that shows the hover, focus and pressed states while disabled. + disabled-state-layer-color: $on-surface, + // Color of the ripple element. ripple-color: rgba($on-surface, 0.1), diff --git a/src/material/core/tokens/m2/mat/_icon-button.scss b/src/material/core/tokens/m2/mat/_icon-button.scss index e28df5673dfc..c0c3a976ea81 100644 --- a/src/material/core/tokens/m2/mat/_icon-button.scss +++ b/src/material/core/tokens/m2/mat/_icon-button.scss @@ -27,6 +27,9 @@ $prefix: (mat, icon-button); // Color of the element that shows the hover, focus and pressed states. state-layer-color: $on-surface, + // Color of the element that shows the hover, focus and pressed states while disabled. + disabled-state-layer-color: $on-surface, + // Color of the ripple element. ripple-color: rgba($on-surface, 0.1), diff --git a/src/material/core/tokens/m2/mat/_outlined-button.scss b/src/material/core/tokens/m2/mat/_outlined-button.scss index fb4ac47888f1..595cc499cb5b 100644 --- a/src/material/core/tokens/m2/mat/_outlined-button.scss +++ b/src/material/core/tokens/m2/mat/_outlined-button.scss @@ -27,6 +27,9 @@ $prefix: (mat, outlined-button); // Color of the element that shows the hover, focus and pressed states. state-layer-color: $on-surface, + // Color of the element that shows the hover, focus and pressed states while disabled. + disabled-state-layer-color: $on-surface, + // Color of the ripple element. ripple-color: rgba($on-surface, 0.1), diff --git a/src/material/core/tokens/m2/mat/_protected-button.scss b/src/material/core/tokens/m2/mat/_protected-button.scss index f3c6c678c138..178e2e379b02 100644 --- a/src/material/core/tokens/m2/mat/_protected-button.scss +++ b/src/material/core/tokens/m2/mat/_protected-button.scss @@ -27,6 +27,9 @@ $prefix: (mat, protected-button); // Color of the element that shows the hover, focus and pressed states. state-layer-color: $on-surface, + // Color of the element that shows the hover, focus and pressed states while disabled. + disabled-state-layer-color: $on-surface, + // Color of the ripple element. ripple-color: rgba($on-surface, 0.1), diff --git a/src/material/core/tokens/m2/mat/_text-button.scss b/src/material/core/tokens/m2/mat/_text-button.scss index 7a509fa7f612..3ec8a465e2aa 100644 --- a/src/material/core/tokens/m2/mat/_text-button.scss +++ b/src/material/core/tokens/m2/mat/_text-button.scss @@ -27,6 +27,9 @@ $prefix: (mat, text-button); // Color of the element that shows the hover, focus and pressed states. state-layer-color: $on-surface, + // Color of the element that shows the hover, focus and pressed states while disabled. + disabled-state-layer-color: $on-surface, + // Color of the ripple element. ripple-color: rgba($on-surface, 0.1), diff --git a/tools/public_api_guard/material/button.md b/tools/public_api_guard/material/button.md index 8ec37557dd66..664bab379e6e 100644 --- a/tools/public_api_guard/material/button.md +++ b/tools/public_api_guard/material/button.md @@ -18,6 +18,9 @@ import { OnInit } from '@angular/core'; import { Platform } from '@angular/cdk/platform'; import { ThemePalette } from '@angular/material/core'; +// @public +export const MAT_BUTTON_CONFIG: InjectionToken; + // @public export const MAT_FAB_DEFAULT_OPTIONS: InjectionToken; @@ -42,6 +45,11 @@ export class MatButton extends MatButtonBase { static ɵfac: i0.ɵɵFactoryDeclaration; } +// @public +export interface MatButtonConfig { + disabledInteractive?: boolean; +} + // @public (undocumented) export class MatButtonModule { // (undocumented)