diff --git a/goldens/material/chips/index.api.md b/goldens/material/chips/index.api.md index d4cb5e82385b..20a1e9b9f378 100644 --- a/goldens/material/chips/index.api.md +++ b/goldens/material/chips/index.api.md @@ -189,7 +189,7 @@ export class MatChipGrid extends MatChipSet implements AfterContentInit, AfterVi _blur(): void; readonly change: EventEmitter; get chipBlurChanges(): Observable; - protected _chipInput: MatChipTextControl; + protected _chipInput?: MatChipTextControl; // (undocumented) _chips: QueryList; readonly controlType: string; @@ -216,8 +216,6 @@ export class MatChipGrid extends MatChipSet implements AfterContentInit, AfterVi // (undocumented) ngAfterContentInit(): void; // (undocumented) - ngAfterViewInit(): void; - // (undocumented) ngControl: NgControl; // (undocumented) ngDoCheck(): void; diff --git a/src/dev-app/chips/chips-demo.html b/src/dev-app/chips/chips-demo.html index 9aecfb6ba882..9fd9b56a011b 100644 --- a/src/dev-app/chips/chips-demo.html +++ b/src/dev-app/chips/chips-demo.html @@ -230,6 +230,31 @@

Options

Add on Blur

+

Chip grid with no Input

+ + + + @for (person of people; track person) { + + @if (showEditIcon) { + + } + @if (peopleWithAvatar && person.avatar) { + {{person.avatar}} + } + {{person.name}} + + + } + + diff --git a/src/material/chips/chip-grid.spec.ts b/src/material/chips/chip-grid.spec.ts index 385dca10c968..8058cb125377 100644 --- a/src/material/chips/chip-grid.spec.ts +++ b/src/material/chips/chip-grid.spec.ts @@ -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. @@ -1234,3 +1302,22 @@ class ChipGridWithRemove { this.chips.splice(event.chip.value, 1); } } + +@Component({ + template: ` + + Foods + + @for (food of chips; track food) { + {{ food }} + } + + Some hint + + `, + imports: [MatChipGrid, MatChipRow, MatFormField, MatLabel, MatHint], +}) +class ChipGridWithoutInput { + chips = ['Pizza', 'Pasta', 'Tacos']; + placeholder: string; +} diff --git a/src/material/chips/chip-grid.ts b/src/material/chips/chip-grid.ts index 5dfe91e9233e..24763b6c5a72 100644 --- a/src/material/chips/chip-grid.ts +++ b/src/material/chips/chip-grid.ts @@ -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, @@ -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. @@ -137,7 +139,7 @@ export class MatChipGrid * @docs-private */ get id(): string { - return this._chipInput.id; + return this._chipInput ? this._chipInput.id : this._uid; } /** @@ -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(); } /** @@ -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 @@ -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'); } /** @@ -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; @@ -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(' ') : []; } /** @@ -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'); + } } /** @@ -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(); } } @@ -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 @@ -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 diff --git a/src/material/chips/chips.md b/src/material/chips/chips.md index af5e6cb88a3c..3464f0acddbd 100644 --- a/src/material/chips/chips.md +++ b/src/material/chips/chips.md @@ -36,7 +36,7 @@ Users can move through the chips using the arrow keys and select/deselect them w Use `` and `` 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 `` as the container. Add an `` element, and register it to the `` by passing the `matChipInputFor` Input. Always use an `` element with ``. Nest a `` element inside the `` 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 `` as the container. Add an `` element, and register it to the `` by passing the `matChipInputFor` Input. Nest a `` element inside the `` for each piece of data entered by the user. An example of using chips for text input.