diff --git a/src/material/button/_m2-icon-button.scss b/src/material/button/_m2-icon-button.scss index f6a420e052d6..694034a1e246 100644 --- a/src/material/button/_m2-icon-button.scss +++ b/src/material/button/_m2-icon-button.scss @@ -7,6 +7,8 @@ $system: m2-utils.get-system($theme); $density-scale: theming.clamp-density(map.get($system, density-scale), -3); $touch-target-display: block; + $disabled: m3-utils.color-with-opacity(map.get($system, on-surface), 38%); + $disabled-container: m3-utils.color-with-opacity(map.get($system, on-surface), 12%); @if ($density-scale < -1) { $touch-target-display: none; @@ -19,8 +21,30 @@ icon-button-touch-target-size: 48px, ), color: ( - icon-button-disabled-icon-color: - m3-utils.color-with-opacity(map.get($system, on-surface), 38%), + icon-button-container-color: transparent, + icon-button-disabled-icon-color: $disabled, + icon-button-filled-container-color: map.get($system, surface), + icon-button-filled-disabled-container-color: $disabled-container, + icon-button-filled-disabled-icon-color: $disabled, + icon-button-filled-disabled-state-layer-color: map.get($system, on-surface-variant), + icon-button-filled-focus-state-layer-opacity: map.get($system, focus-state-layer-opacity), + icon-button-filled-hover-state-layer-opacity: map.get($system, hover-state-layer-opacity), + icon-button-filled-icon-color: map.get($system, on-surface), + icon-button-filled-pressed-state-layer-opacity: map.get($system, pressed-state-layer-opacity), + icon-button-filled-ripple-color: m3-utils.color-with-opacity( + map.get($system, on-surface), map.get($system, pressed-state-layer-opacity)), + icon-button-filled-state-layer-color: map.get($system, on-surface), + icon-button-tonal-container-color: map.get($system, surface), + icon-button-tonal-disabled-container-color: $disabled-container, + icon-button-tonal-disabled-icon-color: $disabled, + icon-button-tonal-disabled-state-layer-color: map.get($system, on-surface-variant), + icon-button-tonal-focus-state-layer-opacity: map.get($system, focus-state-layer-opacity), + icon-button-tonal-hover-state-layer-opacity: map.get($system, hover-state-layer-opacity), + icon-button-tonal-icon-color: map.get($system, on-surface), + icon-button-tonal-pressed-state-layer-opacity: map.get($system, pressed-state-layer-opacity), + icon-button-tonal-ripple-color: m3-utils.color-with-opacity( + map.get($system, on-surface), map.get($system, pressed-state-layer-opacity)), + icon-button-tonal-state-layer-color: map.get($system, on-surface), icon-button-disabled-state-layer-color: map.get($system, on-surface-variant), icon-button-focus-state-layer-opacity: map.get($system, focus-state-layer-opacity), icon-button-hover-state-layer-opacity: map.get($system, hover-state-layer-opacity), @@ -51,6 +75,16 @@ $system: m3-utils.replace-colors-with-variant($system, primary, $color-variant); @return ( + icon-button-filled-container-color: map.get($system, primary), + icon-button-filled-icon-color: map.get($system, on-primary), + icon-button-filled-ripple-color: m3-utils.color-with-opacity( + map.get($system, on-primary), map.get($system, pressed-state-layer-opacity)), + icon-button-filled-state-layer-color: map.get($system, on-primary), + icon-button-tonal-container-color: map.get($system, primary), + icon-button-tonal-icon-color: map.get($system, on-primary), + icon-button-tonal-ripple-color: m3-utils.color-with-opacity( + map.get($system, on-primary), map.get($system, pressed-state-layer-opacity)), + icon-button-tonal-state-layer-color: map.get($system, on-primary), icon-button-icon-color: map.get($system, primary), icon-button-state-layer-color: map.get($system, primary), icon-button-ripple-color: m3-utils.color-with-opacity( diff --git a/src/material/button/_m3-icon-button.scss b/src/material/button/_m3-icon-button.scss index 5639ca2a227c..9eef65f4bb94 100644 --- a/src/material/button/_m3-icon-button.scss +++ b/src/material/button/_m3-icon-button.scss @@ -23,8 +23,35 @@ icon-button-touch-target-size: 48px, ), color: ( + icon-button-container-color: transparent, icon-button-disabled-icon-color: m3-utils.color-with-opacity(map.get($system, on-surface), 38%), + icon-button-filled-container-color: map.get($system, primary), + icon-button-filled-disabled-container-color: + m3-utils.color-with-opacity(map.get($system, on-surface), 12%), + icon-button-filled-disabled-icon-color: + m3-utils.color-with-opacity(map.get($system, on-surface), 38%), + icon-button-filled-disabled-state-layer-color: map.get($system, on-surface-variant), + icon-button-filled-focus-state-layer-opacity: map.get($system, focus-state-layer-opacity), + icon-button-filled-hover-state-layer-opacity: map.get($system, hover-state-layer-opacity), + icon-button-filled-icon-color: map.get($system, on-primary), + icon-button-filled-pressed-state-layer-opacity: map.get($system, pressed-state-layer-opacity), + icon-button-filled-ripple-color: m3-utils.color-with-opacity( + map.get($system, on-primary), map.get($system, pressed-state-layer-opacity)), + icon-button-filled-state-layer-color: map.get($system, on-primary), + icon-button-tonal-container-color: map.get($system, secondary-container), + icon-button-tonal-disabled-container-color: + m3-utils.color-with-opacity(map.get($system, on-surface), 12%), + icon-button-tonal-disabled-icon-color: + m3-utils.color-with-opacity(map.get($system, on-surface), 38%), + icon-button-tonal-disabled-state-layer-color: map.get($system, on-surface-variant), + icon-button-tonal-focus-state-layer-opacity: map.get($system, focus-state-layer-opacity), + icon-button-tonal-hover-state-layer-opacity: map.get($system, hover-state-layer-opacity), + icon-button-tonal-icon-color: map.get($system, on-secondary-container), + icon-button-tonal-pressed-state-layer-opacity: map.get($system, pressed-state-layer-opacity), + icon-button-tonal-ripple-color: m3-utils.color-with-opacity( + map.get($system, on-secondary-container), map.get($system, pressed-state-layer-opacity)), + icon-button-tonal-state-layer-color: map.get($system, on-secondary-container), icon-button-disabled-state-layer-color: map.get($system, on-surface-variant), icon-button-focus-state-layer-opacity: map.get($system, focus-state-layer-opacity), icon-button-hover-state-layer-opacity: map.get($system, hover-state-layer-opacity), diff --git a/src/material/button/button.md b/src/material/button/button.md index bd7b8f4238ee..29bdbb772339 100644 --- a/src/material/button/button.md +++ b/src/material/button/button.md @@ -29,6 +29,9 @@ attribute, for example `matButton="outlined"`: | `outlined` | Medium-emphasis buttons often used for actions that need attention but aren't the primary action. | | `elevated` | Medium-emphasis buttons often used when a button requires visual separation from a patterned background. | +The `matIconButton` supports filled and tonal appearances, for example +`matIconButton="filled"` and `matIconButton="tonal"`. + ### Extended FAB buttons Traditional floating action buttons (FAB) buttons are circular and only have space for a single diff --git a/src/material/button/button.spec.ts b/src/material/button/button.spec.ts index 7c4679ee8334..c5f3377a9ef1 100644 --- a/src/material/button/button.spec.ts +++ b/src/material/button/button.spec.ts @@ -10,6 +10,7 @@ import { MatButtonConfig, MatButtonModule, MatFabDefaultOptions, + MatIconButtonAppearance, } from './index'; describe('MatButton', () => { @@ -119,6 +120,49 @@ describe('MatButton', () => { expect(button.classList).toContain('mat-mdc-outlined-button'); }); + it('should apply the icon button appearance classes', () => { + const fixture = TestBed.createComponent(TestApp); + fixture.detectChanges(); + + const defaultIconButton = fixture.nativeElement.querySelector('.default-icon-button'); + const filledIconButton = fixture.nativeElement.querySelector('.filled-icon-button'); + const legacyIconButton = fixture.nativeElement.querySelector('.legacy-icon-button'); + const tonalIconAnchor = fixture.nativeElement.querySelector('.tonal-icon-anchor'); + + expect(defaultIconButton.classList).not.toContain('mat-mdc-icon-button-filled'); + expect(defaultIconButton.classList).not.toContain('mat-mdc-icon-button-tonal'); + expect(filledIconButton.classList).toContain('mat-mdc-icon-button-filled'); + expect(legacyIconButton.classList).toContain('mat-mdc-icon-button-filled'); + expect(tonalIconAnchor.classList).toContain('mat-mdc-icon-button-tonal'); + }); + + it('should be able to change the icon button appearance dynamically', () => { + const fixture = TestBed.createComponent(TestApp); + const iconButton = fixture.nativeElement.querySelector('.dynamic-icon-button') as HTMLElement; + fixture.detectChanges(); + + expect(iconButton.classList).not.toContain('mat-mdc-icon-button-filled'); + expect(iconButton.classList).not.toContain('mat-mdc-icon-button-tonal'); + + fixture.componentInstance.iconButtonAppearance = 'filled'; + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + expect(iconButton.classList).toContain('mat-mdc-icon-button-filled'); + expect(iconButton.classList).not.toContain('mat-mdc-icon-button-tonal'); + + fixture.componentInstance.iconButtonAppearance = 'tonal'; + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + expect(iconButton.classList).not.toContain('mat-mdc-icon-button-filled'); + expect(iconButton.classList).toContain('mat-mdc-icon-button-tonal'); + + fixture.componentInstance.iconButtonAppearance = ''; + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + expect(iconButton.classList).not.toContain('mat-mdc-icon-button-filled'); + expect(iconButton.classList).not.toContain('mat-mdc-icon-button-tonal'); + }); + describe('button[mat-fab]', () => { it('should have accent palette by default', () => { const fixture = TestBed.createComponent(TestApp); @@ -540,6 +584,21 @@ describe('MatFabDefaultOptions', () => { + + + + + Tonal icon anchor + + `, imports: [MatButtonModule], changeDetection: ChangeDetectionStrategy.Eager, @@ -553,6 +612,7 @@ class TestApp { extended = false; disabledInteractive = false; appearance: MatButtonAppearance = 'text'; + iconButtonAppearance: MatIconButtonAppearance | '' = ''; showProgress = false; increment() { diff --git a/src/material/button/icon-button.scss b/src/material/button/icon-button.scss index 11eb5b947036..b9497531647d 100644 --- a/src/material/button/icon-button.scss +++ b/src/material/button/icon-button.scss @@ -13,7 +13,8 @@ $fallbacks: m3-icon-button.get-tokens(); box-sizing: border-box; border: none; outline: none; - background-color: transparent; + background-color: token-utils.slot( + icon-button-container-color, $fallbacks, $fallback: transparent); fill: currentColor; text-decoration: none; cursor: pointer; @@ -59,7 +60,38 @@ $fallbacks: m3-icon-button.get-tokens(); @include button-base.mat-private-button-disabled { color: token-utils.slot(icon-button-disabled-icon-color, $fallbacks); } -; + + &.mat-mdc-icon-button-filled { + background-color: token-utils.slot(icon-button-filled-container-color, $fallbacks); + color: token-utils.slot(icon-button-filled-icon-color, $fallbacks); + + @include button-base.mat-private-button-ripple( + icon-button-filled-ripple-color, icon-button-filled-state-layer-color, + icon-button-filled-disabled-state-layer-color, + icon-button-filled-hover-state-layer-opacity, icon-button-filled-focus-state-layer-opacity, + icon-button-filled-pressed-state-layer-opacity, $fallbacks); + + @include button-base.mat-private-button-disabled { + background-color: token-utils.slot(icon-button-filled-disabled-container-color, $fallbacks); + color: token-utils.slot(icon-button-filled-disabled-icon-color, $fallbacks); + } + } + + &.mat-mdc-icon-button-tonal { + background-color: token-utils.slot(icon-button-tonal-container-color, $fallbacks); + color: token-utils.slot(icon-button-tonal-icon-color, $fallbacks); + + @include button-base.mat-private-button-ripple( + icon-button-tonal-ripple-color, icon-button-tonal-state-layer-color, + icon-button-tonal-disabled-state-layer-color, + icon-button-tonal-hover-state-layer-opacity, icon-button-tonal-focus-state-layer-opacity, + icon-button-tonal-pressed-state-layer-opacity, $fallbacks); + + @include button-base.mat-private-button-disabled { + background-color: token-utils.slot(icon-button-tonal-disabled-container-color, $fallbacks); + color: token-utils.slot(icon-button-tonal-disabled-icon-color, $fallbacks); + } + } img, svg { diff --git a/src/material/button/icon-button.ts b/src/material/button/icon-button.ts index 1582882d5b36..9a38ead0b148 100644 --- a/src/material/button/icon-button.ts +++ b/src/material/button/icon-button.ts @@ -6,9 +6,18 @@ * found in the LICENSE file at https://angular.dev/license */ -import {Component, ViewEncapsulation} from '@angular/core'; +import {Component, Input, ViewEncapsulation} from '@angular/core'; import {MatButtonBase} from './button-base'; +/** Possible appearances for a `MatIconButton`. */ +export type MatIconButtonAppearance = 'filled' | 'tonal'; + +/** Classes that need to be set for each appearance of the icon button. */ +const APPEARANCE_CLASSES: Map = new Map([ + ['filled', ['mat-mdc-icon-button-filled']], + ['tonal', ['mat-mdc-icon-button-tonal']], +]); + /** * Material Design icon button component. This type of button displays a single interactive icon for * users to perform an action. @@ -25,10 +34,51 @@ import {MatButtonBase} from './button-base'; encapsulation: ViewEncapsulation.None, }) export class MatIconButton extends MatButtonBase { + /** Appearance of the icon button. */ + @Input('matIconButton') + get appearance(): MatIconButtonAppearance | null { + return this._appearance; + } + set appearance(value: MatIconButtonAppearance | '') { + this.setAppearance(value || null); + } + private _appearance: MatIconButtonAppearance | null = null; + + /** Same as `appearance`, but using the legacy `mat-icon-button` attribute selector. */ + @Input('mat-icon-button') + set _legacyAppearance(value: MatIconButtonAppearance | '') { + this.setAppearance(value || null); + } + constructor() { super(); this._rippleLoader.configureRipple(this._elementRef.nativeElement, {centered: true}); } + + /** Programmatically sets the appearance of the icon button. */ + setAppearance(appearance: MatIconButtonAppearance | null): 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 ? APPEARANCE_CLASSES.get(appearance) : null; + + if ((typeof ngDevMode === 'undefined' || ngDevMode) && appearance && !newClasses) { + throw new Error(`Unsupported MatIconButton appearance "${appearance}"`); + } + + if (previousClasses) { + classList.remove(...previousClasses); + } + + if (newClasses) { + classList.add(...newClasses); + } + + this._appearance = appearance; + } } // tslint:disable:variable-name diff --git a/src/material/button/testing/button-harness.spec.ts b/src/material/button/testing/button-harness.spec.ts index 85ef3b581846..8792cedaa1a1 100644 --- a/src/material/button/testing/button-harness.spec.ts +++ b/src/material/button/testing/button-harness.spec.ts @@ -21,7 +21,7 @@ describe('MatButtonHarness', () => { it('should load all button harnesses', async () => { const buttons = await loader.getAllHarnesses(MatButtonHarness); - expect(buttons.length).toBe(22); + expect(buttons.length).toBe(24); }); it('should load button with exact text', async () => { @@ -40,7 +40,7 @@ describe('MatButtonHarness', () => { it('should filter by whether a button is disabled', async () => { const enabledButtons = await loader.getAllHarnesses(MatButtonHarness.with({disabled: false})); const disabledButtons = await loader.getAllHarnesses(MatButtonHarness.with({disabled: true})); - expect(enabledButtons.length).toBe(20); + expect(enabledButtons.length).toBe(22); expect(disabledButtons.length).toBe(2); }); @@ -134,6 +134,8 @@ describe('MatButtonHarness', () => { 'basic', 'icon', 'icon', + 'icon', + 'icon', 'fab', 'mini-fab', 'basic', @@ -164,6 +166,8 @@ describe('MatButtonHarness', () => { 'tonal', null, null, + 'filled', + 'tonal', null, null, 'text', @@ -188,8 +192,10 @@ describe('MatButtonHarness', () => { }); it('should be able to filter buttons based on their appearance', async () => { - const button = await loader.getHarness(MatButtonHarness.with({appearance: 'filled'})); - expect(await button.getText()).toBe('Filled button'); + const buttons = await loader.getAllHarnesses(MatButtonHarness.with({appearance: 'filled'})); + const texts = await parallel(() => buttons.map(button => button.getText())); + + expect(texts).toEqual(['Filled button', 'add', 'Filled anchor']); }); it('should get the appearance of a button with a dynamic appearance', async () => { @@ -243,6 +249,12 @@ describe('MatButtonHarness', () => { + + diff --git a/src/material/button/testing/button-harness.ts b/src/material/button/testing/button-harness.ts index 06184c91e5f1..237d2aa0f580 100644 --- a/src/material/button/testing/button-harness.ts +++ b/src/material/button/testing/button-harness.ts @@ -148,6 +148,14 @@ export class MatButtonHarness extends ContentContainerComponentHarness { async getAppearance(): Promise { const host = await this.host(); + if (await host.hasClass('mat-mdc-icon-button-filled')) { + return 'filled'; + } + + if (await host.hasClass('mat-mdc-icon-button-tonal')) { + return 'tonal'; + } + if (await host.hasClass('mat-mdc-outlined-button')) { return 'outlined'; }