File

libs/ui/chip/src/lib/collection/chip-collection.component.ts

Description

Component that is used to group TsChipComponent instances

Implements

OnInit AfterViewInit AfterContentInit OnDestroy

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>

Metadata

changeDetection ChangeDetectionStrategy.OnPush
encapsulation ViewEncapsulation.None
exportAs tsChipCollection
host {
}
selector ts-chip-collection
styleUrls ./chip-collection.component.scss
templateUrl ./chip-collection.component.html

Index

Properties
Methods
Inputs
Outputs
Accessors

Constructor

constructor(elementRef: ElementRef, changeDetectorRef: ChangeDetectorRef, zone: NgZone)
Parameters :
Name Type Optional
elementRef ElementRef<HTMLElement> No
changeDetectorRef ChangeDetectorRef No
zone NgZone No

Inputs

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

Outputs

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

Methods

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

Properties

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

Accessors

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 :
Name Type Optional
value boolean No
Returns : void
id
getid()
setid(value: string)

Set and get chip collection id

Parameters :
Name Type Optional
value string No
Returns : void
isDisabled
getisDisabled()
setisDisabled(value: boolean)

Get and set disable state

Parameters :
Name Type Optional
value boolean No
Returns : void
isReadonly
getisReadonly()
setisReadonly(value: boolean)

Get and set readonly state

Parameters :
Name Type Optional
value boolean No
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 :
Name Type Optional
value boolean No
Returns : void
tabIndex
gettabIndex()
settabIndex(value: number)

Set and get tabindex

Parameters :
Name Type Optional
value number No
Returns : void
value
getvalue()
setvalue(value: [])

Set and get chip collection value

Parameters :
Name Type Optional
value [] No
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;
      });
    }
  }
}

result-matching ""

    No results matching ""