Skip to content

Commit 04024fe

Browse files
kowsenKyle Owsen
and
Kyle Owsen
authored
feat(material-experimental/mdc-chips): Make chips editable by connecting to the mdc web editing interface (#19618)
* Stop arrow key navigation through matChipRemove directives from deleting mdc-chip-rows. * Make the MDC MatChipRow editable * Fixes to demo page for editable chips * Fix linter issues * Fix incorrect host input param * Fix chip grid test * Simplify chip grid test * Change chip edit span to be a directive instead of a component. * Add tests for fallback MatChipEditInput. * Fixes for comments on PR. * Fix formatting on new longer selector. * Move edit input/output to MatChipRow. * Move ngAcceptInputType property to MatChipRow. * Fix style issues and tests from review. * Vertically center avatar and remove icons to match existing behavior. * Fix selector for vertically centering chip contents. Co-authored-by: Kyle Owsen <[email protected]>
1 parent 36d4c5d commit 04024fe

15 files changed

+518
-47
lines changed

src/dev-app/mdc-chips/mdc-chips-demo.html

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,12 +128,18 @@ <h4>Multi selection</h4>
128128
{{disableInputs ? "Enable" : "Disable"}}
129129
</button>
130130

131+
<button mat-button (click)="editable = !editable">
132+
{{editable ? "Disable editing" : "Enable editing"}}
133+
</button>
134+
131135
<h4>Input is last child of chip grid</h4>
132136

133137
<mat-form-field class="demo-has-chip-list">
134138
<mat-chip-grid #chipGrid1 [(ngModel)]="selectedPeople" required [disabled]="disableInputs">
135139
<mat-chip-row *ngFor="let person of people"
136-
(removed)="remove(person)">
140+
[editable]="editable"
141+
(removed)="remove(person)"
142+
(edited)="edit(person, $event)">
137143
{{person.name}}
138144
<mat-icon matChipRemove>cancel</mat-icon>
139145
</mat-chip-row>

src/dev-app/mdc-chips/mdc-chips-demo.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import {COMMA, ENTER} from '@angular/cdk/keycodes';
1010
import {Component} from '@angular/core';
1111
import {ThemePalette} from '@angular/material/core';
12-
import {MatChipInputEvent} from '@angular/material-experimental/mdc-chips';
12+
import {MatChipInputEvent, MatChipEditedEvent} from '@angular/material-experimental/mdc-chips';
1313

1414
export interface Person {
1515
name: string;
@@ -32,6 +32,7 @@ export class MdcChipsDemo {
3232
addOnBlur = true;
3333
disabledListboxes = false;
3434
disableInputs = false;
35+
editable = false;
3536
message = '';
3637

3738
// Enter, comma, semi-colon
@@ -81,6 +82,18 @@ export class MdcChipsDemo {
8182
}
8283
}
8384

85+
edit(person: Person, event: MatChipEditedEvent): void {
86+
if (!event.value.trim().length) {
87+
this.remove(person);
88+
return;
89+
}
90+
91+
const index = this.people.indexOf(person);
92+
const newPeople = this.people.slice();
93+
newPeople[index] = {...newPeople[index], name: event.value};
94+
this.people = newPeople;
95+
}
96+
8497
toggleVisible(): void {
8598
this.visible = false;
8699
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import {Component, DebugElement} from '@angular/core';
2+
import {async, TestBed, ComponentFixture} from '@angular/core/testing';
3+
import {MatChipEditInput, MatChipsModule} from './index';
4+
import {By} from '@angular/platform-browser';
5+
6+
7+
describe('MDC-based MatChipEditInput', () => {
8+
const DEFAULT_INITIAL_VALUE = 'INITIAL_VALUE';
9+
10+
let fixture: ComponentFixture<any>;
11+
let inputDebugElement: DebugElement;
12+
let inputInstance: MatChipEditInput;
13+
14+
beforeEach(async(() => {
15+
TestBed.configureTestingModule({
16+
imports: [MatChipsModule],
17+
declarations: [
18+
ChipEditInputContainer,
19+
],
20+
});
21+
22+
TestBed.compileComponents();
23+
24+
fixture = TestBed.createComponent(ChipEditInputContainer);
25+
inputDebugElement = fixture.debugElement.query(By.directive(MatChipEditInput))!;
26+
inputInstance = inputDebugElement.injector.get<MatChipEditInput>(MatChipEditInput);
27+
}));
28+
29+
describe('on initialization', () => {
30+
it('should set the initial input text', () => {
31+
inputInstance.initialize(DEFAULT_INITIAL_VALUE);
32+
expect(inputInstance.getNativeElement().textContent).toEqual(DEFAULT_INITIAL_VALUE);
33+
});
34+
35+
it('should focus the input', () => {
36+
inputInstance.initialize(DEFAULT_INITIAL_VALUE);
37+
expect(document.activeElement).toEqual(inputInstance.getNativeElement());
38+
});
39+
});
40+
41+
it('should update the internal value as it is set', () => {
42+
inputInstance.initialize(DEFAULT_INITIAL_VALUE);
43+
const newValue = 'NEW_VALUE';
44+
inputInstance.setValue(newValue);
45+
expect(inputInstance.getValue()).toEqual(newValue);
46+
});
47+
});
48+
49+
@Component({
50+
template: `<span matChipEditInput></span>`,
51+
})
52+
class ChipEditInputContainer {}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {
10+
Directive,
11+
ElementRef,
12+
Inject,
13+
} from '@angular/core';
14+
import {DOCUMENT} from '@angular/common';
15+
16+
/**
17+
* A directive that makes a span editable and exposes functions to modify and retrieve the
18+
* element's contents.
19+
*/
20+
@Directive({
21+
selector: 'span[matChipEditInput]',
22+
host: {
23+
'class': 'mdc-chip__primary-action mat-chip-edit-input',
24+
'role': 'textbox',
25+
'tabindex': '-1',
26+
'contenteditable': 'true',
27+
},
28+
})
29+
export class MatChipEditInput {
30+
constructor(
31+
private readonly _elementRef: ElementRef,
32+
@Inject(DOCUMENT) private readonly _document: any) {}
33+
34+
initialize(initialValue: string) {
35+
this.getNativeElement().focus();
36+
this.setValue(initialValue);
37+
}
38+
39+
getNativeElement(): HTMLElement {
40+
return this._elementRef.nativeElement;
41+
}
42+
43+
setValue(value: string) {
44+
this.getNativeElement().innerText = value;
45+
this._moveCursorToEndOfInput();
46+
}
47+
48+
getValue(): string {
49+
return this.getNativeElement().textContent || '';
50+
}
51+
52+
private _moveCursorToEndOfInput() {
53+
const range = this._document.createRange();
54+
range.selectNodeContents(this.getNativeElement());
55+
range.collapse(false);
56+
const sel = window.getSelection()!;
57+
sel.removeAllRanges();
58+
sel.addRange(range);
59+
}
60+
}

src/material-experimental/mdc-chips/chip-grid.spec.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ describe('MDC-based MatChipGrid', () => {
191191

192192
// Focus and blur the middle item
193193
midItem.focus();
194-
midItem._focusout();
194+
(document.activeElement as HTMLElement).blur();
195195
tick();
196196
zone.simulateZoneExit();
197197

@@ -244,7 +244,7 @@ describe('MDC-based MatChipGrid', () => {
244244

245245
it('should have a focus indicator', () => {
246246
const focusableTextNativeElements = Array.from(chipGridNativeElement
247-
.querySelectorAll('.mat-chip-row-focusable-text-content'));
247+
.querySelectorAll('.mat-mdc-chip-row-focusable-text-content'));
248248

249249
expect(focusableTextNativeElements
250250
.every(element => element.classList.contains('mat-mdc-focus-indicator'))).toBe(true);
@@ -502,6 +502,32 @@ describe('MDC-based MatChipGrid', () => {
502502
expect(manager.activeColumnIndex).toBe(0);
503503
});
504504

505+
it('should ignore all non-tab navigation keyboard events from an editing chip', () => {
506+
setupStandardGrid();
507+
manager = chipGridInstance._keyManager;
508+
testComponent.editable = true;
509+
fixture.detectChanges();
510+
511+
const array = chips.toArray();
512+
const firstItem = array[0];
513+
firstItem.focus();
514+
firstItem._keydown(createKeyboardEvent('keydown', ENTER, 'Enter', document.activeElement!));
515+
fixture.detectChanges();
516+
517+
const activeRowIndex = manager.activeRowIndex;
518+
const activeColumnIndex = manager.activeColumnIndex;
519+
520+
const KEYS_TO_IGNORE = [HOME, END, LEFT_ARROW, RIGHT_ARROW];
521+
for (const key of KEYS_TO_IGNORE) {
522+
const event: KeyboardEvent =
523+
createKeyboardEvent('keydown', key, undefined, document.activeElement!);
524+
chipGridInstance._keydown(event);
525+
fixture.detectChanges();
526+
527+
expect(manager.activeRowIndex).toBe(activeRowIndex);
528+
expect(manager.activeColumnIndex).toBe(activeColumnIndex);
529+
}
530+
});
505531
});
506532
});
507533

@@ -991,7 +1017,8 @@ describe('MDC-based MatChipGrid', () => {
9911017
@Component({
9921018
template: `
9931019
<mat-chip-grid [tabIndex]="tabIndex" #chipGrid>
994-
<mat-chip-row *ngFor="let i of chips">
1020+
<mat-chip-row *ngFor="let i of chips"
1021+
[editable]="editable">
9951022
{{name}} {{i + 1}}
9961023
</mat-chip-row>
9971024
</mat-chip-grid>
@@ -1001,6 +1028,7 @@ class StandardChipGrid {
10011028
name: string = 'Test';
10021029
tabIndex: number = 0;
10031030
chips = [0, 1, 2, 3, 4];
1031+
editable = false;
10041032
}
10051033

10061034
@Component({

src/material-experimental/mdc-chips/chip-grid.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -405,15 +405,16 @@ export class MatChipGrid extends _MatChipGridMixinBase implements AfterContentIn
405405
const target = event.target as HTMLElement;
406406
const keyCode = event.keyCode;
407407
const manager = this._keyManager;
408-
409-
// If they are on an empty input and hit backspace, focus the last chip
410-
if (keyCode === BACKSPACE && this._isEmptyInput(target)) {
408+
if (keyCode === TAB && target.id !== this._chipInput!.id) {
409+
this._allowFocusEscape();
410+
} else if (this._originatesFromEditingChip(event)) {
411+
// No-op, let the editing chip handle all keyboard events except for Tab.
412+
} else if (keyCode === BACKSPACE && this._isEmptyInput(target)) {
413+
// If they are on an empty input and hit backspace, focus the last chip
411414
if (this._chips.length) {
412415
manager.setLastCellActive();
413416
}
414417
event.preventDefault();
415-
} else if (keyCode === TAB && target.id !== this._chipInput!.id ) {
416-
this._allowFocusEscape();
417418
} else if (this._originatesFromChip(event)) {
418419
if (keyCode === HOME) {
419420
manager.setFirstCellActive();
Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,32 @@
1-
<span class="mdc-chip__ripple"></span>
1+
<ng-container *ngIf="!_isEditing()">
2+
<span class="mdc-chip__ripple"></span>
23

3-
<span matRipple class="mat-mdc-chip-ripple"
4-
[matRippleAnimation]="_rippleAnimation"
5-
[matRippleDisabled]="_isRippleDisabled()"
6-
[matRippleCentered]="_isRippleCentered"
7-
[matRippleTrigger]="_elementRef.nativeElement"></span>
4+
<span matRipple class="mat-mdc-chip-ripple"
5+
[matRippleAnimation]="_rippleAnimation"
6+
[matRippleDisabled]="_isRippleDisabled()"
7+
[matRippleCentered]="_isRippleCentered"
8+
[matRippleTrigger]="_elementRef.nativeElement"></span>
9+
</ng-container>
810

9-
<div role="gridcell">
10-
<div #chipContent tabindex="-1"
11-
class="mat-chip-row-focusable-text-content mat-mdc-focus-indicator">
12-
<ng-content select="mat-chip-avatar, [matChipAvatar]"></ng-content>
13-
<span class="mdc-chip__text"><ng-content></ng-content></span>
14-
<ng-content select="mat-chip-trailing-icon,[matChipTrailingIcon]"></ng-content>
11+
<div class="mat-mdc-chip-content">
12+
<div role="gridcell">
13+
<div #chipContent tabindex="-1"
14+
class="mat-mdc-chip-row-focusable-text-content mat-mdc-focus-indicator mdc-chip__primary-action"
15+
[attr.role]="editable ? 'button' : null">
16+
<ng-content select="mat-chip-avatar, [matChipAvatar]"></ng-content>
17+
<span class="mdc-chip__text"><ng-content></ng-content></span>
18+
<ng-content select="mat-chip-trailing-icon,[matChipTrailingIcon]"></ng-content>
19+
</div>
20+
</div>
21+
<div role="gridcell" *ngIf="removeIcon" class="mat-mdc-chip-remove-icon">
22+
<ng-content select="[matChipRemove]"></ng-content>
1523
</div>
1624
</div>
17-
<div role="gridcell" *ngIf="removeIcon">
18-
<ng-content select="[matChipRemove]"></ng-content>
19-
</div>
25+
26+
<div *ngIf="_isEditing()" role="gridcell" class="mat-mdc-chip-edit-input-container">
27+
<ng-content *ngIf="contentEditInput; else defaultMatChipEditInput"
28+
select="[matChipEditInput]"></ng-content>
29+
<ng-template #defaultMatChipEditInput>
30+
<span matChipEditInput></span>
31+
</ng-template>
32+
</div>

0 commit comments

Comments
 (0)