) => {
+ 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));
}
- private _handleChipAnimation = (event: Event) => {
- this._chipSetFoundation.handleChipAnimation(event as ChipAnimationEvent);
- };
+ /** 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());
+ }
- private _handleChipInteraction = (event: Event) => {
- this._chipSetFoundation.handleChipInteraction(event as ChipInteractionEvent);
- };
+ this._redirectDestroyedChipFocus();
+ });
+ }
+
+ /** 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 _handleChipNavigation = (event: Event) => {
- this._chipSetFoundation.handleChipNavigation(event as ChipNavigationEvent);
- };
+ /**
+ * Finds the next appropriate chip to move focus to,
+ * if the currently-focused chip is destroyed.
+ */
+ private _redirectDestroyedChipFocus() {
+ if (this._lastDestroyedFocusedChipIndex == null) {
+ return;
+ }
+
+ 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();
+ }
+
+ this._lastDestroyedFocusedChipIndex = null;
+ }
}
diff --git a/src/material-experimental/mdc-chips/chip.spec.ts b/src/material-experimental/mdc-chips/chip.spec.ts
index 026bbabc697b..a68e7811615c 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,7 +211,6 @@ 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 87af59368d6c..465473b20570 100644
--- a/src/material-experimental/mdc-chips/chip.ts
+++ b/src/material-experimental/mdc-chips/chip.ts
@@ -6,7 +6,6 @@
* 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 {
@@ -38,31 +37,16 @@ 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 {
- MatChipAvatar,
- MatChipTrailingIcon,
- MatChipRemove,
- MAT_CHIP_AVATAR,
- MAT_CHIP_TRAILING_ICON,
- MAT_CHIP_REMOVE,
-} from './chip-icons';
-import {emitCustomEvent} from './emit-event';
+import {take} from 'rxjs/operators';
+import {MatChipAvatar, MatChipTrailingIcon, MatChipRemove} from './chip-icons';
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;
@@ -76,12 +60,19 @@ export interface MatChipEvent {
* Boilerplate for applying mixins to MatChip.
* @docs-private
*/
-abstract class MatChipBase {
- abstract disabled: boolean;
- constructor(public _elementRef: ElementRef) {}
-}
-
-const _MatChipMixinBase = mixinTabIndex(mixinColor(mixinDisableRipple(MatChipBase), 'primary'), -1);
+const _MatChipMixinBase = mixinTabIndex(
+ mixinColor(
+ mixinDisableRipple(
+ mixinDisabled(
+ class {
+ constructor(public _elementRef: ElementRef) {}
+ },
+ ),
+ ),
+ 'primary',
+ ),
+ -1,
+);
/**
* Material design styled Chip base component. Used inside the MatChipSet component.
@@ -90,7 +81,7 @@ const _MatChipMixinBase = mixinTabIndex(mixinColor(mixinDisableRipple(MatChipBas
*/
@Component({
selector: 'mat-basic-chip, mat-chip',
- inputs: ['color', 'disableRipple', 'tabIndex'],
+ inputs: ['color', 'disabled', 'disableRipple', 'tabIndex'],
exportAs: 'matChip',
templateUrl: 'chip.html',
styleUrls: ['chip.css'],
@@ -113,9 +104,11 @@ const _MatChipMixinBase = mixinTabIndex(mixinColor(mixinDisableRipple(MatChipBas
'[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
@@ -139,7 +132,7 @@ export class MatChip
@Input() role: string | null = null;
/** Whether the chip has focus. */
- protected _hasFocusInternal = false;
+ private _hasFocusInternal = false;
/** Whether moving focus into the chip is pending. */
private _pendingFocus: boolean;
@@ -157,21 +150,6 @@ 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;
/**
@@ -217,9 +195,6 @@ 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';
@@ -238,67 +213,12 @@ 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)
@@ -308,29 +228,18 @@ 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._chipFoundation.init();
- this._chipFoundation.setDisabled(this.disabled);
- this._textElement = this._elementRef.nativeElement.querySelector('.mat-mdc-chip-action-label');
+ this._textElement = this._elementRef.nativeElement.querySelector('.mat-mdc-chip-action-label')!;
if (this._pendingFocus) {
this._pendingFocus = false;
@@ -339,14 +248,9 @@ 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();
}
/**
@@ -360,13 +264,6 @@ 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 (
@@ -378,87 +275,85 @@ export class MatChip
);
}
- _getAction(type: MDCChipActionType): MDCChipActionFoundation | undefined {
- switch (type) {
- case MDCChipActionType.PRIMARY:
- return this.primaryAction?._getFoundation();
- case MDCChipActionType.TRAILING:
- return (this.removeIcon || this.trailingIcon)?._getFoundation();
- }
+ /** Returns whether the chip has a trailing icon. */
+ _hasTrailingIcon() {
+ return !!(this.trailingIcon || this.removeIcon);
+ }
- return undefined;
+ /** Handles keyboard events on the chip. */
+ _handleKeydown(event: KeyboardEvent) {
+ if (event.keyCode === BACKSPACE || event.keyCode === DELETE) {
+ event.preventDefault();
+ this.remove();
+ }
}
- _getFoundation() {
- return this._chipFoundation;
+ /** 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;
+ }
+ }
}
- _hasTrailingIcon() {
- return !!(this.trailingIcon || this.removeIcon);
+ /** 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);
+ });
}
- /** Allows for programmatic focusing of the chip. */
- focus(): void {
- if (this.disabled) {
- return;
+ /** Gets all of the actions within the chip. */
+ _getActions(): MatChipAction[] {
+ const result: MatChipAction[] = [];
+
+ if (this.primaryAction) {
+ result.push(this.primaryAction);
}
- // 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.removeIcon) {
+ result.push(this.removeIcon);
}
- if (!this._hasFocus()) {
- this._onFocus.next({chip: this});
- this._hasFocusInternal = true;
+ if (this.trailingIcon) {
+ result.push(this.trailingIcon);
}
- this.primaryAction.focus();
+ return result;
}
- /** 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);
- }
+ /** Handles interactions with the primary action of the chip. */
+ _handlePrimaryActionInteraction() {
+ // Empty here, but is overwritten in child classes.
}
- 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));
- }
- };
+ /** 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})));
+ }
+ }
+ });
+ }
}
diff --git a/src/material-experimental/mdc-chips/emit-event.ts b/src/material-experimental/mdc-chips/emit-event.ts
deleted file mode 100644
index b208b6d3f0d8..000000000000
--- a/src/material-experimental/mdc-chips/emit-event.ts
+++ /dev/null
@@ -1,33 +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
- */
-
-/**
- * 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 ace98686a98e..90b564c19a41 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 './chip-default-options';
+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';
diff --git a/src/material-experimental/mdc-chips/public-api.ts b/src/material-experimental/mdc-chips/public-api.ts
index dc6a425f3914..4971595984dc 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 './chip-default-options';
+export * from './tokens';
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
new file mode 100644
index 000000000000..ea5cd59883e6
--- /dev/null
+++ b/src/material-experimental/mdc-chips/tokens.ts
@@ -0,0 +1,46 @@
+/**
+ * @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');