Skip to content
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
4 changes: 1 addition & 3 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 @@ -216,8 +216,6 @@ export class MatChipGrid extends MatChipSet implements AfterContentInit, AfterVi
// (undocumented)
ngAfterContentInit(): void;
// (undocumented)
ngAfterViewInit(): void;
// (undocumented)
ngControl: NgControl;
// (undocumented)
ngDoCheck(): void;
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;
}
48 changes: 30 additions & 18 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,10 +97,11 @@ 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';
private _errorStateTracker: _ErrorStateTracker;
private _uid = inject(_IdGenerator).getId('mat-chip-grid-');

/**
* List of element ids to propagate to the chipInput's aria-describedby attribute.
Expand Down Expand Up @@ -137,7 +139,7 @@ export class MatChipGrid
* @docs-private
*/
get id(): string {
return this._chipInput.id;
return this._chipInput ? this._chipInput.id : this._uid;
}

/**
Expand Down Expand Up @@ -166,7 +168,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 @@ -285,14 +287,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 All @@ -311,6 +305,9 @@ export class MatChipGrid
registerInput(inputElement: MatChipTextControl): void {
this._chipInput = inputElement;
this._chipInput.setDescribedByIds(this._ariaDescribedbyIds);

// If ids were already attached to host element, can now remove in favor of chipInput
this._elementRef.nativeElement.removeAttribute('aria-describedby');
}

/**
Expand All @@ -328,14 +325,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,7 +355,11 @@ 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(' ') : [];
}

/**
Expand All @@ -365,7 +370,14 @@ export class MatChipGrid
// 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 +441,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 +453,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 +471,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