From 474f5075029a9da055b85084c947cfa547c6f992 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Sat, 28 May 2022 11:02:28 +0200 Subject: [PATCH] Revert "refactor(material-experimental/mdc-chips): remove usage of MDC adapter (#24961)" This reverts commit 50992f38226a5854041003442258045ca2c793a9. --- src/dev-app/mdc-chips/mdc-chips-demo.html | 4 +- .../mdc-chips/BUILD.bazel | 2 + .../mdc-chips/chip-action.ts | 147 ++++++--- .../mdc-chips/chip-default-options.ts | 20 ++ .../mdc-chips/chip-grid.spec.ts | 51 ++-- .../mdc-chips/chip-grid.ts | 121 ++++---- .../mdc-chips/chip-icons.ts | 54 +++- .../mdc-chips/chip-input.ts | 6 +- .../mdc-chips/chip-listbox.spec.ts | 15 +- .../mdc-chips/chip-listbox.ts | 81 ++++- .../mdc-chips/chip-option.html | 4 +- .../mdc-chips/chip-option.spec.ts | 3 +- .../mdc-chips/chip-option.ts | 122 ++++++-- .../mdc-chips/chip-remove.spec.ts | 44 ++- .../mdc-chips/chip-row.spec.ts | 22 +- .../mdc-chips/chip-row.ts | 116 ++++--- .../mdc-chips/chip-set.ts | 250 ++++++++-------- .../mdc-chips/chip.spec.ts | 3 +- src/material-experimental/mdc-chips/chip.ts | 283 ++++++++++++------ .../mdc-chips/emit-event.ts | 33 ++ src/material-experimental/mdc-chips/module.ts | 2 +- .../mdc-chips/public-api.ts | 2 +- src/material-experimental/mdc-chips/tokens.ts | 46 --- 23 files changed, 924 insertions(+), 507 deletions(-) create mode 100644 src/material-experimental/mdc-chips/chip-default-options.ts create mode 100644 src/material-experimental/mdc-chips/emit-event.ts delete mode 100644 src/material-experimental/mdc-chips/tokens.ts diff --git a/src/dev-app/mdc-chips/mdc-chips-demo.html b/src/dev-app/mdc-chips/mdc-chips-demo.html index e669128b9fc1..228e8ea2f88f 100644 --- a/src/dev-app/mdc-chips/mdc-chips-demo.html +++ b/src/dev-app/mdc-chips/mdc-chips-demo.html @@ -147,10 +147,8 @@

Input is last child of chip grid

New Contributor... - - {{person.name}} diff --git a/src/material-experimental/mdc-chips/BUILD.bazel b/src/material-experimental/mdc-chips/BUILD.bazel index 239641aede5c..906bb0a0fe8b 100644 --- a/src/material-experimental/mdc-chips/BUILD.bazel +++ b/src/material-experimental/mdc-chips/BUILD.bazel @@ -29,6 +29,7 @@ ng_module( "@npm//@angular/common", "@npm//@angular/core", "@npm//@angular/forms", + "@npm//@material/chips", ], ) @@ -90,6 +91,7 @@ ng_test_library( "@npm//@angular/common", "@npm//@angular/forms", "@npm//@angular/platform-browser", + "@npm//@material/chips", "@npm//rxjs", ], ) diff --git a/src/material-experimental/mdc-chips/chip-action.ts b/src/material-experimental/mdc-chips/chip-action.ts index 153c71a7efff..a7c42ffcdfa4 100644 --- a/src/material-experimental/mdc-chips/chip-action.ts +++ b/src/material-experimental/mdc-chips/chip-action.ts @@ -6,20 +6,36 @@ * found in the LICENSE file at https://angular.io/license */ -import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion'; -import {ENTER, SPACE} from '@angular/cdk/keycodes'; -import {Directive, ElementRef, Inject, Input} from '@angular/core'; -import {HasTabIndex, mixinTabIndex} from '@angular/material-experimental/mdc-core'; -import {MAT_CHIP} from './tokens'; +import { + AfterViewInit, + ChangeDetectorRef, + Directive, + ElementRef, + Inject, + Input, + OnChanges, + OnDestroy, + SimpleChanges, +} from '@angular/core'; +import {DOCUMENT} from '@angular/common'; +import { + MDCChipActionAdapter, + MDCChipActionFoundation, + MDCChipActionType, + MDCChipPrimaryActionFoundation, +} from '@material/chips'; +import {emitCustomEvent} from './emit-event'; +import { + CanDisable, + HasTabIndex, + mixinDisabled, + mixinTabIndex, +} from '@angular/material-experimental/mdc-core'; -abstract class _MatChipActionBase { - abstract disabled: boolean; -} - -const _MatChipActionMixinBase = mixinTabIndex(_MatChipActionBase, -1); +const _MatChipActionMixinBase = mixinTabIndex(mixinDisabled(class {}), -1); /** - * Section within a chip. + * Interactive element within a chip. * @docs-private */ @Directive({ @@ -27,11 +43,11 @@ const _MatChipActionMixinBase = mixinTabIndex(_MatChipActionBase, -1); inputs: ['disabled', 'tabIndex'], host: { 'class': 'mdc-evolution-chip__action mat-mdc-chip-action', - '[class.mdc-evolution-chip__action--primary]': '_isPrimary', + '[class.mdc-evolution-chip__action--primary]': `_getFoundation().actionType() === ${MDCChipActionType.PRIMARY}`, // Note that while our actions are interactive, we have to add the `--presentational` class, // in order to avoid some super-specific `:hover` styles from MDC. - '[class.mdc-evolution-chip__action--presentational]': '_isPrimary', - '[class.mdc-evolution-chip__action--trailing]': '!_isPrimary', + '[class.mdc-evolution-chip__action--presentational]': `_getFoundation().actionType() === ${MDCChipActionType.PRIMARY}`, + '[class.mdc-evolution-chip__action--trailing]': `_getFoundation().actionType() === ${MDCChipActionType.TRAILING}`, '[attr.tabindex]': '(disabled || !isInteractive) ? null : tabIndex', '[attr.disabled]': "disabled ? '' : null", '[attr.aria-disabled]': 'disabled', @@ -39,59 +55,98 @@ const _MatChipActionMixinBase = mixinTabIndex(_MatChipActionBase, -1); '(keydown)': '_handleKeydown($event)', }, }) -export class MatChipAction extends _MatChipActionMixinBase implements HasTabIndex { +export class MatChipAction + extends _MatChipActionMixinBase + implements AfterViewInit, OnDestroy, CanDisable, HasTabIndex, OnChanges +{ + private _document: Document; + private _foundation: MDCChipActionFoundation; + private _adapter: MDCChipActionAdapter = { + focus: () => this.focus(), + getAttribute: (name: string) => this._elementRef.nativeElement.getAttribute(name), + setAttribute: (name: string, value: string) => { + // MDC tries to update the tabindex directly in the DOM when navigating using the keyboard + // which overrides our own handling. If we detect such a case, assign it to the same property + // as the Angular binding in order to maintain consistency. + if (name === 'tabindex') { + this._updateTabindex(parseInt(value)); + } else { + this._elementRef.nativeElement.setAttribute(name, value); + } + }, + removeAttribute: (name: string) => { + if (name !== 'tabindex') { + this._elementRef.nativeElement.removeAttribute(name); + } + }, + getElementID: () => this._elementRef.nativeElement.id, + emitEvent: (eventName: string, data: T) => { + emitCustomEvent(this._elementRef.nativeElement, this._document, eventName, data, true); + }, + }; + /** Whether the action is interactive. */ @Input() isInteractive = true; - /** Whether this is the primary action in the chip. */ - _isPrimary = true; + _handleClick(event: MouseEvent) { + // Usually these events can't happen while the chip is disabled since the browser won't + // allow them which is what MDC seems to rely on, however the event can be faked in tests. + if (!this.disabled && this.isInteractive) { + this._foundation.handleClick(); + event.preventDefault(); + } + } - /** Whether the action is disabled. */ - @Input() - get disabled(): boolean { - return this._disabled || this._parentChip.disabled; + _handleKeydown(event: KeyboardEvent) { + // Usually these events can't happen while the chip is disabled since the browser won't + // allow them which is what MDC seems to rely on, however the event can be faked in tests. + if (!this.disabled && this.isInteractive) { + this._foundation.handleKeydown(event); + } } - set disabled(value: BooleanInput) { - this._disabled = coerceBooleanProperty(value); + + protected _createFoundation(adapter: MDCChipActionAdapter): MDCChipActionFoundation { + return new MDCChipPrimaryActionFoundation(adapter); } - private _disabled = false; constructor( - public _elementRef: ElementRef, - @Inject(MAT_CHIP) - protected _parentChip: { - _handlePrimaryActionInteraction(): void; - remove(): void; - disabled: boolean; - }, + public _elementRef: ElementRef, + @Inject(DOCUMENT) _document: any, + private _changeDetectorRef: ChangeDetectorRef, ) { super(); + this._foundation = this._createFoundation(this._adapter); if (_elementRef.nativeElement.nodeName === 'BUTTON') { _elementRef.nativeElement.setAttribute('type', 'button'); } } + ngAfterViewInit() { + this._foundation.init(); + this._foundation.setDisabled(this.disabled); + } + + ngOnChanges(changes: SimpleChanges) { + if (changes['disabled']) { + this._foundation.setDisabled(this.disabled); + } + } + + ngOnDestroy() { + this._foundation.destroy(); + } + focus() { this._elementRef.nativeElement.focus(); } - _handleClick(event: MouseEvent) { - if (!this.disabled && this.isInteractive && this._isPrimary) { - event.preventDefault(); - this._parentChip._handlePrimaryActionInteraction(); - } + _getFoundation() { + return this._foundation; } - _handleKeydown(event: KeyboardEvent) { - if ( - (event.keyCode === ENTER || event.keyCode === SPACE) && - !this.disabled && - this.isInteractive && - this._isPrimary - ) { - event.preventDefault(); - this._parentChip._handlePrimaryActionInteraction(); - } + _updateTabindex(value: number) { + this.tabIndex = value; + this._changeDetectorRef.markForCheck(); } } diff --git a/src/material-experimental/mdc-chips/chip-default-options.ts b/src/material-experimental/mdc-chips/chip-default-options.ts new file mode 100644 index 000000000000..3f9f392cf6aa --- /dev/null +++ b/src/material-experimental/mdc-chips/chip-default-options.ts @@ -0,0 +1,20 @@ +/** + * @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.io/license + */ + +import {InjectionToken} from '@angular/core'; + +/** Default options, for the chips module, that can be overridden. */ +export interface MatChipsDefaultOptions { + /** The list of key codes that will trigger a chipEnd event. */ + separatorKeyCodes: readonly number[] | ReadonlySet; +} + +/** Injection token to be used to override the default options for the chips module. */ +export const MAT_CHIPS_DEFAULT_OPTIONS = new InjectionToken( + 'mat-chips-default-options', +); diff --git a/src/material-experimental/mdc-chips/chip-grid.spec.ts b/src/material-experimental/mdc-chips/chip-grid.spec.ts index f0b34e39c298..ef69491bfb5a 100644 --- a/src/material-experimental/mdc-chips/chip-grid.spec.ts +++ b/src/material-experimental/mdc-chips/chip-grid.spec.ts @@ -16,7 +16,6 @@ import { dispatchFakeEvent, dispatchKeyboardEvent, MockNgZone, - patchElementFocus, typeInElement, } from '@angular/cdk/testing/private'; import { @@ -35,6 +34,7 @@ import {MatFormFieldModule} from '@angular/material-experimental/mdc-form-field' import {MatInputModule} from '@angular/material-experimental/mdc-input'; import {By} from '@angular/platform-browser'; import {BrowserAnimationsModule, NoopAnimationsModule} from '@angular/platform-browser/animations'; +import {MDCChipAnimation} from '@material/chips'; import {MatChipEvent, MatChipGrid, MatChipInputEvent, MatChipRow, MatChipsModule} from './index'; describe('MDC-based MatChipGrid', () => { @@ -199,7 +199,6 @@ describe('MDC-based MatChipGrid', () => { // Destroy the middle item testComponent.chips.splice(2, 1); fixture.detectChanges(); - flush(); // Should not have focus expect(chipGridNativeElement.contains(document.activeElement)).toBe(false); @@ -209,7 +208,6 @@ describe('MDC-based MatChipGrid', () => { testComponent.chips = [0]; spyOn(chipGridInstance, 'focus'); - patchElementFocus(chips.last.primaryAction!._elementRef.nativeElement); chips.last.focus(); testComponent.chips.pop(); @@ -218,22 +216,27 @@ describe('MDC-based MatChipGrid', () => { expect(chipGridInstance.focus).toHaveBeenCalled(); }); - it('should move focus to the last chip when the focused chip was deleted inside a component with animations', fakeAsync(() => { - fixture.destroy(); - TestBed.resetTestingModule(); + it( + 'should move focus to the last chip when the focused chip was deleted inside a ' + + 'component with animations', + fakeAsync(() => { + fixture.destroy(); + TestBed.resetTestingModule(); - fixture = createComponent(StandardChipGridWithAnimations, BrowserAnimationsModule); + fixture = createComponent(StandardChipGridWithAnimations, BrowserAnimationsModule); - patchElementFocus(chips.last.primaryAction!._elementRef.nativeElement); - chips.last.focus(); - fixture.detectChanges(); + chips.last.focus(); + fixture.detectChanges(); - dispatchKeyboardEvent(chips.last._elementRef.nativeElement, 'keydown', BACKSPACE); - fixture.detectChanges(); - tick(500); + expect(document.activeElement).toBe(primaryActions[primaryActions.length - 1]); - expect(document.activeElement).toBe(primaryActions[primaryActions.length - 2]); - })); + dispatchKeyboardEvent(chips.last._elementRef.nativeElement, 'keydown', BACKSPACE); + fixture.detectChanges(); + tick(500); + + expect(document.activeElement).toBe(primaryActions[primaryActions.length - 2]); + }), + ); }); it('should have a focus indicator', () => { @@ -391,7 +394,6 @@ describe('MDC-based MatChipGrid', () => { expect(document.activeElement).toBe(primaryActions[1]); directionality.value = 'rtl'; - directionality.change.next('rtl'); fixture.detectChanges(); dispatchKeyboardEvent(primaryActions[1], 'keydown', RIGHT_ARROW); @@ -560,7 +562,14 @@ describe('MDC-based MatChipGrid', () => { // associated chip remove element. trailingActions[2].click(); fixture.detectChanges(); + (chip as any)._handleAnimationend({ + animationName: MDCChipAnimation.EXIT, + target: chip._elementRef.nativeElement, + }); flush(); + (chip as any)._handleTransitionend({target: chip._elementRef.nativeElement}); + flush(); + fixture.detectChanges(); expect(document.activeElement).toBe(primaryActions[3]); })); @@ -580,6 +589,7 @@ describe('MDC-based MatChipGrid', () => { .map(chip => chip.nativeElement); nativeChipGrid = fixture.debugElement.query(By.css('mat-chip-grid'))!.nativeElement; + nativeInput = fixture.nativeElement.querySelector('input'); }); @@ -720,21 +730,18 @@ describe('MDC-based MatChipGrid', () => { it('should blur the form field when the active chip is blurred', fakeAsync(() => { const formField: HTMLElement = fixture.nativeElement.querySelector('.mat-mdc-form-field'); - const firstAction = nativeChips[0].querySelector('.mat-mdc-chip-action') as HTMLElement; - patchElementFocus(firstAction); - firstAction.focus(); + dispatchFakeEvent(nativeChips[0], 'focusin'); fixture.detectChanges(); expect(formField.classList).toContain('mat-focused'); - firstAction.blur(); + dispatchFakeEvent(nativeChips[0], 'focusout'); fixture.detectChanges(); + tick(); fixture.detectChanges(); zone.simulateZoneExit(); fixture.detectChanges(); - flush(); - expect(formField.classList).not.toContain('mat-focused'); })); diff --git a/src/material-experimental/mdc-chips/chip-grid.ts b/src/material-experimental/mdc-chips/chip-grid.ts index cdf0c486b859..e8ed7e87d9e5 100644 --- a/src/material-experimental/mdc-chips/chip-grid.ts +++ b/src/material-experimental/mdc-chips/chip-grid.ts @@ -7,7 +7,7 @@ */ import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion'; -import {hasModifierKey, TAB} from '@angular/cdk/keycodes'; +import {TAB} from '@angular/cdk/keycodes'; import { AfterContentInit, AfterViewInit, @@ -18,6 +18,7 @@ import { DoCheck, ElementRef, EventEmitter, + Inject, Input, OnDestroy, Optional, @@ -33,19 +34,20 @@ import { NgForm, Validators, } from '@angular/forms'; +import {DOCUMENT} from '@angular/common'; import { CanUpdateErrorState, ErrorStateMatcher, mixinErrorState, } from '@angular/material-experimental/mdc-core'; import {MatFormFieldControl} from '@angular/material-experimental/mdc-form-field'; +import {LiveAnnouncer} from '@angular/cdk/a11y'; import {MatChipTextControl} from './chip-text-control'; -import {Observable, Subject, merge} from 'rxjs'; -import {takeUntil} from 'rxjs/operators'; +import {Observable, Subject} from 'rxjs'; +import {startWith, takeUntil} from 'rxjs/operators'; import {MatChipEvent} from './chip'; import {MatChipRow} from './chip-row'; import {MatChipSet} from './chip-set'; -import {Directionality} from '@angular/cdk/bidi'; /** Change event object that is emitted when the chip grid value has changed. */ export class MatChipGridChange { @@ -70,9 +72,10 @@ class MatChipGridBase extends MatChipSet { readonly stateChanges = new Subject(); constructor( + liveAnnouncer: LiveAnnouncer, + document: any, elementRef: ElementRef, changeDetectorRef: ChangeDetectorRef, - dir: Directionality, public _defaultErrorStateMatcher: ErrorStateMatcher, public _parentForm: NgForm, public _parentFormGroup: FormGroupDirective, @@ -83,7 +86,7 @@ class MatChipGridBase extends MatChipSet { */ public ngControl: NgControl, ) { - super(elementRef, changeDetectorRef, dir); + super(liveAnnouncer, document, elementRef, changeDetectorRef); } } const _MatChipGridMixinBase = mixinErrorState(MatChipGridBase); @@ -112,6 +115,7 @@ const _MatChipGridMixinBase = mixinErrorState(MatChipGridBase); '[class.mat-mdc-chip-list-required]': 'required', '(focus)': 'focus()', '(blur)': '_blur()', + '(keydown)': '_keydown($event)', }, providers: [{provide: MatFormFieldControl, useExisting: MatChipGrid}], encapsulation: ViewEncapsulation.None, @@ -249,6 +253,11 @@ export class MatChipGrid return this._getChipStream(chip => chip._onBlur); } + /** Combined stream of all of the child chips' focus events. */ + get chipFocusChanges(): Observable { + return this._getChipStream(chip => chip._onFocus); + } + /** Emits when the chip grid value has been changed by the user. */ @Output() readonly change: EventEmitter = new EventEmitter(); @@ -268,18 +277,20 @@ export class MatChipGrid override _chips: QueryList; constructor( + liveAnnouncer: LiveAnnouncer, + @Inject(DOCUMENT) document: any, elementRef: ElementRef, changeDetectorRef: ChangeDetectorRef, - @Optional() dir: Directionality, @Optional() parentForm: NgForm, @Optional() parentFormGroup: FormGroupDirective, defaultErrorStateMatcher: ErrorStateMatcher, @Optional() @Self() ngControl: NgControl, ) { super( + liveAnnouncer, + document, elementRef, changeDetectorRef, - dir, defaultErrorStateMatcher, parentForm, parentFormGroup, @@ -290,20 +301,23 @@ export class MatChipGrid } } - ngAfterContentInit() { + override ngAfterContentInit() { + super.ngAfterContentInit(); + + this._chips.changes.pipe(startWith(null), takeUntil(this._destroyed)).subscribe(() => { + // Check to see if we have a destroyed chip and need to refocus + this._updateFocusForDestroyedChips(); + this.stateChanges.next(); + }); + this.chipBlurChanges.pipe(takeUntil(this._destroyed)).subscribe(() => { this._blur(); this.stateChanges.next(); }); - - merge(this.chipFocusChanges, this._chips.changes) - .pipe(takeUntil(this._destroyed)) - .subscribe(() => this.stateChanges.next()); } override ngAfterViewInit() { super.ngAfterViewInit(); - if (!this._chipInput && (typeof ngDevMode === 'undefined' || ngDevMode)) { throw Error('mat-chip-grid must be used in combination with matChipInputFor.'); } @@ -348,12 +362,16 @@ export class MatChipGrid return; } - if (!this._chips.length || this._chips.first.disabled) { + if (this._chips.length > 0) { + // MDC sets the tabindex directly on the DOM node when the user is navigating which means + // that we may end up with a `0` value from a previous interaction. We reset it manually + // here to ensure that the state is correct. + this._chips.forEach(chip => chip.primaryAction._updateTabindex(-1)); + this._chips.first.focus(); + } else { // Delay until the next tick, because this can cause a "changed after checked" // error if the input does something on focus (e.g. opens an autocomplete). Promise.resolve().then(() => this._chipInput.focus()); - } else if (this._chips.length) { - this._keyManager.setFirstItemActive(); } this.stateChanges.next(); @@ -414,18 +432,20 @@ export class MatChipGrid /** When blurred, mark the field as touched when focus moved outside the chip grid. */ _blur() { - if (!this.disabled) { - // Check whether the focus moved to chip input. - // If the focus is not moved to chip input, mark the field as touched. If the focus moved - // to chip input, do nothing. - // Timeout is needed to wait for the focus() event trigger on chip input. - setTimeout(() => { - if (!this.focused) { - this._propagateChanges(); - this._markAsTouched(); - } - }); + if (this.disabled) { + return; } + + // Check whether the focus moved to chip input. + // If the focus is not moved to chip input, mark the field as touched. If the focus moved + // to chip input, do nothing. + // Timeout is needed to wait for the focus() event trigger on chip input. + setTimeout(() => { + if (!this.focused) { + this._propagateChanges(); + this._markAsTouched(); + } + }); } /** @@ -440,29 +460,9 @@ export class MatChipGrid } /** Handles custom keyboard events. */ - override _handleKeydown(event: KeyboardEvent) { - if (event.keyCode === TAB) { - if ( - this._chipInput.focused && - hasModifierKey(event, 'shiftKey') && - this._chips.length && - !this._chips.last.disabled - ) { - event.preventDefault(); - - if (this._keyManager.activeItem) { - this._keyManager.setActiveItem(this._keyManager.activeItem); - } else { - this._focusLastChip(); - } - } else { - // Use the super method here since it doesn't check for the input - // focused state. This allows focus to escape if there's only one - // disabled chip left in the list. - super._allowFocusEscape(); - } - } else if (!this._chipInput.focused) { - super._handleKeydown(event); + _keydown(event: KeyboardEvent) { + if (event.keyCode === TAB && (event.target as HTMLElement).id !== this._chipInput.id) { + this._allowFocusEscape(); } this.stateChanges.next(); @@ -470,7 +470,7 @@ export class MatChipGrid _focusLastChip() { if (this._chips.length) { - this._chips.last.focus(); + this._chips.last.primaryAction.focus(); } } @@ -490,4 +490,21 @@ export class MatChipGrid this._changeDetectorRef.markForCheck(); this.stateChanges.next(); } + + /** + * If the amount of chips changed, we need to focus the next closest chip. + */ + private _updateFocusForDestroyedChips() { + // Move focus to the closest chip. If no other chips remain, focus the chip-grid itself. + if (this._lastDestroyedChipIndex != null) { + if (this._chips.length) { + const newChipIndex = Math.min(this._lastDestroyedChipIndex, this._chips.length - 1); + this._chips.toArray()[newChipIndex].focus(); + } else { + this.focus(); + } + } + + this._lastDestroyedChipIndex = null; + } } diff --git a/src/material-experimental/mdc-chips/chip-icons.ts b/src/material-experimental/mdc-chips/chip-icons.ts index 5db06291c672..8e7b79c3c9e7 100644 --- a/src/material-experimental/mdc-chips/chip-icons.ts +++ b/src/material-experimental/mdc-chips/chip-icons.ts @@ -6,10 +6,16 @@ * found in the LICENSE file at https://angular.io/license */ -import {ENTER, SPACE} from '@angular/cdk/keycodes'; -import {Directive} from '@angular/core'; +import {Directive, InjectionToken} from '@angular/core'; +import {MDCChipActionAdapter, MDCChipTrailingActionFoundation} from '@material/chips'; import {MatChipAction} from './chip-action'; -import {MAT_CHIP_AVATAR, MAT_CHIP_REMOVE, MAT_CHIP_TRAILING_ICON} from './tokens'; + +/** + * Injection token that can be used to reference instances of `MatChipAvatar`. It serves as + * alternative token to the actual `MatChipAvatar` class which could cause unnecessary + * retention of the class and its directive metadata. + */ +export const MAT_CHIP_AVATAR = new InjectionToken('MatChipAvatar'); /** * Directive to add CSS classes to chip leading icon. @@ -25,6 +31,15 @@ import {MAT_CHIP_AVATAR, MAT_CHIP_REMOVE, MAT_CHIP_TRAILING_ICON} from './tokens }) export class MatChipAvatar {} +/** + * Injection token that can be used to reference instances of `MatChipTrailingIcon`. It serves as + * alternative token to the actual `MatChipTrailingIcon` class which could cause unnecessary + * retention of the class and its directive metadata. + */ +export const MAT_CHIP_TRAILING_ICON = new InjectionToken( + 'MatChipTrailingIcon', +); + /** * Directive to add CSS classes to and configure attributes for chip trailing icon. * @docs-private @@ -45,9 +60,18 @@ export class MatChipTrailingIcon extends MatChipAction { */ override isInteractive = false; - override _isPrimary = false; + protected override _createFoundation(adapter: MDCChipActionAdapter) { + return new MDCChipTrailingActionFoundation(adapter); + } } +/** + * 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 + * retention of the class and its directive metadata. + */ +export const MAT_CHIP_REMOVE = new InjectionToken('MatChipRemove'); + /** * Directive to remove the parent chip when the trailing icon is clicked or * when the ENTER key is pressed on it. @@ -76,21 +100,19 @@ export class MatChipTrailingIcon extends MatChipAction { providers: [{provide: MAT_CHIP_REMOVE, useExisting: MatChipRemove}], }) export class MatChipRemove extends MatChipAction { - override _isPrimary = false; + protected override _createFoundation(adapter: MDCChipActionAdapter) { + return new MDCChipTrailingActionFoundation(adapter); + } - override _handleClick(event: MouseEvent): void { - if (!this.disabled) { - event.stopPropagation(); - event.preventDefault(); - this._parentChip.remove(); - } + override _handleClick(event: MouseEvent) { + // Some consumers bind `click` events directly on the chip + // which will also pick up clicks on the remove button. + event.stopPropagation(); + super._handleClick(event); } override _handleKeydown(event: KeyboardEvent) { - if ((event.keyCode === ENTER || event.keyCode === SPACE) && !this.disabled) { - event.stopPropagation(); - event.preventDefault(); - this._parentChip.remove(); - } + event.stopPropagation(); + super._handleKeydown(event); } } diff --git a/src/material-experimental/mdc-chips/chip-input.ts b/src/material-experimental/mdc-chips/chip-input.ts index 5894cbdaea64..deb2a519fc16 100644 --- a/src/material-experimental/mdc-chips/chip-input.ts +++ b/src/material-experimental/mdc-chips/chip-input.ts @@ -21,7 +21,7 @@ import { Output, } from '@angular/core'; import {MatFormField, MAT_FORM_FIELD} from '@angular/material-experimental/mdc-form-field'; -import {MatChipsDefaultOptions, MAT_CHIPS_DEFAULT_OPTIONS} from './tokens'; +import {MatChipsDefaultOptions, MAT_CHIPS_DEFAULT_OPTIONS} from './chip-default-options'; import {MatChipGrid} from './chip-grid'; import {MatChipTextControl} from './chip-text-control'; @@ -213,6 +213,10 @@ export class MatChipInput implements MatChipTextControl, AfterContentInit, OnCha /** Checks to see if the (chipEnd) event needs to be emitted. */ _emitChipEnd(event?: KeyboardEvent) { + if (!this.inputElement.value && !!event) { + this._chipGrid._keydown(event); + } + if (!event || this._isSeparatorKey(event)) { this.chipEnd.emit({ input: this.inputElement, diff --git a/src/material-experimental/mdc-chips/chip-listbox.spec.ts b/src/material-experimental/mdc-chips/chip-listbox.spec.ts index 2161a0d43cb3..5bce81eb16db 100644 --- a/src/material-experimental/mdc-chips/chip-listbox.spec.ts +++ b/src/material-experimental/mdc-chips/chip-listbox.spec.ts @@ -1,11 +1,6 @@ import {Direction, Directionality} from '@angular/cdk/bidi'; import {END, HOME, LEFT_ARROW, RIGHT_ARROW, SPACE, TAB} from '@angular/cdk/keycodes'; -import { - dispatchFakeEvent, - dispatchKeyboardEvent, - MockNgZone, - patchElementFocus, -} from '../../cdk/testing/private'; +import {dispatchFakeEvent, dispatchKeyboardEvent, MockNgZone} from '../../cdk/testing/private'; import { Component, DebugElement, @@ -192,7 +187,6 @@ describe('MDC-based MatChipListbox', () => { const midItem = chips.get(2)!; // Focus the middle item - patchElementFocus(midItem.primaryAction!._elementRef.nativeElement); midItem.focus(); // Destroy the middle item @@ -205,7 +199,6 @@ describe('MDC-based MatChipListbox', () => { it('should focus the previous item', () => { // Focus the last item - patchElementFocus(chips.last.primaryAction!._elementRef.nativeElement); chips.last.focus(); // Destroy the last item @@ -234,19 +227,18 @@ describe('MDC-based MatChipListbox', () => { expect(chipListboxNativeElement.contains(document.activeElement)).toBe(false); })); - it('should focus the listbox if the last focused item is removed', fakeAsync(() => { + it('should focus the listbox if the last focused item is removed', () => { testComponent.chips = [0]; fixture.detectChanges(); spyOn(chipListboxInstance, 'focus'); - patchElementFocus(chips.last.primaryAction!._elementRef.nativeElement); chips.last.focus(); testComponent.chips.pop(); fixture.detectChanges(); expect(chipListboxInstance.focus).toHaveBeenCalled(); - })); + }); }); }); @@ -401,7 +393,6 @@ describe('MDC-based MatChipListbox', () => { expect(document.activeElement).toBe(primaryActions[1]); directionality.value = 'rtl'; - directionality.change.next('rtl'); fixture.detectChanges(); dispatchKeyboardEvent(primaryActions[1], 'keydown', RIGHT_ARROW); diff --git a/src/material-experimental/mdc-chips/chip-listbox.ts b/src/material-experimental/mdc-chips/chip-listbox.ts index 6d97647944fd..35fdea92b1bb 100644 --- a/src/material-experimental/mdc-chips/chip-listbox.ts +++ b/src/material-experimental/mdc-chips/chip-listbox.ts @@ -22,6 +22,7 @@ import { ViewEncapsulation, } from '@angular/core'; import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms'; +import {MDCChipActionType} from '@material/chips'; import {Observable} from 'rxjs'; import {startWith, takeUntil} from 'rxjs/operators'; import {MatChip, MatChipEvent} from './chip'; @@ -142,7 +143,15 @@ export class MatChipListbox * is a value from an option. The second is a value from the selection. A boolean * should be returned. */ - @Input() compareWith: (o1: any, o2: any) => boolean = (o1: any, o2: any) => o1 === o2; + @Input() + get compareWith(): (o1: any, o2: any) => boolean { + return this._compareWith; + } + set compareWith(fn: (o1: any, o2: any) => boolean) { + this._compareWith = fn; + this._initializeSelection(); + } + private _compareWith = (o1: any, o2: any) => o1 === o2; /** Whether this chip listbox is required. */ @Input() @@ -159,6 +168,11 @@ export class MatChipListbox return this._getChipStream(chip => chip.selectionChange); } + /** Combined stream of all of the child chips' focus events. */ + get chipFocusChanges(): Observable { + return this._getChipStream(chip => chip._onFocus); + } + /** Combined stream of all of the child chips' blur events. */ get chipBlurChanges(): Observable { return this._getChipStream(chip => chip._onBlur); @@ -186,23 +200,23 @@ export class MatChipListbox }) override _chips: QueryList; - ngAfterContentInit() { + override ngAfterContentInit() { + super.ngAfterContentInit(); + this._chips.changes.pipe(startWith(null), takeUntil(this._destroyed)).subscribe(() => { // Update listbox selectable/multiple properties on chips this._syncListboxProperties(); + + // Reset chips selected/deselected status + this._initializeSelection(); + + // Check to see if we have a destroyed chip and need to refocus + this._updateFocusForDestroyedChips(); }); this.chipBlurChanges.pipe(takeUntil(this._destroyed)).subscribe(() => this._blur()); this.chipSelectionChanges.pipe(takeUntil(this._destroyed)).subscribe(event => { if (event.isUserInput) { - if (!this.multiple && event.selected) { - this._chips.forEach(chip => { - if (chip !== event.source) { - chip.deselect(); - } - }); - } - this._propagateChanges(); } }); @@ -219,12 +233,14 @@ export class MatChipListbox const firstSelectedChip = this._getFirstSelectedChip(); - if (firstSelectedChip && !firstSelectedChip.disabled) { + if (firstSelectedChip) { firstSelectedChip.focus(); } else if (this._chips.length > 0) { - this._keyManager.setFirstItemActive(); - } else { - this._elementRef.nativeElement.focus(); + // MDC sets the tabindex directly on the DOM node when the user is navigating which means + // that we may end up with a `0` value from a previous interaction. We reset it manually + // here to ensure that the state is correct. + this._chips.forEach(chip => chip.primaryAction._updateTabindex(-1)); + this._chips.first.primaryAction.focus(); } } @@ -313,6 +329,21 @@ export class MatChipListbox this._changeDetectorRef.markForCheck(); } + /** + * Initializes the chip listbox selection state to reflect any chips that were preselected. + */ + private _initializeSelection() { + setTimeout(() => { + // Defer setting the value in order to avoid the "Expression + // has changed after it was checked" errors from Angular. + this._chips.forEach((chip, index) => { + if (chip.selected) { + this._chipSetFoundation.setChipSelected(index, MDCChipActionType.PRIMARY, true); + } + }); + }); + } + /** * Deselects every chip in the listbox. * @param skip Chip that should not be deselected. @@ -329,9 +360,9 @@ export class MatChipListbox * Finds and selects the chip based on its value. * @returns Chip that has the corresponding value. */ - private _selectValue(value: any, isUserInput: boolean): MatChip | undefined { + private _selectValue(value: any, isUserInput: boolean = true): MatChip | undefined { const correspondingChip = this._chips.find(chip => { - return chip.value != null && this.compareWith(chip.value, value); + return chip.value != null && this._compareWith(chip.value, value); }); if (correspondingChip) { @@ -364,4 +395,22 @@ export class MatChipListbox return this.selected; } } + + /** + * If the amount of chips changed, we need to update the + * key manager state and focus the next closest chip. + */ + private _updateFocusForDestroyedChips() { + // Move focus to the closest chip. If no other chips remain, focus the chip-listbox itself. + if (this._lastDestroyedChipIndex != null) { + if (this._chips.length) { + const newChipIndex = Math.min(this._lastDestroyedChipIndex, this._chips.length - 1); + this._chips.toArray()[newChipIndex].focus(); + } else { + this.focus(); + } + } + + this._lastDestroyedChipIndex = null; + } } diff --git a/src/material-experimental/mdc-chips/chip-option.html b/src/material-experimental/mdc-chips/chip-option.html index 4b04272ce88c..262203c58f69 100644 --- a/src/material-experimental/mdc-chips/chip-option.html +++ b/src/material-experimental/mdc-chips/chip-option.html @@ -11,7 +11,9 @@ [disabled]="disabled" [attr.aria-selected]="ariaSelected" [attr.aria-label]="ariaLabel" - role="option"> + role="option" + (blur)="_blur()" + (focus)="focus()"> diff --git a/src/material-experimental/mdc-chips/chip-option.spec.ts b/src/material-experimental/mdc-chips/chip-option.spec.ts index 0bf8561e1410..f6e95dc74020 100644 --- a/src/material-experimental/mdc-chips/chip-option.spec.ts +++ b/src/material-experimental/mdc-chips/chip-option.spec.ts @@ -312,7 +312,7 @@ describe('MDC-based Option Chips', () => {
{{name}} @@ -329,6 +329,7 @@ class SingleChip { selectable: boolean = true; shouldShow: boolean = true; + chipFocus: (event?: MatChipEvent) => void = () => {}; chipDestroy: (event?: MatChipEvent) => void = () => {}; chipSelectionChange: (event?: MatChipSelectionChange) => void = () => {}; } diff --git a/src/material-experimental/mdc-chips/chip-option.ts b/src/material-experimental/mdc-chips/chip-option.ts index 94995f46db65..dfca8a135d6c 100644 --- a/src/material-experimental/mdc-chips/chip-option.ts +++ b/src/material-experimental/mdc-chips/chip-option.ts @@ -14,10 +14,17 @@ import { Input, Output, ViewEncapsulation, + AfterViewInit, OnInit, } from '@angular/core'; +import { + ActionInteractionEvent, + MDCChipActionInteractionTrigger, + MDCChipActionType, + MDCChipCssClasses, +} from '@material/chips'; +import {take} from 'rxjs/operators'; import {MatChip} from './chip'; -import {MAT_CHIP} from './tokens'; /** Event object emitted by MatChipOption when selected or deselected. */ export class MatChipSelectionChange { @@ -39,7 +46,7 @@ export class MatChipSelectionChange { selector: 'mat-basic-chip-option, mat-chip-option', templateUrl: 'chip-option.html', styleUrls: ['chip.css'], - inputs: ['color', 'disabled', 'disableRipple', 'tabIndex'], + inputs: ['color', 'disableRipple', 'tabIndex'], host: { 'class': 'mat-mdc-chip mat-mdc-chip-option mdc-evolution-chip mdc-evolution-chip--filter', '[class.mat-mdc-chip-selected]': 'selected', @@ -48,12 +55,6 @@ export class MatChipSelectionChange { '[class.mat-mdc-chip-with-avatar]': 'leadingIcon', '[class.mdc-evolution-chip--selectable]': 'selectable', '[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 - // starts and removes it once the animation is finished. We don't need to go through all - // the trouble, because we only care about the selection animation. MDC needs to do it, - // 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-graphic]': '_hasLeadingGraphic()', '[class.mdc-evolution-chip--with-primary-icon]': 'leadingIcon', @@ -65,14 +66,20 @@ export class MatChipSelectionChange { '[attr.role]': 'role', '[id]': 'id', }, - providers: [ - {provide: MatChip, useExisting: MatChipOption}, - {provide: MAT_CHIP, useExisting: MatChipOption}, - ], + providers: [{provide: MatChip, useExisting: MatChipOption}], encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, }) -export class MatChipOption extends MatChip implements OnInit { +export class MatChipOption extends MatChip implements OnInit, AfterViewInit { + /** Whether the component is done initializing. */ + private _isInitialized: boolean; + + /** + * Selected state that was assigned before the component was initializing + * and which needs to be synced back up with the foundation. + */ + private _pendingSelectedState: boolean | undefined; + /** Whether the chip list is selectable. */ chipListSelectable: boolean = true; @@ -98,12 +105,21 @@ export class MatChipOption extends MatChip implements OnInit { /** Whether the chip is selected. */ @Input() get selected(): boolean { - return this._selected; + return ( + this._pendingSelectedState ?? this._chipFoundation.isActionSelected(MDCChipActionType.PRIMARY) + ); } set selected(value: BooleanInput) { - this._setSelectedState(coerceBooleanProperty(value), false); + if (this.selectable) { + const coercedValue = coerceBooleanProperty(value); + + if (this._isInitialized) { + this._setSelectedState(coercedValue, false); + } else { + this._pendingSelectedState = coercedValue; + } + } } - private _selected = false; /** The ARIA selected applied to the chip. */ get ariaSelected(): string | null { @@ -125,30 +141,82 @@ export class MatChipOption extends MatChip implements OnInit { this.role = 'presentation'; } + override ngAfterViewInit() { + super.ngAfterViewInit(); + this._isInitialized = true; + + if (this._pendingSelectedState != null) { + // Note that we want to clear the pending state before calling `_setSelectedState`, because + // we want it to read the actual selected state instead falling back to the pending one. + const selectedState = this._pendingSelectedState; + this._pendingSelectedState = undefined; + this._setSelectedState(selectedState, false); + } + } + /** Selects the chip. */ select(): void { - this._setSelectedState(true, false); + if (this.selectable) { + this._setSelectedState(true, false); + } } /** Deselects the chip. */ deselect(): void { - this._setSelectedState(false, false); + if (this.selectable) { + this._setSelectedState(false, false); + } } /** Selects this chip and emits userInputSelection event */ selectViaInteraction(): void { - this._setSelectedState(true, true); + if (this.selectable) { + this._setSelectedState(true, true); + } } /** Toggles the current selected state of this chip. */ toggleSelected(isUserInput: boolean = false): boolean { - this._setSelectedState(!this.selected, isUserInput); + if (this.selectable) { + this._setSelectedState(!this.selected, isUserInput); + } + return this.selected; } - override _handlePrimaryActionInteraction() { - if (this.selectable && !this.disabled) { - this.toggleSelected(true); + /** Resets the state of the chip when it loses focus. */ + _blur(): void { + // When animations are enabled, Angular may end up removing the chip from the DOM a little + // earlier than usual, causing it to be blurred and throwing off the logic in the chip list + // that moves focus not the next item. To work around the issue, we defer marking the chip + // as not focused until the next time the zone stabilizes. + this._ngZone.onStable.pipe(take(1)).subscribe(() => { + this._ngZone.run(() => { + this._hasFocusInternal = false; + this._onBlur.next({chip: this}); + }); + }); + } + + protected override _onChipInteraction(event: ActionInteractionEvent) { + const {trigger, source} = event.detail; + + // Non-selection interactions should work the same as other chips. + if ( + source !== MDCChipActionType.PRIMARY || + (trigger !== MDCChipActionInteractionTrigger.CLICK && + trigger !== MDCChipActionInteractionTrigger.ENTER_KEY && + trigger !== MDCChipActionInteractionTrigger.SPACEBAR_KEY) + ) { + super._onChipInteraction(event); + } else if (this.selectable && !this.disabled) { + // Otherwise only let the event through if the chip is enabled and selectable. + this._chipFoundation.handleActionInteraction(event); + this.selectionChange.emit({ + source: this, + isUserInput: true, + selected: this.selected, + }); } } @@ -159,12 +227,18 @@ export class MatChipOption extends MatChip implements OnInit { private _setSelectedState(isSelected: boolean, isUserInput: boolean) { if (isSelected !== this.selected) { - this._selected = isSelected; + this._chipFoundation.setActionSelected(MDCChipActionType.PRIMARY, isSelected); this.selectionChange.emit({ source: this, isUserInput, selected: this.selected, }); } + + // MDC won't assign the selected class until the animation finishes, but that may not + // happen if animations are disabled. If we detect such a case, assign the class manually. + if (this._animationsDisabled) { + this._elementRef.nativeElement.classList.toggle(MDCChipCssClasses.SELECTED, isSelected); + } } } diff --git a/src/material-experimental/mdc-chips/chip-remove.spec.ts b/src/material-experimental/mdc-chips/chip-remove.spec.ts index b747e7d0c879..427ffb9fe3f3 100644 --- a/src/material-experimental/mdc-chips/chip-remove.spec.ts +++ b/src/material-experimental/mdc-chips/chip-remove.spec.ts @@ -3,11 +3,13 @@ import {waitForAsync, ComponentFixture, TestBed, fakeAsync, flush} from '@angula import {dispatchKeyboardEvent, dispatchMouseEvent} from '@angular/cdk/testing/private'; import {By} from '@angular/platform-browser'; import {SPACE, ENTER} from '@angular/cdk/keycodes'; +import {MDCChipAnimation, MDCChipCssClasses} from '@material/chips/chip'; import {MatChip, MatChipsModule} from './index'; describe('MDC-based Chip Remove', () => { let fixture: ComponentFixture; let testChip: TestChip; + let chipInstance: MatChip; let chipNativeElement: HTMLElement; beforeEach(waitForAsync(() => { @@ -26,8 +28,28 @@ describe('MDC-based Chip Remove', () => { const chipDebugElement = fixture.debugElement.query(By.directive(MatChip))!; chipNativeElement = chipDebugElement.nativeElement; + chipInstance = chipDebugElement.componentInstance; })); + function triggerRemoveSequence() { + // At the time of writing, MDC's removal sequence requires the following to happen: + // 1. Button is clicked, triggering the animation. + // 2. Before the animation has finished, the `--hidden` class is added. + // 3. Animation callback fires at some point. It doesn't really matter for the test, + // but it does queue up some `requestAnimationFrame` calls that we need to flush. + // 4. `transitionend` callback fires and finishes the removal sequence if the + // `--hidden` class exists. + fixture.detectChanges(); + (chipInstance as any)._handleAnimationend({ + animationName: MDCChipAnimation.EXIT, + target: chipNativeElement, + }); + flush(); + (chipInstance as any)._handleTransitionend({target: chipNativeElement}); + flush(); + fixture.detectChanges(); + } + describe('basic behavior', () => { it('should apply a CSS class to the remove icon', fakeAsync(() => { const buttonElement = chipNativeElement.querySelector('.mdc-evolution-chip__icon--trailing')!; @@ -49,12 +71,21 @@ describe('MDC-based Chip Remove', () => { fixture.detectChanges(); chipNativeElement.querySelector('button')!.click(); - fixture.detectChanges(); - flush(); + triggerRemoveSequence(); expect(testChip.didRemove).toHaveBeenCalled(); })); + it('should not start MDC exit animation if parent chip is disabled', fakeAsync(() => { + testChip.removable = true; + testChip.disabled = true; + fixture.detectChanges(); + + chipNativeElement.querySelector('button')!.click(); + + expect(chipNativeElement.classList.contains(MDCChipCssClasses.HIDDEN)).toBe(false); + })); + it('should not make the element aria-hidden when it is focusable', fakeAsync(() => { const buttonElement = chipNativeElement.querySelector('button')!; @@ -69,8 +100,7 @@ describe('MDC-based Chip Remove', () => { fixture.detectChanges(); const event = dispatchKeyboardEvent(buttonElement, 'keydown', SPACE); - fixture.detectChanges(); - flush(); + triggerRemoveSequence(); expect(event.defaultPrevented).toBe(true); })); @@ -82,8 +112,7 @@ describe('MDC-based Chip Remove', () => { fixture.detectChanges(); const event = dispatchKeyboardEvent(buttonElement, 'keydown', ENTER); - fixture.detectChanges(); - flush(); + triggerRemoveSequence(); expect(event.defaultPrevented).toBe(true); })); @@ -96,8 +125,7 @@ describe('MDC-based Chip Remove', () => { it('should prevent the default click action', fakeAsync(() => { const buttonElement = chipNativeElement.querySelector('button')!; const event = dispatchMouseEvent(buttonElement, 'click'); - fixture.detectChanges(); - flush(); + triggerRemoveSequence(); expect(event.defaultPrevented).toBe(true); })); diff --git a/src/material-experimental/mdc-chips/chip-row.spec.ts b/src/material-experimental/mdc-chips/chip-row.spec.ts index 88c85e91c705..cb85899e480a 100644 --- a/src/material-experimental/mdc-chips/chip-row.spec.ts +++ b/src/material-experimental/mdc-chips/chip-row.spec.ts @@ -127,7 +127,7 @@ describe('MDC-based Row Chips', () => { spyOn(testComponent, 'chipRemove'); - chipInstance._handleKeydown(DELETE_EVENT); + chipInstance._keydown(DELETE_EVENT); fixture.detectChanges(); expect(testComponent.chipRemove).toHaveBeenCalled(); @@ -138,7 +138,7 @@ describe('MDC-based Row Chips', () => { spyOn(testComponent, 'chipRemove'); - chipInstance._handleKeydown(BACKSPACE_EVENT); + chipInstance._keydown(BACKSPACE_EVENT); fixture.detectChanges(); expect(testComponent.chipRemove).toHaveBeenCalled(); @@ -156,7 +156,7 @@ describe('MDC-based Row Chips', () => { spyOn(testComponent, 'chipRemove'); - chipInstance._handleKeydown(DELETE_EVENT); + chipInstance._keydown(DELETE_EVENT); fixture.detectChanges(); expect(testComponent.chipRemove).not.toHaveBeenCalled(); @@ -168,7 +168,7 @@ describe('MDC-based Row Chips', () => { spyOn(testComponent, 'chipRemove'); // Use the delete to remove the chip - chipInstance._handleKeydown(BACKSPACE_EVENT); + chipInstance._keydown(BACKSPACE_EVENT); fixture.detectChanges(); expect(testComponent.chipRemove).not.toHaveBeenCalled(); @@ -266,8 +266,8 @@ describe('MDC-based Row Chips', () => { expect(testComponent.chipDestroy).not.toHaveBeenCalled(); }); - it('should stop editing on blur', fakeAsync(() => { - chipInstance._onBlur.next(); + it('should stop editing on focusout', fakeAsync(() => { + dispatchFakeEvent(primaryAction, 'focusout', true); flush(); expect(testComponent.chipEdit).toHaveBeenCalled(); })); @@ -313,6 +313,16 @@ describe('MDC-based Row Chips', () => { expect(document.activeElement).toBe(primaryAction); })); + it('should focus the chip content if the body has focus on completion', fakeAsync(() => { + const chipValue = 'chip value'; + editInputInstance.setValue(chipValue); + (document.activeElement as HTMLElement).blur(); + dispatchKeyboardEvent(getEditInput(), 'keydown', ENTER); + fixture.detectChanges(); + flush(); + expect(document.activeElement).toBe(primaryAction); + })); + it('should not change focus if another element has focus on completion', fakeAsync(() => { const chipValue = 'chip value'; editInputInstance.setValue(chipValue); diff --git a/src/material-experimental/mdc-chips/chip-row.ts b/src/material-experimental/mdc-chips/chip-row.ts index c5f3bb96af3a..46a5308c7948 100644 --- a/src/material-experimental/mdc-chips/chip-row.ts +++ b/src/material-experimental/mdc-chips/chip-row.ts @@ -6,7 +6,8 @@ * found in the LICENSE file at https://angular.io/license */ -import {ENTER} from '@angular/cdk/keycodes'; +import {Directionality} from '@angular/cdk/bidi'; +import {BACKSPACE, DELETE, ENTER} from '@angular/cdk/keycodes'; import {ANIMATION_MODULE_TYPE} from '@angular/platform-browser/animations'; import { AfterViewInit, @@ -33,8 +34,6 @@ import { import {FocusMonitor} from '@angular/cdk/a11y'; import {MatChip, MatChipEvent} from './chip'; import {MatChipEditInput} from './chip-edit-input'; -import {takeUntil} from 'rxjs/operators'; -import {MAT_CHIP} from './tokens'; /** Represents an event fired on an individual `mat-chip` when it is edited. */ export interface MatChipEditedEvent extends MatChipEvent { @@ -50,7 +49,7 @@ export interface MatChipEditedEvent extends MatChipEvent { selector: 'mat-chip-row, mat-basic-chip-row', templateUrl: 'chip-row.html', styleUrls: ['chip.css'], - inputs: ['color', 'disabled', 'disableRipple', 'tabIndex'], + inputs: ['color', 'disableRipple', 'tabIndex'], host: { 'class': 'mat-mdc-chip mat-mdc-chip-row mdc-evolution-chip', '[class.mat-mdc-chip-with-avatar]': 'leadingIcon', @@ -69,12 +68,12 @@ export interface MatChipEditedEvent extends MatChipEvent { '[attr.aria-label]': 'null', '[attr.role]': 'role', '(mousedown)': '_mousedown($event)', - '(dblclick)': '_doubleclick($event)', + '(keydown)': '_keydown($event)', + '(dblclick)': '_doubleclick()', + '(focusin)': '_focusin($event)', + '(focusout)': '_focusout($event)', }, - providers: [ - {provide: MatChip, useExisting: MatChipRow}, - {provide: MAT_CHIP, useExisting: MatChipRow}, - ], + providers: [{provide: MatChip, useExisting: MatChipRow}], encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, }) @@ -95,12 +94,19 @@ export class MatChipRow extends MatChip implements AfterViewInit { _isEditing = false; + /** + * Timeout used to give some time between `focusin` and `focusout` + * in order to determine whether focus has left the chip. + */ + private _focusoutTimeout: number | null; + constructor( changeDetectorRef: ChangeDetectorRef, elementRef: ElementRef, ngZone: NgZone, focusMonitor: FocusMonitor, @Inject(DOCUMENT) _document: any, + @Optional() dir: Directionality, @Optional() @Inject(ANIMATION_MODULE_TYPE) animationMode?: string, @Optional() @Inject(MAT_RIPPLE_GLOBAL_OPTIONS) @@ -113,22 +119,48 @@ export class MatChipRow extends MatChip implements AfterViewInit { ngZone, focusMonitor, _document, + dir, animationMode, globalRippleOptions, tabIndex, ); this.role = 'row'; - this._onBlur.pipe(takeUntil(this.destroyed)).subscribe(() => { + } + + override _hasTrailingIcon() { + // The trailing icon is hidden while editing. + return !this._isEditing && super._hasTrailingIcon(); + } + + /** + * Emits a blur event when one of the gridcells loses focus, unless focus moved + * to the other gridcell. + */ + _focusout() { + if (this._focusoutTimeout) { + clearTimeout(this._focusoutTimeout); + } + + // Wait to see if focus moves to the other gridcell + this._focusoutTimeout = window.setTimeout(() => { if (this._isEditing) { this._onEditFinish(); } + + this._hasFocusInternal = false; + this._onBlur.next({chip: this}); }); } - override _hasTrailingIcon() { - // The trailing icon is hidden while editing. - return !this._isEditing && super._hasTrailingIcon(); + /** Records that the chip has focus when one of the gridcells is focused. */ + _focusin() { + if (this._focusoutTimeout) { + clearTimeout(this._focusoutTimeout); + this._focusoutTimeout = null; + } + + this._hasFocusInternal = true; } /** Sends focus to the first gridcell when the user clicks anywhere inside the chip. */ @@ -142,36 +174,41 @@ export class MatChipRow extends MatChip implements AfterViewInit { } } - override _handleKeydown(event: KeyboardEvent): void { - if (event.keyCode === ENTER && !this.disabled) { - if (this._isEditing) { - event.preventDefault(); - this._onEditFinish(); - } else if (this.editable) { - this._startEditing(event); - } - } else if (this._isEditing) { - // Stop the event from reaching the chip set in order to avoid navigating. - event.stopPropagation(); - } else { - super._handleKeydown(event); + /** Handles custom key presses. */ + _keydown(event: KeyboardEvent): void { + if (this.disabled) { + return; } - } - _doubleclick(event: MouseEvent) { - if (!this.disabled && this.editable) { - this._startEditing(event); + switch (event.keyCode) { + case ENTER: + if (this._isEditing) { + event.preventDefault(); + // Wrap in a timeout so the timing is consistent as when it is emitted in `focusout`. + setTimeout(() => this._onEditFinish()); + } else if (this.editable) { + this._startEditing(); + } + break; + case DELETE: + case BACKSPACE: + if (!this._isEditing) { + // Remove the focused chip + this.remove(); + // Always prevent so page navigation does not occur + event.preventDefault(); + } + break; } } - private _startEditing(event: Event) { - if ( - !this.primaryAction || - (this.removeIcon && this._getSourceAction(event.target as Node) === this.removeIcon) - ) { - return; + _doubleclick() { + if (!this.disabled && this.editable) { + this._startEditing(); } + } + private _startEditing() { // The value depends on the DOM so we need to extract it before we flip the flag. const value = this.value; @@ -185,10 +222,6 @@ export class MatChipRow extends MatChip implements AfterViewInit { } private _onEditFinish() { - this._isEditing = false; - this.primaryAction.isInteractive = true; - this.edited.emit({chip: this, value: this._getEditInput().getValue()}); - // If the edit input is still focused or focus was returned to the body after it was destroyed, // return focus to the chip contents. if ( @@ -197,6 +230,9 @@ export class MatChipRow extends MatChip implements AfterViewInit { ) { this.primaryAction.focus(); } + this.edited.emit({chip: this, value: this._getEditInput().getValue()}); + this.primaryAction.isInteractive = true; + this._isEditing = false; } /** diff --git a/src/material-experimental/mdc-chips/chip-set.ts b/src/material-experimental/mdc-chips/chip-set.ts index ede85c8e3ed2..8552ea0e5c55 100644 --- a/src/material-experimental/mdc-chips/chip-set.ts +++ b/src/material-experimental/mdc-chips/chip-set.ts @@ -6,27 +6,38 @@ * found in the LICENSE file at https://angular.io/license */ -import {FocusKeyManager} from '@angular/cdk/a11y'; -import {Directionality} from '@angular/cdk/bidi'; +import {LiveAnnouncer} from '@angular/cdk/a11y'; import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion'; +import {DOCUMENT} from '@angular/common'; import { + AfterContentInit, AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChildren, ElementRef, + Inject, Input, OnDestroy, - Optional, QueryList, ViewEncapsulation, } from '@angular/core'; import {HasTabIndex, mixinTabIndex} from '@angular/material-experimental/mdc-core'; +import { + MDCChipSetFoundation, + MDCChipSetAdapter, + MDCChipFoundation, + MDCChipEvents, + ChipAnimationEvent, + ChipInteractionEvent, + ChipNavigationEvent, + MDCChipActionType, +} from '@material/chips'; import {merge, Observable, Subject} from 'rxjs'; import {startWith, switchMap, takeUntil} from 'rxjs/operators'; import {MatChip, MatChipEvent} from './chip'; -import {MatChipAction} from './chip-action'; +import {emitCustomEvent} from './emit-event'; /** * Boilerplate for applying mixins to MatChipSet. @@ -53,7 +64,6 @@ const _MatChipSetMixinBase = mixinTabIndex(MatChipSetBase); styleUrls: ['chip-set.css'], host: { 'class': 'mat-mdc-chip-set mdc-evolution-chip-set', - '(keydown)': '_handleKeydown($event)', '[attr.role]': 'role', }, encapsulation: ViewEncapsulation.None, @@ -61,13 +71,17 @@ const _MatChipSetMixinBase = mixinTabIndex(MatChipSetBase); }) export class MatChipSet extends _MatChipSetMixinBase - implements AfterViewInit, HasTabIndex, OnDestroy + implements AfterContentInit, AfterViewInit, HasTabIndex, OnDestroy { - /** Index of the last destroyed chip that had focus. */ - private _lastDestroyedFocusedChipIndex: number | null = null; + /** + * When a chip is destroyed, we store the index of the destroyed chip until the chips + * query list notifies about the update. This is necessary because we cannot determine an + * appropriate chip that should receive focus until the array of chips updated completely. + */ + protected _lastDestroyedChipIndex: number | null = null; - /** Used to manage focus within the chip list. */ - protected _keyManager: FocusKeyManager; + /** The MDC foundation containing business logic for MDC chip-set. */ + protected _chipSetFoundation: MDCChipSetFoundation; /** Subject that emits when the component has been destroyed. */ protected _destroyed = new Subject(); @@ -75,16 +89,59 @@ export class MatChipSet /** Role to use if it hasn't been overwritten by the user. */ protected _defaultRole = 'presentation'; - /** Combined stream of all of the child chips' focus events. */ - get chipFocusChanges(): Observable { - return this._getChipStream(chip => chip._onFocus); - } - /** Combined stream of all of the child chips' remove events. */ get chipDestroyedChanges(): Observable { return this._getChipStream(chip => chip.destroyed); } + /** + * Implementation of the MDC chip-set adapter interface. + * These methods are called by the chip set foundation. + */ + protected _chipSetAdapter: MDCChipSetAdapter = { + announceMessage: message => this._liveAnnouncer.announce(message), + emitEvent: (eventName, eventDetail) => { + emitCustomEvent(this._elementRef.nativeElement, this._document, eventName, eventDetail, true); + }, + getAttribute: name => this._elementRef.nativeElement.getAttribute(name), + getChipActionsAtIndex: index => this._chipFoundation(index)?.getActions() || [], + getChipCount: () => this._chips.length, + getChipIdAtIndex: index => this._chipFoundation(index)?.getElementID() || '', + getChipIndexById: id => { + return this._chips.toArray().findIndex(chip => chip._getFoundation().getElementID() === id); + }, + isChipFocusableAtIndex: (index, actionType) => { + return this._chipFoundation(index)?.isActionFocusable(actionType) || false; + }, + isChipSelectableAtIndex: (index, actionType) => { + return this._chipFoundation(index)?.isActionSelectable(actionType) || false; + }, + isChipSelectedAtIndex: (index, actionType) => { + return this._chipFoundation(index)?.isActionSelected(actionType) || false; + }, + removeChipAtIndex: index => this._chips.toArray()[index]?.remove(), + setChipFocusAtIndex: (index, action, behavior) => { + this._chipFoundation(index)?.setActionFocus(action, behavior); + }, + setChipSelectedAtIndex: (index, actionType, isSelected) => { + // Setting the trailing action as deselected ends up deselecting the entire chip. + // This is working as expected, but it's not something we want so we only apply the + // selected state to the primary chip. + if (actionType === MDCChipActionType.PRIMARY) { + this._chipFoundation(index)?.setActionSelected(actionType, isSelected); + } + }, + startChipAnimationAtIndex: (index, animation) => { + this._chipFoundation(index)?.startAnimation(animation); + }, + }; + + /** + * Map from class to whether the class is enabled. + * Enabled classes are set on the MDC chip-set div. + */ + _mdcClasses: {[key: string]: boolean} = {}; + /** Whether the chip set is disabled. */ @Input() get disabled(): boolean { @@ -129,27 +186,56 @@ export class MatChipSet }) _chips: QueryList; - /** Flat list of all the actions contained within the chips. */ - _chipActions = new QueryList(); - constructor( + private _liveAnnouncer: LiveAnnouncer, + @Inject(DOCUMENT) private _document: any, protected _elementRef: ElementRef, protected _changeDetectorRef: ChangeDetectorRef, - @Optional() private _dir: Directionality, ) { super(_elementRef); + const element = _elementRef.nativeElement; + this._chipSetFoundation = new MDCChipSetFoundation(this._chipSetAdapter); + element.addEventListener(MDCChipEvents.ANIMATION, this._handleChipAnimation); + element.addEventListener(MDCChipEvents.INTERACTION, this._handleChipInteraction); + element.addEventListener(MDCChipEvents.NAVIGATION, this._handleChipNavigation); } ngAfterViewInit() { - this._setUpFocusManagement(); - this._trackChipSetChanges(); - this._trackDestroyedFocusedChip(); + this._chipSetFoundation.init(); + } + + ngAfterContentInit() { + this._chips.changes.pipe(startWith(null), takeUntil(this._destroyed)).subscribe(() => { + if (this.disabled) { + // Since this happens after the content has been + // checked, we need to defer it to the next tick. + Promise.resolve().then(() => { + this._syncChipsState(); + }); + } + }); + + this.chipDestroyedChanges.pipe(takeUntil(this._destroyed)).subscribe((event: MatChipEvent) => { + const chip = event.chip; + const chipIndex = this._chips.toArray().indexOf(event.chip); + + // In case the chip that will be removed is currently focused, we temporarily store + // the index in order to be able to determine an appropriate sibling chip that will + // receive focus. + if (this._isValidIndex(chipIndex) && chip._hasFocus()) { + this._lastDestroyedChipIndex = chipIndex; + } + }); } ngOnDestroy() { - this._chipActions.destroy(); + const element = this._elementRef.nativeElement; + element.removeEventListener(MDCChipEvents.ANIMATION, this._handleChipAnimation); + element.removeEventListener(MDCChipEvents.INTERACTION, this._handleChipInteraction); + element.removeEventListener(MDCChipEvents.NAVIGATION, this._handleChipNavigation); this._destroyed.next(); this._destroyed.complete(); + this._chipSetFoundation.destroy(); } /** Checks whether any of the chips is focused. */ @@ -170,13 +256,6 @@ export class MatChipSet /** Dummy method for subclasses to override. Base chip set cannot be focused. */ focus() {} - /** Handles keyboard events on the chip set. */ - _handleKeydown(event: KeyboardEvent) { - if (this._originatesFromChip(event)) { - this._keyManager.onKeydown(event); - } - } - /** * Utility to ensure all indexes are valid. * @@ -187,6 +266,11 @@ export class MatChipSet return index >= 0 && index < this._chips.length; } + /** Checks whether an event comes from inside a chip element. */ + protected _originatesFromChip(event: Event): boolean { + return this._checkForClassInHierarchy(event, 'mdc-evolution-chip'); + } + /** * Removes the `tabindex` from the chip grid and resets it back afterwards, allowing the * user to tab out of it. This prevents the grid from capturing focus and redirecting @@ -218,110 +302,34 @@ export class MatChipSet ); } - /** Checks whether an event comes from inside a chip element. */ - protected _originatesFromChip(event: Event): boolean { + protected _checkForClassInHierarchy(event: Event, className: string) { let currentElement = event.target as HTMLElement | null; while (currentElement && currentElement !== this._elementRef.nativeElement) { // Null check the classList, because IE and Edge don't support it on all elements. - if (currentElement.classList && currentElement.classList.contains('mdc-evolution-chip')) { + if (currentElement.classList && currentElement.classList.contains(className)) { return true; } + currentElement = currentElement.parentElement; } - return false; - } - - /** Sets up the chip set's focus management logic. */ - private _setUpFocusManagement() { - // Create a flat `QueryList` containing the actions of all of the chips. - // This allows us to navigate both within the chip and move to the next/previous - // one using the existing `ListKeyManager`. - this._chips.changes.pipe(startWith(this._chips)).subscribe((chips: QueryList) => { - const actions: MatChipAction[] = []; - chips.forEach(chip => chip._getActions().forEach(action => actions.push(action))); - this._chipActions.reset(actions); - this._chipActions.notifyOnChanges(); - }); - - this._keyManager = new FocusKeyManager(this._chipActions) - .withVerticalOrientation() - .withHorizontalOrientation(this._dir ? this._dir.value : 'ltr') - .withHomeAndEnd() - // Skip non-interactive and disabled actions since the user can't do anything with them. - .skipPredicate(action => !action.isInteractive || action.disabled); - // Keep the manager active index in sync so that navigation picks - // up from the current chip if the user clicks into the list directly. - this.chipFocusChanges.pipe(takeUntil(this._destroyed)).subscribe(({chip}) => { - const action = chip._getSourceAction(document.activeElement as Element); - - if (action) { - this._keyManager.updateActiveItem(action); - } - }); - - this._dir?.change - .pipe(takeUntil(this._destroyed)) - .subscribe(direction => this._keyManager.withHorizontalOrientation(direction)); - } - - /** Listens to changes in the chip set and syncs up the state of the individual chips. */ - private _trackChipSetChanges() { - this._chips.changes.pipe(startWith(null), takeUntil(this._destroyed)).subscribe(() => { - if (this.disabled) { - // Since this happens after the content has been - // checked, we need to defer it to the next tick. - Promise.resolve().then(() => this._syncChipsState()); - } - - this._redirectDestroyedChipFocus(); - }); + return false; } - /** Starts tracking the destroyed chips in order to capture the focused one. */ - private _trackDestroyedFocusedChip() { - this.chipDestroyedChanges.pipe(takeUntil(this._destroyed)).subscribe((event: MatChipEvent) => { - const chipArray = this._chips.toArray(); - const chipIndex = chipArray.indexOf(event.chip); - - // If the focused chip is destroyed, save its index so that we can move focus to the next - // chip. We only save the index here, rather than move the focus immediately, because we want - // to wait until the chip is removed from the chip list before focusing the next one. This - // allows us to keep focus on the same index if the chip gets swapped out. - if (this._isValidIndex(chipIndex) && event.chip._hasFocus()) { - this._lastDestroyedFocusedChipIndex = chipIndex; - } - }); + private _chipFoundation(index: number): MDCChipFoundation | undefined { + return this._chips.toArray()[index]?._getFoundation(); } - /** - * Finds the next appropriate chip to move focus to, - * if the currently-focused chip is destroyed. - */ - private _redirectDestroyedChipFocus() { - if (this._lastDestroyedFocusedChipIndex == null) { - return; - } + private _handleChipAnimation = (event: Event) => { + this._chipSetFoundation.handleChipAnimation(event as ChipAnimationEvent); + }; - if (this._chips.length) { - const newIndex = Math.min(this._lastDestroyedFocusedChipIndex, this._chips.length - 1); - const chipToFocus = this._chips.toArray()[newIndex]; - - if (chipToFocus.disabled) { - // If we're down to one disabled chip, move focus back to the set. - if (this._chips.length === 1) { - this.focus(); - } else { - this._keyManager.setPreviousItemActive(); - } - } else { - chipToFocus.focus(); - } - } else { - this.focus(); - } + private _handleChipInteraction = (event: Event) => { + this._chipSetFoundation.handleChipInteraction(event as ChipInteractionEvent); + }; - this._lastDestroyedFocusedChipIndex = null; - } + private _handleChipNavigation = (event: Event) => { + this._chipSetFoundation.handleChipNavigation(event as ChipNavigationEvent); + }; } diff --git a/src/material-experimental/mdc-chips/chip.spec.ts b/src/material-experimental/mdc-chips/chip.spec.ts index a68e7811615c..026bbabc697b 100644 --- a/src/material-experimental/mdc-chips/chip.spec.ts +++ b/src/material-experimental/mdc-chips/chip.spec.ts @@ -194,7 +194,7 @@ describe('MDC-based MatChip', () => {
{{name}} @@ -211,6 +211,7 @@ class SingleChip { value: any; rippleDisabled: boolean = false; + chipFocus: (event?: MatChipEvent) => void = () => {}; chipDestroy: (event?: MatChipEvent) => void = () => {}; chipRemove: (event?: MatChipEvent) => void = () => {}; } diff --git a/src/material-experimental/mdc-chips/chip.ts b/src/material-experimental/mdc-chips/chip.ts index 465473b20570..87af59368d6c 100644 --- a/src/material-experimental/mdc-chips/chip.ts +++ b/src/material-experimental/mdc-chips/chip.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ +import {Directionality} from '@angular/cdk/bidi'; import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion'; import {ANIMATION_MODULE_TYPE} from '@angular/platform-browser/animations'; import { @@ -37,16 +38,31 @@ import { mixinColor, mixinDisableRipple, mixinTabIndex, - mixinDisabled, RippleGlobalOptions, } from '@angular/material-experimental/mdc-core'; +import { + MDCChipFoundation, + MDCChipAdapter, + MDCChipActionType, + MDCChipActionFocusBehavior, + MDCChipActionFoundation, + MDCChipActionEvents, + ActionInteractionEvent, + ActionNavigationEvent, + MDCChipActionInteractionTrigger, +} from '@material/chips'; import {FocusMonitor} from '@angular/cdk/a11y'; import {Subject} from 'rxjs'; -import {take} from 'rxjs/operators'; -import {MatChipAvatar, MatChipTrailingIcon, MatChipRemove} from './chip-icons'; +import { + MatChipAvatar, + MatChipTrailingIcon, + MatChipRemove, + MAT_CHIP_AVATAR, + MAT_CHIP_TRAILING_ICON, + MAT_CHIP_REMOVE, +} from './chip-icons'; +import {emitCustomEvent} from './emit-event'; import {MatChipAction} from './chip-action'; -import {BACKSPACE, DELETE} from '@angular/cdk/keycodes'; -import {MAT_CHIP, MAT_CHIP_AVATAR, MAT_CHIP_REMOVE, MAT_CHIP_TRAILING_ICON} from './tokens'; let uid = 0; @@ -60,19 +76,12 @@ export interface MatChipEvent { * Boilerplate for applying mixins to MatChip. * @docs-private */ -const _MatChipMixinBase = mixinTabIndex( - mixinColor( - mixinDisableRipple( - mixinDisabled( - class { - constructor(public _elementRef: ElementRef) {} - }, - ), - ), - 'primary', - ), - -1, -); +abstract class MatChipBase { + abstract disabled: boolean; + constructor(public _elementRef: ElementRef) {} +} + +const _MatChipMixinBase = mixinTabIndex(mixinColor(mixinDisableRipple(MatChipBase), 'primary'), -1); /** * Material design styled Chip base component. Used inside the MatChipSet component. @@ -81,7 +90,7 @@ const _MatChipMixinBase = mixinTabIndex( */ @Component({ selector: 'mat-basic-chip, mat-chip', - inputs: ['color', 'disabled', 'disableRipple', 'tabIndex'], + inputs: ['color', 'disableRipple', 'tabIndex'], exportAs: 'matChip', templateUrl: 'chip.html', styleUrls: ['chip.css'], @@ -104,11 +113,9 @@ const _MatChipMixinBase = mixinTabIndex( '[attr.role]': 'role', '[attr.tabindex]': 'role ? tabIndex : null', '[attr.aria-label]': 'ariaLabel', - '(keydown)': '_handleKeydown($event)', }, encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, - providers: [{provide: MAT_CHIP, useExisting: MatChip}], }) export class MatChip extends _MatChipMixinBase @@ -132,7 +139,7 @@ export class MatChip @Input() role: string | null = null; /** Whether the chip has focus. */ - private _hasFocusInternal = false; + protected _hasFocusInternal = false; /** Whether moving focus into the chip is pending. */ private _pendingFocus: boolean; @@ -150,6 +157,21 @@ export class MatChip /** ARIA label for the content of the chip. */ @Input('aria-label') ariaLabel: string | null = null; + @Input() + get disabled(): boolean { + return this._disabled; + } + set disabled(value: BooleanInput) { + this._disabled = coerceBooleanProperty(value); + + if (this.removeIcon) { + this.removeIcon.disabled = this._disabled; + } + + this._chipFoundation.setDisabled(this._disabled); + } + protected _disabled: boolean = false; + private _textElement!: HTMLElement; /** @@ -195,6 +217,9 @@ export class MatChip /** Emitted when the chip is destroyed. */ @Output() readonly destroyed: EventEmitter = new EventEmitter(); + /** The MDC foundation containing business logic for MDC chip. */ + _chipFoundation: MDCChipFoundation; + /** The unstyled chip selector for this component. */ protected basicChipAttrName = 'mat-basic-chip'; @@ -213,12 +238,67 @@ export class MatChip /** Action receiving the primary set of user interactions. */ @ViewChild(MatChipAction) primaryAction: MatChipAction; + /** + * Implementation of the MDC chip adapter interface. + * These methods are called by the chip foundation. + */ + protected _chipAdapter: MDCChipAdapter = { + addClass: className => this._setMdcClass(className, true), + removeClass: className => this._setMdcClass(className, false), + hasClass: className => this._elementRef.nativeElement.classList.contains(className), + emitEvent: (eventName: string, data: T) => { + emitCustomEvent(this._elementRef.nativeElement, this._document, eventName, data, true); + }, + setStyleProperty: (propertyName: string, value: string) => { + this._elementRef.nativeElement.style.setProperty(propertyName, value); + }, + isRTL: () => this._dir?.value === 'rtl', + getAttribute: attributeName => this._elementRef.nativeElement.getAttribute(attributeName), + getElementID: () => this._elementRef.nativeElement.id, + getOffsetWidth: () => this._elementRef.nativeElement.offsetWidth, + getActions: () => { + const result: MDCChipActionType[] = []; + + if (this._getAction(MDCChipActionType.PRIMARY)) { + result.push(MDCChipActionType.PRIMARY); + } + + if (this._getAction(MDCChipActionType.TRAILING)) { + result.push(MDCChipActionType.TRAILING); + } + + return result; + }, + isActionSelectable: (action: MDCChipActionType) => { + return this._getAction(action)?.isSelectable() || false; + }, + isActionSelected: (action: MDCChipActionType) => { + return this._getAction(action)?.isSelected() || false; + }, + isActionDisabled: (action: MDCChipActionType) => { + return this._getAction(action)?.isDisabled() || false; + }, + isActionFocusable: (action: MDCChipActionType) => { + return this._getAction(action)?.isFocusable() || false; + }, + setActionSelected: (action: MDCChipActionType, isSelected: boolean) => { + this._getAction(action)?.setSelected(isSelected); + }, + setActionDisabled: (action: MDCChipActionType, isDisabled: boolean) => { + this._getAction(action)?.setDisabled(isDisabled); + }, + setActionFocus: (action: MDCChipActionType, behavior: MDCChipActionFocusBehavior) => { + this._getAction(action)?.setFocus(behavior); + }, + }; + constructor( public _changeDetectorRef: ChangeDetectorRef, elementRef: ElementRef, protected _ngZone: NgZone, private _focusMonitor: FocusMonitor, @Inject(DOCUMENT) _document: any, + @Optional() private _dir: Directionality, @Optional() @Inject(ANIMATION_MODULE_TYPE) animationMode?: string, @Optional() @Inject(MAT_RIPPLE_GLOBAL_OPTIONS) @@ -228,18 +308,29 @@ export class MatChip super(elementRef); const element = elementRef.nativeElement; this._document = _document; + this._chipFoundation = new MDCChipFoundation(this._chipAdapter); this._animationsDisabled = animationMode === 'NoopAnimations'; this._isBasicChip = element.hasAttribute(this.basicChipAttrName) || element.tagName.toLowerCase() === this.basicChipAttrName; + element.addEventListener(MDCChipActionEvents.INTERACTION, this._handleActionInteraction); + element.addEventListener(MDCChipActionEvents.NAVIGATION, this._handleActionNavigation); + _focusMonitor.monitor(elementRef, true); + + _ngZone.runOutsideAngular(() => { + element.addEventListener('transitionend', this._handleTransitionend); + element.addEventListener('animationend', this._handleAnimationend); + }); + if (tabIndex != null) { this.tabIndex = parseInt(tabIndex) ?? this.defaultTabIndex; } - this._monitorFocus(); } ngAfterViewInit() { - this._textElement = this._elementRef.nativeElement.querySelector('.mat-mdc-chip-action-label')!; + this._chipFoundation.init(); + this._chipFoundation.setDisabled(this.disabled); + this._textElement = this._elementRef.nativeElement.querySelector('.mat-mdc-chip-action-label'); if (this._pendingFocus) { this._pendingFocus = false; @@ -248,9 +339,14 @@ export class MatChip } ngOnDestroy() { + const element = this._elementRef.nativeElement; + element.removeEventListener(MDCChipActionEvents.INTERACTION, this._handleActionInteraction); + element.removeEventListener(MDCChipActionEvents.NAVIGATION, this._handleActionNavigation); + element.removeEventListener('transitionend', this._handleTransitionend); + element.removeEventListener('animationend', this._handleAnimationend); + this._chipFoundation.destroy(); this._focusMonitor.stopMonitoring(this._elementRef); this.destroyed.emit({chip: this}); - this.destroyed.complete(); } /** @@ -264,6 +360,13 @@ export class MatChip } } + /** Sets whether the given CSS class should be applied to the MDC chip. */ + private _setMdcClass(cssClass: string, active: boolean) { + const classes = this._elementRef.nativeElement.classList; + active ? classes.add(cssClass) : classes.remove(cssClass); + this._changeDetectorRef.markForCheck(); + } + /** Whether or not the ripple should be disabled. */ _isRippleDisabled(): boolean { return ( @@ -275,85 +378,87 @@ export class MatChip ); } - /** Returns whether the chip has a trailing icon. */ - _hasTrailingIcon() { - return !!(this.trailingIcon || this.removeIcon); - } - - /** Handles keyboard events on the chip. */ - _handleKeydown(event: KeyboardEvent) { - if (event.keyCode === BACKSPACE || event.keyCode === DELETE) { - event.preventDefault(); - this.remove(); + _getAction(type: MDCChipActionType): MDCChipActionFoundation | undefined { + switch (type) { + case MDCChipActionType.PRIMARY: + return this.primaryAction?._getFoundation(); + case MDCChipActionType.TRAILING: + return (this.removeIcon || this.trailingIcon)?._getFoundation(); } - } - /** Allows for programmatic focusing of the chip. */ - focus(): void { - if (!this.disabled) { - // If `focus` is called before `ngAfterViewInit`, we won't have access to the primary action. - // This can happen if the consumer tries to focus a chip immediately after it is added. - // Queue the method to be called again on init. - if (this.primaryAction) { - this.primaryAction.focus(); - } else { - this._pendingFocus = true; - } - } + return undefined; } - /** Gets the action that contains a specific target node. */ - _getSourceAction(target: Node): MatChipAction | undefined { - return this._getActions().find(action => { - const element = action._elementRef.nativeElement; - return element === target || element.contains(target); - }); + _getFoundation() { + return this._chipFoundation; } - /** Gets all of the actions within the chip. */ - _getActions(): MatChipAction[] { - const result: MatChipAction[] = []; + _hasTrailingIcon() { + return !!(this.trailingIcon || this.removeIcon); + } - if (this.primaryAction) { - result.push(this.primaryAction); + /** Allows for programmatic focusing of the chip. */ + focus(): void { + if (this.disabled) { + return; } - if (this.removeIcon) { - result.push(this.removeIcon); + // If `focus` is called before `ngAfterViewInit`, we won't have access to the primary action. + // This can happen if the consumer tries to focus a chip immediately after it is added. + // Queue the method to be called again on init. + if (!this.primaryAction) { + this._pendingFocus = true; + return; } - if (this.trailingIcon) { - result.push(this.trailingIcon); + if (!this._hasFocus()) { + this._onFocus.next({chip: this}); + this._hasFocusInternal = true; } - return result; + this.primaryAction.focus(); } - /** Handles interactions with the primary action of the chip. */ - _handlePrimaryActionInteraction() { - // Empty here, but is overwritten in child classes. + /** Overridden by MatChipOption. */ + protected _onChipInteraction(event: ActionInteractionEvent) { + const removeElement = this.removeIcon?._elementRef.nativeElement; + const trigger = event.detail.trigger; + + // MDC's removal process requires an `animationend` event followed by a `transitionend` + // event coming from the chip, which in turn will call `remove`. While we can stub + // out these events in our own tests, they can be difficult to fake for consumers that are + // testing our components or are wrapping them. We skip the entire sequence and trigger the + // removal directly in order to make the component easier to deal with. + if ( + removeElement && + (trigger === MDCChipActionInteractionTrigger.CLICK || + trigger === MDCChipActionInteractionTrigger.ENTER_KEY || + trigger === MDCChipActionInteractionTrigger.SPACEBAR_KEY) && + (event.target === removeElement || removeElement.contains(event.target)) + ) { + this.remove(); + } else { + this._chipFoundation.handleActionInteraction(event); + } } - /** Starts the focus monitoring process on the chip. */ - private _monitorFocus() { - this._focusMonitor.monitor(this._elementRef, true).subscribe(origin => { - const hasFocus = origin !== null; - - if (hasFocus !== this._hasFocusInternal) { - this._hasFocusInternal = hasFocus; - - if (hasFocus) { - this._onFocus.next({chip: this}); - } else { - // When animations are enabled, Angular may end up removing the chip from the DOM a little - // earlier than usual, causing it to be blurred and throwing off the logic in the chip list - // that moves focus not the next item. To work around the issue, we defer marking the chip - // as not focused until the next time the zone stabilizes. - this._ngZone.onStable - .pipe(take(1)) - .subscribe(() => this._ngZone.run(() => this._onBlur.next({chip: this}))); - } - } - }); - } + private _handleActionInteraction = (event: Event) => { + this._onChipInteraction(event as ActionInteractionEvent); + }; + + private _handleActionNavigation = (event: Event) => { + this._chipFoundation.handleActionNavigation(event as ActionNavigationEvent); + }; + + private _handleTransitionend = (event: TransitionEvent) => { + if (event.target === this._elementRef.nativeElement) { + this._ngZone.run(() => this._chipFoundation.handleTransitionEnd()); + } + }; + + private _handleAnimationend = (event: AnimationEvent) => { + if (event.target === this._elementRef.nativeElement) { + this._ngZone.run(() => this._chipFoundation.handleAnimationEnd(event)); + } + }; } diff --git a/src/material-experimental/mdc-chips/emit-event.ts b/src/material-experimental/mdc-chips/emit-event.ts new file mode 100644 index 000000000000..b208b6d3f0d8 --- /dev/null +++ b/src/material-experimental/mdc-chips/emit-event.ts @@ -0,0 +1,33 @@ +/** + * @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.io/license + */ + +/** + * Emits a custom event from an element. + * @param element Element from which to emit the event. + * @param _document Document that the element is placed in. + * @param eventName Name of the event. + * @param data Data attached to the event. + * @param shouldBubble Whether the event should bubble. + */ +export function emitCustomEvent( + element: HTMLElement, + _document: Document, + eventName: string, + data: T, + shouldBubble: boolean, +): void { + let event: CustomEvent; + if (typeof CustomEvent === 'function') { + event = new CustomEvent(eventName, {bubbles: shouldBubble, detail: data}); + } else { + event = _document.createEvent('CustomEvent'); + event.initCustomEvent(eventName, shouldBubble, false, data); + } + + element.dispatchEvent(event); +} diff --git a/src/material-experimental/mdc-chips/module.ts b/src/material-experimental/mdc-chips/module.ts index 90b564c19a41..ace98686a98e 100644 --- a/src/material-experimental/mdc-chips/module.ts +++ b/src/material-experimental/mdc-chips/module.ts @@ -15,7 +15,7 @@ import { MatRippleModule, } from '@angular/material-experimental/mdc-core'; import {MatChip} from './chip'; -import {MAT_CHIPS_DEFAULT_OPTIONS, MatChipsDefaultOptions} from './tokens'; +import {MAT_CHIPS_DEFAULT_OPTIONS, MatChipsDefaultOptions} from './chip-default-options'; import {MatChipEditInput} from './chip-edit-input'; import {MatChipGrid} from './chip-grid'; import {MatChipAvatar, MatChipRemove, MatChipTrailingIcon} from './chip-icons'; diff --git a/src/material-experimental/mdc-chips/public-api.ts b/src/material-experimental/mdc-chips/public-api.ts index 4971595984dc..dc6a425f3914 100644 --- a/src/material-experimental/mdc-chips/public-api.ts +++ b/src/material-experimental/mdc-chips/public-api.ts @@ -14,7 +14,7 @@ export * from './chip-listbox'; export * from './chip-grid'; export * from './module'; export * from './chip-input'; -export * from './tokens'; +export * from './chip-default-options'; export * from './chip-icons'; export * from './chip-text-control'; export * from './chip-edit-input'; diff --git a/src/material-experimental/mdc-chips/tokens.ts b/src/material-experimental/mdc-chips/tokens.ts deleted file mode 100644 index ea5cd59883e6..000000000000 --- a/src/material-experimental/mdc-chips/tokens.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * @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.io/license - */ - -import {InjectionToken} from '@angular/core'; - -/** Default options, for the chips module, that can be overridden. */ -export interface MatChipsDefaultOptions { - /** The list of key codes that will trigger a chipEnd event. */ - separatorKeyCodes: readonly number[] | ReadonlySet; -} - -/** Injection token to be used to override the default options for the chips module. */ -export const MAT_CHIPS_DEFAULT_OPTIONS = new InjectionToken( - 'mat-chips-default-options', -); - -/** - * Injection token that can be used to reference instances of `MatChipAvatar`. It serves as - * alternative token to the actual `MatChipAvatar` class which could cause unnecessary - * retention of the class and its directive metadata. - */ -export const MAT_CHIP_AVATAR = new InjectionToken('MatChipAvatar'); - -/** - * Injection token that can be used to reference instances of `MatChipTrailingIcon`. It serves as - * alternative token to the actual `MatChipTrailingIcon` class which could cause unnecessary - * retention of the class and its directive metadata. - */ -export const MAT_CHIP_TRAILING_ICON = new InjectionToken('MatChipTrailingIcon'); - -/** - * 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 - * retention of the class and its directive metadata. - */ -export const MAT_CHIP_REMOVE = new InjectionToken('MatChipRemove'); - -/** - * Injection token used to avoid a circular dependency between the `MatChip` and `MatChipAction`. - */ -export const MAT_CHIP = new InjectionToken('MatChip');