From fe5ea55dc70f15e5ab380183caf429883d19a38c Mon Sep 17 00:00:00 2001 From: Andrey Dolgachev Date: Fri, 2 May 2025 12:57:41 -0700 Subject: [PATCH] feat(material/chips): add (optional) edit icon to input chips --- goldens/material/chips/index.api.md | 32 ++++++++++-- goldens/material/chips/testing/index.api.md | 13 +++++ .../chips-input/chips-input-example.html | 3 ++ src/dev-app/chips/chips-demo.html | 10 ++++ src/dev-app/chips/chips-demo.ts | 15 +++--- src/material/chips/chip-action.ts | 7 ++- src/material/chips/chip-grid.spec.ts | 4 +- src/material/chips/chip-icons.ts | 52 ++++++++++++++++++- src/material/chips/chip-row.html | 5 ++ src/material/chips/chip-row.spec.ts | 19 +++++++ src/material/chips/chip-row.ts | 22 ++++++-- src/material/chips/chip.scss | 30 +++++++++-- src/material/chips/chip.ts | 27 +++++++++- src/material/chips/module.ts | 3 +- .../chips/testing/chip-edit-harness.ts | 37 +++++++++++++ .../chips/testing/chip-harness-filters.ts | 2 + src/material/chips/testing/chip-harness.ts | 10 ++++ src/material/chips/testing/public-api.ts | 1 + src/material/chips/tokens.ts | 7 +++ 19 files changed, 275 insertions(+), 24 deletions(-) create mode 100644 src/material/chips/testing/chip-edit-harness.ts diff --git a/goldens/material/chips/index.api.md b/goldens/material/chips/index.api.md index 8c3c61b45309..fdcc60891262 100644 --- a/goldens/material/chips/index.api.md +++ b/goldens/material/chips/index.api.md @@ -35,6 +35,9 @@ export const MAT_CHIP: InjectionToken; // @public export const MAT_CHIP_AVATAR: InjectionToken; +// @public +export const MAT_CHIP_EDIT: InjectionToken; + // @public export const MAT_CHIP_LISTBOX_CONTROL_VALUE_ACCESSOR: any; @@ -50,6 +53,7 @@ export const MAT_CHIPS_DEFAULT_OPTIONS: InjectionToken; // @public export class MatChip implements OnInit, AfterViewInit, AfterContentInit, DoCheck, OnDestroy { constructor(...args: unknown[]); + protected _allEditIcons: QueryList; protected _allLeadingIcons: QueryList; protected _allRemoveIcons: QueryList; protected _allTrailingIcons: QueryList; @@ -68,6 +72,8 @@ export class MatChip implements OnInit, AfterViewInit, AfterContentInit, DoCheck disableRipple: boolean; // (undocumented) protected _document: Document; + _edit(event: Event): void; + editIcon: MatChipEdit; // (undocumented) _elementRef: ElementRef; focus(): void; @@ -119,7 +125,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; } @@ -132,6 +138,22 @@ export class MatChipAvatar { static ɵfac: i0.ɵɵFactoryDeclaration; } +// @public +export class MatChipEdit extends MatChipAction { + // (undocumented) + _handleClick(event: MouseEvent): void; + // (undocumented) + _handleKeydown(event: KeyboardEvent): void; + // (undocumented) + _isLeading: boolean; + // (undocumented) + _isPrimary: boolean; + // (undocumented) + static ɵdir: i0.ɵɵDirectiveDeclaration; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration; +} + // @public export interface MatChipEditedEvent extends MatChipEvent { value: string; @@ -420,6 +442,8 @@ export class MatChipRow extends MatChip implements AfterViewInit { contentEditInput?: MatChipEditInput; defaultEditInput?: MatChipEditInput; // (undocumented) + _edit(): void; + // (undocumented) editable: boolean; readonly edited: EventEmitter; // (undocumented) @@ -430,6 +454,8 @@ export class MatChipRow extends MatChip implements AfterViewInit { // (undocumented) _handleKeydown(event: KeyboardEvent): void; // (undocumented) + protected _hasLeadingActionIcon(): boolean; + // (undocumented) _hasTrailingIcon(): boolean; // (undocumented) _isEditing: boolean; @@ -438,7 +464,7 @@ export class MatChipRow extends MatChip implements AfterViewInit { // (undocumented) ngAfterViewInit(): void; // (undocumented) - static ɵcmp: i0.ɵɵComponentDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; } @@ -515,7 +541,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 f23dead77a18..150b9d3fe079 100644 --- a/goldens/material/chips/testing/index.api.md +++ b/goldens/material/chips/testing/index.api.md @@ -16,6 +16,10 @@ import { TestKey } from '@angular/cdk/testing'; export interface ChipAvatarHarnessFilters extends BaseHarnessFilters { } +// @public (undocumented) +export interface ChipEditHarnessFilters extends BaseHarnessFilters { +} + // @public (undocumented) export interface ChipEditInputHarnessFilters extends BaseHarnessFilters { } @@ -67,6 +71,14 @@ export class MatChipAvatarHarness extends ComponentHarness { static with(this: ComponentHarnessConstructor, options?: ChipAvatarHarnessFilters): HarnessPredicate; } +// @public +export class MatChipEditHarness extends ComponentHarness { + click(): Promise; + // (undocumented) + static hostSelector: string; + static with(this: ComponentHarnessConstructor, options?: ChipEditHarnessFilters): HarnessPredicate; +} + // @public export class MatChipEditInputHarness extends ComponentHarness { // (undocumented) @@ -89,6 +101,7 @@ export class MatChipGridHarness extends ComponentHarness { // @public export class MatChipHarness extends ContentContainerComponentHarness { + geEditButton(filter?: ChipEditHarnessFilters): Promise; getAvatar(filter?: ChipAvatarHarnessFilters): Promise; getRemoveButton(filter?: ChipRemoveHarnessFilters): Promise; getText(): Promise; diff --git a/src/components-examples/material/chips/chips-input/chips-input-example.html b/src/components-examples/material/chips/chips-input/chips-input-example.html index 3d29e5442015..b1e75ee2426a 100644 --- a/src/components-examples/material/chips/chips-input/chips-input-example.html +++ b/src/components-examples/material/chips/chips-input/chips-input-example.html @@ -8,6 +8,9 @@ (edited)="edit(fruit, $event)" [aria-description]="'press enter to edit ' + fruit.name" > + {{fruit.name}} + } + @if (peopleWithAvatar && person.avatar) { + {{person.avatar}} + } {{person.name}} + * + * ``` + */ + +@Directive({ + selector: '[matChipEdit]', + host: { + 'class': + 'mat-mdc-chip-edit mat-mdc-chip-avatar mat-focus-indicator ' + + 'mdc-evolution-chip__icon mdc-evolution-chip__icon--primary', + 'role': 'button', + '[attr.aria-hidden]': 'null', + }, + providers: [{provide: MAT_CHIP_EDIT, useExisting: MatChipEdit}], +}) +export class MatChipEdit extends MatChipAction { + override _isPrimary = false; + override _isLeading = true; + + override _handleClick(event: MouseEvent): void { + if (!this.disabled) { + event.stopPropagation(); + event.preventDefault(); + this._parentChip._edit(); + } + } + + override _handleKeydown(event: KeyboardEvent) { + if ((event.keyCode === ENTER || event.keyCode === SPACE) && !this.disabled) { + event.stopPropagation(); + event.preventDefault(); + this._parentChip._edit(); + } + } +} + /** * Directive to remove the parent chip when the trailing icon is clicked or * when the ENTER key is pressed on it. diff --git a/src/material/chips/chip-row.html b/src/material/chips/chip-row.html index f2f5b0af5190..47f53c59b303 100644 --- a/src/material/chips/chip-row.html +++ b/src/material/chips/chip-row.html @@ -2,6 +2,11 @@ } +@if (_hasLeadingActionIcon()) { + + + +} { })); }); + describe('with edit icon', () => { + beforeEach(async () => { + testComponent.showEditIcon = true; + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + }); + + it('should begin editing on edit click', () => { + expect(chipNativeElement.querySelector('.mat-chip-edit-input')).toBeFalsy(); + dispatchFakeEvent(chipNativeElement.querySelector('.mat-mdc-chip-edit')!, 'click'); + fixture.detectChanges(); + expect(chipNativeElement.querySelector('.mat-chip-edit-input')).toBeTruthy(); + }); + }); + describe('a11y', () => { it('should apply `ariaLabel` and `ariaDesciption` to the primary gridcell', () => { fixture.componentInstance.ariaLabel = 'chip name'; @@ -488,6 +503,9 @@ describe('Row Chips', () => { (destroyed)="chipDestroy($event)" (removed)="chipRemove($event)" (edited)="chipEdit($event)" [aria-label]="ariaLabel" [aria-description]="ariaDescription"> + @if (showEditIcon) { + + } {{name}} @if (useCustomEditInput) { @@ -509,6 +527,7 @@ class SingleChip { removable: boolean = true; shouldShow: boolean = true; editable: boolean = false; + showEditIcon: boolean = false; useCustomEditInput: boolean = true; ariaLabel: string | null = null; ariaDescription: string | null = null; diff --git a/src/material/chips/chip-row.ts b/src/material/chips/chip-row.ts index a6b478664226..f7db3d950ff2 100644 --- a/src/material/chips/chip-row.ts +++ b/src/material/chips/chip-row.ts @@ -46,6 +46,7 @@ export interface MatChipEditedEvent extends MatChipEvent { '[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', @@ -130,6 +131,11 @@ export class MatChipRow extends MatChip implements AfterViewInit { }); } + protected _hasLeadingActionIcon() { + // The leading action (edit) icon is hidden while editing. + return !this._isEditing && !!this.editIcon; + } + override _hasTrailingIcon() { // The trailing icon is hidden while editing. return !this._isEditing && super._hasTrailingIcon(); @@ -174,10 +180,18 @@ export class MatChipRow extends MatChip implements AfterViewInit { } } - private _startEditing(event: Event) { + override _edit(): void { + // markForCheck necessary for edit input to be rendered + this._changeDetectorRef.markForCheck(); + this._startEditing(); + } + + private _startEditing(event?: Event) { if ( !this.primaryAction || - (this.removeIcon && this._getSourceAction(event.target as Node) === this.removeIcon) + (this.removeIcon && + !!event && + this._getSourceAction(event.target as Node) === this.removeIcon) ) { return; } @@ -191,7 +205,9 @@ export class MatChipRow extends MatChip implements AfterViewInit { afterNextRender( () => { this._getEditInput().initialize(value); - this._editStartPending = false; + + // Necessary when using edit icon to prevent edit from aborting + setTimeout(() => this._ngZone.run(() => (this._editStartPending = false))); }, {injector: this._injector}, ); diff --git a/src/material/chips/chip.scss b/src/material/chips/chip.scss index c0ae4c2d4407..27b9640c9dd1 100644 --- a/src/material/chips/chip.scss +++ b/src/material/chips/chip.scss @@ -130,8 +130,19 @@ $fallbacks: m3-chip.get-tokens(); // Moved out into variables, because the selectors are too long. $with-graphic: '.mdc-evolution-chip--with-primary-graphic'; + $with-leading: '.mdc-evolution-chip--with-leading-action'; $with-trailing: '.mdc-evolution-chip--with-trailing-action'; + .mat-mdc-standard-chip#{$with-leading} & { + padding-left: 0; + padding-right: $_action-padding; + } + + [dir='rtl'] .mat-mdc-standard-chip#{$with-leading} & { + padding-left: $_action-padding; + padding-right: 0; + } + .mat-mdc-standard-chip#{$with-trailing} & { padding-left: $_action-padding; padding-right: 0; @@ -142,6 +153,11 @@ $fallbacks: m3-chip.get-tokens(); padding-right: $_action-padding; } + .mat-mdc-standard-chip#{$with-leading}#{$with-trailing} & { + padding-left: 0; + padding-right: 0; + } + .mat-mdc-standard-chip#{$with-graphic}#{$with-trailing} & { padding-left: 0; padding-right: 0; @@ -173,7 +189,7 @@ $fallbacks: m3-chip.get-tokens(); } } -.mdc-evolution-chip__action--trailing { +.mdc-evolution-chip__action--secondary { position: relative; overflow: visible; @@ -199,7 +215,6 @@ $fallbacks: m3-chip.get-tokens(); padding-right: $_trailing-action-padding; } - .mdc-evolution-chip--with-avatar#{$with-graphic}#{$with-trailing} & { padding-left: $_avatar-trailing-padding; padding-right: $_avatar-trailing-padding; @@ -262,6 +277,7 @@ $fallbacks: m3-chip.get-tokens(); // Moved out into variables, because the selectors are too long. $with-icon: '.mdc-evolution-chip--with-primary-icon'; $with-graphic: '.mdc-evolution-chip--with-primary-graphic'; + $with-leading: '.mdc-evolution-chip--with-leading-action'; $with-trailing: '.mdc-evolution-chip--with-trailing-action'; .mdc-evolution-chip--selectable:not(.mdc-evolution-chip--selected):not(#{$with-icon}) & { @@ -297,6 +313,10 @@ $fallbacks: m3-chip.get-tokens(); padding-left: $_avatar-trailing-padding; padding-right: $_avatar-leading-padding; } + + .mdc-evolution-chip--with-avatar#{$with-graphic}#{$with-leading} & { + padding-left: 0; + } } .mdc-evolution-chip__checkmark { @@ -499,7 +519,7 @@ $fallbacks: m3-chip.get-tokens(); } } -.mat-mdc-chip-remove { +.mat-mdc-chip-edit, .mat-mdc-chip-remove { opacity: token-utils.slot(chip-trailing-action-opacity, $fallbacks); &:focus { @@ -650,7 +670,7 @@ $fallbacks: m3-chip.get-tokens(); } } -.mat-mdc-chip-remove { +.mat-mdc-chip-edit, .mat-mdc-chip-remove { &::before { $default-border-width: focus-indicators-private.$default-border-width; $offset: var(--mat-focus-indicator-border-width, #{$default-border-width}); @@ -714,6 +734,6 @@ $fallbacks: m3-chip.get-tokens(); // Prevents icon from being cut off when text spacing is increased. // .mat-mdc-chip-remove selector necessary for remove button with icon. // Fixes b/250063405. -.mdc-evolution-chip__icon, .mat-mdc-chip-remove .mat-icon { +.mdc-evolution-chip__icon, .mat-mdc-chip-edit .mat-icon, .mat-mdc-chip-remove .mat-icon { min-height: fit-content; } diff --git a/src/material/chips/chip.ts b/src/material/chips/chip.ts index 2aa5a827a7f7..85e2cde30552 100644 --- a/src/material/chips/chip.ts +++ b/src/material/chips/chip.ts @@ -44,8 +44,14 @@ import { } from '../core'; import {Subject, Subscription, merge} from 'rxjs'; import {MatChipAction} from './chip-action'; -import {MatChipAvatar, MatChipRemove, MatChipTrailingIcon} from './chip-icons'; -import {MAT_CHIP, MAT_CHIP_AVATAR, MAT_CHIP_REMOVE, MAT_CHIP_TRAILING_ICON} from './tokens'; +import {MatChipAvatar, MatChipEdit, MatChipRemove, MatChipTrailingIcon} from './chip-icons'; +import { + MAT_CHIP, + MAT_CHIP_AVATAR, + MAT_CHIP_EDIT, + MAT_CHIP_REMOVE, + MAT_CHIP_TRAILING_ICON, +} from './tokens'; /** Represents an event fired on an individual `mat-chip`. */ export interface MatChipEvent { @@ -133,6 +139,10 @@ export class MatChip implements OnInit, AfterViewInit, AfterContentInit, DoCheck @ContentChildren(MAT_CHIP_TRAILING_ICON, {descendants: true}) protected _allTrailingIcons: QueryList; + /** All edit icons present in the chip. */ + @ContentChildren(MAT_CHIP_EDIT, {descendants: true}) + protected _allEditIcons: QueryList; + /** All remove icons present in the chip. */ @ContentChildren(MAT_CHIP_REMOVE, {descendants: true}) protected _allRemoveIcons: QueryList; @@ -225,6 +235,9 @@ export class MatChip implements OnInit, AfterViewInit, AfterContentInit, DoCheck /** The chip's leading icon. */ @ContentChild(MAT_CHIP_AVATAR) leadingIcon: MatChipAvatar; + /** The chip's leading edit icon. */ + @ContentChild(MAT_CHIP_EDIT) editIcon: MatChipEdit; + /** The chip's trailing icon. */ @ContentChild(MAT_CHIP_TRAILING_ICON) trailingIcon: MatChipTrailingIcon; @@ -279,6 +292,7 @@ export class MatChip implements OnInit, AfterViewInit, AfterContentInit, DoCheck this._actionChanges = merge( this._allLeadingIcons.changes, this._allTrailingIcons.changes, + this._allEditIcons.changes, this._allRemoveIcons.changes, ).subscribe(() => this._changeDetectorRef.markForCheck()); } @@ -358,6 +372,10 @@ export class MatChip implements OnInit, AfterViewInit, AfterContentInit, DoCheck _getActions(): MatChipAction[] { const result: MatChipAction[] = []; + if (this.editIcon) { + result.push(this.editIcon); + } + if (this.primaryAction) { result.push(this.primaryAction); } @@ -378,6 +396,11 @@ export class MatChip implements OnInit, AfterViewInit, AfterContentInit, DoCheck // Empty here, but is overwritten in child classes. } + /** Handles interactions with the edit action of the chip. */ + _edit(event: Event) { + // Empty here, but is overwritten in child classes. + } + /** Starts the focus monitoring process on the chip. */ private _monitorFocus() { this._focusMonitor.monitor(this._elementRef, true).subscribe(origin => { diff --git a/src/material/chips/module.ts b/src/material/chips/module.ts index 36771d4f9a41..105e6e88d16e 100644 --- a/src/material/chips/module.ts +++ b/src/material/chips/module.ts @@ -13,7 +13,7 @@ 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, MatChipRemove, MatChipTrailingIcon} from './chip-icons'; +import {MatChipAvatar, MatChipEdit, MatChipRemove, MatChipTrailingIcon} from './chip-icons'; import {MatChipInput} from './chip-input'; import {MatChipListbox} from './chip-listbox'; import {MatChipRow} from './chip-row'; @@ -24,6 +24,7 @@ import {MatChipAction} from './chip-action'; const CHIP_DECLARATIONS = [ MatChip, MatChipAvatar, + MatChipEdit, MatChipEditInput, MatChipGrid, MatChipInput, diff --git a/src/material/chips/testing/chip-edit-harness.ts b/src/material/chips/testing/chip-edit-harness.ts new file mode 100644 index 000000000000..dacebe95f5cd --- /dev/null +++ b/src/material/chips/testing/chip-edit-harness.ts @@ -0,0 +1,37 @@ +/** + * @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 {ChipEditHarnessFilters} from './chip-harness-filters'; + +/** Harness for interacting with a standard Material chip edit button in tests. */ +export class MatChipEditHarness extends ComponentHarness { + static hostSelector = '.mat-mdc-chip-edit'; + + /** + * Gets a `HarnessPredicate` that can be used to search for a chip edit 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: ChipEditHarnessFilters = {}, + ): HarnessPredicate { + return new HarnessPredicate(this, options); + } + + /** Clicks the edit button. */ + async click(): Promise { + return (await this.host()).click(); + } +} diff --git a/src/material/chips/testing/chip-harness-filters.ts b/src/material/chips/testing/chip-harness-filters.ts index 27a95806ebe3..d42e3c6a803a 100644 --- a/src/material/chips/testing/chip-harness-filters.ts +++ b/src/material/chips/testing/chip-harness-filters.ts @@ -43,6 +43,8 @@ export interface ChipRowHarnessFilters extends ChipHarnessFilters {} export interface ChipSetHarnessFilters extends BaseHarnessFilters {} +export interface ChipEditHarnessFilters extends BaseHarnessFilters {} + export interface ChipRemoveHarnessFilters extends BaseHarnessFilters {} export interface ChipAvatarHarnessFilters extends BaseHarnessFilters {} diff --git a/src/material/chips/testing/chip-harness.ts b/src/material/chips/testing/chip-harness.ts index 36fa99e5dedd..4b7c5133c70f 100644 --- a/src/material/chips/testing/chip-harness.ts +++ b/src/material/chips/testing/chip-harness.ts @@ -15,9 +15,11 @@ import { import {MatChipAvatarHarness} from './chip-avatar-harness'; import { ChipAvatarHarnessFilters, + ChipEditHarnessFilters, ChipHarnessFilters, ChipRemoveHarnessFilters, } from './chip-harness-filters'; +import {MatChipEditHarness} from './chip-edit-harness'; import {MatChipRemoveHarness} from './chip-remove-harness'; /** Harness for interacting with a mat-chip in tests. */ @@ -62,6 +64,14 @@ export class MatChipHarness extends ContentContainerComponentHarness { await hostEl.sendKeys(TestKey.DELETE); } + /** + * Gets the edit button inside of a chip. + * @param filter Optionally filters which chips are included. + */ + async geEditButton(filter: ChipEditHarnessFilters = {}): Promise { + return this.locatorFor(MatChipEditHarness.with(filter))(); + } + /** * Gets the remove button inside of a chip. * @param filter Optionally filters which chips are included. diff --git a/src/material/chips/testing/public-api.ts b/src/material/chips/testing/public-api.ts index 49398cf712f8..222ff8ce2414 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-edit-harness'; export * from './chip-harness'; export * from './chip-harness-filters'; export * from './chip-input-harness'; diff --git a/src/material/chips/tokens.ts b/src/material/chips/tokens.ts index 9c90f0253a0d..a95ed2c0b132 100644 --- a/src/material/chips/tokens.ts +++ b/src/material/chips/tokens.ts @@ -46,6 +46,13 @@ export const MAT_CHIP_AVATAR = new InjectionToken('MatChipAvatar'); */ export const MAT_CHIP_TRAILING_ICON = new InjectionToken('MatChipTrailingIcon'); +/** + * Injection token that can be used to reference instances of `MatChipEdit`. It serves as + * alternative token to the actual `MatChipEdit` class which could cause unnecessary + * retention of the class and its directive metadata. + */ +export const MAT_CHIP_EDIT = new InjectionToken('MatChipEdit'); + /** * Injection token that can be used to reference instances of `MatChipRemove`. It serves as * alternative token to the actual `MatChipRemove` class which could cause unnecessary