Skip to content

feat(material-experimental/mdc-chips): Make chips editable by connecting to the mdc web editing interface #19618

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 18 commits into from
Jul 11, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/dev-app/mdc-chips/mdc-chips-demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -128,12 +128,18 @@ <h4>Multi selection</h4>
{{disableInputs ? "Enable" : "Disable"}}
</button>

<button mat-button (click)="editable = !editable">
{{editable ? "Disable editing" : "Enable editing"}}
</button>

<h4>Input is last child of chip grid</h4>

<mat-form-field class="demo-has-chip-list">
<mat-chip-grid #chipGrid1 [(ngModel)]="selectedPeople" required [disabled]="disableInputs">
<mat-chip-row *ngFor="let person of people"
(removed)="remove(person)">
[editable]="editable"
(removed)="remove(person)"
(edited)="edit(person, $event)">
{{person.name}}
<mat-icon matChipRemove>cancel</mat-icon>
</mat-chip-row>
Expand Down
15 changes: 14 additions & 1 deletion src/dev-app/mdc-chips/mdc-chips-demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import {COMMA, ENTER} from '@angular/cdk/keycodes';
import {Component} from '@angular/core';
import {ThemePalette} from '@angular/material/core';
import {MatChipInputEvent} from '@angular/material-experimental/mdc-chips';
import {MatChipInputEvent, MatChipEditedEvent} from '@angular/material-experimental/mdc-chips';

export interface Person {
name: string;
Expand All @@ -32,6 +32,7 @@ export class MdcChipsDemo {
addOnBlur = true;
disabledListboxes = false;
disableInputs = false;
editable = false;
message = '';

// Enter, comma, semi-colon
Expand Down Expand Up @@ -81,6 +82,18 @@ export class MdcChipsDemo {
}
}

edit(person: Person, event: MatChipEditedEvent): void {
if (!event.value.trim().length) {
this.remove(person);
return;
}

const index = this.people.indexOf(person);
const newPeople = this.people.slice();
newPeople[index] = {...newPeople[index], name: event.value};
this.people = newPeople;
}

toggleVisible(): void {
this.visible = false;
}
Expand Down
52 changes: 52 additions & 0 deletions src/material-experimental/mdc-chips/chip-edit-input.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import {Component, DebugElement} from '@angular/core';
import {async, TestBed, ComponentFixture} from '@angular/core/testing';
import {MatChipEditInput, MatChipsModule} from './index';
import {By} from '@angular/platform-browser';


describe('MDC-based MatChipEditInput', () => {
const DEFAULT_INITIAL_VALUE = 'INITIAL_VALUE';

let fixture: ComponentFixture<any>;
let inputDebugElement: DebugElement;
let inputInstance: MatChipEditInput;

beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [MatChipsModule],
declarations: [
ChipEditInputContainer,
],
});

TestBed.compileComponents();

fixture = TestBed.createComponent(ChipEditInputContainer);
inputDebugElement = fixture.debugElement.query(By.directive(MatChipEditInput))!;
inputInstance = inputDebugElement.injector.get<MatChipEditInput>(MatChipEditInput);
}));

describe('on initialization', () => {
it('should set the initial input text', () => {
inputInstance.initialize(DEFAULT_INITIAL_VALUE);
expect(inputInstance.getNativeElement().textContent).toEqual(DEFAULT_INITIAL_VALUE);
});

it('should focus the input', () => {
inputInstance.initialize(DEFAULT_INITIAL_VALUE);
expect(document.activeElement).toEqual(inputInstance.getNativeElement());
});
});

it('should update the internal value as it is set', () => {
inputInstance.initialize(DEFAULT_INITIAL_VALUE);
const newValue = 'NEW_VALUE';
inputInstance.setValue(newValue);
expect(inputInstance.getValue()).toEqual(newValue);
});
});

@Component({
template: `<span matChipEditInput></span>`,
})
class ChipEditInputContainer {}
60 changes: 60 additions & 0 deletions src/material-experimental/mdc-chips/chip-edit-input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* @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 {
Directive,
ElementRef,
Inject,
} from '@angular/core';
import {DOCUMENT} from '@angular/common';

/**
* A directive that makes a span editable and exposes functions to modify and retrieve the
* element's contents.
*/
@Directive({
selector: 'span[matChipEditInput]',
host: {
'class': 'mdc-chip__primary-action mat-chip-edit-input',
'role': 'textbox',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jelbourn are you aware of any a11y issues with this?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't really know the a11y nuances of contenteditable, so this would require some manual testing

'tabindex': '-1',
'contenteditable': 'true',
},
})
export class MatChipEditInput {
constructor(
private readonly _elementRef: ElementRef,
@Inject(DOCUMENT) private readonly _document: any) {}

initialize(initialValue: string) {
this.getNativeElement().focus();
this.setValue(initialValue);
}

getNativeElement(): HTMLElement {
return this._elementRef.nativeElement;
}

setValue(value: string) {
this.getNativeElement().innerText = value;
this._moveCursorToEndOfInput();
}

getValue(): string {
return this.getNativeElement().textContent || '';
}

private _moveCursorToEndOfInput() {
const range = this._document.createRange();
range.selectNodeContents(this.getNativeElement());
range.collapse(false);
const sel = window.getSelection()!;
sel.removeAllRanges();
sel.addRange(range);
}
}
34 changes: 31 additions & 3 deletions src/material-experimental/mdc-chips/chip-grid.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ describe('MDC-based MatChipGrid', () => {

// Focus and blur the middle item
midItem.focus();
midItem._focusout();
(document.activeElement as HTMLElement).blur();
tick();
zone.simulateZoneExit();

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

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

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

it('should ignore all non-tab navigation keyboard events from an editing chip', () => {
setupStandardGrid();
manager = chipGridInstance._keyManager;
testComponent.editable = true;
fixture.detectChanges();

const array = chips.toArray();
const firstItem = array[0];
firstItem.focus();
firstItem._keydown(createKeyboardEvent('keydown', ENTER, 'Enter', document.activeElement!));
fixture.detectChanges();

const activeRowIndex = manager.activeRowIndex;
const activeColumnIndex = manager.activeColumnIndex;

const KEYS_TO_IGNORE = [HOME, END, LEFT_ARROW, RIGHT_ARROW];
for (const key of KEYS_TO_IGNORE) {
const event: KeyboardEvent =
createKeyboardEvent('keydown', key, undefined, document.activeElement!);
chipGridInstance._keydown(event);
fixture.detectChanges();

expect(manager.activeRowIndex).toBe(activeRowIndex);
expect(manager.activeColumnIndex).toBe(activeColumnIndex);
}
});
});
});

Expand Down Expand Up @@ -991,7 +1017,8 @@ describe('MDC-based MatChipGrid', () => {
@Component({
template: `
<mat-chip-grid [tabIndex]="tabIndex" #chipGrid>
<mat-chip-row *ngFor="let i of chips">
<mat-chip-row *ngFor="let i of chips"
[editable]="editable">
{{name}} {{i + 1}}
</mat-chip-row>
</mat-chip-grid>
Expand All @@ -1001,6 +1028,7 @@ class StandardChipGrid {
name: string = 'Test';
tabIndex: number = 0;
chips = [0, 1, 2, 3, 4];
editable = false;
}

@Component({
Expand Down
11 changes: 6 additions & 5 deletions src/material-experimental/mdc-chips/chip-grid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -405,15 +405,16 @@ export class MatChipGrid extends _MatChipGridMixinBase implements AfterContentIn
const target = event.target as HTMLElement;
const keyCode = event.keyCode;
const manager = this._keyManager;

// If they are on an empty input and hit backspace, focus the last chip
if (keyCode === BACKSPACE && this._isEmptyInput(target)) {
if (keyCode === TAB && target.id !== this._chipInput!.id) {
this._allowFocusEscape();
} else if (this._originatesFromEditingChip(event)) {
// No-op, let the editing chip handle all keyboard events except for Tab.
} else if (keyCode === BACKSPACE && this._isEmptyInput(target)) {
// If they are on an empty input and hit backspace, focus the last chip
if (this._chips.length) {
manager.setLastCellActive();
}
event.preventDefault();
} else if (keyCode === TAB && target.id !== this._chipInput!.id ) {
this._allowFocusEscape();
} else if (this._originatesFromChip(event)) {
if (keyCode === HOME) {
manager.setFirstCellActive();
Expand Down
43 changes: 28 additions & 15 deletions src/material-experimental/mdc-chips/chip-row.html
Original file line number Diff line number Diff line change
@@ -1,19 +1,32 @@
<span class="mdc-chip__ripple"></span>
<ng-container *ngIf="!_isEditing()">
<span class="mdc-chip__ripple"></span>

<span matRipple class="mat-mdc-chip-ripple"
[matRippleAnimation]="_rippleAnimation"
[matRippleDisabled]="_isRippleDisabled()"
[matRippleCentered]="_isRippleCentered"
[matRippleTrigger]="_elementRef.nativeElement"></span>
<span matRipple class="mat-mdc-chip-ripple"
[matRippleAnimation]="_rippleAnimation"
[matRippleDisabled]="_isRippleDisabled()"
[matRippleCentered]="_isRippleCentered"
[matRippleTrigger]="_elementRef.nativeElement"></span>
</ng-container>

<div role="gridcell">
<div #chipContent tabindex="-1"
class="mat-chip-row-focusable-text-content mat-mdc-focus-indicator">
<ng-content select="mat-chip-avatar, [matChipAvatar]"></ng-content>
<span class="mdc-chip__text"><ng-content></ng-content></span>
<ng-content select="mat-chip-trailing-icon,[matChipTrailingIcon]"></ng-content>
<div class="mat-mdc-chip-content">
<div role="gridcell">
<div #chipContent tabindex="-1"
class="mat-mdc-chip-row-focusable-text-content mat-mdc-focus-indicator mdc-chip__primary-action"
[attr.role]="editable ? 'button' : null">
<ng-content select="mat-chip-avatar, [matChipAvatar]"></ng-content>
<span class="mdc-chip__text"><ng-content></ng-content></span>
<ng-content select="mat-chip-trailing-icon,[matChipTrailingIcon]"></ng-content>
</div>
</div>
<div role="gridcell" *ngIf="removeIcon" class="mat-mdc-chip-remove-icon">
<ng-content select="[matChipRemove]"></ng-content>
</div>
</div>
<div role="gridcell" *ngIf="removeIcon">
<ng-content select="[matChipRemove]"></ng-content>
</div>

<div *ngIf="_isEditing()" role="gridcell" class="mat-mdc-chip-edit-input-container">
<ng-content *ngIf="contentEditInput; else defaultMatChipEditInput"
select="[matChipEditInput]"></ng-content>
<ng-template #defaultMatChipEditInput>
<span matChipEditInput></span>
</ng-template>
</div>
Loading