diff --git a/goldens/material/chips/index.api.md b/goldens/material/chips/index.api.md index 9ff35385222d..a4d6ebc9afc0 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,20 @@ export class MatChipAvatar { static ɵfac: i0.ɵɵFactoryDeclaration; } +// @public +export class MatChipEdit extends MatChipAction { + // (undocumented) + _handleClick(event: MouseEvent): void; + // (undocumented) + _handleKeydown(event: KeyboardEvent): void; + // (undocumented) + _isPrimary: boolean; + // (undocumented) + static ɵdir: i0.ɵɵDirectiveDeclaration; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration; +} + // @public export interface MatChipEditedEvent extends MatChipEvent { value: string; @@ -420,6 +440,8 @@ export class MatChipRow extends MatChip implements AfterViewInit { contentEditInput?: MatChipEditInput; defaultEditInput?: MatChipEditInput; // (undocumented) + _edit(): void; + // (undocumented) editable: boolean; readonly edited: EventEmitter; // (undocumented) @@ -427,6 +449,7 @@ export class MatChipRow extends MatChip implements AfterViewInit { _handleFocus(): void; // (undocumented) _handleKeydown(event: KeyboardEvent): void; + protected _hasLeadingIcon(): boolean; // (undocumented) _hasTrailingIcon(): boolean; // (undocumented) @@ -434,7 +457,7 @@ export class MatChipRow extends MatChip implements AfterViewInit { // (undocumented) _isRippleDisabled(): boolean; // (undocumented) - static ɵcmp: i0.ɵɵComponentDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; } @@ -511,7 +534,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/dev-app/chips/chips-demo.html b/src/dev-app/chips/chips-demo.html index 99726bb6fc22..9aecfb6ba882 100644 --- a/src/dev-app/chips/chips-demo.html +++ b/src/dev-app/chips/chips-demo.html @@ -160,6 +160,8 @@

Multi selection

Disabled Editable + Show Avatar + Show Edit Icon Disabled Interactive

Input is last child of chip grid

@@ -172,6 +174,14 @@

Input is last child of chip grid

[editable]="editable" (removed)="remove(person)" (edited)="edit(person, $event)"> + @if (showEditIcon) { + + } + @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 _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..27bf4fab29e3 100644 --- a/src/material/chips/chip-row.html +++ b/src/material/chips/chip-row.html @@ -2,12 +2,17 @@ } +@if (!_isEditing && editIcon) { + + + +} - @if (leadingIcon) { + @if (!_isEditing && leadingIcon) { diff --git a/src/material/chips/chip-row.spec.ts b/src/material/chips/chip-row.spec.ts index 08d957d65896..167575b5f0e0 100644 --- a/src/material/chips/chip-row.spec.ts +++ b/src/material/chips/chip-row.spec.ts @@ -351,6 +351,21 @@ describe('Row Chips', () => { })); }); + 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'; @@ -403,6 +418,9 @@ describe('Row Chips', () => { (destroyed)="chipDestroy($event)" (removed)="chipRemove($event)" (edited)="chipEdit($event)" [aria-label]="ariaLabel" [aria-description]="ariaDescription"> + @if (showEditIcon) { + + } {{name}} @if (useCustomEditInput) { @@ -424,6 +442,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 8325255a5d41..6e6a9a55e11e 100644 --- a/src/material/chips/chip-row.ts +++ b/src/material/chips/chip-row.ts @@ -41,15 +41,16 @@ 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]': '_hasLeadingIcon()', '[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]': '!!editIcon', '[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]': '_hasLeadingIcon()', + '[class.mdc-evolution-chip--with-primary-icon]': '_hasLeadingIcon()', + '[class.mdc-evolution-chip--with-avatar]': '_hasLeadingIcon()', '[class.mat-mdc-chip-highlighted]': 'highlighted', '[class.mat-mdc-chip-with-trailing-icon]': '_hasTrailingIcon()', '[id]': 'id', @@ -107,6 +108,11 @@ export class MatChipRow extends MatChip implements AfterViewInit { }); } + /** Returns whether the chip has a leading icon. */ + protected _hasLeadingIcon() { + return !this._isEditing && !!(this.editIcon || this.leadingIcon); + } + override _hasTrailingIcon() { // The trailing icon is hidden while editing. return !this._isEditing && super._hasTrailingIcon(); @@ -141,10 +147,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; } @@ -158,7 +172,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 0108a8d5e200..dd382c09467d 100644 --- a/src/material/chips/chip.scss +++ b/src/material/chips/chip.scss @@ -274,6 +274,7 @@ $fallbacks: m3-chip.get-tokens(m3-system.$theme-with-system-vars); // 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}) & { @@ -305,6 +306,11 @@ $fallbacks: m3-chip.get-tokens(m3-system.$theme-with-system-vars); padding-right: $_avatar-trailing-padding; } + .mdc-evolution-chip--with-avatar#{$with-graphic}#{$with-leading} & { + padding-left: 0; + padding-right: $_avatar-trailing-padding; + } + [dir='rtl'] .mdc-evolution-chip--with-avatar#{$with-graphic}#{$with-trailing} & { padding-left: $_avatar-trailing-padding; padding-right: $_avatar-leading-padding; @@ -531,7 +537,7 @@ $fallbacks: m3-chip.get-tokens(m3-system.$theme-with-system-vars); } } - .mat-mdc-chip-remove { + .mat-mdc-chip-edit, .mat-mdc-chip-remove { opacity: token-utils.slot(trailing-action-opacity); &:focus { @@ -683,7 +689,7 @@ $fallbacks: m3-chip.get-tokens(m3-system.$theme-with-system-vars); } } -.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}); @@ -747,6 +753,6 @@ $fallbacks: m3-chip.get-tokens(m3-system.$theme-with-system-vars); // 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