From 6f4f5193634a85a60af9aecedc006d426fdc9fe1 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Fri, 30 Oct 2026 09:34:22 +0100 Subject: [PATCH] feat(material/chips): add leading icon support Currently users can only project an avatar in the leading slot which is used for images. These changes add support for a leading icon. Fixes #31628. --- goldens/material/chips/index.api.md | 26 ++++++-- goldens/material/chips/testing/index.api.md | 12 ++++ .../chips/chips-icons/chips-icons-example.css | 1 + .../chips-icons/chips-icons-example.html | 19 ++++++ .../chips/chips-icons/chips-icons-example.ts | 14 +++++ .../material/chips/index.ts | 1 + src/dev-app/chips/chips-demo.html | 19 ++++++ src/material/chips/chip-icons.ts | 19 +++++- src/material/chips/chip-option.html | 2 +- src/material/chips/chip-option.spec.ts | 19 +++++- src/material/chips/chip-option.ts | 11 ++-- src/material/chips/chip-row.html | 4 +- src/material/chips/chip-row.spec.ts | 19 ++++++ src/material/chips/chip-row.ts | 9 +-- src/material/chips/chip.html | 4 +- src/material/chips/chip.scss | 5 ++ src/material/chips/chip.spec.ts | 61 +++++++++++++++++++ src/material/chips/chip.ts | 47 ++++++++++---- src/material/chips/chips-module.ts | 9 ++- src/material/chips/chips.md | 4 +- .../chips/testing/chip-harness-filters.ts | 2 + .../chips/testing/chip-harness.spec.ts | 34 +++++++++-- src/material/chips/testing/chip-harness.ts | 15 ++++- .../testing/chip-leading-icon-harness.ts | 32 ++++++++++ src/material/chips/testing/public-api.ts | 1 + src/material/chips/tokens.ts | 7 +++ 26 files changed, 352 insertions(+), 44 deletions(-) create mode 100644 src/components-examples/material/chips/chips-icons/chips-icons-example.css create mode 100644 src/components-examples/material/chips/chips-icons/chips-icons-example.html create mode 100644 src/components-examples/material/chips/chips-icons/chips-icons-example.ts create mode 100644 src/material/chips/testing/chip-leading-icon-harness.ts diff --git a/goldens/material/chips/index.api.md b/goldens/material/chips/index.api.md index 5225a9fd59ae..e9a81d25c93f 100644 --- a/goldens/material/chips/index.api.md +++ b/goldens/material/chips/index.api.md @@ -41,6 +41,9 @@ export const MAT_CHIP_AVATAR: InjectionToken; // @public export const MAT_CHIP_EDIT: InjectionToken; +// @public +export const MAT_CHIP_LEADING_ICON: InjectionToken; + // @public export const MAT_CHIP_LISTBOX_CONTROL_VALUE_ACCESSOR: any; @@ -56,13 +59,15 @@ export const MAT_CHIPS_DEFAULT_OPTIONS: InjectionToken; // @public export class MatChip implements OnInit, AfterViewInit, AfterContentInit, DoCheck, OnDestroy { constructor(); + protected _allAvatars: QueryList; protected _allEditIcons: QueryList; - protected _allLeadingIcons: QueryList; + protected _allLeadingIcons: QueryList; protected _allRemoveIcons: QueryList; protected _allTrailingIcons: QueryList; _animationsDisabled: boolean; ariaDescription: string | null; ariaLabel: string | null; + avatar: MatChipAvatar; protected basicChipAttrName: string; // (undocumented) _changeDetectorRef: ChangeDetectorRef; @@ -87,6 +92,7 @@ export class MatChip implements OnInit, AfterViewInit, AfterContentInit, DoCheck // (undocumented) _hasFocus(): boolean; _hasInteractiveActions(): boolean; + _hasLeadingGraphic(): boolean; _hasTrailingIcon(): boolean; highlighted: boolean; id: string; @@ -94,7 +100,7 @@ export class MatChip implements OnInit, AfterViewInit, AfterContentInit, DoCheck protected _injector: Injector; _isBasicChip: boolean; _isRippleDisabled(): boolean; - leadingIcon: MatChipAvatar; + leadingIcon: MatChipLeadingIcon; // (undocumented) static ngAcceptInputType_disabled: unknown; // (undocumented) @@ -129,7 +135,7 @@ export class MatChip implements OnInit, AfterViewInit, AfterContentInit, DoCheck // (undocumented) protected _value: any; // (undocumented) - static ɵcmp: i0.ɵɵComponentDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; } @@ -325,6 +331,14 @@ export interface MatChipInputEvent { value: string; } +// @public +export class MatChipLeadingIcon { + // (undocumented) + static ɵdir: i0.ɵɵDirectiveDeclaration; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration; +} + // @public export class MatChipListbox extends MatChipSet implements AfterContentInit, OnDestroy, ControlValueAccessor { ariaOrientation: 'horizontal' | 'vertical'; @@ -418,7 +432,7 @@ export class MatChipOption extends MatChip implements OnInit { _setSelectedState(isSelected: boolean, isUserInput: boolean, emitEvent: boolean): void; toggleSelected(isUserInput?: boolean): boolean; // (undocumented) - static ɵcmp: i0.ɵɵComponentDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; } @@ -469,7 +483,7 @@ export class MatChipRow extends MatChip implements AfterViewInit, OnDestroy { // (undocumented) ngOnDestroy(): void; // (undocumented) - static ɵcmp: i0.ɵɵComponentDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; } @@ -547,7 +561,7 @@ export class MatChipsModule { // (undocumented) static ɵinj: i0.ɵɵInjectorDeclaration; // (undocumented) - static ɵmod: i0.ɵɵNgModuleDeclaration; + static ɵmod: i0.ɵɵNgModuleDeclaration; } // @public diff --git a/goldens/material/chips/testing/index.api.md b/goldens/material/chips/testing/index.api.md index 150b9d3fe079..cecad1b7ae2d 100644 --- a/goldens/material/chips/testing/index.api.md +++ b/goldens/material/chips/testing/index.api.md @@ -42,6 +42,10 @@ export interface ChipInputHarnessFilters extends BaseHarnessFilters { value?: string | RegExp; } +// @public (undocumented) +export interface ChipLeadingIconHarnessFilters extends BaseHarnessFilters { +} + // @public (undocumented) export interface ChipListboxHarnessFilters extends BaseHarnessFilters { disabled?: boolean; @@ -103,6 +107,7 @@ export class MatChipGridHarness extends ComponentHarness { export class MatChipHarness extends ContentContainerComponentHarness { geEditButton(filter?: ChipEditHarnessFilters): Promise; getAvatar(filter?: ChipAvatarHarnessFilters): Promise; + getLeadingIcon(filter?: ChipLeadingIconHarnessFilters): Promise; getRemoveButton(filter?: ChipRemoveHarnessFilters): Promise; getText(): Promise; // (undocumented) @@ -130,6 +135,13 @@ export class MatChipInputHarness extends ComponentHarness { static with(this: ComponentHarnessConstructor, options?: ChipInputHarnessFilters): HarnessPredicate; } +// @public +export class MatChipLeadingIconHarness extends ComponentHarness { + // (undocumented) + static hostSelector: string; + static with(this: ComponentHarnessConstructor, options?: ChipLeadingIconHarnessFilters): HarnessPredicate; +} + // @public export class MatChipListboxHarness extends ComponentHarness { getChips(filter?: ChipOptionHarnessFilters): Promise; diff --git a/src/components-examples/material/chips/chips-icons/chips-icons-example.css b/src/components-examples/material/chips/chips-icons/chips-icons-example.css new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/src/components-examples/material/chips/chips-icons/chips-icons-example.css @@ -0,0 +1 @@ + diff --git a/src/components-examples/material/chips/chips-icons/chips-icons-example.html b/src/components-examples/material/chips/chips-icons/chips-icons-example.html new file mode 100644 index 000000000000..c27c63657683 --- /dev/null +++ b/src/components-examples/material/chips/chips-icons/chips-icons-example.html @@ -0,0 +1,19 @@ + + + flight + Flight + + + hotel + Hotel + star + + + music_note + Music + + + + diff --git a/src/components-examples/material/chips/chips-icons/chips-icons-example.ts b/src/components-examples/material/chips/chips-icons/chips-icons-example.ts new file mode 100644 index 000000000000..636c42463401 --- /dev/null +++ b/src/components-examples/material/chips/chips-icons/chips-icons-example.ts @@ -0,0 +1,14 @@ +import {Component} from '@angular/core'; +import {MatChipsModule} from '@angular/material/chips'; +import {MatIconModule} from '@angular/material/icon'; + +/** + * @title Chips with icons + */ +@Component({ + selector: 'chips-icons-example', + templateUrl: 'chips-icons-example.html', + styleUrl: 'chips-icons-example.css', + imports: [MatChipsModule, MatIconModule], +}) +export class ChipsIconsExample {} diff --git a/src/components-examples/material/chips/index.ts b/src/components-examples/material/chips/index.ts index 91259f168c06..80684c9d984c 100644 --- a/src/components-examples/material/chips/index.ts +++ b/src/components-examples/material/chips/index.ts @@ -10,3 +10,4 @@ export {ChipsReactiveFormExample} from './chips-reactive-form/chips-reactive-for export {ChipsSignalFormExample} from './chips-signal-form/chips-signal-form-example'; export {ChipsTemplateFormExample} from './chips-template-form/chips-template-form-example'; export {ChipsAvatarExample} from './chips-avatar/chips-avatar-example'; +export {ChipsIconsExample} from './chips-icons/chips-icons-example'; diff --git a/src/dev-app/chips/chips-demo.html b/src/dev-app/chips/chips-demo.html index 9fd9b56a011b..d8e292b8928b 100644 --- a/src/dev-app/chips/chips-demo.html +++ b/src/dev-app/chips/chips-demo.html @@ -90,6 +90,25 @@

With avatar, icons, and color

star_border + + flight + Flight + + + + hotel + Hotel + star + + + + music_note + Music + + +

With Events

diff --git a/src/material/chips/chip-icons.ts b/src/material/chips/chip-icons.ts index ba1e0b044bdc..386e79ad65f9 100644 --- a/src/material/chips/chip-icons.ts +++ b/src/material/chips/chip-icons.ts @@ -9,7 +9,13 @@ import {ENTER, SPACE} from '@angular/cdk/keycodes'; import {Directive} from '@angular/core'; import {MatChipAction, MatChipContent} from './chip-action'; -import {MAT_CHIP_AVATAR, MAT_CHIP_EDIT, MAT_CHIP_REMOVE, MAT_CHIP_TRAILING_ICON} from './tokens'; +import { + MAT_CHIP_AVATAR, + MAT_CHIP_EDIT, + MAT_CHIP_LEADING_ICON, + MAT_CHIP_REMOVE, + MAT_CHIP_TRAILING_ICON, +} from './tokens'; /** Avatar image within a chip. */ @Directive({ @@ -22,6 +28,17 @@ import {MAT_CHIP_AVATAR, MAT_CHIP_EDIT, MAT_CHIP_REMOVE, MAT_CHIP_TRAILING_ICON} }) export class MatChipAvatar {} +/** Non-interactive leading icon in a chip. */ +@Directive({ + selector: 'mat-chip-leading-icon, [matChipLeadingIcon]', + host: { + 'class': 'mat-mdc-chip-leading-icon mdc-evolution-chip__icon mdc-evolution-chip__icon--primary', + 'aria-hidden': 'true', + }, + providers: [{provide: MAT_CHIP_LEADING_ICON, useExisting: MatChipLeadingIcon}], +}) +export class MatChipLeadingIcon {} + /** Non-interactive trailing icon in a chip. */ @Directive({ selector: 'mat-chip-trailing-icon, [matChipTrailingIcon]', diff --git a/src/material/chips/chip-option.html b/src/material/chips/chip-option.html index 151971bab96e..a347bdddf3fb 100644 --- a/src/material/chips/chip-option.html +++ b/src/material/chips/chip-option.html @@ -10,7 +10,7 @@ role="option"> @if (_hasLeadingGraphic()) { - + { expect(chipNativeElement.querySelector('.mat-mdc-chip-graphic')).toBeTruthy(); expect(chipNativeElement.classList).toContain('mdc-evolution-chip--with-primary-graphic'); }); + + it('displays checkmark graphic when leading icon is provided', () => { + testComponent.selected = true; + testComponent.leadingIconLabel = 'star'; + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + + expect(chipNativeElement.querySelector('.mat-mdc-chip-graphic')).toBeTruthy(); + expect(chipNativeElement.classList).toContain('mdc-evolution-chip--with-primary-graphic'); + expect(chipNativeElement.classList).toContain('mat-mdc-chip-with-leading-icon'); + expect(chipNativeElement.classList).not.toContain('mat-mdc-chip-with-avatar'); + }); }); it('should contain a focus indicator inside the text label', () => { @@ -395,12 +408,15 @@ describe('Option Chips', () => { @if (avatarLabel) { {{avatarLabel}} } + @if (leadingIconLabel) { + {{leadingIconLabel}} + } {{name}} } `, - imports: [MatChipListbox, MatChipOption, MatChipAvatar], + imports: [MatChipListbox, MatChipOption, MatChipAvatar, MatChipLeadingIcon], changeDetection: ChangeDetectionStrategy.Eager, }) class SingleChip { @@ -414,6 +430,7 @@ class SingleChip { ariaLabel: string | null = null; ariaDescription: string | null = null; avatarLabel: string | null = null; + leadingIconLabel: string | null = null; chipDestroy: (event?: MatChipEvent) => void = () => {}; chipSelectionChange: (event?: MatChipSelectionChange) => void = () => {}; diff --git a/src/material/chips/chip-option.ts b/src/material/chips/chip-option.ts index 09897387dac0..4693fe624cb0 100644 --- a/src/material/chips/chip-option.ts +++ b/src/material/chips/chip-option.ts @@ -50,7 +50,8 @@ export class MatChipSelectionChange { '[class.mat-mdc-chip-selected]': 'selected', '[class.mat-mdc-chip-multiple]': '_chipListMultiple', '[class.mat-mdc-chip-disabled]': 'disabled', - '[class.mat-mdc-chip-with-avatar]': 'leadingIcon', + '[class.mat-mdc-chip-with-avatar]': 'avatar', + '[class.mat-mdc-chip-with-leading-icon]': 'leadingIcon', '[class.mdc-evolution-chip--disabled]': 'disabled', '[class.mdc-evolution-chip--selected]': 'selected', // This class enables the transition on the checkmark. Usually MDC adds it when selection @@ -59,9 +60,9 @@ export class MatChipSelectionChange { // because they also have an exit animation that we don't care about. '[class.mdc-evolution-chip--selecting]': '!_animationsDisabled', '[class.mdc-evolution-chip--with-trailing-action]': '_hasTrailingIcon()', - '[class.mdc-evolution-chip--with-primary-icon]': 'leadingIcon', + '[class.mdc-evolution-chip--with-primary-icon]': 'leadingIcon || avatar', '[class.mdc-evolution-chip--with-primary-graphic]': '_hasLeadingGraphic()', - '[class.mdc-evolution-chip--with-avatar]': 'leadingIcon', + '[class.mdc-evolution-chip--with-avatar]': 'avatar', '[class.mat-mdc-chip-highlighted]': 'highlighted', '[class.mat-mdc-chip-with-trailing-icon]': '_hasTrailingIcon()', '[attr.tabindex]': 'null', @@ -181,8 +182,8 @@ export class MatChipOption extends MatChip implements OnInit { } } - _hasLeadingGraphic() { - if (this.leadingIcon) { + override _hasLeadingGraphic() { + if (this.leadingIcon || this.avatar) { return true; } diff --git a/src/material/chips/chip-row.html b/src/material/chips/chip-row.html index 55ed032a3186..58f011bdd0e0 100644 --- a/src/material/chips/chip-row.html +++ b/src/material/chips/chip-row.html @@ -12,9 +12,9 @@ [disabled]="disabled" [attr.aria-description]="ariaDescription" [attr.aria-label]="ariaLabel"> - @if (leadingIcon) { + @if (_hasLeadingGraphic()) { - + } diff --git a/src/material/chips/chip-row.spec.ts b/src/material/chips/chip-row.spec.ts index 299a9f4a24ea..6374f2e9af8d 100644 --- a/src/material/chips/chip-row.spec.ts +++ b/src/material/chips/chip-row.spec.ts @@ -81,6 +81,21 @@ describe('Row Chips', () => { expect(chipNativeElement.classList).toContain('mat-warn'); }); + it('supports a leading icon', () => { + testComponent.showLeadingIcon = true; + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + + expect(chipInstance.leadingIcon).toBeTruthy(); + expect(chipNativeElement.classList).toContain('mat-mdc-chip-with-leading-icon'); + expect(chipNativeElement.classList).toContain('mdc-evolution-chip--with-primary-icon'); + expect(chipNativeElement.classList).toContain('mdc-evolution-chip--with-primary-graphic'); + expect(chipNativeElement.classList).not.toContain('mat-mdc-chip-with-avatar'); + expect( + chipNativeElement.querySelector('.mat-mdc-chip-graphic [matChipLeadingIcon]'), + ).toBeTruthy(); + }); + it('allows removal', () => { spyOn(testComponent, 'chipRemove'); @@ -514,6 +529,9 @@ describe('Row Chips', () => { @if (showEditIcon) { } + @if (showLeadingIcon) { + leading + } {{name}} @if (removable) { @@ -542,6 +560,7 @@ class SingleChip { shouldShow: boolean = true; editable: boolean = false; showEditIcon: boolean = false; + showLeadingIcon = false; useCustomEditInput: boolean = true; showTrailingIcon = false; ariaLabel: string | null = null; diff --git a/src/material/chips/chip-row.ts b/src/material/chips/chip-row.ts index a43dc91669a4..4cfef4c731d0 100644 --- a/src/material/chips/chip-row.ts +++ b/src/material/chips/chip-row.ts @@ -43,16 +43,17 @@ export interface MatChipEditedEvent extends MatChipEvent { styleUrl: 'chip.css', host: { 'class': 'mat-mdc-chip mat-mdc-chip-row mdc-evolution-chip', - '[class.mat-mdc-chip-with-avatar]': 'leadingIcon', + '[class.mat-mdc-chip-with-avatar]': 'avatar', + '[class.mat-mdc-chip-with-leading-icon]': 'leadingIcon', '[class.mat-mdc-chip-disabled]': 'disabled', '[class.mat-mdc-chip-editing]': '_isEditing', '[class.mat-mdc-chip-editable]': 'editable', '[class.mdc-evolution-chip--disabled]': 'disabled', '[class.mdc-evolution-chip--with-leading-action]': '_hasLeadingActionIcon()', '[class.mdc-evolution-chip--with-trailing-action]': '_hasTrailingIcon()', - '[class.mdc-evolution-chip--with-primary-graphic]': 'leadingIcon', - '[class.mdc-evolution-chip--with-primary-icon]': 'leadingIcon', - '[class.mdc-evolution-chip--with-avatar]': 'leadingIcon', + '[class.mdc-evolution-chip--with-primary-graphic]': '_hasLeadingGraphic()', + '[class.mdc-evolution-chip--with-primary-icon]': 'leadingIcon || avatar', + '[class.mdc-evolution-chip--with-avatar]': 'avatar', '[class.mat-mdc-chip-highlighted]': 'highlighted', '[class.mat-mdc-chip-with-trailing-icon]': '_hasTrailingIcon()', '[id]': 'id', diff --git a/src/material/chips/chip.html b/src/material/chips/chip.html index 1d9eb6efd943..875176cc54a3 100644 --- a/src/material/chips/chip.html +++ b/src/material/chips/chip.html @@ -2,9 +2,9 @@ - @if (leadingIcon) { + @if (_hasLeadingGraphic()) { - + } diff --git a/src/material/chips/chip.scss b/src/material/chips/chip.scss index b5a316cb75dc..f4ed70fe59eb 100644 --- a/src/material/chips/chip.scss +++ b/src/material/chips/chip.scss @@ -509,6 +509,10 @@ $fallbacks: m3-chip.get-tokens(); opacity: token-utils.slot(chip-with-avatar-disabled-avatar-opacity, $fallbacks); } +.mdc-evolution-chip--disabled:not(.mdc-evolution-chip--selected) .mat-mdc-chip-leading-icon { + opacity: token-utils.slot(chip-with-icon-disabled-icon-opacity, $fallbacks); +} + .mdc-evolution-chip--disabled .mdc-evolution-chip__icon--trailing { opacity: token-utils.slot(chip-with-trailing-icon-disabled-trailing-icon-opacity, $fallbacks); } @@ -588,6 +592,7 @@ $fallbacks: m3-chip.get-tokens(); // to all elements on the page which can cause the graphic to be clipped. // Set an explicit `box-sizing` in order to avoid these issues. .mat-mdc-chip-graphic, + .mat-mdc-chip-leading-icon, .mat-mdc-chip-trailing-icon { box-sizing: content-box; } diff --git a/src/material/chips/chip.spec.ts b/src/material/chips/chip.spec.ts index b08260c16d1e..22d176545378 100644 --- a/src/material/chips/chip.spec.ts +++ b/src/material/chips/chip.spec.ts @@ -140,6 +140,55 @@ describe('MatChip', () => { expect(chipInstance.value).toBeNull(); }); + + it('should support a leading icon', () => { + testComponent.showLeadingIcon = true; + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + + expect(chipInstance.leadingIcon).toBeTruthy(); + expect(chipNativeElement.classList).toContain('mat-mdc-chip-with-leading-icon'); + expect(chipNativeElement.classList).toContain('mdc-evolution-chip--with-primary-icon'); + expect(chipNativeElement.classList).toContain('mdc-evolution-chip--with-primary-graphic'); + expect(chipNativeElement.classList).not.toContain('mat-mdc-chip-with-avatar'); + expect(chipNativeElement.classList).not.toContain('mdc-evolution-chip--with-avatar'); + expect( + chipNativeElement.querySelector('.mat-mdc-chip-graphic [matChipLeadingIcon]'), + ).toBeTruthy(); + }); + + it('should support an avatar', () => { + testComponent.showAvatar = true; + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + + expect(chipInstance.avatar).toBeTruthy(); + expect(chipNativeElement.classList).toContain('mat-mdc-chip-with-avatar'); + expect(chipNativeElement.classList).toContain('mdc-evolution-chip--with-avatar'); + expect(chipNativeElement.classList).toContain('mdc-evolution-chip--with-primary-graphic'); + expect(chipNativeElement.classList).not.toContain('mat-mdc-chip-with-leading-icon'); + expect(chipNativeElement.querySelector('.mat-mdc-chip-graphic [matChipAvatar]')).toBeTruthy(); + }); + + it('should support both leading and trailing icons', () => { + testComponent.showLeadingIcon = true; + testComponent.showTrailingIcon = true; + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + + expect(chipInstance.leadingIcon).toBeTruthy(); + expect(chipInstance.trailingIcon).toBeTruthy(); + expect(chipNativeElement.classList).toContain('mat-mdc-chip-with-leading-icon'); + expect(chipNativeElement.classList).toContain('mat-mdc-chip-with-trailing-icon'); + expect( + chipNativeElement.querySelector('.mat-mdc-chip-graphic [matChipLeadingIcon]'), + ).toBeTruthy(); + expect( + chipNativeElement.querySelector( + '.mdc-evolution-chip__cell--trailing [matChipTrailingIcon]', + ), + ).toBeTruthy(); + }); }); }); @@ -152,6 +201,15 @@ describe('MatChip', () => { [color]="color" [disabled]="disabled" (destroyed)="chipDestroy($event)" (removed)="chipRemove($event)" [value]="value" [disableRipple]="rippleDisabled"> + @if (showLeadingIcon) { + leading + } + @if (showAvatar) { + avatar + } + @if (showTrailingIcon) { + trailing + } {{name}} @@ -167,6 +225,9 @@ class SingleChip { color: string = 'primary'; removable: boolean = true; shouldShow: boolean = true; + showLeadingIcon = false; + showAvatar = false; + showTrailingIcon = false; value: any; rippleDisabled: boolean = false; diff --git a/src/material/chips/chip.ts b/src/material/chips/chip.ts index bf87b4e38be6..dd7695cd4e46 100644 --- a/src/material/chips/chip.ts +++ b/src/material/chips/chip.ts @@ -43,11 +43,18 @@ import { } from '../core'; import {Subject, Subscription, merge} from 'rxjs'; import {MatChipAction, MatChipContent} from './chip-action'; -import {MatChipAvatar, MatChipEdit, MatChipRemove, MatChipTrailingIcon} from './chip-icons'; +import { + MatChipAvatar, + MatChipEdit, + MatChipLeadingIcon, + MatChipRemove, + MatChipTrailingIcon, +} from './chip-icons'; import { MAT_CHIP, MAT_CHIP_AVATAR, MAT_CHIP_EDIT, + MAT_CHIP_LEADING_ICON, MAT_CHIP_REMOVE, MAT_CHIP_TRAILING_ICON, } from './tokens'; @@ -74,10 +81,11 @@ export interface MatChipEvent { '[class.mdc-evolution-chip]': '!_isBasicChip', '[class.mdc-evolution-chip--disabled]': 'disabled', '[class.mdc-evolution-chip--with-trailing-action]': '_hasTrailingIcon()', - '[class.mdc-evolution-chip--with-primary-graphic]': 'leadingIcon', - '[class.mdc-evolution-chip--with-primary-icon]': 'leadingIcon', - '[class.mdc-evolution-chip--with-avatar]': 'leadingIcon', - '[class.mat-mdc-chip-with-avatar]': 'leadingIcon', + '[class.mdc-evolution-chip--with-primary-graphic]': '_hasLeadingGraphic()', + '[class.mdc-evolution-chip--with-primary-icon]': 'leadingIcon || avatar', + '[class.mdc-evolution-chip--with-avatar]': 'avatar', + '[class.mat-mdc-chip-with-avatar]': 'avatar', + '[class.mat-mdc-chip-with-leading-icon]': 'leadingIcon', '[class.mat-mdc-chip-highlighted]': 'highlighted', '[class.mat-mdc-chip-disabled]': 'disabled', '[class.mat-mdc-basic-chip]': '_isBasicChip', @@ -131,7 +139,11 @@ export class MatChip implements OnInit, AfterViewInit, AfterContentInit, DoCheck /** All avatars present in the chip. */ @ContentChildren(MAT_CHIP_AVATAR, {descendants: true}) - protected _allLeadingIcons!: QueryList; + protected _allAvatars!: QueryList; + + /** All leading icons present in the chip. */ + @ContentChildren(MAT_CHIP_LEADING_ICON, {descendants: true}) + protected _allLeadingIcons!: QueryList; /** All trailing icons present in the chip. */ @ContentChildren(MAT_CHIP_TRAILING_ICON, {descendants: true}) @@ -230,8 +242,11 @@ export class MatChip implements OnInit, AfterViewInit, AfterContentInit, DoCheck /** The unstyled chip selector for this component. */ protected basicChipAttrName = 'mat-basic-chip'; + /** The chip's avatar. */ + @ContentChild(MAT_CHIP_AVATAR) avatar!: MatChipAvatar; + /** The chip's leading icon. */ - @ContentChild(MAT_CHIP_AVATAR) leadingIcon!: MatChipAvatar; + @ContentChild(MAT_CHIP_LEADING_ICON) leadingIcon!: MatChipLeadingIcon; /** The chip's leading edit icon. */ @ContentChild(MAT_CHIP_EDIT) editIcon!: MatChipEdit; @@ -286,6 +301,7 @@ export class MatChip implements OnInit, AfterViewInit, AfterContentInit, DoCheck // Since the styling depends on the presence of some // actions, we have to mark for check on changes. this._actionChanges = merge( + this._allAvatars.changes, this._allLeadingIcons.changes, this._allTrailingIcons.changes, this._allEditIcons.changes, @@ -298,12 +314,12 @@ export class MatChip implements OnInit, AfterViewInit, AfterContentInit, DoCheck } ngOnDestroy() { - this.destroyed.emit({chip: this}); - this.destroyed.complete(); - this._focusMonitor.stopMonitoring(this._elementRef); - this._rippleLoader?.destroyRipple(this._elementRef.nativeElement); - this._actionChanges?.unsubscribe(); - } + this.destroyed.emit({chip: this}); + this.destroyed.complete(); + this._focusMonitor.stopMonitoring(this._elementRef); + this._rippleLoader?.destroyRipple(this._elementRef.nativeElement); + this._actionChanges?.unsubscribe(); + } /** * Allows for programmatic removal of the chip. @@ -329,6 +345,11 @@ export class MatChip implements OnInit, AfterViewInit, AfterContentInit, DoCheck ); } + /** Returns whether the chip has a leading icon or avatar. */ + _hasLeadingGraphic() { + return !!(this.leadingIcon || this.avatar); + } + /** Returns whether the chip has a trailing icon. */ _hasTrailingIcon() { return !!(this.trailingIcon || this.removeIcon); diff --git a/src/material/chips/chips-module.ts b/src/material/chips/chips-module.ts index 7970d1fe1bf8..81edbac0a512 100644 --- a/src/material/chips/chips-module.ts +++ b/src/material/chips/chips-module.ts @@ -13,7 +13,13 @@ import {MatChip} from './chip'; import {MAT_CHIPS_DEFAULT_OPTIONS, MatChipsDefaultOptions} from './tokens'; import {MatChipEditInput} from './chip-edit-input'; import {MatChipGrid} from './chip-grid'; -import {MatChipAvatar, MatChipEdit, MatChipRemove, MatChipTrailingIcon} from './chip-icons'; +import { + MatChipAvatar, + MatChipEdit, + MatChipLeadingIcon, + MatChipRemove, + MatChipTrailingIcon, +} from './chip-icons'; import {MatChipInput} from './chip-input'; import {MatChipListbox} from './chip-listbox'; import {MatChipRow} from './chip-row'; @@ -29,6 +35,7 @@ const CHIP_DECLARATIONS = [ MatChipEditInput, MatChipGrid, MatChipInput, + MatChipLeadingIcon, MatChipListbox, MatChipOption, MatChipRemove, diff --git a/src/material/chips/chips.md b/src/material/chips/chips.md index c0c0851f70a8..7b22ae94e8c3 100644 --- a/src/material/chips/chips.md +++ b/src/material/chips/chips.md @@ -78,12 +78,14 @@ You can add icons to chips to identify entities (like individuals) and provide a You can add two additional icons to an individual chip. A chip has two slots to display icons using content projection. All variants of chips support adding icons including ``, ``, and ``. -A chip has a front slot for adding an avatar image. To add an avatar, nest an element with `matChipAvatar` attribute inside of ``. +A chip has a front slot for adding an avatar image or leading icon. To add an avatar, nest an element with `matChipAvatar` attribute inside of ``. To add a leading icon, nest an element with `matChipLeadingIcon` attribute inside of ``. You can add an additional icon to the back slot by nesting an element with either the `matChipTrailingIcon` or `matChipRemove` attribute. + + #### Remove Button Sometimes the end user would like the ability to remove a chip. You can provide that functionality using `matChipRemove`. `matChipRemove` renders to the back slot of a chip and triggers the `removed` Output when clicked. diff --git a/src/material/chips/testing/chip-harness-filters.ts b/src/material/chips/testing/chip-harness-filters.ts index d42e3c6a803a..44323ad85000 100644 --- a/src/material/chips/testing/chip-harness-filters.ts +++ b/src/material/chips/testing/chip-harness-filters.ts @@ -49,4 +49,6 @@ export interface ChipRemoveHarnessFilters extends BaseHarnessFilters {} export interface ChipAvatarHarnessFilters extends BaseHarnessFilters {} +export interface ChipLeadingIconHarnessFilters extends BaseHarnessFilters {} + export interface ChipEditInputHarnessFilters extends BaseHarnessFilters {} diff --git a/src/material/chips/testing/chip-harness.spec.ts b/src/material/chips/testing/chip-harness.spec.ts index 8fd39bfee45a..eaa17bf28c8e 100644 --- a/src/material/chips/testing/chip-harness.spec.ts +++ b/src/material/chips/testing/chip-harness.spec.ts @@ -6,6 +6,7 @@ import {MatIconModule} from '../../icon'; import {MatIconHarness} from '../../icon/testing'; import {MatChipsModule} from '../index'; import {MatChipHarness} from './chip-harness'; +import {MatChipLeadingIconHarness} from './chip-leading-icon-harness'; describe('MatChipHarness', () => { let fixture: ComponentFixture; @@ -19,23 +20,24 @@ describe('MatChipHarness', () => { it('should get correct number of chip harnesses', async () => { const harnesses = await loader.getAllHarnesses(MatChipHarness); - expect(harnesses.length).toBe(5); + expect(harnesses.length).toBe(6); }); it('should get the chip text content', async () => { const harnesses = await loader.getAllHarnesses(MatChipHarness); expect(await harnesses[0].getText()).toBe('Basic Chip'); expect(await harnesses[1].getText()).toBe('Chip'); - expect(await harnesses[2].getText()).toBe('Chip with avatar'); - expect(await harnesses[3].getText()).toBe('Disabled Chip'); - expect(await harnesses[4].getText()).toBe('Chip Row'); + expect(await harnesses[2].getText()).toBe('Chip with leading icon'); + expect(await harnesses[3].getText()).toBe('Chip with avatar'); + expect(await harnesses[4].getText()).toBe('Disabled Chip'); + expect(await harnesses[5].getText()).toBe('Chip Row'); }); it('should be able to remove a chip', async () => { const removeChipSpy = spyOn(fixture.componentInstance, 'removeChip'); const harnesses = await loader.getAllHarnesses(MatChipHarness); - await harnesses[4].remove(); + await harnesses[5].remove(); expect(removeChipSpy).toHaveBeenCalledTimes(1); }); @@ -43,7 +45,7 @@ describe('MatChipHarness', () => { it('should get the disabled state of a chip', async () => { const harnesses = await loader.getAllHarnesses(MatChipHarness); const disabledStates = await parallel(() => harnesses.map(harness => harness.isDisabled())); - expect(disabledStates).toEqual([false, false, false, true, false]); + expect(disabledStates).toEqual([false, false, false, false, true, false]); }); it('should get the remove button of a chip', async () => { @@ -63,6 +65,25 @@ describe('MatChipHarness', () => { expect(await avatarHost?.getAttribute('aria-label')).toBe('Coronavirus'); }); + it('should find leading icon in chip', async () => { + const chip = await loader.getHarness( + MatChipHarness.with({ + selector: '.mat-mdc-chip-with-leading-icon', + }), + ); + const leadingIcon = await chip.getLeadingIcon(); + expect(leadingIcon).toBeTruthy(); + const leadingIconHost = await leadingIcon?.host(); + expect(await leadingIconHost?.text()).toBe('leading_icon'); + }); + + it('should find leading icon with loader', async () => { + const leadingIcon = await loader.getHarness(MatChipLeadingIconHarness); + expect(leadingIcon).toBeTruthy(); + const host = await leadingIcon.host(); + expect(await host.text()).toBe('leading_icon'); + }); + it('should find icon in chip', async () => { const chip = await loader.getHarness( MatChipHarness.with({ @@ -80,6 +101,7 @@ describe('MatChipHarness', () => { template: ` Basic Chip Chip trailing_icon + leading_icon Chip with leading icon coronavirus Chip with avatar diff --git a/src/material/chips/testing/chip-harness.ts b/src/material/chips/testing/chip-harness.ts index 4b7c5133c70f..b946e0ca772f 100644 --- a/src/material/chips/testing/chip-harness.ts +++ b/src/material/chips/testing/chip-harness.ts @@ -17,9 +17,11 @@ import { ChipAvatarHarnessFilters, ChipEditHarnessFilters, ChipHarnessFilters, + ChipLeadingIconHarnessFilters, ChipRemoveHarnessFilters, } from './chip-harness-filters'; import {MatChipEditHarness} from './chip-edit-harness'; +import {MatChipLeadingIconHarness} from './chip-leading-icon-harness'; import {MatChipRemoveHarness} from './chip-remove-harness'; /** Harness for interacting with a mat-chip in tests. */ @@ -49,7 +51,8 @@ export class MatChipHarness extends ContentContainerComponentHarness { /** Gets a promise for the text content the option. */ async getText(): Promise { return (await this.host()).text({ - exclude: '.mat-mdc-chip-avatar, .mat-mdc-chip-trailing-icon, .mat-icon', + exclude: + '.mat-mdc-chip-avatar, .mat-mdc-chip-leading-icon, .mat-mdc-chip-trailing-icon, .mat-icon', }); } @@ -87,4 +90,14 @@ export class MatChipHarness extends ContentContainerComponentHarness { async getAvatar(filter: ChipAvatarHarnessFilters = {}): Promise { return this.locatorForOptional(MatChipAvatarHarness.with(filter))(); } + + /** + * Gets the leading icon inside a chip. + * @param filter Optionally filters which leading icons are included. + */ + async getLeadingIcon( + filter: ChipLeadingIconHarnessFilters = {}, + ): Promise { + return this.locatorForOptional(MatChipLeadingIconHarness.with(filter))(); + } } diff --git a/src/material/chips/testing/chip-leading-icon-harness.ts b/src/material/chips/testing/chip-leading-icon-harness.ts new file mode 100644 index 000000000000..b7dfbc051469 --- /dev/null +++ b/src/material/chips/testing/chip-leading-icon-harness.ts @@ -0,0 +1,32 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { + ComponentHarness, + ComponentHarnessConstructor, + HarnessPredicate, +} from '@angular/cdk/testing'; +import {ChipLeadingIconHarnessFilters} from './chip-harness-filters'; + +/** Harness for interacting with a standard Material chip leading icon in tests. */ +export class MatChipLeadingIconHarness extends ComponentHarness { + static hostSelector = '.mat-mdc-chip-leading-icon'; + + /** + * Gets a `HarnessPredicate` that can be used to search for a chip leading icon with specific + * attributes. + * @param options Options for filtering which input instances are considered a match. + * @return a `HarnessPredicate` configured with the given options. + */ + static with( + this: ComponentHarnessConstructor, + options: ChipLeadingIconHarnessFilters = {}, + ): HarnessPredicate { + return new HarnessPredicate(this, options); + } +} diff --git a/src/material/chips/testing/public-api.ts b/src/material/chips/testing/public-api.ts index 222ff8ce2414..2a97c946ca12 100644 --- a/src/material/chips/testing/public-api.ts +++ b/src/material/chips/testing/public-api.ts @@ -7,6 +7,7 @@ */ export * from './chip-avatar-harness'; +export * from './chip-leading-icon-harness'; export * from './chip-edit-harness'; export * from './chip-harness'; export * from './chip-harness-filters'; diff --git a/src/material/chips/tokens.ts b/src/material/chips/tokens.ts index 945ac4bc39b2..0d7b7e110157 100644 --- a/src/material/chips/tokens.ts +++ b/src/material/chips/tokens.ts @@ -46,6 +46,13 @@ export const MAT_CHIPS_DEFAULT_OPTIONS = new InjectionToken