From c5482c945f9f2b80ada2942a4e4f70d65626b32f Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Tue, 11 Jan 2022 15:56:25 +0100 Subject: [PATCH] feat(material-experimental/mdc-chips): switch to evolution API (#23931) * feat(material-experimental/mdc-chips): switch to evolution API Reworks the MDC-based chips to use the new `evolution` API instead of the deprecated one we're currently using. The new API comes with a lot of markup changes and some behavior differences. The new API also allows to remove the `GridKeyManager`, because the keyboard navigation is handled correctly by MDC. * refactor(material-experimental/mdc-chips): reduce specificity and bundle size Reworks the theme of the MDC-based chips to produce less specific and more compact CSS. --- scripts/check-mdc-tests-config.ts | 6 + src/dev-app/chips/chips-demo.html | 4 +- src/dev-app/mdc-chips/mdc-chips-demo.html | 4 +- .../mdc-chips/BUILD.bazel | 22 +- .../mdc-chips/_chips-theme.scss | 106 ++--- .../mdc-chips/chip-action.ts | 151 +++++++ .../mdc-chips/chip-edit-input.spec.ts | 2 +- .../mdc-chips/chip-edit-input.ts | 2 +- .../mdc-chips/chip-grid.spec.ts | 308 ++++++-------- .../mdc-chips/chip-grid.ts | 166 +++----- .../mdc-chips/chip-icons.ts | 139 ++---- .../mdc-chips/chip-input.spec.ts | 26 +- .../mdc-chips/chip-input.ts | 12 +- .../mdc-chips/chip-listbox.spec.ts | 298 +++++-------- .../mdc-chips/chip-listbox.ts | 236 ++--------- .../mdc-chips/chip-option.html | 43 +- .../mdc-chips/chip-option.spec.ts | 71 +--- .../mdc-chips/chip-option.ts | 193 +++++---- .../mdc-chips/chip-remove.spec.ts | 155 +++---- .../mdc-chips/chip-row.html | 52 ++- .../mdc-chips/chip-row.spec.ts | 129 +++--- .../mdc-chips/chip-row.ts | 180 ++++---- .../mdc-chips/chip-set.scss | 32 ++ .../mdc-chips/chip-set.ts | 274 ++++++------ src/material-experimental/mdc-chips/chip.html | 25 +- src/material-experimental/mdc-chips/chip.scss | 208 +++++++++ .../mdc-chips/chip.spec.ts | 26 +- src/material-experimental/mdc-chips/chip.ts | 398 +++++++++--------- .../mdc-chips/chips.scss | 184 -------- .../mdc-chips/emit-event.ts | 33 ++ .../mdc-chips/grid-focus-key-manager.ts | 36 -- .../mdc-chips/grid-key-manager.ts | 285 ------------- src/material-experimental/mdc-chips/module.ts | 6 +- .../mdc-chips/testing/chip-harness.ts | 2 + .../mdc-chips/testing/chip-option-harness.ts | 2 +- .../testing/chip-row-harness.spec.ts | 16 +- .../mdc-chips/testing/chip-row-harness.ts | 12 + .../mdc-helpers/_focus-indicators.scss | 14 +- 38 files changed, 1659 insertions(+), 2199 deletions(-) create mode 100644 src/material-experimental/mdc-chips/chip-action.ts create mode 100644 src/material-experimental/mdc-chips/chip-set.scss create mode 100644 src/material-experimental/mdc-chips/chip.scss delete mode 100644 src/material-experimental/mdc-chips/chips.scss create mode 100644 src/material-experimental/mdc-chips/emit-event.ts delete mode 100644 src/material-experimental/mdc-chips/grid-focus-key-manager.ts delete mode 100644 src/material-experimental/mdc-chips/grid-key-manager.ts diff --git a/scripts/check-mdc-tests-config.ts b/scripts/check-mdc-tests-config.ts index 40c3a43d9599..6ece0271e3c0 100644 --- a/scripts/check-mdc-tests-config.ts +++ b/scripts/check-mdc-tests-config.ts @@ -36,6 +36,12 @@ export const config = { // This test checks something that isn't supported in the MDC form field. 'should propagate the dynamic `placeholder` value to the form field', + + // Disabled, because the MDC-based chip input doesn't deal with focus escaping anymore. + 'should not allow focus to escape when tabbing backwards', + + // Disabled, because preventing the default action isn't required. + 'should prevent the default click action when the chip is disabled', ], 'mdc-dialog': [ // These tests are verifying implementation details that are not relevant for MDC. diff --git a/src/dev-app/chips/chips-demo.html b/src/dev-app/chips/chips-demo.html index 77bd05a2341b..b5ebf78d29b4 100644 --- a/src/dev-app/chips/chips-demo.html +++ b/src/dev-app/chips/chips-demo.html @@ -70,12 +70,12 @@

With avatar and icons

- + Mal - + Husi + - -
- - - -
-
- + + + diff --git a/src/material-experimental/mdc-chips/chip-option.spec.ts b/src/material-experimental/mdc-chips/chip-option.spec.ts index 6a66bc9703f2..626f8f58494e 100644 --- a/src/material-experimental/mdc-chips/chip-option.spec.ts +++ b/src/material-experimental/mdc-chips/chip-option.spec.ts @@ -1,6 +1,5 @@ import {Directionality} from '@angular/cdk/bidi'; -import {SPACE} from '@angular/cdk/keycodes'; -import {createKeyboardEvent, dispatchFakeEvent} from '../../cdk/testing/private'; +import {dispatchFakeEvent, dispatchKeyboardEvent} from '@angular/cdk/testing/private'; import {Component, DebugElement, ViewChild} from '@angular/core'; import {waitForAsync, ComponentFixture, fakeAsync, flush, TestBed} from '@angular/core/testing'; import { @@ -8,7 +7,6 @@ import { RippleGlobalOptions, } from '@angular/material-experimental/mdc-core'; import {By} from '@angular/platform-browser'; -import {deprecated} from '@material/chips'; import {Subject} from 'rxjs'; import { MatChipEvent, @@ -17,11 +15,13 @@ import { MatChipSelectionChange, MatChipsModule, } from './index'; +import {SPACE} from '@angular/cdk/keycodes'; describe('MDC-based Option Chips', () => { let fixture: ComponentFixture; let chipDebugElement: DebugElement; let chipNativeElement: HTMLElement; + let primaryAction: HTMLElement; let chipInstance: MatChipOption; let globalRippleOptions: RippleGlobalOptions; let dir = 'ltr'; @@ -58,6 +58,7 @@ describe('MDC-based Option Chips', () => { chipDebugElement = fixture.debugElement.query(By.directive(MatChipOption))!; chipNativeElement = chipDebugElement.nativeElement; chipInstance = chipDebugElement.injector.get(MatChipOption); + primaryAction = chipNativeElement.querySelector('.mdc-evolution-chip__action--primary')!; testComponent = fixture.debugElement.componentInstance; }); @@ -72,8 +73,8 @@ describe('MDC-based Option Chips', () => { counter++; }); - chipNativeElement.focus(); - chipNativeElement.focus(); + primaryAction.focus(); + primaryAction.focus(); fixture.detectChanges(); expect(counter).toBe(1); @@ -121,16 +122,6 @@ describe('MDC-based Option Chips', () => { expect(event.defaultPrevented).toBe(false); }); - it('should prevent the default click action when the chip is disabled', () => { - chipInstance.disabled = true; - fixture.detectChanges(); - - const event = dispatchFakeEvent(chipNativeElement, 'click'); - fixture.detectChanges(); - - expect(event.defaultPrevented).toBe(true); - }); - it('should not dispatch `selectionChange` event when deselecting a non-selected chip', () => { chipInstance.deselect(); @@ -204,7 +195,6 @@ describe('MDC-based Option Chips', () => { }); it('should selects/deselects the currently focused chip on SPACE', () => { - const SPACE_EVENT = createKeyboardEvent('keydown', SPACE); const CHIP_SELECTED_EVENT: MatChipSelectionChange = { source: chipInstance, isUserInput: true, @@ -220,7 +210,7 @@ describe('MDC-based Option Chips', () => { spyOn(testComponent, 'chipSelectionChange'); // Use the spacebar to select the chip - chipInstance._keydown(SPACE_EVENT); + dispatchKeyboardEvent(primaryAction, 'keydown', SPACE); fixture.detectChanges(); expect(chipInstance.selected).toBeTruthy(); @@ -228,7 +218,7 @@ describe('MDC-based Option Chips', () => { expect(testComponent.chipSelectionChange).toHaveBeenCalledWith(CHIP_SELECTED_EVENT); // Use the spacebar to deselect the chip - chipInstance._keydown(SPACE_EVENT); + dispatchKeyboardEvent(primaryAction, 'keydown', SPACE); fixture.detectChanges(); expect(chipInstance.selected).toBeFalsy(); @@ -237,24 +227,24 @@ describe('MDC-based Option Chips', () => { }); it('should have correct aria-selected in single selection mode', () => { - expect(chipNativeElement.hasAttribute('aria-selected')).toBe(false); + expect(primaryAction.hasAttribute('aria-selected')).toBe(false); testComponent.selected = true; fixture.detectChanges(); - expect(chipNativeElement.getAttribute('aria-selected')).toBe('true'); + expect(primaryAction.getAttribute('aria-selected')).toBe('true'); }); it('should have the correct aria-selected in multi-selection mode', fakeAsync(() => { testComponent.chipList.multiple = true; flush(); fixture.detectChanges(); - expect(chipNativeElement.getAttribute('aria-selected')).toBe('false'); + expect(primaryAction.getAttribute('aria-selected')).toBe('false'); testComponent.selected = true; fixture.detectChanges(); - expect(chipNativeElement.getAttribute('aria-selected')).toBe('true'); + expect(primaryAction.getAttribute('aria-selected')).toBe('true'); })); it('should disable focus on the checkmark', fakeAsync(() => { @@ -263,7 +253,7 @@ describe('MDC-based Option Chips', () => { flush(); fixture.detectChanges(); - const checkmark = chipNativeElement.querySelector('.mdc-chip__checkmark-svg')!; + const checkmark = chipNativeElement.querySelector('.mdc-evolution-chip__checkmark-svg')!; expect(checkmark.getAttribute('focusable')).toBe('false'); })); }); @@ -275,51 +265,34 @@ describe('MDC-based Option Chips', () => { }); it('SPACE ignores selection', () => { - const SPACE_EVENT = createKeyboardEvent('keydown', SPACE); - spyOn(testComponent, 'chipSelectionChange'); // Use the spacebar to attempt to select the chip - chipInstance._keydown(SPACE_EVENT); + dispatchKeyboardEvent(primaryAction, 'keydown', SPACE); fixture.detectChanges(); - expect(chipInstance.selected).toBeFalsy(); + expect(chipInstance.selected).toBe(false); expect(testComponent.chipSelectionChange).not.toHaveBeenCalled(); }); it('should not have the aria-selected attribute', () => { - expect(chipNativeElement.hasAttribute('aria-selected')).toBe(false); + expect(primaryAction.hasAttribute('aria-selected')).toBe(false); }); }); - it('should update the aria-label for disabled chips', () => { - expect(chipNativeElement.getAttribute('aria-disabled')).toBe('false'); + it('should update the aria-disabled for disabled chips', () => { + expect(primaryAction.getAttribute('aria-disabled')).toBe('false'); testComponent.disabled = true; fixture.detectChanges(); - expect(chipNativeElement.getAttribute('aria-disabled')).toBe('true'); + expect(primaryAction.getAttribute('aria-disabled')).toBe('true'); }); }); - it('should hide the leading icon when initialized as selected', () => { - // We need to recreate the fixture before change detection has - // run so we can capture the behavior we're testing for. - fixture.destroy(); - fixture = TestBed.createComponent(SingleChip); - testComponent = fixture.debugElement.componentInstance; - testComponent.selected = true; - fixture.detectChanges(); - chipDebugElement = fixture.debugElement.query(By.directive(MatChipOption))!; - chipNativeElement = chipDebugElement.nativeElement; - chipInstance = chipDebugElement.injector.get(MatChipOption); - - const avatar = fixture.nativeElement.querySelector('.avatar'); - expect(avatar.classList).toContain(deprecated.chipCssClasses.HIDDEN_LEADING_ICON); - }); - - it('should have a focus indicator', () => { - expect(chipNativeElement.classList.contains('mat-mdc-focus-indicator')).toBe(true); + it('should contain a focus indicator inside the text label', () => { + const label = chipNativeElement.querySelector('.mdc-evolution-chip__text-label'); + expect(label?.querySelector('.mat-mdc-focus-indicator')).toBeTruthy(); }); }); }); diff --git a/src/material-experimental/mdc-chips/chip-option.ts b/src/material-experimental/mdc-chips/chip-option.ts index 3b9362ce17ac..dfca8a135d6c 100644 --- a/src/material-experimental/mdc-chips/chip-option.ts +++ b/src/material-experimental/mdc-chips/chip-option.ts @@ -7,7 +7,6 @@ */ import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion'; -import {SPACE} from '@angular/cdk/keycodes'; import { ChangeDetectionStrategy, Component, @@ -15,9 +14,15 @@ import { Input, Output, ViewEncapsulation, - AfterContentInit, + AfterViewInit, + OnInit, } from '@angular/core'; -import {deprecated} from '@material/chips'; +import { + ActionInteractionEvent, + MDCChipActionInteractionTrigger, + MDCChipActionType, + MDCChipCssClasses, +} from '@material/chips'; import {take} from 'rxjs/operators'; import {MatChip} from './chip'; @@ -40,32 +45,41 @@ export class MatChipSelectionChange { @Component({ selector: 'mat-basic-chip-option, mat-chip-option', templateUrl: 'chip-option.html', - styleUrls: ['chips.css'], + styleUrls: ['chip.css'], inputs: ['color', 'disableRipple', 'tabIndex'], host: { - 'role': 'option', - 'class': 'mat-mdc-focus-indicator mat-mdc-chip-option', - '[class.mat-mdc-chip-disabled]': 'disabled', - '[class.mat-mdc-chip-highlighted]': 'highlighted', - '[class.mat-mdc-chip-with-avatar]': 'leadingIcon', - '[class.mat-mdc-chip-with-trailing-icon]': 'trailingIcon || removeIcon', + 'class': 'mat-mdc-chip mat-mdc-chip-option mdc-evolution-chip mdc-evolution-chip--filter', '[class.mat-mdc-chip-selected]': 'selected', '[class.mat-mdc-chip-multiple]': '_chipListMultiple', + '[class.mat-mdc-chip-disabled]': 'disabled', + '[class.mat-mdc-chip-with-avatar]': 'leadingIcon', + '[class.mdc-evolution-chip--selectable]': 'selectable', + '[class.mdc-evolution-chip--disabled]': 'disabled', + '[class.mdc-evolution-chip--with-trailing-action]': '_hasTrailingIcon()', + '[class.mdc-evolution-chip--with-primary-graphic]': '_hasLeadingGraphic()', + '[class.mdc-evolution-chip--with-primary-icon]': 'leadingIcon', + '[class.mdc-evolution-chip--with-avatar]': 'leadingIcon', + '[class.mat-mdc-chip-highlighted]': 'highlighted', + '[class.mat-mdc-chip-with-trailing-icon]': '_hasTrailingIcon()', + '[attr.tabindex]': 'null', + '[attr.aria-label]': 'null', + '[attr.role]': 'role', '[id]': 'id', - '[tabIndex]': 'tabIndex', - '[attr.disabled]': 'disabled || null', - '[attr.aria-disabled]': 'disabled.toString()', - '[attr.aria-selected]': 'ariaSelected', - '(click)': '_click($event)', - '(keydown)': '_keydown($event)', - '(focus)': 'focus()', - '(blur)': '_blur()', }, providers: [{provide: MatChip, useExisting: MatChipOption}], encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, }) -export class MatChipOption extends MatChip implements AfterContentInit { +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; @@ -91,16 +105,19 @@ export class MatChipOption extends MatChip implements AfterContentInit { /** Whether the chip is selected. */ @Input() get selected(): boolean { - return this._chipFoundation.isSelected(); + return ( + this._pendingSelectedState ?? this._chipFoundation.isActionSelected(MDCChipActionType.PRIMARY) + ); } set selected(value: BooleanInput) { - if (!this.selectable) { - return; - } - const coercedValue = coerceBooleanProperty(value); - if (coercedValue != this._chipFoundation.isSelected()) { - this._chipFoundation.setSelected(coerceBooleanProperty(value)); - this._dispatchSelectionChange(); + if (this.selectable) { + const coercedValue = coerceBooleanProperty(value); + + if (this._isInitialized) { + this._setSelectedState(coercedValue, false); + } else { + this._pendingSelectedState = coercedValue; + } } } @@ -120,77 +137,53 @@ export class MatChipOption extends MatChip implements AfterContentInit { @Output() readonly selectionChange: EventEmitter = new EventEmitter(); - override ngAfterContentInit() { - super.ngAfterContentInit(); + ngOnInit() { + this.role = 'presentation'; + } + + override ngAfterViewInit() { + super.ngAfterViewInit(); + this._isInitialized = true; - if (this.selected && this.leadingIcon) { - this.leadingIcon.setClass(deprecated.chipCssClasses.HIDDEN_LEADING_ICON, 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 { - if (!this.selectable) { - return; - } else if (!this.selected) { - this._chipFoundation.setSelected(true); - this._dispatchSelectionChange(); + if (this.selectable) { + this._setSelectedState(true, false); } } /** Deselects the chip. */ deselect(): void { - if (!this.selectable) { - return; - } else if (this.selected) { - this._chipFoundation.setSelected(false); - this._dispatchSelectionChange(); + if (this.selectable) { + this._setSelectedState(false, false); } } /** Selects this chip and emits userInputSelection event */ selectViaInteraction(): void { - if (!this.selectable) { - return; - } else if (!this.selected) { - this._chipFoundation.setSelected(true); - this._dispatchSelectionChange(true); + if (this.selectable) { + this._setSelectedState(true, true); } } /** Toggles the current selected state of this chip. */ toggleSelected(isUserInput: boolean = false): boolean { - if (!this.selectable) { - return this.selected; + if (this.selectable) { + this._setSelectedState(!this.selected, isUserInput); } - this._chipFoundation.setSelected(!this.selected); - this._dispatchSelectionChange(isUserInput); return this.selected; } - /** Emits a selection change event. */ - private _dispatchSelectionChange(isUserInput = false) { - this.selectionChange.emit({ - source: this, - isUserInput, - selected: this.selected, - }); - } - - /** Allows for programmatic focusing of the chip. */ - focus(): void { - if (this.disabled) { - return; - } - - if (!this._hasFocus()) { - this._elementRef.nativeElement.focus(); - this._onFocus.next({chip: this}); - } - this._hasFocusInternal = 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 @@ -205,31 +198,47 @@ export class MatChipOption extends MatChip implements AfterContentInit { }); } - /** Handles click events on the chip. */ - _click(event: MouseEvent) { - if (this.disabled) { - event.preventDefault(); - } else { - this._handleInteraction(event); - event.stopPropagation(); + 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, + }); } } - /** Handles custom key presses. */ - _keydown(event: KeyboardEvent): void { - if (this.disabled) { - return; - } + _hasLeadingGraphic() { + // The checkmark graphic is built in for multi-select chip lists. + return this.leadingIcon || (this._chipListMultiple && this.selectable); + } - switch (event.keyCode) { - case SPACE: - this.toggleSelected(true); + private _setSelectedState(isSelected: boolean, isUserInput: boolean) { + if (isSelected !== this.selected) { + this._chipFoundation.setActionSelected(MDCChipActionType.PRIMARY, isSelected); + this.selectionChange.emit({ + source: this, + isUserInput, + selected: this.selected, + }); + } - // Always prevent space from scrolling the page since the list has focus - event.preventDefault(); - break; - default: - this._handleInteraction(event); + // 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 17ebd0dae4e7..02060a4dce04 100644 --- a/src/material-experimental/mdc-chips/chip-remove.spec.ts +++ b/src/material-experimental/mdc-chips/chip-remove.spec.ts @@ -1,14 +1,15 @@ -import {dispatchKeyboardEvent, createKeyboardEvent, dispatchEvent} from '../../cdk/testing/private'; -import {Component, DebugElement} from '@angular/core'; +import {Component} from '@angular/core'; +import {waitForAsync, ComponentFixture, TestBed, fakeAsync, flush} from '@angular/core/testing'; +import {dispatchKeyboardEvent} from '@angular/cdk/testing/private'; import {By} from '@angular/platform-browser'; -import {waitForAsync, ComponentFixture, TestBed} from '@angular/core/testing'; -import {SPACE, ENTER, TAB} from '@angular/cdk/keycodes'; +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 chipDebugElement: DebugElement; + let chipInstance: MatChip; let chipNativeElement: HTMLElement; beforeEach( @@ -28,148 +29,120 @@ describe('MDC-based Chip Remove', () => { testChip = fixture.debugElement.componentInstance; fixture.detectChanges(); - chipDebugElement = fixture.debugElement.query(By.directive(MatChip))!; + const chipDebugElement = fixture.debugElement.query(By.directive(MatChip))!; chipNativeElement = chipDebugElement.nativeElement; + chipInstance = chipDebugElement.componentInstance; }), ); - describe('basic behavior', () => { - it('should apply a CSS class to the remove icon', () => { - let buttonElement = chipNativeElement.querySelector('button')!; + 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')!; expect(buttonElement.classList).toContain('mat-mdc-chip-remove'); - }); + })); - it('should ensure that the button cannot submit its parent form', () => { + it('should ensure that the button cannot submit its parent form', fakeAsync(() => { const buttonElement = chipNativeElement.querySelector('button')!; - expect(buttonElement.getAttribute('type')).toBe('button'); - }); + })); - it('should not set the `type` attribute on non-button elements', () => { + it('should not set the `type` attribute on non-button elements', fakeAsync(() => { const buttonElement = chipNativeElement.querySelector('span.mat-mdc-chip-remove')!; - expect(buttonElement.hasAttribute('type')).toBe(false); - }); - - it('should emit (removed) event when exit animation is complete', () => { - let buttonElement = chipNativeElement.querySelector('button')!; + })); + it('should emit (removed) event when exit animation is complete', fakeAsync(() => { testChip.removable = true; fixture.detectChanges(); - spyOn(testChip, 'didRemove'); - buttonElement.click(); - fixture.detectChanges(); + chipNativeElement.querySelector('button')!.click(); + triggerRemoveSequence(); expect(testChip.didRemove).toHaveBeenCalled(); - }); - - it('should not start MDC exit animation if parent chip is disabled', () => { - let buttonElement = chipNativeElement.querySelector('button')!; + })); + it('should not start MDC exit animation if parent chip is disabled', fakeAsync(() => { testChip.removable = true; testChip.disabled = true; fixture.detectChanges(); - buttonElement.click(); - fixture.detectChanges(); + chipNativeElement.querySelector('button')!.click(); - expect(chipNativeElement.classList.contains('mdc-chip--exit')).toBe(false); - }); + expect(chipNativeElement.classList.contains(MDCChipCssClasses.HIDDEN)).toBe(false); + })); - it('should not make the element aria-hidden when it is focusable', () => { + it('should not make the element aria-hidden when it is focusable', fakeAsync(() => { const buttonElement = chipNativeElement.querySelector('button')!; - expect(buttonElement.getAttribute('tabindex')).toBe('0'); + expect(buttonElement.getAttribute('tabindex')).toBe('-1'); expect(buttonElement.hasAttribute('aria-hidden')).toBe(false); - }); + })); - it('should prevent the default SPACE action', () => { + it('should prevent the default SPACE action', fakeAsync(() => { const buttonElement = chipNativeElement.querySelector('button')!; testChip.removable = true; fixture.detectChanges(); const event = dispatchKeyboardEvent(buttonElement, 'keydown', SPACE); - fixture.detectChanges(); + triggerRemoveSequence(); expect(event.defaultPrevented).toBe(true); - }); + })); - it('should not prevent the default SPACE action when a modifier key is pressed', () => { - const buttonElement = chipNativeElement.querySelector('button')!; - - testChip.removable = true; - fixture.detectChanges(); - - const event = createKeyboardEvent('keydown', SPACE, undefined, {shift: true}); - dispatchEvent(buttonElement, event); - fixture.detectChanges(); - - expect(event.defaultPrevented).toBe(false); - }); - - it('should prevent the default ENTER action', () => { + it('should prevent the default ENTER action', fakeAsync(() => { const buttonElement = chipNativeElement.querySelector('button')!; testChip.removable = true; fixture.detectChanges(); const event = dispatchKeyboardEvent(buttonElement, 'keydown', ENTER); - fixture.detectChanges(); + triggerRemoveSequence(); expect(event.defaultPrevented).toBe(true); - }); - - it('should not prevent the default ENTER action when a modifier key is pressed', () => { - const buttonElement = chipNativeElement.querySelector('button')!; - - testChip.removable = true; - fixture.detectChanges(); - - const event = createKeyboardEvent('keydown', ENTER, undefined, {shift: true}); - dispatchEvent(buttonElement, event); - fixture.detectChanges(); - - expect(event.defaultPrevented).toBe(false); - }); - - it('should not remove on any key press', () => { - let buttonElement = chipNativeElement.querySelector('button')!; - - testChip.removable = true; - fixture.detectChanges(); - - spyOn(testChip, 'didRemove'); - dispatchKeyboardEvent(buttonElement, 'keydown', TAB); - fixture.detectChanges(); - - expect(testChip.didRemove).not.toHaveBeenCalled(); - }); - - it('should have a focus indicator', () => { - const buttonElement = chipNativeElement.querySelector('button')!; + })); + it('should have a focus indicator', fakeAsync(() => { + const buttonElement = chipNativeElement.querySelector('.mdc-evolution-chip__icon--trailing')!; expect(buttonElement.classList.contains('mat-mdc-focus-indicator')).toBe(true); - }); + })); }); }); @Component({ template: ` - - - - + + + + + + `, }) class TestChip { removable: boolean; disabled = false; - - didRemove() {} + didRemove = jasmine.createSpy('didRemove spy'); } diff --git a/src/material-experimental/mdc-chips/chip-row.html b/src/material-experimental/mdc-chips/chip-row.html index ebc293d08a2d..2c8970d46aec 100644 --- a/src/material-experimental/mdc-chips/chip-row.html +++ b/src/material-experimental/mdc-chips/chip-row.html @@ -1,31 +1,37 @@ - - - + + -
-
-
+ + +
-
-
- -
-
+ + + + + + + + + + + + + -
- - - - -
+ + + diff --git a/src/material-experimental/mdc-chips/chip-row.spec.ts b/src/material-experimental/mdc-chips/chip-row.spec.ts index bad5387bc2b0..c130cda43d36 100644 --- a/src/material-experimental/mdc-chips/chip-row.spec.ts +++ b/src/material-experimental/mdc-chips/chip-row.spec.ts @@ -1,6 +1,11 @@ import {Directionality} from '@angular/cdk/bidi'; -import {BACKSPACE, DELETE, RIGHT_ARROW, ENTER} from '@angular/cdk/keycodes'; -import {createKeyboardEvent, dispatchEvent, dispatchFakeEvent} from '../../cdk/testing/private'; +import {BACKSPACE, DELETE, ENTER} from '@angular/cdk/keycodes'; +import { + createKeyboardEvent, + dispatchEvent, + dispatchFakeEvent, + dispatchKeyboardEvent, +} from '../../cdk/testing/private'; import {Component, DebugElement, ElementRef, ViewChild} from '@angular/core'; import {waitForAsync, ComponentFixture, TestBed, flush, fakeAsync} from '@angular/core/testing'; import {By} from '@angular/platform-browser'; @@ -10,7 +15,6 @@ import { MatChipEditInput, MatChipEvent, MatChipGrid, - MatChipRemove, MatChipRow, MatChipsModule, } from './index'; @@ -20,7 +24,6 @@ describe('MDC-based Row Chips', () => { let chipDebugElement: DebugElement; let chipNativeElement: HTMLElement; let chipInstance: MatChipRow; - let removeIconInstance: MatChipRemove; let dir = 'ltr'; @@ -55,9 +58,6 @@ describe('MDC-based Row Chips', () => { chipNativeElement = chipDebugElement.nativeElement; chipInstance = chipDebugElement.injector.get(MatChipRow); testComponent = fixture.debugElement.componentInstance; - - const removeIconDebugElement = fixture.debugElement.query(By.directive(MatChipRemove))!; - removeIconInstance = removeIconDebugElement.injector.get(MatChipRemove); }); describe('basic behaviors', () => { @@ -134,17 +134,6 @@ describe('MDC-based Row Chips', () => { expect(testComponent.chipRemove).toHaveBeenCalled(); }); - - it('arrow key navigation does not emit the (removed) event', () => { - const ARROW_KEY_EVENT = createKeyboardEvent('keydown', RIGHT_ARROW); - - spyOn(testComponent, 'chipRemove'); - - removeIconInstance.interaction.next(ARROW_KEY_EVENT); - fixture.detectChanges(); - - expect(testComponent.chipRemove).not.toHaveBeenCalled(); - }); }); describe('when removable is false', () => { @@ -178,12 +167,16 @@ describe('MDC-based Row Chips', () => { }); it('should update the aria-label for disabled chips', () => { - expect(chipNativeElement.getAttribute('aria-disabled')).toBe('false'); + const primaryActionElement = chipNativeElement.querySelector( + '.mdc-evolution-chip__action--primary', + )!; + + expect(primaryActionElement.getAttribute('aria-disabled')).toBe('false'); testComponent.disabled = true; fixture.detectChanges(); - expect(chipNativeElement.getAttribute('aria-disabled')).toBe('true'); + expect(primaryActionElement.getAttribute('aria-disabled')).toBe('true'); }); describe('focus management', () => { @@ -191,7 +184,7 @@ describe('MDC-based Row Chips', () => { dispatchFakeEvent(chipNativeElement, 'mousedown'); fixture.detectChanges(); - expect(document.activeElement).toHaveClass('mat-mdc-chip-row-focusable-text-content'); + expect(document.activeElement).toHaveClass('mdc-evolution-chip__action--primary'); }); it('emits focus only once for multiple focus() calls', () => { @@ -215,46 +208,46 @@ describe('MDC-based Row Chips', () => { fixture.detectChanges(); }); - it('should apply the mdc-chip--editable class', () => { - expect(chipNativeElement.classList).toContain('mdc-chip--editable'); - }); - it('should begin editing on double click', () => { + expect(chipNativeElement.querySelector('.mat-chip-edit-input')).toBeFalsy(); dispatchFakeEvent(chipNativeElement, 'dblclick'); - expect(chipNativeElement.classList).toContain('mdc-chip--editing'); + fixture.detectChanges(); + expect(chipNativeElement.querySelector('.mat-chip-edit-input')).toBeTruthy(); }); it('should begin editing on ENTER', () => { - chipInstance.focus(); - const primaryActionElement = chipNativeElement.querySelector('.mdc-chip__primary-action')!; - const enterEvent = createKeyboardEvent('keydown', ENTER, 'Enter'); - dispatchEvent(primaryActionElement, enterEvent); - expect(chipNativeElement.classList).toContain('mdc-chip--editing'); + expect(chipNativeElement.querySelector('.mat-chip-edit-input')).toBeFalsy(); + dispatchKeyboardEvent(chipNativeElement, 'keydown', ENTER); + fixture.detectChanges(); + expect(chipNativeElement.querySelector('.mat-chip-edit-input')).toBeTruthy(); }); }); describe('editing behavior', () => { let editInputInstance: MatChipEditInput; - let chipContentElement: HTMLElement; + let primaryAction: HTMLElement; - beforeEach(() => { + beforeEach(fakeAsync(() => { testComponent.editable = true; fixture.detectChanges(); dispatchFakeEvent(chipNativeElement, 'dblclick'); - spyOn(testComponent, 'chipEdit'); fixture.detectChanges(); + flush(); + spyOn(testComponent, 'chipEdit'); const editInputDebugElement = fixture.debugElement.query(By.directive(MatChipEditInput))!; editInputInstance = editInputDebugElement.injector.get(MatChipEditInput); - - const chipContentSelector = '.mat-mdc-chip-row-focusable-text-content'; - chipContentElement = chipNativeElement.querySelector(chipContentSelector) as HTMLElement; - }); + primaryAction = chipNativeElement.querySelector('.mdc-evolution-chip__action--primary')!; + })); function keyDownOnPrimaryAction(keyCode: number, key: string) { - const primaryActionElement = chipNativeElement.querySelector('.mdc-chip__primary-action')!; const keyDownEvent = createKeyboardEvent('keydown', keyCode, key); - dispatchEvent(primaryActionElement, keyDownEvent); + dispatchEvent(primaryAction, keyDownEvent); + fixture.detectChanges(); + } + + function getEditInput(): HTMLElement { + return chipNativeElement.querySelector('.mat-chip-edit-input')!; } it('should not delete the chip on DELETE or BACKSPACE', () => { @@ -264,33 +257,27 @@ describe('MDC-based Row Chips', () => { expect(testComponent.chipDestroy).not.toHaveBeenCalled(); }); - it('should ignore mousedown events', () => { - spyOn(testComponent, 'chipFocus'); - dispatchFakeEvent(chipNativeElement, 'mousedown'); - expect(testComponent.chipFocus).not.toHaveBeenCalled(); - }); - it('should stop editing on focusout', fakeAsync(() => { - const primaryActionElement = chipNativeElement.querySelector('.mdc-chip__primary-action')!; - dispatchFakeEvent(primaryActionElement, 'focusout', true); + dispatchFakeEvent(primaryAction, 'focusout', true); flush(); - expect(chipNativeElement.classList).not.toContain('mdc-chip--editing'); expect(testComponent.chipEdit).toHaveBeenCalled(); })); - it('should stop editing on ENTER', () => { - keyDownOnPrimaryAction(ENTER, 'Enter'); - expect(chipNativeElement.classList).not.toContain('mdc-chip--editing'); + it('should stop editing on ENTER', fakeAsync(() => { + dispatchKeyboardEvent(getEditInput(), 'keydown', ENTER); + fixture.detectChanges(); + flush(); expect(testComponent.chipEdit).toHaveBeenCalled(); - }); + })); - it('should emit the new chip value when editing completes', () => { + it('should emit the new chip value when editing completes', fakeAsync(() => { const chipValue = 'chip value'; editInputInstance.setValue(chipValue); - keyDownOnPrimaryAction(ENTER, 'Enter'); + dispatchKeyboardEvent(getEditInput(), 'keydown', ENTER); + flush(); const expectedValue = jasmine.objectContaining({value: chipValue}); expect(testComponent.chipEdit).toHaveBeenCalledWith(expectedValue); - }); + })); it('should use the projected edit input if provided', () => { expect(editInputInstance.getNativeElement()).toHaveClass('projected-edit-input'); @@ -308,28 +295,33 @@ describe('MDC-based Row Chips', () => { expect(editInputNoProject.getNativeElement()).not.toHaveClass('projected-edit-input'); }); - it('should focus the chip content if the edit input has focus on completion', () => { + it('should focus the chip content if the edit input has focus on completion', fakeAsync(() => { const chipValue = 'chip value'; editInputInstance.setValue(chipValue); - keyDownOnPrimaryAction(ENTER, 'Enter'); - expect(document.activeElement).toBe(chipContentElement); - }); + dispatchKeyboardEvent(getEditInput(), 'keydown', ENTER); + fixture.detectChanges(); + flush(); + expect(document.activeElement).toBe(primaryAction); + })); - it('should focus the chip content if the body has focus on completion', () => { + 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(); - keyDownOnPrimaryAction(ENTER, 'Enter'); - expect(document.activeElement).toBe(chipContentElement); - }); + dispatchKeyboardEvent(getEditInput(), 'keydown', ENTER); + fixture.detectChanges(); + flush(); + expect(document.activeElement).toBe(primaryAction); + })); - it('should not change focus if another element has focus on completion', () => { + it('should not change focus if another element has focus on completion', fakeAsync(() => { const chipValue = 'chip value'; editInputInstance.setValue(chipValue); testComponent.chipInput.nativeElement.focus(); keyDownOnPrimaryAction(ENTER, 'Enter'); - expect(document.activeElement).not.toBe(chipContentElement); - }); + flush(); + expect(document.activeElement).not.toBe(primaryAction); + })); }); }); }); @@ -340,7 +332,7 @@ describe('MDC-based Row Chips', () => {
{{name}} @@ -361,7 +353,6 @@ class SingleChip { editable: boolean = false; useCustomEditInput: boolean = true; - chipFocus: (event?: MatChipEvent) => void = () => {}; chipDestroy: (event?: MatChipEvent) => void = () => {}; chipRemove: (event?: MatChipEvent) => void = () => {}; chipEdit: (event?: MatChipEditedEvent) => void = () => {}; diff --git a/src/material-experimental/mdc-chips/chip-row.ts b/src/material-experimental/mdc-chips/chip-row.ts index 08c57d940136..46a5308c7948 100644 --- a/src/material-experimental/mdc-chips/chip-row.ts +++ b/src/material-experimental/mdc-chips/chip-row.ts @@ -7,11 +7,11 @@ */ import {Directionality} from '@angular/cdk/bidi'; -import {BACKSPACE, DELETE} from '@angular/cdk/keycodes'; +import {BACKSPACE, DELETE, ENTER} from '@angular/cdk/keycodes'; import {ANIMATION_MODULE_TYPE} from '@angular/platform-browser/animations'; import { - AfterContentInit, AfterViewInit, + Attribute, ChangeDetectionStrategy, ChangeDetectorRef, Component, @@ -31,9 +31,9 @@ import { MAT_RIPPLE_GLOBAL_OPTIONS, RippleGlobalOptions, } from '@angular/material-experimental/mdc-core'; +import {FocusMonitor} from '@angular/cdk/a11y'; import {MatChip, MatChipEvent} from './chip'; import {MatChipEditInput} from './chip-edit-input'; -import {GridKeyManagerRow} from './grid-key-manager'; /** Represents an event fired on an individual `mat-chip` when it is edited. */ export interface MatChipEditedEvent extends MatChipEvent { @@ -48,23 +48,28 @@ export interface MatChipEditedEvent extends MatChipEvent { @Component({ selector: 'mat-chip-row, mat-basic-chip-row', templateUrl: 'chip-row.html', - styleUrls: ['chips.css'], + styleUrls: ['chip.css'], inputs: ['color', 'disableRipple', 'tabIndex'], host: { - 'role': 'row', - 'class': 'mat-mdc-chip-row', + 'class': 'mat-mdc-chip mat-mdc-chip-row mdc-evolution-chip', + '[class.mat-mdc-chip-with-avatar]': 'leadingIcon', '[class.mat-mdc-chip-disabled]': 'disabled', + '[class.mat-mdc-chip-editing]': '_isEditing', + '[class.mat-mdc-chip-editable]': 'editable', + '[class.mdc-evolution-chip--disabled]': 'disabled', + '[class.mdc-evolution-chip--with-trailing-action]': '_hasTrailingIcon()', + '[class.mdc-evolution-chip--with-primary-graphic]': 'leadingIcon', + '[class.mdc-evolution-chip--with-primary-icon]': 'leadingIcon', + '[class.mdc-evolution-chip--with-avatar]': 'leadingIcon', '[class.mat-mdc-chip-highlighted]': 'highlighted', - '[class.mat-mdc-chip-with-avatar]': 'leadingIcon', - '[class.mat-mdc-chip-with-trailing-icon]': 'trailingIcon || removeIcon', - '[class.mdc-chip--editable]': 'editable', + '[class.mat-mdc-chip-with-trailing-icon]': '_hasTrailingIcon()', '[id]': 'id', - '[attr.disabled]': 'disabled || null', - '[attr.aria-disabled]': 'disabled.toString()', - '[tabIndex]': 'tabIndex', + '[attr.tabindex]': 'null', + '[attr.aria-label]': 'null', + '[attr.role]': 'role', '(mousedown)': '_mousedown($event)', - '(dblclick)': '_dblclick($event)', '(keydown)': '_keydown($event)', + '(dblclick)': '_doubleclick()', '(focusin)': '_focusin($event)', '(focusout)': '_focusout($event)', }, @@ -72,10 +77,7 @@ export interface MatChipEditedEvent extends MatChipEvent { encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, }) -export class MatChipRow - extends MatChip - implements AfterContentInit, AfterViewInit, GridKeyManagerRow -{ +export class MatChipRow extends MatChip implements AfterViewInit { protected override basicChipAttrName = 'mat-basic-chip-row'; @Input() editable: boolean = false; @@ -84,20 +86,13 @@ export class MatChipRow @Output() readonly edited: EventEmitter = new EventEmitter(); - /** - * The focusable wrapper element in the first gridcell, which contains all - * chip content other than the remove icon. - */ - @ViewChild('chipContent') chipContent: ElementRef; - /** The default chip edit input that is used if none is projected into this chip row. */ @ViewChild(MatChipEditInput) defaultEditInput?: MatChipEditInput; /** The projected chip edit input. */ @ContentChild(MatChipEditInput) contentEditInput?: MatChipEditInput; - /** The focusable grid cells for this row. Implemented as part of GridKeyManagerRow. */ - cells!: HTMLElement[]; + _isEditing = false; /** * Timeout used to give some time between `focusin` and `focusout` @@ -106,100 +101,77 @@ export class MatChipRow private _focusoutTimeout: number | null; constructor( - @Inject(DOCUMENT) private readonly _document: any, 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) globalRippleOptions?: RippleGlobalOptions, + @Attribute('tabindex') tabIndex?: string, ) { - super(changeDetectorRef, elementRef, ngZone, dir, animationMode, globalRippleOptions); - } - - override ngAfterContentInit() { - super.ngAfterContentInit(); - - if (this.removeIcon) { - // Defer setting the value in order to avoid the "Expression - // has changed after it was checked" errors from Angular. - setTimeout(() => { - // removeIcon has tabIndex 0 for regular chips, but should only be focusable by - // the GridFocusKeyManager for row chips. - this.removeIcon.tabIndex = -1; - }); - } + super( + changeDetectorRef, + elementRef, + ngZone, + focusMonitor, + _document, + dir, + animationMode, + globalRippleOptions, + tabIndex, + ); + + this.role = 'row'; } - override ngAfterViewInit() { - super.ngAfterViewInit(); - this.cells = this.removeIcon - ? [this.chipContent.nativeElement, this.removeIcon._elementRef.nativeElement] - : [this.chipContent.nativeElement]; - } - - /** - * Allows for programmatic focusing of the chip. - * Sends focus to the first grid cell. The row chip element itself - * is never focused. - */ - focus(): void { - if (this.disabled) { - return; - } - - if (!this._hasFocusInternal) { - this._onFocus.next({chip: this}); - } - - this.chipContent.nativeElement.focus(); + 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(event: FocusEvent) { + _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}); - this._handleInteraction(event); }); } /** Records that the chip has focus when one of the gridcells is focused. */ - _focusin(event: FocusEvent) { + _focusin() { if (this._focusoutTimeout) { clearTimeout(this._focusoutTimeout); this._focusoutTimeout = null; } this._hasFocusInternal = true; - this._handleInteraction(event); } /** Sends focus to the first gridcell when the user clicks anywhere inside the chip. */ _mousedown(event: MouseEvent) { - if (this._isEditing()) { - return; - } + if (!this._isEditing) { + if (!this.disabled) { + this.focus(); + } - if (!this.disabled) { - this.focus(); + event.preventDefault(); } - - event.preventDefault(); - } - - _dblclick(event: MouseEvent) { - this._handleInteraction(event); } /** Handles custom key presses. */ @@ -207,44 +179,60 @@ export class MatChipRow if (this.disabled) { return; } - if (this._isEditing()) { - this._handleInteraction(event); - return; - } + 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: - // Remove the focused chip - this.remove(); - // Always prevent so page navigation does not occur - event.preventDefault(); + if (!this._isEditing) { + // Remove the focused chip + this.remove(); + // Always prevent so page navigation does not occur + event.preventDefault(); + } break; - default: - this._handleInteraction(event); } } - _isEditing() { - return this._chipFoundation.isEditing(); + _doubleclick() { + if (!this.disabled && this.editable) { + this._startEditing(); + } } - protected override _onEditStart() { + private _startEditing() { + // The value depends on the DOM so we need to extract it before we flip the flag. + const value = this.value; + + // Make the primary action non-interactive so that it doesn't + // navigate when the user presses the arrow keys while editing. + this.primaryAction.isInteractive = false; + this._isEditing = true; + // Defer initializing the input so it has time to be added to the DOM. - setTimeout(() => { - this._getEditInput().initialize(this.value); - }); + setTimeout(() => this._getEditInput().initialize(value)); } - protected override _onEditFinish() { + private _onEditFinish() { // 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 ( this._document.activeElement === this._getEditInput().getNativeElement() || this._document.activeElement === this._document.body ) { - this.chipContent.nativeElement.focus(); + 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.scss b/src/material-experimental/mdc-chips/chip-set.scss new file mode 100644 index 000000000000..4c4fc242066b --- /dev/null +++ b/src/material-experimental/mdc-chips/chip-set.scss @@ -0,0 +1,32 @@ +@use '@material/chips/chip-set' as mdc-chip-set; +@use '../mdc-helpers/mdc-helpers'; + +@include mdc-chip-set.core-styles($query: mdc-helpers.$mat-base-styles-query); + +// Ensures that the internal chip container spans the entire outer container width, if the +// outer container width is customized. This is used by some wrapper components in g3. +.mat-mdc-chip-set { + .mdc-evolution-chip-set__chips { + min-width: 100%; + } +} + +// Angular Material supports vertically-stacked chips, which MDC does not. +.mat-mdc-chip-set-stacked { + flex-direction: column; + align-items: flex-start; + + .mat-mdc-chip { + width: 100%; + } +} + +input.mat-mdc-chip-input { + flex: 1 0 150px; + margin-left: 8px; + + [dir='rtl'] & { + margin-left: 0; + margin-right: 8px; + } +} diff --git a/src/material-experimental/mdc-chips/chip-set.ts b/src/material-experimental/mdc-chips/chip-set.ts index ddbb16bada01..1b8787de063a 100644 --- a/src/material-experimental/mdc-chips/chip-set.ts +++ b/src/material-experimental/mdc-chips/chip-set.ts @@ -6,8 +6,9 @@ * found in the LICENSE file at https://angular.io/license */ -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, @@ -16,19 +17,27 @@ import { Component, ContentChildren, ElementRef, + Inject, Input, OnDestroy, - Optional, QueryList, ViewEncapsulation, } from '@angular/core'; import {HasTabIndex, mixinTabIndex} from '@angular/material-experimental/mdc-core'; -import {deprecated} from '@material/chips'; -import {merge, Observable, Subject, Subscription} from 'rxjs'; -import {startWith, takeUntil} from 'rxjs/operators'; +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'; - -let uid = 0; +import {emitCustomEvent} from './emit-event'; /** * Boilerplate for applying mixins to MatChipSet. @@ -47,14 +56,17 @@ const _MatChipSetMixinBase = mixinTabIndex(MatChipSetBase); */ @Component({ selector: 'mat-chip-set', - template: '', - styleUrls: ['chips.css'], + template: ` + + + + `, + styleUrls: ['chip-set.css'], host: { - 'class': 'mat-mdc-chip-set mdc-chip-set', + 'class': 'mat-mdc-chip-set mdc-evolution-chip-set', '[attr.role]': 'role', // TODO: replace this binding with use of AriaDescriber '[attr.aria-describedby]': '_ariaDescribedby || null', - '[id]': '_uid', }, encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, @@ -63,15 +75,6 @@ export class MatChipSet extends _MatChipSetMixinBase implements AfterContentInit, AfterViewInit, HasTabIndex, OnDestroy { - /** Subscription to remove changes in chips. */ - private _chipRemoveSubscription: Subscription | null; - - /** Subscription to destroyed events in chips. */ - private _chipDestroyedSubscription: Subscription | null; - - /** Subscription to chip interactions. */ - private _chipInteractionSubscription: Subscription | 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 @@ -80,38 +83,61 @@ export class MatChipSet protected _lastDestroyedChipIndex: number | null = null; /** The MDC foundation containing business logic for MDC chip-set. */ - protected _chipSetFoundation: deprecated.MDCChipSetFoundation; + protected _chipSetFoundation: MDCChipSetFoundation; /** Subject that emits when the component has been destroyed. */ protected _destroyed = new Subject(); + /** 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: deprecated.MDCChipSetAdapter = { - hasClass: className => this._hasMdcClass(className), - // No-op. We keep track of chips via ContentChildren, which will be updated when a chip is - // removed. - removeChipAtIndex: () => {}, - // No-op for base chip set. MatChipListbox overrides the adapter to provide this method. - selectChipAtIndex: () => {}, - getIndexOfChipById: (id: string) => this._chips.toArray().findIndex(chip => chip.id === id), - focusChipPrimaryActionAtIndex: () => {}, - focusChipTrailingActionAtIndex: () => {}, - removeFocusFromChipAtIndex: () => {}, - isRTL: () => !!this._dir && this._dir.value === 'rtl', - getChipListCount: () => this._chips.length, - // TODO(mmalerba): Implement using LiveAnnouncer. - announceMessage: () => {}, + 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); + }, }; /** The aria-describedby attribute on the chip list for improved a11y. */ _ariaDescribedby: string; - /** Uid of the chip set */ - _uid: string = `mat-mdc-chip-set-${uid++}`; - /** * Map from class to whether the class is enabled. * Enabled classes are set on the MDC chip-set div. @@ -154,21 +180,6 @@ export class MatChipSet return this._hasFocusedChip(); } - /** Combined stream of all of the child chips' remove events. */ - get chipRemoveChanges(): Observable { - return merge(...this._chips.map(chip => chip.removed)); - } - - /** Combined stream of all of the child chips' remove events. */ - get chipDestroyedChanges(): Observable { - return merge(...this._chips.map(chip => chip.destroyed)); - } - - /** Combined stream of all of the child chips' interaction events. */ - get chipInteractionChanges(): Observable { - return merge(...this._chips.map(chip => chip.interaction)); - } - /** The chips that are part of this chip set. */ @ContentChildren(MatChip, { // We need to use `descendants: true`, because Ivy will no longer match @@ -178,12 +189,17 @@ export class MatChipSet _chips: QueryList; constructor( - protected _elementRef: ElementRef, + private _liveAnnouncer: LiveAnnouncer, + @Inject(DOCUMENT) private _document: any, + protected _elementRef: ElementRef, protected _changeDetectorRef: ChangeDetectorRef, - @Optional() protected _dir: Directionality, ) { super(_elementRef); - this._chipSetFoundation = new deprecated.MDCChipSetFoundation(this._chipSetAdapter); + 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() { @@ -199,13 +215,26 @@ export class MatChipSet this._syncChipsState(); }); } + }); + + this.chipDestroyedChanges.pipe(takeUntil(this._destroyed)).subscribe((event: MatChipEvent) => { + const chip = event.chip; + const chipIndex = this._chips.toArray().indexOf(event.chip); - this._resetChips(); + // 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._dropSubscriptions(); + 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(); @@ -226,82 +255,6 @@ export class MatChipSet } } - /** Sets whether the given CSS class should be applied to the MDC chip. */ - protected _setMdcClass(cssClass: string, active: boolean) { - const classes = this._elementRef.nativeElement.classList; - active ? classes.add(cssClass) : classes.remove(cssClass); - this._changeDetectorRef.markForCheck(); - } - - /** Adapter method that returns true if the chip set has the given MDC class. */ - protected _hasMdcClass(className: string) { - return this._elementRef.nativeElement.classList.contains(className); - } - - /** Updates subscriptions to chip events. */ - private _resetChips() { - this._dropSubscriptions(); - this._subscribeToChipEvents(); - } - - /** Subscribes to events on the child chips. */ - protected _subscribeToChipEvents() { - this._listenToChipsRemove(); - this._listenToChipsDestroyed(); - this._listenToChipsInteraction(); - } - - /** Subscribes to chip removal events. */ - private _listenToChipsRemove() { - this._chipRemoveSubscription = this.chipRemoveChanges.subscribe((event: MatChipEvent) => { - this._chipSetFoundation.handleChipRemoval({ - chipId: event.chip.id, - // TODO(mmalerba): Add removal message. - removedAnnouncement: null, - }); - }); - } - - /** Subscribes to chip destroyed events. */ - private _listenToChipsDestroyed() { - this._chipDestroyedSubscription = this.chipDestroyedChanges.subscribe((event: MatChipEvent) => { - const chip = event.chip; - const chipIndex: number = 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; - } - }); - } - - /** Subscribes to chip interaction events. */ - private _listenToChipsInteraction() { - this._chipInteractionSubscription = this.chipInteractionChanges.subscribe((id: string) => { - this._chipSetFoundation.handleChipInteraction({chipId: id}); - }); - } - - /** Unsubscribes from all chip events. */ - protected _dropSubscriptions() { - if (this._chipRemoveSubscription) { - this._chipRemoveSubscription.unsubscribe(); - this._chipRemoveSubscription = null; - } - - if (this._chipInteractionSubscription) { - this._chipInteractionSubscription.unsubscribe(); - this._chipInteractionSubscription = null; - } - - if (this._chipDestroyedSubscription) { - this._chipDestroyedSubscription.unsubscribe(); - this._chipDestroyedSubscription = null; - } - } - /** Dummy method for subclasses to override. Base chip set cannot be focused. */ focus() {} @@ -317,18 +270,41 @@ export class MatChipSet /** Checks whether an event comes from inside a chip element. */ protected _originatesFromChip(event: Event): boolean { - return this._checkForClassInHierarchy(event, 'mdc-chip'); + return this._checkForClassInHierarchy(event, 'mdc-evolution-chip'); } /** - * Checks whether an event comes from inside a chip element in the editing - * state. + * 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 + * it back to the first chip, creating a focus trap, if it user tries to tab away. */ - protected _originatesFromEditingChip(event: Event): boolean { - return this._checkForClassInHierarchy(event, 'mdc-chip--editing'); + protected _allowFocusEscape() { + const previousTabIndex = this.tabIndex; + + if (this.tabIndex !== -1) { + this.tabIndex = -1; + + setTimeout(() => { + this.tabIndex = previousTabIndex; + this._changeDetectorRef.markForCheck(); + }); + } } - private _checkForClassInHierarchy(event: Event, className: string) { + /** + * Gets a stream of events from all the chips within the set. + * The stream will automatically incorporate any newly-added chips. + */ + protected _getChipStream( + mappingFunction: (chip: C) => Observable, + ): Observable { + return this._chips.changes.pipe( + startWith(null), + switchMap(() => merge(...(this._chips as QueryList).map(mappingFunction))), + ); + } + + protected _checkForClassInHierarchy(event: Event, className: string) { let currentElement = event.target as HTMLElement | null; while (currentElement && currentElement !== this._elementRef.nativeElement) { @@ -342,4 +318,20 @@ export class MatChipSet return false; } + + private _chipFoundation(index: number): MDCChipFoundation | undefined { + return this._chips.toArray()[index]?._getFoundation(); + } + + private _handleChipAnimation = (event: Event) => { + this._chipSetFoundation.handleChipAnimation(event as ChipAnimationEvent); + }; + + private _handleChipInteraction = (event: Event) => { + this._chipSetFoundation.handleChipInteraction(event as ChipInteractionEvent); + }; + + private _handleChipNavigation = (event: Event) => { + this._chipSetFoundation.handleChipNavigation(event as ChipNavigationEvent); + }; } diff --git a/src/material-experimental/mdc-chips/chip.html b/src/material-experimental/mdc-chips/chip.html index fd8b2c0278de..35283fd77ea0 100644 --- a/src/material-experimental/mdc-chips/chip.html +++ b/src/material-experimental/mdc-chips/chip.html @@ -1,12 +1,23 @@ - - + + + +
+ + + + + + + +
+
- -
-
-
- + + + diff --git a/src/material-experimental/mdc-chips/chip.scss b/src/material-experimental/mdc-chips/chip.scss new file mode 100644 index 000000000000..4fa66e348c5f --- /dev/null +++ b/src/material-experimental/mdc-chips/chip.scss @@ -0,0 +1,208 @@ +@use '@material/chips/chip' as mdc-chip; +@use '@material/chips/chip-theme' as mdc-chip-theme; +@use '../../material/core/style/layout-common'; +@use '../../cdk/a11y'; +@use '../mdc-helpers/mdc-helpers'; + +@include mdc-chip.without-ripple-styles($query: mdc-helpers.$mat-base-styles-query); + +.mat-mdc-standard-chip { + @include mdc-chip-theme.theme-styles(( + with-avatar-avatar-shape: ( + family: 'rounded', + radius: (14px, 14px, 14px, 14px) + ), + with-icon-icon-size: 18px, + with-leading-icon-disabled-leading-icon-opacity: 0.38, + with-leading-icon-leading-icon-size: 20px, + with-trailing-icon-disabled-trailing-icon-opacity: 0.38, + with-avatar-avatar-size: 28px, + with-avatar-disabled-avatar-opacity: 0.38, + with-icon-disabled-icon-opacity: 0.38, + with-trailing-icon-trailing-icon-size: 18px, + flat-disabled-outline-opacity: 0.12, + flat-disabled-unselected-outline-opacity: 0.12, + flat-selected-outline-width: 0, + outline-width: 1px, + flat-unselected-outline-width: 1px, + flat-outline-width: 1px, + disabled-label-text-opacity: 0.38, + disabled-outline-opacity: 0.12, + elevated-disabled-container-opacity: 0.12, + container-height: 32px, + container-shape: ( + family: 'rounded', + radius: (16px, 16px, 16px, 16px) + ), + )); + + @include a11y.high-contrast(active, off) { + outline: solid 1px; + + &.cdk-focused { + // Use 2px here since the dotted outline is a little thinner. + outline: dotted 2px; + } + + .mdc-evolution-chip__checkmark-path { + // SVG colors won't be changed in high contrast mode and since the checkmark is white + // by default, it'll blend in with the background in black-on-white mode. Override the + // color to ensure that it's visible. We need !important, because the theme styles are + // very specific. + stroke: #000 !important; + } + } + + // Angular Material supports disabled chips, which MDC does not. + // Dim the disabled chips and stop MDC from changing the icon color on click. + &.mdc-evolution-chip--disabled { + opacity: 0.4; + } + + // MDC sets `overflow: hidden` on these elements in order to truncate the text. This is + // unnecessary since our chips don't truncate their text and it makes it difficult to style + // the strong focus indicators so we need to override it. + .mdc-evolution-chip__cell--primary, + .mdc-evolution-chip__action--primary, + .mat-mdc-chip-action-label { + overflow: visible; + } + + // Ensures that the trailing icon is pushed to the end if the chip has a set width. + .mdc-evolution-chip__cell--primary { + width: 100%; + } + + // MDC sizes and positions this element using `width`, `height` and `padding`. + // This usually works, but it's common for apps to add `box-sizing: border-box` + // to all elements on the page which can cause the graphic to be clipped. + // Set an explicit `box-sizing` in order to avoid these issues. + .mat-mdc-chip-graphic, + .mat-mdc-chip-trailing-icon { + box-sizing: content-box; + } + + + &._mat-animation-noopable { + &, + .mdc-evolution-chip__graphic, + .mdc-evolution-chip__checkmark, + .mdc-evolution-chip__checkmark-path { + // MDC listens to `transitionend` events on some of these + // elements so we can't disable the transitions completely. + transition-duration: 1ms; + animation-duration: 1ms; + } + } +} + +// MDC's focus and hover indication is handled through their ripple which we currently +// don't use due to size concerns so we have to re-implement it ourselves. +.mat-mdc-chip-focus-overlay { + @include layout-common.fill; + pointer-events: none; + opacity: 0; + border-radius: inherit; + transition: opacity 150ms linear; + + ._mat-animation-noopable & { + transition: none; + } + + .mat-mdc-basic-chip & { + display: none; + } + + .mat-mdc-chip:hover & { + opacity: 0.04; + } + + .mat-mdc-chip.cdk-focused & { + opacity: 0.12; + } +} + +// The ripple container should match the bounds of the entire chip. +.mat-mdc-chip-ripple { + @include layout-common.fill; + + // Disable pointer events for the ripple container and state overlay because the container + // will overlay the user content and we don't want to disable mouse events on the user content. + // Pointer events can be safely disabled because the ripple trigger element is the host element. + pointer-events: none; + + // Inherit the border radius from the parent so that state overlay and ripples don't exceed the + // parent button boundaries. + border-radius: inherit; +} + +.mat-mdc-chip-avatar { + // In case an icon or text is used as an avatar. + text-align: center; + line-height: 1; +} + +// Required for the strong focus indicator to fill the chip. +.mat-mdc-chip { + position: relative; +} + +.mat-mdc-chip-action-label { + // MDC centers the text, but we have a lot of internal customers who have it at the start. + text-align: left; + + [dir='rtl'] & { + text-align: right; + } + + // When a chip has a trailing action, it'll have two focusable elements when navigating with + // the arrow keys: the primary action and the trailing one. If that's the case, we apply + // `position: relative` to the primary action container so that the indicators is only around + // the text label. This allows for it to be distinguished from the indicator on the trailing icon. + .mat-mdc-chip.mdc-evolution-chip--with-trailing-action & { + position: relative; + } + + .mat-mdc-chip-primary-focus-indicator { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + pointer-events: none; + } +} + +.mat-mdc-chip-remove { + .mat-icon { + width: inherit; + height: inherit; + font-size: inherit; + box-sizing: content-box; + } +} + +// Fades out the trailing icon slightly so that it can become darker when focused. +// The MDC theming has variables for this, but the focus/hover states don't seem to work. +.mat-mdc-chip-remove { + opacity: 0.54; + + &:focus { + opacity: 1; + } +} + +.mat-chip-edit-input { + cursor: text; + display: inline-block; + color: inherit; + outline: 0; +} + +// Single-selection chips show their selected state using a background color which won't be visible +// in high contrast mode. This isn't necessary in multi-selection since there's a checkmark. +.mat-mdc-chip-selected:not(.mat-mdc-chip-multiple) { + @include a11y.high-contrast(active, off) { + outline-width: 3px; + } +} diff --git a/src/material-experimental/mdc-chips/chip.spec.ts b/src/material-experimental/mdc-chips/chip.spec.ts index eba8e531fef8..47390203c972 100644 --- a/src/material-experimental/mdc-chips/chip.spec.ts +++ b/src/material-experimental/mdc-chips/chip.spec.ts @@ -58,19 +58,12 @@ describe('MDC-based MatChip', () => { expect(chip.getAttribute('tabindex')).toBe('3'); }); - it('should be able to set a static tabindex', () => { - fixture = TestBed.createComponent(BasicChipWithStaticTabindex); - fixture.detectChanges(); - - const chip = fixture.nativeElement.querySelector('mat-basic-chip'); - expect(chip.getAttribute('tabindex')).toBe('3'); - }); - it('should be able to set a dynamic tabindex', () => { fixture = TestBed.createComponent(BasicChipWithBoundTabindex); fixture.detectChanges(); const chip = fixture.nativeElement.querySelector('mat-basic-chip'); + expect(chip.getAttribute('tabindex')).toBe('12'); fixture.componentInstance.tabindex = 15; @@ -93,6 +86,7 @@ describe('MDC-based MatChip', () => { describe('MatChip', () => { let testComponent: SingleChip; + let primaryAction: HTMLElement; beforeEach(() => { fixture = TestBed.createComponent(SingleChip); @@ -104,6 +98,7 @@ describe('MDC-based MatChip', () => { chipRippleDebugElement = chipDebugElement.query(By.directive(MatRipple))!; chipRippleInstance = chipRippleDebugElement.injector.get(MatRipple); testComponent = fixture.debugElement.componentInstance; + primaryAction = chipNativeElement.querySelector('.mdc-evolution-chip__action--primary')!; }); it('adds the `mat-chip` class', () => { @@ -169,17 +164,10 @@ describe('MDC-based MatChip', () => { .toBe(true); }); - it('should update the aria-label for disabled chips', () => { - expect(chipNativeElement.getAttribute('aria-disabled')).toBe('false'); - + it('should make disabled chips non-focusable', () => { testComponent.disabled = true; fixture.detectChanges(); - - expect(chipNativeElement.getAttribute('aria-disabled')).toBe('true'); - }); - - it('should make disabled chips non-focusable', () => { - expect(chipNativeElement.getAttribute('tabindex')).toBeFalsy(); + expect(primaryAction.hasAttribute('tabindex')).toBe(false); }); it('should return the chip text if value is undefined', () => { @@ -236,12 +224,12 @@ class SingleChip { class BasicChip {} @Component({ - template: `Hello`, + template: `Hello`, }) class BasicChipWithStaticTabindex {} @Component({ - template: `Hello`, + template: `Hello`, }) class BasicChipWithBoundTabindex { tabindex = 12; diff --git a/src/material-experimental/mdc-chips/chip.ts b/src/material-experimental/mdc-chips/chip.ts index ecac78189c33..87af59368d6c 100644 --- a/src/material-experimental/mdc-chips/chip.ts +++ b/src/material-experimental/mdc-chips/chip.ts @@ -10,13 +10,11 @@ import {Directionality} from '@angular/cdk/bidi'; import {BooleanInput, coerceBooleanProperty} from '@angular/cdk/coercion'; import {ANIMATION_MODULE_TYPE} from '@angular/platform-browser/animations'; import { - AfterContentInit, AfterViewInit, Component, ChangeDetectionStrategy, ChangeDetectorRef, ContentChild, - Directive, ElementRef, EventEmitter, Inject, @@ -27,7 +25,9 @@ import { Output, ViewEncapsulation, ViewChild, + Attribute, } from '@angular/core'; +import {DOCUMENT} from '@angular/common'; import { CanColor, CanDisable, @@ -40,10 +40,19 @@ import { mixinTabIndex, RippleGlobalOptions, } from '@angular/material-experimental/mdc-core'; -import {deprecated} from '@material/chips'; -import {SPACE, ENTER, hasModifierKey} from '@angular/cdk/keycodes'; +import { + MDCChipFoundation, + MDCChipAdapter, + MDCChipActionType, + MDCChipActionFocusBehavior, + MDCChipActionFoundation, + MDCChipActionEvents, + ActionInteractionEvent, + ActionNavigationEvent, + MDCChipActionInteractionTrigger, +} from '@material/chips'; +import {FocusMonitor} from '@angular/cdk/a11y'; import {Subject} from 'rxjs'; -import {takeUntil} from 'rxjs/operators'; import { MatChipAvatar, MatChipTrailingIcon, @@ -52,6 +61,8 @@ import { MAT_CHIP_TRAILING_ICON, MAT_CHIP_REMOVE, } from './chip-icons'; +import {emitCustomEvent} from './emit-event'; +import {MatChipAction} from './chip-action'; let uid = 0; @@ -61,17 +72,6 @@ export interface MatChipEvent { chip: MatChip; } -/** - * Directive to add MDC CSS to non-basic chips. - * @docs-private - */ -@Directive({ - selector: `mat-chip, mat-chip-option, mat-chip-row, [mat-chip], [mat-chip-option], - [mat-chip-row]`, - host: {'class': 'mat-mdc-chip mdc-chip'}, -}) -export class MatChipCssInternalOnly {} - /** * Boilerplate for applying mixins to MatChip. * @docs-private @@ -90,37 +90,39 @@ const _MatChipMixinBase = mixinTabIndex(mixinColor(mixinDisableRipple(MatChipBas */ @Component({ selector: 'mat-basic-chip, mat-chip', - inputs: ['color', 'disableRipple'], + inputs: ['color', 'disableRipple', 'tabIndex'], exportAs: 'matChip', templateUrl: 'chip.html', - styleUrls: ['chips.css'], + styleUrls: ['chip.css'], host: { - '[class.mat-mdc-chip-disabled]': 'disabled', - '[class.mat-mdc-chip-highlighted]': 'highlighted', + 'class': 'mat-mdc-chip', + '[class.mdc-evolution-chip]': '!_isBasicChip', + '[class.mdc-evolution-chip--disabled]': 'disabled', + '[class.mdc-evolution-chip--with-trailing-action]': '_hasTrailingIcon()', + '[class.mdc-evolution-chip--with-primary-graphic]': 'leadingIcon', + '[class.mdc-evolution-chip--with-primary-icon]': 'leadingIcon', + '[class.mdc-evolution-chip--with-avatar]': 'leadingIcon', '[class.mat-mdc-chip-with-avatar]': 'leadingIcon', - '[class.mat-mdc-chip-with-trailing-icon]': 'trailingIcon || removeIcon', + '[class.mat-mdc-chip-highlighted]': 'highlighted', + '[class.mat-mdc-chip-disabled]': 'disabled', '[class.mat-mdc-basic-chip]': '_isBasicChip', '[class.mat-mdc-standard-chip]': '!_isBasicChip', + '[class.mat-mdc-chip-with-trailing-icon]': '_hasTrailingIcon()', '[class._mat-animation-noopable]': '_animationsDisabled', '[id]': 'id', - '[attr.disabled]': 'disabled || null', - '[attr.aria-disabled]': 'disabled.toString()', - '(transitionend)': '_handleTransitionEnd($event)', + '[attr.role]': 'role', + '[attr.tabindex]': 'role ? tabIndex : null', + '[attr.aria-label]': 'ariaLabel', }, encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, }) export class MatChip extends _MatChipMixinBase - implements - AfterContentInit, - AfterViewInit, - CanColor, - CanDisableRipple, - CanDisable, - HasTabIndex, - OnDestroy + implements AfterViewInit, CanColor, CanDisableRipple, CanDisable, HasTabIndex, OnDestroy { + protected _document: Document; + /** Whether the ripple is centered on the chip. */ readonly _isRippleCentered = false; @@ -130,30 +132,30 @@ export class MatChip /** Emits when the chip is blurred. */ readonly _onBlur = new Subject(); - readonly REMOVE_ICON_HANDLED_KEYS: ReadonlySet = new Set([SPACE, ENTER]); - /** Whether this chip is a basic (unstyled) chip. */ readonly _isBasicChip: boolean; + /** Role for the root of the chip. */ + @Input() role: string | null = null; + /** Whether the chip has focus. */ protected _hasFocusInternal = false; + /** Whether moving focus into the chip is pending. */ + private _pendingFocus: boolean; + /** Whether animations for the chip are enabled. */ _animationsDisabled: boolean; - _handleTransitionEnd(event: TransitionEvent) { - this._chipFoundation.handleTransitionEnd(event); - } - _hasFocus() { return this._hasFocusInternal; } - /** Default unique id for the chip. */ - private _uniqueId = `mat-mdc-chip-${uid++}`; - /** A unique id for the chip. If none is supplied, it will be auto-generated. */ - @Input() id: string = this._uniqueId; + @Input() id: string = `mat-mdc-chip-${uid++}`; + + /** ARIA label for the content of the chip. */ + @Input('aria-label') ariaLabel: string | null = null; @Input() get disabled(): boolean { @@ -161,15 +163,21 @@ export class MatChip } 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; - /** The value of the chip. Defaults to the content inside the mdc-chip__text element. */ + /** + * The value of the chip. Defaults to the content inside + * the `mat-mdc-chip-action-label` element. + */ @Input() get value(): any { return this._value !== undefined ? this._value : this._textElement.textContent!.trim(); @@ -203,17 +211,14 @@ export class MatChip } protected _highlighted: boolean = false; - /** Emitted when the user interacts with the chip. */ - @Output() readonly interaction = new EventEmitter(); + /** Emitted when a chip is to be removed. */ + @Output() readonly removed: EventEmitter = new EventEmitter(); /** Emitted when the chip is destroyed. */ @Output() readonly destroyed: EventEmitter = new EventEmitter(); - /** Emitted when a chip is to be removed. */ - @Output() readonly removed: EventEmitter = new EventEmitter(); - /** The MDC foundation containing business logic for MDC chip. */ - _chipFoundation: deprecated.MDCChipFoundation; + _chipFoundation: MDCChipFoundation; /** The unstyled chip selector for this component. */ protected basicChipAttrName = 'mat-basic-chip'; @@ -230,151 +235,118 @@ export class MatChip /** Reference to the MatRipple instance of the chip. */ @ViewChild(MatRipple) ripple: MatRipple; + /** 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: deprecated.MDCChipAdapter = { + protected _chipAdapter: MDCChipAdapter = { addClass: className => this._setMdcClass(className, true), removeClass: className => this._setMdcClass(className, false), hasClass: className => this._elementRef.nativeElement.classList.contains(className), - addClassToLeadingIcon: className => this.leadingIcon.setClass(className, true), - removeClassFromLeadingIcon: className => this.leadingIcon.setClass(className, false), - eventTargetHasClass: (target: EventTarget | null, className: string) => { - // We need to null check the `classList`, because IE and Edge don't - // support it on SVG elements and Edge seems to throw for ripple - // elements, because they're outside the DOM. - return target && (target as Element).classList - ? (target as Element).classList.contains(className) - : false; + emitEvent: (eventName: string, data: T) => { + emitCustomEvent(this._elementRef.nativeElement, this._document, eventName, data, true); }, - notifyInteraction: () => this._notifyInteraction(), - notifySelection: () => { - // No-op. We call dispatchSelectionEvent ourselves in MatChipOption, - // because we want to specify whether selection occurred via user - // input. + setStyleProperty: (propertyName: string, value: string) => { + this._elementRef.nativeElement.style.setProperty(propertyName, value); }, - notifyNavigation: () => this._notifyNavigation(), - notifyTrailingIconInteraction: () => {}, - notifyRemoval: () => this.remove(), - notifyEditStart: () => { - this._onEditStart(); - this._changeDetectorRef.markForCheck(); + 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; }, - notifyEditFinish: () => { - this._onEditFinish(); - this._changeDetectorRef.markForCheck(); + isActionSelectable: (action: MDCChipActionType) => { + return this._getAction(action)?.isSelectable() || false; }, - getComputedStyleValue: propertyName => { - // This function is run when a chip is removed so it might be - // invoked during server-side rendering. Add some extra checks just in - // case. - if (typeof window !== 'undefined' && window) { - const getComputedStyle = window.getComputedStyle(this._elementRef.nativeElement); - return getComputedStyle.getPropertyValue(propertyName); - } - return ''; + isActionSelected: (action: MDCChipActionType) => { + return this._getAction(action)?.isSelected() || false; }, - setStyleProperty: (propertyName: string, value: string) => { - this._elementRef.nativeElement.style.setProperty(propertyName, value); + isActionDisabled: (action: MDCChipActionType) => { + return this._getAction(action)?.isDisabled() || false; }, - hasLeadingIcon: () => !!this.leadingIcon, - isTrailingActionNavigable: () => { - if (this.trailingIcon) { - return this.trailingIcon.isNavigable(); - } - return false; + isActionFocusable: (action: MDCChipActionType) => { + return this._getAction(action)?.isFocusable() || false; }, - isRTL: () => !!this._dir && this._dir.value === 'rtl', - focusPrimaryAction: () => { - // Angular Material MDC chips fully manage focus. TODO: Managing focus - // and handling keyboard events was added by MDC after our - // implementation; consider consolidating. + setActionSelected: (action: MDCChipActionType, isSelected: boolean) => { + this._getAction(action)?.setSelected(isSelected); }, - focusTrailingAction: () => {}, - removeTrailingActionFocus: () => {}, - setPrimaryActionAttr: (name: string, value: string) => { - // MDC is currently using this method to set aria-checked on choice - // and filter chips, which in the MDC templates have role="checkbox" - // and role="radio" respectively. We have role="option" on those chips - // instead, so we do not want aria-checked. Since we also manage the - // tabindex ourselves, we don't allow MDC to set it. - if (name === 'aria-checked' || name === 'tabindex') { - return; - } - this._elementRef.nativeElement.setAttribute(name, value); + setActionDisabled: (action: MDCChipActionType, isDisabled: boolean) => { + this._getAction(action)?.setDisabled(isDisabled); + }, + setActionFocus: (action: MDCChipActionType, behavior: MDCChipActionFocusBehavior) => { + this._getAction(action)?.setFocus(behavior); }, - // The 2 functions below are used by the MDC ripple, which we aren't using, - // so they will never be called - getRootBoundingClientRect: () => this._elementRef.nativeElement.getBoundingClientRect(), - getCheckmarkBoundingClientRect: () => null, - getAttribute: attr => this._elementRef.nativeElement.getAttribute(attr), }; constructor( public _changeDetectorRef: ChangeDetectorRef, - elementRef: ElementRef, + 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) private _globalRippleOptions?: RippleGlobalOptions, + @Attribute('tabindex') tabIndex?: string, ) { super(elementRef); - this._chipFoundation = new deprecated.MDCChipFoundation(this._chipAdapter); + const element = elementRef.nativeElement; + this._document = _document; + this._chipFoundation = new MDCChipFoundation(this._chipAdapter); this._animationsDisabled = animationMode === 'NoopAnimations'; this._isBasicChip = - elementRef.nativeElement.hasAttribute(this.basicChipAttrName) || - elementRef.nativeElement.tagName.toLowerCase() === this.basicChipAttrName; - } - - ngAfterContentInit() { - this._initRemoveIcon(); + 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; + } } ngAfterViewInit() { this._chipFoundation.init(); - this._textElement = this._elementRef.nativeElement.querySelector('.mdc-chip__text'); + this._chipFoundation.setDisabled(this.disabled); + this._textElement = this._elementRef.nativeElement.querySelector('.mat-mdc-chip-action-label'); + + if (this._pendingFocus) { + this._pendingFocus = false; + this.focus(); + } } ngOnDestroy() { - this.destroyed.emit({chip: this}); + 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(); - } - - /** Sets up the remove icon chip foundation, and subscribes to remove icon events. */ - private _initRemoveIcon() { - if (this.removeIcon) { - this._chipFoundation.setShouldRemoveOnTrailingIconClick(true); - this.removeIcon.disabled = this.disabled; - - this.removeIcon.interaction.pipe(takeUntil(this.destroyed)).subscribe(event => { - // The MDC chip foundation calls stopPropagation() for any trailing icon interaction - // event, even ones it doesn't handle, so we want to avoid passing it keyboard events - // for which we have a custom handler. Note that we assert the type of the event using - // the `type`, because `instanceof KeyboardEvent` can throw during server-side rendering. - const isKeyboardEvent = event.type.startsWith('key'); - - if ( - this.disabled || - (isKeyboardEvent && !this.REMOVE_ICON_HANDLED_KEYS.has((event as KeyboardEvent).keyCode)) - ) { - return; - } - - this.remove(); - - if (isKeyboardEvent && !hasModifierKey(event as KeyboardEvent)) { - const keyCode = (event as KeyboardEvent).keyCode; - - // Prevent default space and enter presses so we don't scroll the page or submit forms. - if (keyCode === SPACE || keyCode === ENTER) { - event.preventDefault(); - } - } - }); - } + this._focusMonitor.stopMonitoring(this._elementRef); + this.destroyed.emit({chip: this}); } /** @@ -395,58 +367,98 @@ export class MatChip this._changeDetectorRef.markForCheck(); } - /** Forwards interaction events to the MDC chip foundation. */ - _handleInteraction(event: MouseEvent | KeyboardEvent | FocusEvent) { - if (this.disabled) { - return; - } + /** Whether or not the ripple should be disabled. */ + _isRippleDisabled(): boolean { + return ( + this.disabled || + this.disableRipple || + this._animationsDisabled || + this._isBasicChip || + !!this._globalRippleOptions?.disabled + ); + } - if (event.type === 'click') { - this._chipFoundation.handleClick(); - return; + _getAction(type: MDCChipActionType): MDCChipActionFoundation | undefined { + switch (type) { + case MDCChipActionType.PRIMARY: + return this.primaryAction?._getFoundation(); + case MDCChipActionType.TRAILING: + return (this.removeIcon || this.trailingIcon)?._getFoundation(); } - if (event.type === 'dblclick') { - this._chipFoundation.handleDoubleClick(); - } + return undefined; + } + + _getFoundation() { + return this._chipFoundation; + } - if (event.type === 'keydown') { - this._chipFoundation.handleKeydown(event as KeyboardEvent); + _hasTrailingIcon() { + return !!(this.trailingIcon || this.removeIcon); + } + + /** Allows for programmatic focusing of the chip. */ + focus(): void { + if (this.disabled) { return; } - if (event.type === 'focusout') { - this._chipFoundation.handleFocusOut(event as FocusEvent); + // 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 (event.type === 'focusin') { - this._chipFoundation.handleFocusIn(event as FocusEvent); + if (!this._hasFocus()) { + this._onFocus.next({chip: this}); + this._hasFocusInternal = true; } - } - /** Whether or not the ripple should be disabled. */ - _isRippleDisabled(): boolean { - return ( - this.disabled || - this.disableRipple || - this._animationsDisabled || - this._isBasicChip || - !!this._globalRippleOptions?.disabled - ); + this.primaryAction.focus(); } - _notifyInteraction() { - this.interaction.emit(this.id); + /** 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); + } } - _notifyNavigation() { - // TODO: This is a new feature added by MDC. Consider exposing it to users - // in the future. - } + private _handleActionInteraction = (event: Event) => { + this._onChipInteraction(event as ActionInteractionEvent); + }; - /** Overridden by MatChipRow. */ - protected _onEditStart() {} + private _handleActionNavigation = (event: Event) => { + this._chipFoundation.handleActionNavigation(event as ActionNavigationEvent); + }; - /** Overridden by MatChipRow. */ - protected _onEditFinish() {} + 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/chips.scss b/src/material-experimental/mdc-chips/chips.scss deleted file mode 100644 index a54134b604a3..000000000000 --- a/src/material-experimental/mdc-chips/chips.scss +++ /dev/null @@ -1,184 +0,0 @@ -@use '@material/chips/deprecated' as mdc-chips; -@use '@material/ripple' as mdc-ripple; -@use '../../material/core/style/layout-common'; -@use '../../cdk/a11y'; -@use '../mdc-helpers/mdc-helpers'; - -@include mdc-chips.without-ripple($query: mdc-helpers.$mat-base-styles-query); -@include mdc-chips.set-core-styles($query: mdc-helpers.$mat-base-styles-query); - -.mat-mdc-chip { - // MDC uses a pointer cursor - cursor: default; - - &._mat-animation-noopable { - animation: none; - transition: none; - - .mdc-chip__checkmark-svg { - transition: none; - } - } - - @include a11y.high-contrast(active, off) { - outline: solid 1px; - - &:focus { - // Use 2px here since the dotted outline is a little thinner. - outline: dotted 2px; - } - } -} - -// The ripple container should match the bounds of the entire chip. -.mat-mdc-chip-ripple { - @include layout-common.fill; - - // Disable pointer events for the ripple container and state overlay because the container - // will overlay the user content and we don't want to disable mouse events on the user content. - // Pointer events can be safely disabled because the ripple trigger element is the host element. - pointer-events: none; - - // Inherit the border radius from the parent so that state overlay and ripples don't exceed the - // parent button boundaries. - border-radius: inherit; -} - -// The MDC chip styles related to hover and focus states are intertwined with the MDC ripple styles. -// We currently don't use the MDC ripple due to size concerns, therefore we need to add some -// additional styles to restore these states. -.mdc-chip__ripple { - @include mdc-ripple.target-common($query: structure); - - &::after, &::before { - @include layout-common.fill; - content: ''; - pointer-events: none; - opacity: 0; - border-radius: inherit; - - ._mat-animation-noopable & { - transition: none; - } - } -} - -// Angular Material supports disabled chips, which MDC does not. -// Dim the disabled chips and stop MDC from changing the icon color on click. -.mat-mdc-chip-disabled.mat-mdc-chip { - opacity: 0.4; - - .mat-mdc-chip-trailing-icon, .mat-mdc-chip-row-focusable-text-content { - pointer-events: none; - } - - // Do not show state interactions for disabled chips. - .mdc-chip__ripple::after, .mdc-chip__ripple::before { - display: none; - } -} - -// Angular Material supports vertically-stacked chips, which MDC does not. -.mat-mdc-chip-set-stacked { - flex-direction: column; - align-items: flex-start; - - .mat-mdc-chip { - width: 100%; - } -} - -// Add styles for the matChipInputFor input element. -$mat-chip-input-width: 150px; - -input.mat-mdc-chip-input { - flex: 1 0 $mat-chip-input-width; -} - -// The margin value is set in MDC. -$chip-margin: 4px; - -// Don't let the chip margin increase the mat-form-field height. -.mat-mdc-chip-grid { - margin: -$chip-margin; - - // Keep the mat-chip-grid height the same even when there are no chips. - input.mat-mdc-chip-input { - margin: $chip-margin; - } -} - -.mdc-chip__checkmark-path { - ._mat-animation-noopable & { - transition: none; - } - - @include a11y.high-contrast(black-on-white, off) { - // SVG colors won't be changed in high contrast mode and since the checkmark is white - // by default, it'll blend in with the background in black-on-white mode. Override the color - // to ensure that it's visible. We need !important, because the theme styles are very specific. - stroke: #000 !important; - } -} - -// Needed for the focus indicator. -.mat-mdc-chip-row-focusable-text-content { - position: relative; -} - -.mat-mdc-chip-remove { - // Reset the user agent styles in case a button is used. - border: none; - -webkit-appearance: none; - -moz-appearance: none; - padding: 0; - background: none; - - .mat-icon { - width: inherit; - height: inherit; - font-size: inherit; - } -} - -// Single-selection chips show their selected state using a background color which won't be visible -// in high contrast mode. This isn't necessary in multi-selection since there's a checkmark. -.mat-mdc-chip-selected:not(.mat-mdc-chip-multiple) { - @include a11y.high-contrast(active, off) { - outline-width: 3px; - } -} - -.mat-mdc-chip-row-focusable-text-content, -.mat-mdc-chip-remove-icon { - display: flex; - align-items: center; -} - -.mat-mdc-chip-content { - display: inline-flex; -} - -.mdc-chip--editing { - background-color: transparent; - display: flex; - flex-direction: column; - - .mat-mdc-chip-content { - pointer-events: none; - height: 0; - overflow: hidden; - } -} - -.mat-chip-edit-input { - cursor: text; - display: inline-block; -} - -.mat-mdc-chip-edit-input-container { - width: 100%; - height: 100%; - display: flex; - align-items: center; -} 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/grid-focus-key-manager.ts b/src/material-experimental/mdc-chips/grid-focus-key-manager.ts deleted file mode 100644 index 40aa6d2280ba..000000000000 --- a/src/material-experimental/mdc-chips/grid-focus-key-manager.ts +++ /dev/null @@ -1,36 +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 {GridKeyManager} from './grid-key-manager'; - -/** - * A version of GridKeyManager where the cells are HTMLElements, and focus() - * is called on a cell when it becomes active. - */ -export class GridFocusKeyManager extends GridKeyManager { - /** - * Sets the active cell to the cell at the specified - * indices and focuses the newly active cell. - * @param cell Row and column indices of the cell to be set as active. - */ - override setActiveCell(cell: {row: number; column: number}): void; - - /** - * Sets the active cell to the cell that is specified and focuses it. - * @param cell Cell to be set as active. - */ - override setActiveCell(cell: HTMLElement): void; - - override setActiveCell(cell: any): void { - super.setActiveCell(cell); - - if (this.activeCell) { - this.activeCell.focus(); - } - } -} diff --git a/src/material-experimental/mdc-chips/grid-key-manager.ts b/src/material-experimental/mdc-chips/grid-key-manager.ts deleted file mode 100644 index d956d2f05b91..000000000000 --- a/src/material-experimental/mdc-chips/grid-key-manager.ts +++ /dev/null @@ -1,285 +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 {QueryList} from '@angular/core'; -import {Subject} from 'rxjs'; -import {UP_ARROW, DOWN_ARROW, LEFT_ARROW, RIGHT_ARROW, HOME, END} from '@angular/cdk/keycodes'; - -/** The keys handled by the GridKeyManager keydown method. */ -export const NAVIGATION_KEYS = [DOWN_ARROW, UP_ARROW, RIGHT_ARROW, LEFT_ARROW]; - -/** This interface is for rows that can be passed to a GridKeyManager. */ -export interface GridKeyManagerRow { - cells: T[]; -} - -/** - * This class manages keyboard events for grids. If you pass it a query list - * of GridKeyManagerRow, it will set the active cell correctly when arrow events occur. - * - * GridKeyManager expects that rows may change dynamically, but the cells for a given row are - * static. It also expects that all rows have the same number of cells. - */ -export class GridKeyManager { - private _activeRowIndex = -1; - private _activeColumnIndex = -1; - private _activeRow: GridKeyManagerRow | null = null; - private _activeCell: T | null = null; - private _dir: 'ltr' | 'rtl' = 'ltr'; - private _homeAndEnd = false; - - constructor(private _rows: QueryList> | GridKeyManagerRow[]) { - // We allow for the rows to be an array because, in some cases, the consumer may - // not have access to a QueryList of the rows they want to manage (e.g. when the - // rows aren't being collected via `ViewChildren` or `ContentChildren`). - if (_rows instanceof QueryList) { - _rows.changes.subscribe((newRows: QueryList>) => { - if (this._activeRow) { - const newIndex = newRows.toArray().indexOf(this._activeRow); - - if (newIndex > -1 && newIndex !== this._activeRowIndex) { - this._activeRowIndex = newIndex; - } - } - }); - } - } - - /** Stream that emits whenever the active cell of the grid manager changes. */ - change = new Subject<{row: number; column: number}>(); - - /** - * Configures the directionality of the key manager's horizontal movement. - * @param direction Direction which is considered forward movement across a row. - * - * If withDirectionality is not set, the default is 'ltr'. - */ - withDirectionality(direction: 'ltr' | 'rtl'): this { - this._dir = direction; - return this; - } - - /** - * Sets the active cell to the cell at the indices specified. - * @param cell The row and column containing the cell to be set as active. - */ - setActiveCell(cell: {row: number; column: number}): void; - - /** - * Sets the active cell to the cell. - * @param cell The cell to be set as active. - */ - setActiveCell(cell: T): void; - - setActiveCell(cell: any): void { - const previousRowIndex = this._activeRowIndex; - const previousColumnIndex = this._activeColumnIndex; - - this.updateActiveCell(cell); - - if ( - this._activeRowIndex !== previousRowIndex || - this._activeColumnIndex !== previousColumnIndex - ) { - this.change.next({row: this._activeRowIndex, column: this._activeColumnIndex}); - } - } - - /** - * Configures the key manager to activate the first and last items - * respectively when the Home or End key is pressed. - * @param enabled Whether pressing the Home or End key activates the first/last item. - */ - withHomeAndEnd(enabled: boolean = true): this { - this._homeAndEnd = enabled; - return this; - } - - /** - * Sets the active cell depending on the key event passed in. - * @param event Keyboard event to be used for determining which element should be active. - */ - onKeydown(event: KeyboardEvent): void { - const keyCode = event.keyCode; - - switch (keyCode) { - case DOWN_ARROW: - this.setNextRowActive(); - break; - - case UP_ARROW: - this.setPreviousRowActive(); - break; - - case RIGHT_ARROW: - this._dir === 'rtl' ? this.setPreviousColumnActive() : this.setNextColumnActive(); - break; - - case LEFT_ARROW: - this._dir === 'rtl' ? this.setNextColumnActive() : this.setPreviousColumnActive(); - break; - - case HOME: - if (this._homeAndEnd) { - this.setFirstCellActive(); - break; - } else { - return; - } - - case END: - if (this._homeAndEnd) { - this.setLastCellActive(); - break; - } else { - return; - } - - default: - // Note that we return here, in order to avoid preventing - // the default action of non-navigational keys. - return; - } - - event.preventDefault(); - } - - /** Index of the currently active row. */ - get activeRowIndex(): number { - return this._activeRowIndex; - } - - /** Index of the currently active column. */ - get activeColumnIndex(): number { - return this._activeColumnIndex; - } - - /** The active cell. */ - get activeCell(): T | null { - return this._activeCell; - } - - /** Sets the active cell to the first cell in the grid. */ - setFirstCellActive(): void { - this._setActiveCellByIndex(0, 0); - } - - /** Sets the active cell to the last cell in the grid. */ - setLastCellActive(): void { - const lastRowIndex = this._rows.length - 1; - const lastRow = this._getRowsArray()[lastRowIndex]; - this._setActiveCellByIndex(lastRowIndex, lastRow.cells.length - 1); - } - - /** Sets the active row to the next row in the grid. Active column is unchanged. */ - setNextRowActive(): void { - this._activeRowIndex < 0 ? this.setFirstCellActive() : this._setActiveCellByDelta(1, 0); - } - - /** Sets the active row to the previous row in the grid. Active column is unchanged. */ - setPreviousRowActive(): void { - this._setActiveCellByDelta(-1, 0); - } - - /** - * Sets the active column to the next column in the grid. - * Active row is unchanged, unless we reach the end of a row. - */ - setNextColumnActive(): void { - this._activeRowIndex < 0 ? this.setFirstCellActive() : this._setActiveCellByDelta(0, 1); - } - - /** - * Sets the active column to the previous column in the grid. - * Active row is unchanged, unless we reach the end of a row. - */ - setPreviousColumnActive(): void { - this._setActiveCellByDelta(0, -1); - } - - /** - * Allows setting the active cell without any other effects. - * @param cell Row and column of the cell to be set as active. - */ - updateActiveCell(cell: {row: number; column: number}): void; - - /** - * Allows setting the active cell without any other effects. - * @param cell Cell to be set as active. - */ - updateActiveCell(cell: T): void; - - updateActiveCell(cell: any): void { - const rowArray = this._getRowsArray(); - - if ( - typeof cell === 'object' && - typeof cell.row === 'number' && - typeof cell.column === 'number' - ) { - this._activeRowIndex = cell.row; - this._activeColumnIndex = cell.column; - this._activeRow = rowArray[cell.row] || null; - this._activeCell = this._activeRow ? this._activeRow.cells[cell.column] || null : null; - } else { - rowArray.forEach((row, rowIndex) => { - const columnIndex = row.cells.indexOf(cell); - if (columnIndex !== -1) { - this._activeRowIndex = rowIndex; - this._activeColumnIndex = columnIndex; - this._activeRow = row; - this._activeCell = row.cells[columnIndex]; - } - }); - } - } - - /** - * This method sets the active cell, given the row and columns deltas - * between the currently active cell and the new active cell. - */ - private _setActiveCellByDelta(rowDelta: -1 | 0 | 1, columnDelta: -1 | 0 | 1): void { - // If delta puts us past the last cell in a row, move to the first cell of the next row. - if (this._activeRow && this._activeColumnIndex + columnDelta >= this._activeRow.cells.length) { - this._setActiveCellByIndex(this._activeRowIndex + 1, 0); - - // If delta puts us prior to the first cell in a row, move to the last cell of the previous row. - } else if (this._activeColumnIndex + columnDelta < 0) { - const previousRowIndex = this._activeRowIndex - 1; - const previousRow = this._getRowsArray()[previousRowIndex]; - if (previousRow) { - this._setActiveCellByIndex(previousRowIndex, previousRow.cells.length - 1); - } - } else { - this._setActiveCellByIndex( - this._activeRowIndex + rowDelta, - this._activeColumnIndex + columnDelta, - ); - } - } - - /** - * Sets the active cell to the cell at the indices specified, if they are valid. - */ - private _setActiveCellByIndex(rowIndex: number, columnIndex: number): void { - const rows = this._getRowsArray(); - - const targetRow = rows[rowIndex]; - - if (!targetRow || !targetRow.cells[columnIndex]) { - return; - } - - this.setActiveCell({row: rowIndex, column: columnIndex}); - } - - /** Returns the rows as an array. */ - private _getRowsArray(): GridKeyManagerRow[] { - return this._rows instanceof QueryList ? this._rows.toArray() : this._rows; - } -} diff --git a/src/material-experimental/mdc-chips/module.ts b/src/material-experimental/mdc-chips/module.ts index 7ce8256d6caf..ace98686a98e 100644 --- a/src/material-experimental/mdc-chips/module.ts +++ b/src/material-experimental/mdc-chips/module.ts @@ -14,7 +14,7 @@ import { MatCommonModule, MatRippleModule, } from '@angular/material-experimental/mdc-core'; -import {MatChip, MatChipCssInternalOnly} from './chip'; +import {MatChip} from './chip'; import {MAT_CHIPS_DEFAULT_OPTIONS, MatChipsDefaultOptions} from './chip-default-options'; import {MatChipEditInput} from './chip-edit-input'; import {MatChipGrid} from './chip-grid'; @@ -24,11 +24,11 @@ import {MatChipListbox} from './chip-listbox'; import {MatChipRow} from './chip-row'; import {MatChipOption} from './chip-option'; import {MatChipSet} from './chip-set'; +import {MatChipAction} from './chip-action'; const CHIP_DECLARATIONS = [ MatChip, MatChipAvatar, - MatChipCssInternalOnly, MatChipEditInput, MatChipGrid, MatChipInput, @@ -43,7 +43,7 @@ const CHIP_DECLARATIONS = [ @NgModule({ imports: [MatCommonModule, CommonModule, MatRippleModule], exports: [MatCommonModule, CHIP_DECLARATIONS], - declarations: CHIP_DECLARATIONS, + declarations: [MatChipAction, CHIP_DECLARATIONS], providers: [ ErrorStateMatcher, { diff --git a/src/material-experimental/mdc-chips/testing/chip-harness.ts b/src/material-experimental/mdc-chips/testing/chip-harness.ts index 735a5ac40aac..fa880ca782ba 100644 --- a/src/material-experimental/mdc-chips/testing/chip-harness.ts +++ b/src/material-experimental/mdc-chips/testing/chip-harness.ts @@ -17,6 +17,8 @@ import {MatChipRemoveHarness} from './chip-remove-harness'; /** Harness for interacting with a mat-chip in tests. */ export class MatChipHarness extends ContentContainerComponentHarness { + protected _primaryAction = this.locatorFor('.mdc-evolution-chip__action--primary'); + static hostSelector = '.mat-mdc-basic-chip, .mat-mdc-chip'; /** diff --git a/src/material-experimental/mdc-chips/testing/chip-option-harness.ts b/src/material-experimental/mdc-chips/testing/chip-option-harness.ts index c3e73e3485bb..8005a3c7573e 100644 --- a/src/material-experimental/mdc-chips/testing/chip-option-harness.ts +++ b/src/material-experimental/mdc-chips/testing/chip-option-harness.ts @@ -56,6 +56,6 @@ export class MatChipOptionHarness extends MatChipHarness { /** Toggles the selected state of the given chip. */ async toggle(): Promise { - return (await this.host()).sendKeys(' '); + return (await this._primaryAction()).click(); } } diff --git a/src/material-experimental/mdc-chips/testing/chip-row-harness.spec.ts b/src/material-experimental/mdc-chips/testing/chip-row-harness.spec.ts index 67ee6f528ef7..4a497932ad04 100644 --- a/src/material-experimental/mdc-chips/testing/chip-row-harness.spec.ts +++ b/src/material-experimental/mdc-chips/testing/chip-row-harness.spec.ts @@ -24,15 +24,25 @@ describe('MatChipRowHarness', () => { const harnesses = await loader.getAllHarnesses(MatChipRowHarness); expect(harnesses.length).toBe(2); }); + + it('should get whether the chip is editable', async () => { + const harness = await loader.getHarness(MatChipRowHarness); + expect(await harness.isEditable()).toBe(false); + + fixture.componentInstance.editable = true; + expect(await harness.isEditable()).toBe(true); + }); }); @Component({ template: ` - Basic Chip Row - Chip Row + Basic Chip Row + Chip Row `, }) -class ChipRowHarnessTest {} +class ChipRowHarnessTest { + editable = false; +} diff --git a/src/material-experimental/mdc-chips/testing/chip-row-harness.ts b/src/material-experimental/mdc-chips/testing/chip-row-harness.ts index 216d068847c5..39ed349bd5fe 100644 --- a/src/material-experimental/mdc-chips/testing/chip-row-harness.ts +++ b/src/material-experimental/mdc-chips/testing/chip-row-harness.ts @@ -10,6 +10,8 @@ import {HarnessPredicate} from '@angular/cdk/testing'; import {ChipRowHarnessFilters} from './chip-harness-filters'; import {MatChipHarness} from './chip-harness'; +// TODO(crisbeto): add harness for the chip edit input inside the row. + /** Harness for interacting with a mat-chip-row in tests. */ export class MatChipRowHarness extends MatChipHarness { static override hostSelector = '.mat-mdc-chip-row'; @@ -27,4 +29,14 @@ export class MatChipRowHarness extends MatChipHarness { InstanceType >; } + + /** Whether the chip is editable. */ + async isEditable(): Promise { + return (await this.host()).hasClass('mat-mdc-chip-editable'); + } + + /** Whether the chip is currently being edited. */ + async isEditing(): Promise { + return (await this.host()).hasClass('mat-mdc-chip-editing'); + } } diff --git a/src/material-experimental/mdc-helpers/_focus-indicators.scss b/src/material-experimental/mdc-helpers/_focus-indicators.scss index cc19e4907524..e30dfcc9303b 100644 --- a/src/material-experimental/mdc-helpers/_focus-indicators.scss +++ b/src/material-experimental/mdc-helpers/_focus-indicators.scss @@ -42,7 +42,7 @@ .mat-mdc-unelevated-button .mat-mdc-focus-indicator::before, .mat-mdc-raised-button .mat-mdc-focus-indicator::before, .mdc-fab .mat-mdc-focus-indicator::before, - .mat-mdc-focus-indicator.mdc-chip::before { + .mat-mdc-chip-action-label .mat-mdc-focus-indicator::before { margin: -($border-width + 2px); } @@ -50,11 +50,16 @@ margin: -($border-width + 3px); } - .mat-mdc-focus-indicator.mat-mdc-chip-remove::before, - .mat-mdc-focus-indicator.mat-mdc-chip-row-focusable-text-content::before { + .mat-mdc-focus-indicator.mat-mdc-chip-remove::before { margin: -$border-width; } + // MDC sets a padding a on the button which stretches out the focus indicator. + .mat-mdc-focus-indicator.mat-mdc-chip-remove::before { + left: 8px; + right: 8px; + } + .mat-mdc-focus-indicator.mat-mdc-tab::before, .mat-mdc-focus-indicator.mat-mdc-tab-link::before { margin: 5px; @@ -78,6 +83,9 @@ .mat-mdc-slide-toggle-focused .mat-mdc-focus-indicator::before, .mat-mdc-radio-button.cdk-focused .mat-mdc-focus-indicator::before, + // In the chips the individual actions have focus so we target a different element. + .mat-mdc-chip-action:focus .mat-mdc-focus-indicator::before, + // For buttons and list items, render the focus indicator when the parent // button or list item is focused. .mat-mdc-button-base:focus .mat-mdc-focus-indicator::before,