libs/ui/chip/src/lib/collection/chip-collection.component.ts
Component that is used to group TsChipComponent instances
OnInit
AfterViewInit
AfterContentInit
OnDestroy
<ts-chip-collection
[allowMultipleSelections]="true"
aria-orientation="vertical"
[isDisabled]="false"
[isReadonly]="false"
[isRemovable]="true"
[isSelectable]="false"
[orientation]="horizontal"
[tabIndex]="1"
[value]="myValue"
(collectionChange)="collectionChange($event)"
(removed)="chipRemoved($event)"
(tabUpdateFocus)="tabFocusUpdated()"
></ts-chip-collection>
<example-url>https://getterminus.github.io/ui-demos-release/components/chip</example-url>
changeDetection | ChangeDetectionStrategy.OnPush |
encapsulation | ViewEncapsulation.None |
exportAs | tsChipCollection |
host | { |
selector | ts-chip-collection |
styleUrls | ./chip-collection.component.scss |
templateUrl | ./chip-collection.component.html |
Properties |
Methods |
Inputs |
Outputs |
Accessors |
constructor(elementRef: ElementRef
|
||||||||||||
Parameters :
|
allowMultipleSelections | |
Type : boolean
|
|
Whether the user should be allowed to select multiple chips. |
aria-orientation | |
Type : "horizontal" | "vertical"
|
|
Default value : 'horizontal'
|
|
Orientation of the chip collection. |
id | |
Type : string
|
|
Set and get chip collection id |
isDisabled | |
Type : boolean
|
|
Get and set disable state |
isReadonly | |
Type : boolean
|
|
Get and set readonly state |
isSelectable | |
Type : boolean
|
|
Whether or not this chip collection is selectable. When a chip collection is not selectable, all the chips are not selectable. |
orientation | |
Type : TsChipCollectionOrientation
|
|
Default value : 'horizontal'
|
|
Orientation of the chip - either horizontal or vertical. Default to horizontal. |
tabIndex | |
Type : number
|
|
Set and get tabindex |
value | |
Type : []
|
|
Set and get chip collection value |
collectionChange | |
Type : EventEmitter
|
|
Event emitted when the chip collection value has been changed by the user. |
removed | |
Type : EventEmitter
|
|
Emitted when a chip is to be removed. |
tabUpdateFocus | |
Type : EventEmitter
|
|
Emitted when tab pressed with chip focused |
Public blur |
blur()
|
When blurred, mark the field as touched when focus moved outside the chip collection.
Returns :
void
|
Public focus |
focus()
|
Focuses the first non-disabled chip in this chip collection, or the associated input when there are no eligible chips.
Returns :
void
|
Public chips |
Type : QueryList<TsChipComponent>
|
Decorators :
@ContentChildren(TsChipComponent)
|
The chip components contained within this chip collection. |
Protected uid |
Default value : `ts-chip-collection-${nextUniqueId++}`
|
Uid of the chip collection |
Public zone |
Type : NgZone
|
empty |
getempty()
|
Determine whether there is at least one chip in collection.
Returns :
boolean
|
focused |
getfocused()
|
Whether any chips has focus
Returns :
boolean
|
role |
getrole()
|
The ARIA role applied to the chip list
Returns :
string | null
|
allowMultipleSelections | ||||||
getallowMultipleSelections()
|
||||||
setallowMultipleSelections(value: boolean)
|
||||||
Whether the user should be allowed to select multiple chips.
Parameters :
Returns :
void
|
id | ||||||
getid()
|
||||||
setid(value: string)
|
||||||
Set and get chip collection id
Parameters :
Returns :
void
|
isDisabled | ||||||
getisDisabled()
|
||||||
setisDisabled(value: boolean)
|
||||||
Get and set disable state
Parameters :
Returns :
void
|
isReadonly | ||||||
getisReadonly()
|
||||||
setisReadonly(value: boolean)
|
||||||
Get and set readonly state
Parameters :
Returns :
void
|
isSelectable | ||||||
getisSelectable()
|
||||||
setisSelectable(value: boolean)
|
||||||
Whether or not this chip collection is selectable. When a chip collection is not selectable, all the chips are not selectable.
Parameters :
Returns :
void
|
tabIndex | ||||||
gettabIndex()
|
||||||
settabIndex(value: number)
|
||||||
Set and get tabindex
Parameters :
Returns :
void
|
value | ||||||
getvalue()
|
||||||
setvalue(value: [])
|
||||||
Set and get chip collection value
Parameters :
Returns :
void
|
import { FocusKeyManager } from '@angular/cdk/a11y';
import { SelectionModel } from '@angular/cdk/collections';
import {
AfterContentInit,
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ContentChildren,
ElementRef,
EventEmitter,
Input,
NgZone,
OnDestroy,
OnInit,
Output,
QueryList,
ViewEncapsulation,
} from '@angular/core';
import {
merge,
Observable,
} from 'rxjs';
import { startWith } from 'rxjs/operators';
import {
KEYS,
untilComponentDestroyed,
} from '@terminus/fe-utilities';
import {
TsChipComponent,
TsChipEvent,
TsChipSelectionChange,
} from '../chip/chip.component';
// Increasing integer for generating unique ids for chip-collection components.
// @internal
let nextUniqueId = 0;
/**
* Possible orientations for {@link TsChipCollectionComponent}
*/
export type TsChipCollectionOrientation
= 'horizontal'
| 'vertical'
;
/**
* Change event object that is emitted when the chip collection value has changed.
*/
export class TsChipCollectionChange {
constructor(
// Chip collection that emitted the event
public source: TsChipCollectionComponent,
// Value of the chip collection when the event was emitted
public value: string[],
) { }
}
/**
* Component that is used to group {@link TsChipComponent} instances
*
* @example
* <ts-chip-collection
* [allowMultipleSelections]="true"
* aria-orientation="vertical"
* [isDisabled]="false"
* [isReadonly]="false"
* [isRemovable]="true"
* [isSelectable]="false"
* [orientation]="horizontal"
* [tabIndex]="1"
* [value]="myValue"
* (collectionChange)="collectionChange($event)"
* (removed)="chipRemoved($event)"
* (tabUpdateFocus)="tabFocusUpdated()"
* ></ts-chip-collection>
*
* <example-url>https://getterminus.github.io/ui-demos-release/components/chip</example-url>
*/
@Component({
selector: 'ts-chip-collection',
templateUrl: './chip-collection.component.html',
styleUrls: ['./chip-collection.component.scss'],
host: {
'class': 'ts-chip-collection',
'[class.ts-chip-collection--disabled]': 'isDisabled',
'[class.ts-chip-collection--vertical]': 'orientation === "vertical"',
'[class.ts-chip-collection--selectable]': 'isSelectable',
'[attr.tabindex]': 'isDisabled ? null : tabIndex',
'[attr.aria-describedby]': 'ariaDescribedby || null',
'[attr.aria-disabled]': 'isDisabled',
'[attr.aria-multiselectable]': 'allowMultipleSelections',
'[attr.aria-orientation]': 'ariaOrientation',
'[attr.aria-readonly]': 'isReadonly',
'[attr.aria-required]': 'false',
'[attr.aria-selectable]': 'isSelectable',
'[attr.role]': 'role',
'(focus)': 'focus()',
'(blur)': 'blur()',
'(keydown)': 'keydown($event)',
'[id]': 'id',
},
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
exportAs: 'tsChipCollection',
})
export class TsChipCollectionComponent implements OnInit, AfterViewInit, AfterContentInit, OnDestroy {
/**
* Uid of the chip collection
*/
protected uid = `ts-chip-collection-${nextUniqueId++}`;
/**
* The aria-describedby attribute on the chip collection for improved a11y.
*
* @internal
*/
public ariaDescribedby!: string;
/**
* User defined tab index.
*
* When it is not null, use user defined tab index. Otherwise use _tabIndex
*
* @internal
*/
public _userTabIndex: number | null = null;
/**
* When a chip is destroyed, we store the index of the destroyed chip until the chips
* query list notifies about the update. This is necessary because we cannot determine an
* appropriate chip that should receive focus until the array of chips updated completely.
*
* @internal
*/
public lastDestroyedChipIndex: number | null = null;
/**
* The FocusKeyManager which handles focus.
*
* @internal
*/
public keyManager!: FocusKeyManager<TsChipComponent>;
/**
* Manage selections
*
* @internal
*/
public selectionModel!: SelectionModel<TsChipComponent>;
/**
* Function when touched
*
* @internal
*/
public onTouched = () => { };
/**
* Function when changed
*
* @internal
*/
public onChange: (value: string[]) => void = () => { };
/**
* Combined stream of all of the child chips' selection change events.
*
* @internal
*/
public get chipSelectionChanges(): Observable<TsChipSelectionChange> {
// eslint-disable-next-line deprecation/deprecation
return merge(...this.chips.map(chip => chip.selectionChange));
}
/**
* Combined stream of all of the child chips' focus change events.
*
* @internal
*/
public get chipFocusChanges(): Observable<TsChipEvent> {
// eslint-disable-next-line deprecation/deprecation
return merge(...this.chips.map(chip => chip.onFocus));
}
/**
* Combined stream of all of the child chips' blur change events.
*
* @internal
*/
public get chipBlurChanges(): Observable<TsChipEvent> {
// eslint-disable-next-line deprecation/deprecation
return merge(...this.chips.map(chip => chip.blurred));
}
/**
* Combined stream of all of the child chips' remove change events.
*
* @internal
*/
public get chipDestroyChanges(): Observable<TsChipEvent> {
// eslint-disable-next-line deprecation/deprecation
return merge(...this.chips.map(chip => chip.destroyed));
}
/**
* Determine whether there is at least one chip in collection.
*/
public get empty(): boolean {
return this.chips && this.chips.length === 0;
}
/**
* Whether any chips has focus
*/
public get focused(): boolean {
return this.chips.some(chip => chip.hasFocus);
}
/**
* The ARIA role applied to the chip list
*/
public get role(): string | null {
return this._role;
}
private _role: string | null = null;
/**
* The chip components contained within this chip collection.
*/
@ContentChildren(TsChipComponent)
public chips!: QueryList<TsChipComponent>;
/**
* Whether the user should be allowed to select multiple chips.
*
* @param value
*/
@Input()
public set allowMultipleSelections(value: boolean) {
this._allowMultipleSelections = value;
this.syncChipsState();
}
public get allowMultipleSelections(): boolean {
return this._allowMultipleSelections;
}
private _allowMultipleSelections = false;
/**
* Orientation of the chip collection.
*/
@Input('aria-orientation')
public ariaOrientation: 'horizontal' | 'vertical' = 'horizontal';
/**
* Set and get chip collection id
*
* @param value
*/
@Input()
public set id(value: string) {
this._id = value || this.uid;
}
public get id(): string {
return this._id;
}
private _id: string = this.uid;
/**
* Get and set disable state
*
* @param value
*/
@Input()
public set isDisabled(value: boolean) {
this._disabled = value;
this.syncChipsState();
}
public get isDisabled(): boolean {
return this._disabled;
}
private _disabled = false;
/**
* Get and set readonly state
*
* @param value
*/
@Input()
public set isReadonly(value: boolean) {
this._readonly = value;
}
public get isReadonly(): boolean {
return this._readonly;
}
private _readonly = false;
/**
* Whether or not this chip collection is selectable. When a chip collection is not selectable,
* all the chips are not selectable.
*
* @param value
*/
@Input()
public set isSelectable(value: boolean) {
this._selectable = value;
this.syncChipsState();
}
public get isSelectable(): boolean {
return this._selectable;
}
private _selectable = true;
/**
* Orientation of the chip - either horizontal or vertical. Default to horizontal.
*/
@Input()
public orientation: TsChipCollectionOrientation = 'horizontal';
/**
* Set and get tabindex
*
* @param value
*/
@Input()
public set tabIndex(value: number) {
this._userTabIndex = value;
this._tabIndex = value;
}
public get tabIndex(): number {
return this._tabIndex;
}
private _tabIndex = 0;
/**
* Set and get chip collection value
*
* @param value
*/
@Input()
public set value(value: string[]) {
this._value = value;
}
public get value(): string[] {
return this._value;
}
private _value = [''];
/**
* Event emitted when the chip collection value has been changed by the user.
*/
@Output()
public readonly collectionChange = new EventEmitter<TsChipCollectionChange>();
/**
* Emitted when a chip is to be removed.
*/
@Output()
public readonly removed = new EventEmitter<TsChipEvent>();
/**
* Emitted when tab pressed with chip focused
*/
@Output()
public readonly tabUpdateFocus = new EventEmitter<void>();
constructor(
protected elementRef: ElementRef<HTMLElement>,
private changeDetectorRef: ChangeDetectorRef,
public zone: NgZone,
) {
zone.runOutsideAngular(() => {
this._role = this.empty ? null : 'listbox';
});
}
/**
* Initialize the selection model
*/
public ngOnInit(): void {
this.selectionModel = new SelectionModel<TsChipComponent>(this.allowMultipleSelections, undefined, false);
}
/**
* Initialize the key manager and listen for chip changes
*/
public ngAfterViewInit(): void {
this.keyManager = new FocusKeyManager<TsChipComponent>(this.chips)
.withWrap()
.withVerticalOrientation()
.withHorizontalOrientation('ltr');
this.keyManager.tabOut.pipe(untilComponentDestroyed(this)).subscribe(() => {
this.tabUpdateFocus.emit();
});
// When the collection changes, re-subscribe
// eslint-disable-next-line deprecation/deprecation
this.chips.changes.pipe(startWith<void, null>(null), untilComponentDestroyed(this)).subscribe(() => {
if (this.isDisabled || this.isReadonly) {
// Since this happens after the content has been checked, we need to defer it to the next tick.
Promise.resolve().then(() => {
this.syncChipsState();
});
}
this.resetChips();
// Check to see if we need to update our tab index
Promise.resolve().then(() => {
this.updateTabIndex();
});
// Check to see if we have a destroyed chip and need to refocus
this.updateFocusForDestroyedChips();
this.propagateChanges();
});
}
/**
* Trigger an initial sync after the content has loaded
*/
public ngAfterContentInit(): void {
Promise.resolve().then(() => {
this.syncChipsState();
});
}
/**
* Needed for untilComponentDestroyed
*/
public ngOnDestroy() { }
/**
* When blurred, mark the field as touched when focus moved outside the chip collection.
*/
public blur(): void {
// istanbul ignore else
if (!this.focused) {
this.keyManager.setActiveItem(-1);
}
}
/**
* Focuses the first non-disabled chip in this chip collection, or the associated input when there are no eligible chips.
*/
public focus(): void {
if (this.isDisabled) {
return;
}
// istanbul ignore else
if (this.chips.length > 0) {
this.keyManager.setFirstItemActive();
}
}
/**
* Pass events to the keyboard manager.
*
* @internal
*
* @param event - They KeyboardEvent
*/
public keydown(event: KeyboardEvent): void {
event.stopPropagation();
const target = event.target as HTMLElement;
const keyCode = event.code;
// If they are on an empty input and hit backspace, focus the last chip
if (keyCode === KEYS.BACKSPACE.code && TsChipCollectionComponent.isInputEmpty(target)) {
this.keyManager.setLastItemActive();
event.preventDefault();
} else if (target && target.classList.contains('ts-chip')) {
if (keyCode === KEYS.HOME.code) {
this.keyManager.setFirstItemActive();
event.preventDefault();
} else if (keyCode === KEYS.END.code) {
this.keyManager.setLastItemActive();
event.preventDefault();
} if (this.allowMultipleSelections && keyCode === KEYS.A.code && event.ctrlKey) {
// Select all with CTRL+A
const hasDeselectedChips = this.chips.some(chip => !chip.isDisabled && !chip.selected);
this.chips.forEach(chip => {
// istanbul ignore else
if (!chip.isDisabled) {
hasDeselectedChips ? chip.select() : chip.deselect();
}
});
event.preventDefault();
} else {
this.keyManager.onKeydown(event);
}
}
}
/**
* Utility to for whether input field is empty
*
* @param element - An HTMLElement
* @returns boolean
*/
private static isInputEmpty(element: HTMLElement): boolean {
if (element && element.nodeName.toLowerCase() === 'input') {
const input = element as HTMLInputElement;
return !input.value;
}
return false;
}
/**
* Check the tab index as you should not be allowed to focus an empty list.
*
* @internal
*/
private updateTabIndex(): void {
// If we have 0 chips, we should not allow keyboard focus
this.tabIndex = this._userTabIndex || (this.chips.length === 0 ? -1 : 0);
}
/**
* If the amount of chips changed, we need to update the key manager state and focus the next closest chip.
*/
private updateFocusForDestroyedChips(): void {
// Move focus to the closest chip. If no other chips remain, focus the chip-collection itself.
if (this.lastDestroyedChipIndex !== null) {
if (this.chips.length) {
const newChipIndex = Math.min(this.lastDestroyedChipIndex, this.chips.length - 1);
this.keyManager.setActiveItem(newChipIndex);
} else {
this.focus();
}
}
this.lastDestroyedChipIndex = null;
}
/**
* Emits change event to set the model value.
*/
private propagateChanges(): void {
const valueToEmit = this.chips.map(chip => chip.value || '');
this._value = valueToEmit;
this.collectionChange.emit(new TsChipCollectionChange(this, valueToEmit));
this.onChange(valueToEmit);
this.changeDetectorRef.markForCheck();
}
/**
* Utility to ensure all indexes are valid.
*
* @param index - The index to be checked.
* @returns True if the index is valid for our collection of chips.
*/
private isValidIndex(index: number): boolean {
return index >= 0 && index < this.chips.length;
}
/**
* Reset all the chips subscription
*/
private resetChips(): void {
this.listenToChipsFocus();
this.listenToChipsSelection();
this.listenToChipsRemoved();
}
/**
* Listens to user-generated selection events on each chip.
*/
private listenToChipsSelection(): void {
this.chipSelectionChanges.pipe(untilComponentDestroyed(this)).subscribe(event => {
event.source.selected
? this.selectionModel.select(event.source)
: this.selectionModel.deselect(event.source);
// For single selection chip collection, make sure the deselected value is unselected.
if (!this.allowMultipleSelections) {
this.chips.forEach(chip => {
if (!this.selectionModel.isSelected(chip) && chip.selected) {
chip.deselect();
}
});
}
});
}
/**
* Listens to user-generated selection events on each chip.
*/
private listenToChipsFocus(): void {
this.chipFocusChanges.pipe(untilComponentDestroyed(this)).subscribe(event => {
const chipIndex: number = this.chips.toArray().indexOf(event.chip);
// istanbul ignore else
if (this.isValidIndex(chipIndex)) {
this.keyManager.updateActiveItem(chipIndex);
}
});
this.chipBlurChanges.pipe(untilComponentDestroyed(this)).subscribe(() => {
this.blur();
});
}
/**
* Listens to remove events on each chip.
*/
private listenToChipsRemoved(): void {
this.chipDestroyChanges.pipe(untilComponentDestroyed(this)).subscribe(event => {
const chip = event.chip;
const chipIndex = this.chips.toArray().indexOf(event.chip);
// In case the chip that will be removed is currently focused, we temporarily store the index in order to be able to determine an
// appropriate sibling chip that will receive focus.
// istanbul ignore else
if (this.isValidIndex(chipIndex) && chip.hasFocus) {
this.lastDestroyedChipIndex = chipIndex;
}
this.removed.emit(new TsChipEvent(chip));
});
}
/**
* Syncs the collection's state with the individual chips.
*/
private syncChipsState(): void {
// istanbul ignore else
if (this.chips && this.chips.length) {
this.chips.forEach(chip => {
chip.allowMultiple = this.allowMultipleSelections;
chip.chipCollectionMultiple = this.allowMultipleSelections;
chip.isDisabled = this.isDisabled;
chip.chipCollectionRemovable = !this.isReadonly && !this.isDisabled;
chip.isSelectable = this.isSelectable;
});
}
}
}