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/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)