Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 14 additions & 46 deletions src/material/button/button-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,22 @@ import {
import {_StructuralStylesLoader, MatRippleLoader, ThemePalette} from '../core';
import {_CdkPrivateStyleLoader} from '@angular/cdk/private';

/**
* Possible appearances for a `MatButton`.
* See https://m3.material.io/components/buttons/overview
*/
export type MatButtonAppearance = 'text' | 'filled' | 'elevated' | 'outlined';

/** 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;

/** Default palette color to apply to buttons. */
color?: ThemePalette;

/** Default appearance for plain buttons (not icon buttons or FABs). */
defaultAppearance?: MatButtonAppearance;
}

/** Injection token that can be used to provide the default options the button component. */
Expand Down Expand Up @@ -58,45 +67,14 @@ function transformTabIndex(value: unknown): number | undefined {
return value == null ? undefined : numberAttribute(value);
}

/** List of classes to add to buttons instances based on host attribute selector. */
const HOST_SELECTOR_MDC_CLASS_PAIR: {attribute: string; mdcClasses: string[]}[] = [
{
attribute: 'mat-button',
mdcClasses: ['mdc-button', 'mat-mdc-button'],
},
{
attribute: 'mat-flat-button',
mdcClasses: ['mdc-button', 'mdc-button--unelevated', 'mat-mdc-unelevated-button'],
},
{
attribute: 'mat-raised-button',
mdcClasses: ['mdc-button', 'mdc-button--raised', 'mat-mdc-raised-button'],
},
{
attribute: 'mat-stroked-button',
mdcClasses: ['mdc-button', 'mdc-button--outlined', 'mat-mdc-outlined-button'],
},
{
attribute: 'mat-fab',
mdcClasses: ['mdc-fab', 'mat-mdc-fab-base', 'mat-mdc-fab'],
},
{
attribute: 'mat-mini-fab',
mdcClasses: ['mdc-fab', 'mat-mdc-fab-base', 'mdc-fab--mini', 'mat-mdc-mini-fab'],
},
{
attribute: 'mat-icon-button',
mdcClasses: ['mdc-icon-button', 'mat-mdc-icon-button'],
},
];

/** Base class for all buttons. */
@Directive()
export class MatButtonBase implements AfterViewInit, OnDestroy {
_elementRef = inject(ElementRef);
_elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
_ngZone = inject(NgZone);
_animationMode = inject(ANIMATION_MODULE_TYPE, {optional: true});

protected readonly _config = inject(MAT_BUTTON_CONFIG, {optional: true});
private readonly _focusMonitor = inject(FocusMonitor);
private _cleanupClick: (() => void) | undefined;
private _renderer = inject(Renderer2);
Expand Down Expand Up @@ -179,22 +157,12 @@ export class MatButtonBase implements AfterViewInit, OnDestroy {

constructor() {
inject(_CdkPrivateStyleLoader).load(_StructuralStylesLoader);
const config = inject(MAT_BUTTON_CONFIG, {optional: true});
const element: HTMLElement = this._elementRef.nativeElement;
const classList = (element as HTMLElement).classList;
const element = this._elementRef.nativeElement;

this._isAnchor = element.tagName === 'A';
this.disabledInteractive = config?.disabledInteractive ?? false;
this.color = config?.color ?? null;
this.disabledInteractive = this._config?.disabledInteractive ?? false;
this.color = this._config?.color ?? null;
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) {
if (element.hasAttribute(attribute)) {
classList.add(...mdcClasses);
}
}
}

ngAfterViewInit() {
Expand Down
52 changes: 45 additions & 7 deletions src/material/button/button.spec.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,18 @@
import {createMouseEvent, dispatchEvent} from '@angular/cdk/testing/private';
import {ApplicationRef, Component} from '@angular/core';
import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {ThemePalette} from '../core';
import {By} from '@angular/platform-browser';
import {
MAT_BUTTON_CONFIG,
MAT_FAB_DEFAULT_OPTIONS,
MatButtonAppearance,
MatButtonConfig,
MatButtonModule,
MatFabDefaultOptions,
} from './index';

describe('MatButton', () => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
imports: [MatButtonModule, TestApp],
});
}));

// General button tests
it('should apply class based on color attribute', () => {
let fixture = TestBed.createComponent(TestApp);
Expand Down Expand Up @@ -84,6 +80,45 @@ describe('MatButton', () => {
expect(buttonDebugElement.nativeElement.classList.contains('custom-class')).toBe(true);
});

it('should be able to change the button appearance dynamically', () => {
const fixture = TestBed.createComponent(TestApp);
const button = fixture.nativeElement.querySelector('.dynamic') as HTMLElement;
fixture.detectChanges();

expect(button.classList).toContain('mat-mdc-button');
expect(button.classList).not.toContain('mat-mdc-outlined-button');
expect(button.classList).not.toContain('mat-mdc-raised-button');

fixture.componentInstance.appearance = 'outlined';
fixture.changeDetectorRef.markForCheck();
fixture.detectChanges();
expect(button.classList).not.toContain('mat-mdc-button');
expect(button.classList).toContain('mat-mdc-outlined-button');
expect(button.classList).not.toContain('mat-mdc-raised-button');

fixture.componentInstance.appearance = 'elevated';
fixture.changeDetectorRef.markForCheck();
fixture.detectChanges();
expect(button.classList).not.toContain('mat-mdc-button');
expect(button.classList).not.toContain('mat-mdc-outlined-button');
expect(button.classList).toContain('mat-mdc-raised-button');
});

it('should be able to configure the default button appearance', () => {
const config: MatButtonConfig = {
defaultAppearance: 'outlined',
};

TestBed.configureTestingModule({
providers: [{provide: MAT_BUTTON_CONFIG, useValue: config}],
});

const fixture = TestBed.createComponent(TestApp);
const button = fixture.nativeElement.querySelector('.default-appearance') as HTMLElement;
fixture.detectChanges();
expect(button.classList).toContain('mat-mdc-outlined-button');
});

describe('button[mat-fab]', () => {
it('should have accent palette by default', () => {
const fixture = TestBed.createComponent(TestApp);
Expand Down Expand Up @@ -422,6 +457,8 @@ describe('MatFabDefaultOptions', () => {
<button mat-fab>Fab Button</button>
<button mat-fab [extended]="extended" class="extended-fab-test">Extended</button>
<button mat-mini-fab>Mini Fab Button</button>
<button class="dynamic" [matButton]="appearance">Dynamic button</button>
<button class="default-appearance" matButton>Dynamic button</button>
`,
imports: [MatButtonModule],
})
Expand All @@ -433,6 +470,7 @@ class TestApp {
tabIndex: number;
extended = false;
disabledInteractive = false;
appearance: MatButtonAppearance = 'text';

increment() {
this.clickCount++;
Expand Down
111 changes: 93 additions & 18 deletions src/material/button/button.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,29 @@
* found in the LICENSE file at https://angular.dev/license
*/

import {ChangeDetectionStrategy, Component, ViewEncapsulation} from '@angular/core';
import {MAT_BUTTON_HOST, MatButtonBase} from './button-base';
import {ChangeDetectionStrategy, Component, Input, ViewEncapsulation} from '@angular/core';
import {MAT_BUTTON_HOST, MatButtonAppearance, MatButtonBase} from './button-base';

/**
* Classes that need to be set for each appearance of the button.
* Note that we use a `Map` here to avoid issues with property renaming.
*/
const APPEARANCE_CLASSES: Map<MatButtonAppearance, readonly string[]> = new Map([
['text', ['mat-mdc-button']],
['filled', ['mdc-button--unelevated', 'mat-mdc-unelevated-button']],
['elevated', ['mdc-button--raised', 'mat-mdc-raised-button']],
['outlined', ['mdc-button--outlined', 'mat-mdc-outlined-button']],
]);

/**
* Material Design button component. Users interact with a button to perform an action.
* See https://material.io/components/buttons
*
* The `MatButton` class applies to native button elements and captures the appearances for
* "text button", "outlined button", and "contained button" per the Material Design
* specification. `MatButton` additionally captures an additional "flat" appearance, which matches
* "contained" but without elevation.
* See https://m3.material.io/components/buttons/overview
*/
@Component({
selector: `
button[mat-button], button[mat-raised-button], button[mat-flat-button],
button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button],
a[mat-stroked-button]
button[matButton], a[matButton], button[mat-button], button[mat-raised-button],
button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button],
a[mat-flat-button], a[mat-stroked-button]
`,
templateUrl: 'button.html',
styleUrls: ['button.css', 'button-high-contrast.css'],
Expand All @@ -31,18 +37,87 @@ import {MAT_BUTTON_HOST, MatButtonBase} from './button-base';
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MatButton extends MatButtonBase {}
export class MatButton extends MatButtonBase {
/** Appearance of the button. */
@Input('matButton')
get appearance(): MatButtonAppearance | null {
return this._appearance;
}
set appearance(value: MatButtonAppearance | '') {
// Allow empty string so users can do `<button matButton></button>`
// without having to write out `="text"` every time.
this.setAppearance(value || this._config?.defaultAppearance || 'text');
}
private _appearance: MatButtonAppearance | null = null;

constructor(...args: unknown[]);

constructor() {
super();
const element = this._elementRef.nativeElement;
const inferredAppearance = _inferAppearance(element);

// This class is common across all the appearances so we add it ahead of time.
element.classList.add('mdc-button');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would be easier to just make this a host binding?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was trying to match the behavior of what we do at the moment. Moving it into a host binding can change the order in which the class is applied which can break internal clients and I was trying to minimize the amount of breakages. I can try moving it in a follow-up. We'll have to shuffle around the host object a bit anyways if we want to get type checking support for it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah okay that makes sense


// Only set the appearance if we managed to infer it from the static attributes, rather than
// doing something like `setAppearance(inferredAppearance || 'text')`, because doing so can
// cause the fallback appearance's classes to be set and then immediately replaced when
// the input value is assigned.
if (inferredAppearance) {
this.setAppearance(inferredAppearance);
}
}

/** Programmatically sets the appearance of the button. */
setAppearance(appearance: MatButtonAppearance): void {
if (appearance === this._appearance) {
return;
}

const classList = this._elementRef.nativeElement.classList;
const previousClasses = this._appearance ? APPEARANCE_CLASSES.get(this._appearance) : null;
const newClasses = APPEARANCE_CLASSES.get(appearance)!;

if ((typeof ngDevMode === 'undefined' || ngDevMode) && !newClasses) {
throw new Error(`Unsupported MatButton appearance "${appearance}"`);
}

if (previousClasses) {
classList.remove(...previousClasses);
}

classList.add(...newClasses);
this._appearance = appearance;
}
}

/** Infers the button's appearance from its static attributes. */
function _inferAppearance(button: HTMLElement): MatButtonAppearance | null {
if (button.hasAttribute('mat-raised-button')) {
return 'elevated';
}

if (button.hasAttribute('mat-stroked-button')) {
return 'outlined';
}

if (button.hasAttribute('mat-flat-button')) {
return 'filled';
}

if (button.hasAttribute('mat-button')) {
return 'text';
}

return null;
}

// tslint:disable:variable-name
/**
* Material Design button component for anchor elements. Anchor elements are used to provide
* links for the user to navigate across different routes or pages.
* See https://material.io/components/buttons
*
* The `MatAnchor` class applies to native anchor elements and captures the appearances for
* "text button", "outlined button", and "contained button" per the Material Design
* specification. `MatAnchor` additionally captures an additional "flat" appearance, which matches
* "contained" but without elevation.
* See https://m3.material.io/components/buttons/overview
*/
export const MatAnchor = MatButton;
export type MatAnchor = MatButton;
Expand Down
16 changes: 10 additions & 6 deletions src/material/button/fab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,12 @@ const defaults = MAT_FAB_DEFAULT_OPTIONS_FACTORY();
/**
* Material Design floating action button (FAB) component. These buttons represent the primary
* or most common action for users to interact with.
* See https://material.io/components/buttons-floating-action-button/
* See https://m3.material.io/components/floating-action-button/overview
*
* The `MatFabButton` class has two appearances: normal and extended.
*/
@Component({
selector: `button[mat-fab], a[mat-fab]`,
selector: `button[mat-fab], a[mat-fab], button[matFab], a[matFab]`,
templateUrl: 'button.html',
styleUrl: 'fab.css',
host: {
Expand All @@ -86,6 +86,8 @@ export class MatFabButton extends MatButtonBase {

constructor() {
super();
const element = this._elementRef.nativeElement;
element.classList.add('mdc-fab', 'mat-mdc-fab-base', 'mat-mdc-fab');
this._options = this._options || defaults;
this.color = this._options!.color || defaults.color;
}
Expand All @@ -94,10 +96,10 @@ export class MatFabButton extends MatButtonBase {
/**
* Material Design mini floating action button (FAB) component. These buttons represent the primary
* or most common action for users to interact with.
* See https://material.io/components/buttons-floating-action-button/
* See https://m3.material.io/components/floating-action-button/overview
*/
@Component({
selector: `button[mat-mini-fab], a[mat-mini-fab]`,
selector: `button[mat-mini-fab], a[mat-mini-fab], button[matMiniFab], a[matMiniFab]`,
templateUrl: 'button.html',
styleUrl: 'fab.css',
host: MAT_BUTTON_HOST,
Expand All @@ -114,6 +116,8 @@ export class MatMiniFabButton extends MatButtonBase {

constructor() {
super();
const element = this._elementRef.nativeElement;
element.classList.add('mdc-fab', 'mat-mdc-fab-base', 'mdc-fab--mini', 'mat-mdc-mini-fab');
this._options = this._options || defaults;
this.color = this._options!.color || defaults.color;
}
Expand All @@ -123,7 +127,7 @@ export class MatMiniFabButton extends MatButtonBase {
/**
* Material Design floating action button (FAB) component for anchor elements. Anchor elements
* are used to provide links for the user to navigate across different routes or pages.
* See https://material.io/components/buttons-floating-action-button/
* See https://m3.material.io/components/floating-action-button/overview
*
* The `MatFabAnchor` class has two appearances: normal and extended.
*/
Expand All @@ -133,7 +137,7 @@ export type MatFabAnchor = MatFabButton;
/**
* Material Design mini floating action button (FAB) component for anchor elements. Anchor elements
* are used to provide links for the user to navigate across different routes or pages.
* See https://material.io/components/buttons-floating-action-button/
* See https://m3.material.io/components/floating-action-button/overview
*/
export const MatMiniFabAnchor = MatMiniFabButton;
export type MatMiniFabAnchor = MatMiniFabButton;
Expand Down
Loading