Skip to content

feat(material/chips): make ChipInput optional for MatChipGrid #31693

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

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
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
11 changes: 7 additions & 4 deletions goldens/material/chips/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ export class MatChipGrid extends MatChipSet implements AfterContentInit, AfterVi
_blur(): void;
readonly change: EventEmitter<MatChipGridChange>;
get chipBlurChanges(): Observable<MatChipEvent>;
protected _chipInput: MatChipTextControl;
protected _chipInput?: MatChipTextControl;
// (undocumented)
_chips: QueryList<MatChipRow>;
readonly controlType: string;
Expand All @@ -209,15 +209,16 @@ export class MatChipGrid extends MatChipSet implements AfterContentInit, AfterVi
_focusLastChip(): void;
_handleKeydown(event: KeyboardEvent): void;
get id(): string;
set id(value: string);
// (undocumented)
protected _id: string;
// (undocumented)
static ngAcceptInputType_disabled: unknown;
// (undocumented)
static ngAcceptInputType_required: unknown;
// (undocumented)
ngAfterContentInit(): void;
// (undocumented)
ngAfterViewInit(): void;
// (undocumented)
ngControl: NgControl;
// (undocumented)
ngDoCheck(): void;
Expand All @@ -241,6 +242,8 @@ export class MatChipGrid extends MatChipSet implements AfterContentInit, AfterVi
setDisabledState(isDisabled: boolean): void;
get shouldLabelFloat(): boolean;
readonly stateChanges: Subject<void>;
// (undocumented)
protected _uid: string;
updateErrorState(): void;
get value(): any;
set value(value: any);
Expand All @@ -249,7 +252,7 @@ export class MatChipGrid extends MatChipSet implements AfterContentInit, AfterVi
readonly valueChange: EventEmitter<any>;
writeValue(value: any): void;
// (undocumented)
static ɵcmp: i0.ɵɵComponentDeclaration<MatChipGrid, "mat-chip-grid", never, { "disabled": { "alias": "disabled"; "required": false; }; "placeholder": { "alias": "placeholder"; "required": false; }; "required": { "alias": "required"; "required": false; }; "value": { "alias": "value"; "required": false; }; "errorStateMatcher": { "alias": "errorStateMatcher"; "required": false; }; }, { "change": "change"; "valueChange": "valueChange"; }, ["_chips"], ["*"], true, never>;
static ɵcmp: i0.ɵɵComponentDeclaration<MatChipGrid, "mat-chip-grid", never, { "disabled": { "alias": "disabled"; "required": false; }; "id": { "alias": "id"; "required": false; }; "placeholder": { "alias": "placeholder"; "required": false; }; "required": { "alias": "required"; "required": false; }; "value": { "alias": "value"; "required": false; }; "errorStateMatcher": { "alias": "errorStateMatcher"; "required": false; }; }, { "change": "change"; "valueChange": "valueChange"; }, ["_chips"], ["*"], true, never>;
// (undocumented)
static ɵfac: i0.ɵɵFactoryDeclaration<MatChipGrid, never>;
}
Expand Down
25 changes: 25 additions & 0 deletions src/dev-app/chips/chips-demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,31 @@ <h4>Options</h4>
<mat-checkbox name="addOnBlur" [(ngModel)]="addOnBlur">Add on Blur</mat-checkbox>
</p>

<h4>Chip grid with no Input</h4>

<mat-form-field class="demo-has-chip-list">
<mat-chip-grid #chipGrid3 [(ngModel)]="selectedPeople" required [disabled]="disableInputs">
@for (person of people; track person) {
<mat-chip-row
[editable]="editable"
(removed)="remove(person)"
(edited)="edit(person, $event)">
@if (showEditIcon) {
<button matChipEdit aria-label="Edit contributor">
<mat-icon>edit</mat-icon>
</button>
}
@if (peopleWithAvatar && person.avatar) {
<mat-chip-avatar>{{person.avatar}}</mat-chip-avatar>
}
{{person.name}}
<button matChipRemove aria-label="Remove contributor">
<mat-icon>close</mat-icon>
</button>
</mat-chip-row>
}
</mat-chip-grid>
</mat-form-field>
</mat-card-content>
</mat-card>

Expand Down
87 changes: 87 additions & 0 deletions src/material/chips/chip-grid.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,74 @@ describe('MatChipGrid', () => {
});
});

describe('ChipGrid without input', () => {
it('should not throw when used without a chip input', () => {
expect(() => createComponent(ChipGridWithoutInput)).not.toThrow();
});

it('should be able to focus the first chip', () => {
const fixture = createComponent(ChipGridWithoutInput);
chipGridInstance.focus();
fixture.detectChanges();
expect(document.activeElement).toBe(primaryActions[0]);
});

it('should not do anything on focus if there are no chips', () => {
const fixture = createComponent(ChipGridWithoutInput);
(testComponent as unknown as ChipGridWithoutInput).chips = [];
fixture.changeDetectorRef.markForCheck();
fixture.detectChanges();

chipGridInstance.focus();
fixture.detectChanges();

expect(chipGridNativeElement.contains(document.activeElement)).toBe(false);
});

it('should have a default id on the component instance', () => {
createComponent(ChipGridWithoutInput);
expect(chipGridInstance.id).toMatch(/^mat-chip-grid-\w+$/);
});

it('should have empty getters that work without an input', () => {
const fixture = createComponent(ChipGridWithoutInput);
expect(chipGridInstance.empty).toBe(false);

(testComponent as unknown as ChipGridWithoutInput).chips = [];
fixture.changeDetectorRef.markForCheck();
fixture.detectChanges();

expect(chipGridInstance.empty).toBe(true);
});

it('should have a placeholder getter that works without an input', () => {
const fixture = createComponent(ChipGridWithoutInput);
(testComponent as unknown as ChipGridWithoutInput).placeholder = 'Hello';
fixture.changeDetectorRef.markForCheck();
fixture.detectChanges();
expect(chipGridInstance.placeholder).toBe('Hello');
});

it('should have a focused getter that works without an input', () => {
const fixture = createComponent(ChipGridWithoutInput);
expect(chipGridInstance.focused).toBe(false);

chipGridInstance.focus();
fixture.detectChanges();

expect(chipGridInstance.focused).toBe(true);
});

it('should set aria-describedby on the grid when there is no input', fakeAsync(() => {
const fixture = createComponent(ChipGridWithoutInput);
const hint = fixture.debugElement.query(By.css('mat-hint')).nativeElement;
flush();
fixture.detectChanges();

expect(chipGridNativeElement.getAttribute('aria-describedby')).toBe(hint.id);
}));
});

describe('with chip remove', () => {
it('should properly focus next item if chip is removed through click', fakeAsync(() => {
// TODO(crisbeto): this test fails without the NoopAnimationsModule for some reason.
Expand Down Expand Up @@ -1234,3 +1302,22 @@ class ChipGridWithRemove {
this.chips.splice(event.chip.value, 1);
}
}

@Component({
template: `
<mat-form-field>
<mat-label>Foods</mat-label>
<mat-chip-grid #chipGrid [placeholder]="placeholder">
@for (food of chips; track food) {
<mat-chip-row>{{ food }}</mat-chip-row>
}
</mat-chip-grid>
<mat-hint>Some hint</mat-hint>
</mat-form-field>
`,
imports: [MatChipGrid, MatChipRow, MatFormField, MatLabel, MatHint],
})
class ChipGridWithoutInput {
chips = ['Pizza', 'Pasta', 'Tacos'];
placeholder: string;
}
55 changes: 35 additions & 20 deletions src/material/chips/chip-grid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* found in the LICENSE file at https://angular.dev/license
*/

import {_IdGenerator} from '@angular/cdk/a11y';
import {DOWN_ARROW, hasModifierKey, TAB, UP_ARROW} from '@angular/cdk/keycodes';
import {
AfterContentInit,
Expand Down Expand Up @@ -96,9 +97,10 @@ export class MatChipGrid
readonly controlType: string = 'mat-chip-grid';

/** The chip input to add more chips */
protected _chipInput: MatChipTextControl;
protected _chipInput?: MatChipTextControl;

protected override _defaultRole = 'grid';
protected _uid = inject(_IdGenerator).getId('mat-chip-grid-');
private _errorStateTracker: _ErrorStateTracker;

/**
Expand Down Expand Up @@ -136,9 +138,14 @@ export class MatChipGrid
* Implemented as part of MatFormFieldControl.
* @docs-private
*/
@Input()
get id(): string {
return this._chipInput.id;
return this._chipInput ? this._chipInput.id : this._id;
}
set id(value: string) {
this._id = value || this._uid;
}
protected _id: string;

/**
* Implemented as part of MatFormFieldControl.
Expand Down Expand Up @@ -166,7 +173,7 @@ export class MatChipGrid

/** Whether any chips or the matChipInput inside of this chip-grid has focus. */
override get focused(): boolean {
return this._chipInput.focused || this._hasFocusedChip();
return this._chipInput?.focused || this._hasFocusedChip();
}

/**
Expand Down Expand Up @@ -272,6 +279,9 @@ export class MatChipGrid
parentForm,
this.stateChanges,
);

// Force setter to be called in case id was not specified.
this.id = this.id;
}

ngAfterContentInit() {
Expand All @@ -285,14 +295,6 @@ export class MatChipGrid
.subscribe(() => this.stateChanges.next());
}

override ngAfterViewInit() {
super.ngAfterViewInit();

if (!this._chipInput && (typeof ngDevMode === 'undefined' || ngDevMode)) {
throw Error('mat-chip-grid must be used in combination with matChipInputFor.');
}
}

ngDoCheck() {
if (this.ngControl) {
// We need to re-evaluate this on every change detection cycle, because there are some
Expand Down Expand Up @@ -328,14 +330,18 @@ export class MatChipGrid
* are no eligible chips.
*/
override focus(): void {
if (this.disabled || this._chipInput.focused) {
if (this.disabled || this._chipInput?.focused) {
return;
}

if (!this._chips.length || this._chips.first.disabled) {
if (!this._chipInput) {
return;
}

// Delay until the next tick, because this can cause a "changed after checked"
// error if the input does something on focus (e.g. opens an autocomplete).
Promise.resolve().then(() => this._chipInput.focus());
Promise.resolve().then(() => this._chipInput!.focus());
} else {
const activeItem = this._keyManager.activeItem;

Expand All @@ -354,18 +360,27 @@ export class MatChipGrid
* @docs-private
*/
get describedByIds(): string[] {
return this._chipInput?.describedByIds || [];
if (this._chipInput) {
return this._chipInput.describedByIds || [];
}
const existing = this._elementRef.nativeElement.getAttribute('aria-describedby');
return existing ? existing.split(' ') : [];
}

/**
* Implemented as part of MatFormFieldControl.
* @docs-private
*/
setDescribedByIds(ids: string[]) {
// We must keep this up to date to handle the case where ids are set
// before the chip input is registered.
this._ariaDescribedbyIds = ids;
this._chipInput?.setDescribedByIds(ids);

if (this._chipInput) {
this._chipInput.setDescribedByIds(ids);
} else if (ids.length) {
this._elementRef.nativeElement.setAttribute('aria-describedby', ids.join(' '));
} else {
this._elementRef.nativeElement.removeAttribute('aria-describedby');
}
}

/**
Expand Down Expand Up @@ -429,7 +444,7 @@ export class MatChipGrid
* it back to the first chip, creating a focus trap, if it user tries to tab away.
*/
protected override _allowFocusEscape() {
if (!this._chipInput.focused) {
if (!this._chipInput?.focused) {
super._allowFocusEscape();
}
}
Expand All @@ -441,7 +456,7 @@ export class MatChipGrid

if (keyCode === TAB) {
if (
this._chipInput.focused &&
this._chipInput?.focused &&
hasModifierKey(event, 'shiftKey') &&
this._chips.length &&
!this._chips.last.disabled
Expand All @@ -459,7 +474,7 @@ export class MatChipGrid
// disabled chip left in the list.
super._allowFocusEscape();
}
} else if (!this._chipInput.focused) {
} else if (!this._chipInput?.focused) {
// The up and down arrows are supposed to navigate between the individual rows in the grid.
// We do this by filtering the actions down to the ones that have the same `_isPrimary`
// flag as the active action and moving focus between them ourseles instead of delegating
Expand Down
2 changes: 1 addition & 1 deletion src/material/chips/chips.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Users can move through the chips using the arrow keys and select/deselect them w

Use `<mat-chip-grid>` and `<mat-chip-row>` for assisting users with text entry.

Chips are always used inside a container. To create chips connected to an input field, start by creating a `<mat-chip-grid>` as the container. Add an `<input/>` element, and register it to the `<mat-chip-grid>` by passing the `matChipInputFor` Input. Always use an `<input/>` element with `<mat-chip-grid>`. Nest a `<mat-chip-row>` element inside the `<mat-chip-grid>` for each piece of data entered by the user. An example of using chips for text input.
Chips are always used inside a container. To create chips connected to an input field, start by creating a `<mat-chip-grid>` as the container. Add an `<input/>` element, and register it to the `<mat-chip-grid>` by passing the `matChipInputFor` Input. Nest a `<mat-chip-row>` element inside the `<mat-chip-grid>` for each piece of data entered by the user. An example of using chips for text input.

<!-- example(chips-input) -->

Expand Down
Loading