igniteui-angular-sovn
Version:
Ignite UI for Angular is a dependency-free Angular toolkit for building modern web apps
545 lines (492 loc) • 21.3 kB
text/typescript
import { NgIf, NgTemplateOutlet } from '@angular/common';
import {
AfterViewInit, ChangeDetectorRef, Component, ElementRef, EventEmitter, HostListener, Inject, Injector,
Optional, Output, ViewChild
} from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { takeUntil } from 'rxjs/operators';
import { IgxComboAddItemComponent } from '../combo/combo-add-item.component';
import { IgxComboDropDownComponent } from '../combo/combo-dropdown.component';
import { IgxComboItemComponent } from '../combo/combo-item.component';
import { IgxComboAPIService } from '../combo/combo.api';
import { IgxComboBaseDirective, IGX_COMBO_COMPONENT } from '../combo/combo.common';
import { DisplayDensityToken, IDisplayDensityOptions } from '../core/density';
import { IgxSelectionAPIService } from '../core/selection';
import { CancelableEventArgs, IBaseCancelableBrowserEventArgs, IBaseEventArgs, PlatformUtil } from '../core/utils';
import { IgxButtonDirective } from '../directives/button/button.directive';
import { IgxForOfDirective } from '../directives/for-of/for_of.directive';
import { IgxRippleDirective } from '../directives/ripple/ripple.directive';
import { IgxTextSelectionDirective } from '../directives/text-selection/text-selection.directive';
import { IgxIconService } from '../icon/icon.service';
import { IgxInputGroupType, IGX_INPUT_GROUP_TYPE } from '../input-group/public_api';
import { IgxComboFilteringPipe, IgxComboGroupingPipe } from '../combo/combo.pipes';
import { IgxDropDownItemNavigationDirective } from '../drop-down/drop-down-navigation.directive';
import { IgxIconComponent } from '../icon/icon.component';
import { IgxSuffixDirective } from '../directives/suffix/suffix.directive';
import { IgxInputDirective } from '../directives/input/input.directive';
import { IgxInputGroupComponent } from '../input-group/input-group.component';
/** Emitted when an igx-simple-combo's selection is changing. */
export interface ISimpleComboSelectionChangingEventArgs extends CancelableEventArgs, IBaseEventArgs {
/** An object which represents the value that is currently selected */
oldSelection: any;
/** An object which represents the value that will be selected after this event */
newSelection: any;
/** The text that will be displayed in the combo text box */
displayText: string;
}
/**
* Represents a drop-down list that provides filtering functionality, allowing users to choose a single option from a predefined list.
*
* @igxModule IgxSimpleComboModule
* @igxTheme igx-combo-theme
* @igxKeywords combobox, single combo selection
* @igxGroup Grids & Lists
*
* @remarks
* It provides the ability to filter items as well as perform single selection on the provided data.
* Additionally, it exposes keyboard navigation and custom styling capabilities.
* @example
* ```html
* <igx-simple-combo [itemsMaxHeight]="250" [data]="locationData"
* [displayKey]="'field'" [valueKey]="'field'"
* placeholder="Location" searchPlaceholder="Search...">
* </igx-simple-combo>
* ```
*/
export class IgxSimpleComboComponent extends IgxComboBaseDirective implements ControlValueAccessor, AfterViewInit {
/** @hidden @internal */
public dropdown: IgxComboDropDownComponent;
/** @hidden @internal */
public addItem: IgxComboAddItemComponent;
/**
* Emitted when item selection is changing, before the selection completes
*
* ```html
* <igx-simple-combo (selectionChanging)='handleSelection()'></igx-simple-combo>
* ```
*/
public selectionChanging = new EventEmitter<ISimpleComboSelectionChangingEventArgs>();
private textSelection: IgxTextSelectionDirective;
/** @hidden @internal */
public composing = false;
private _updateInput = true;
// stores the last filtered value - move to common?
private _internalFilter = '';
private _collapsing = false;
/** @hidden @internal */
public get filteredData(): any[] | null {
return this._filteredData;
}
/** @hidden @internal */
public set filteredData(val: any[] | null) {
this._filteredData = this.groupKey ? (val || []).filter((e) => e.isHeader !== true) : val;
this.checkMatch();
}
/** @hidden @internal */
public override get searchValue(): string {
return this._searchValue;
}
public override set searchValue(val: string) {
this._searchValue = val;
}
private get selectedItem(): any {
return this.selectionService.get(this.id).values().next().value;
}
constructor(elementRef: ElementRef,
cdr: ChangeDetectorRef,
selectionService: IgxSelectionAPIService,
comboAPI: IgxComboAPIService,
_iconService: IgxIconService,
private platformUtil: PlatformUtil,
_displayDensityOptions: IDisplayDensityOptions,
_inputGroupType: IgxInputGroupType,
_injector: Injector) {
super(elementRef, cdr, selectionService, comboAPI,
_iconService, _displayDensityOptions, _inputGroupType, _injector);
this.comboAPI.register(this);
}
/** @hidden @internal */
public onArrowDown(event: Event): void {
if (this.collapsed) {
event.preventDefault();
event.stopPropagation();
this.open();
} else {
if (this.virtDir.igxForOf.length > 0 && !this.selectedItem) {
this.dropdown.navigateNext();
this.dropdownContainer.nativeElement.focus();
} else if (this.allowCustomValues) {
this.addItem?.element.nativeElement.focus();
}
}
}
/**
* Select a defined item
*
* @param item the item to be selected
* ```typescript
* this.combo.select("New York");
* ```
*/
public select(item: any): void {
if (item !== undefined) {
const newSelection = this.selectionService.add_items(this.id, item instanceof Array ? item : [item], true);
this.setSelection(newSelection);
}
}
/**
* Deselect the currently selected item
*
* @param item the items to be deselected
* ```typescript
* this.combo.deselect("New York");
* ```
*/
public deselect(): void {
this.clearSelection();
}
/** @hidden @internal */
public writeValue(value: any): void {
const oldSelection = this.selection;
this.selectionService.select_items(this.id, this.isValid(value) ? [value] : [], true);
this.cdr.markForCheck();
this._value = this.createDisplayText(this.selection, oldSelection);
this.filterValue = this._internalFilter = this._value?.toString();
}
/** @hidden @internal */
public override ngAfterViewInit(): void {
this.virtDir.contentSizeChange.pipe(takeUntil(this.destroy$)).subscribe(() => {
if (this.selection.length > 0) {
const index = this.virtDir.igxForOf.findIndex(e => {
let current = e? e[this.valueKey] : undefined;
if (this.valueKey === null || this.valueKey === undefined) {
current = e;
}
return current === this.selection[0];
});
if (!this.isRemote) {
// navigate to item only if we have local data
// as with remote data this will fiddle with igxFor's scroll handler
// and will trigger another chunk load which will break the visualization
this.dropdown.navigateItem(index);
}
}
});
this.dropdown.opening.pipe(takeUntil(this.destroy$)).subscribe((args) => {
if (args.cancel) {
return;
}
this._collapsing = false;
const filtered = this.filteredData.find(this.findAllMatches);
if (filtered === undefined || filtered === null) {
this.filterValue = this.searchValue = this.comboInput.value;
return;
}
this.filterValue = this.searchValue = '';
});
this.dropdown.opened.pipe(takeUntil(this.destroy$)).subscribe(() => {
if (this.composing) {
this.comboInput.focus();
}
this._internalFilter = this.comboInput.value;
});
this.dropdown.closing.pipe(takeUntil(this.destroy$)).subscribe((args) => {
if (args.cancel) {
return;
}
if (this.getEditElement() && !args.event) {
this._collapsing = true;
} else {
this.clearOnBlur();
this._onTouchedCallback();
}
this.comboInput.focus();
});
this.dropdown.closed.pipe(takeUntil(this.destroy$)).subscribe(() => {
this.filterValue = this._internalFilter = this.comboInput.value;
});
// in reactive form the control is not present initially
// and sets the selection to an invalid value in writeValue method
if (!this.isValid(this.selectedItem)) {
this.selectionService.clear(this.id);
this._value = '';
}
super.ngAfterViewInit();
}
/** @hidden @internal */
public override handleInputChange(event?: any): void {
if (event !== undefined) {
this.filterValue = this._internalFilter = this.searchValue = typeof event === 'string' ? event : event.target.value;
}
this._onChangeCallback(this.searchValue);
if (this.collapsed && this.comboInput.focused) {
this.open();
}
if (!this.comboInput.value.trim() && this.selection.length) {
// handle clearing of input by space
this.clearSelection();
this._onChangeCallback(null);
this.filterValue = '';
}
if (this.selection.length) {
this.selectionService.clear(this.id);
}
// when filtering the focused item should be the first item or the currently selected item
if (!this.dropdown.focusedItem || this.dropdown.focusedItem.id !== this.dropdown.items[0].id) {
this.dropdown.navigateFirst();
}
super.handleInputChange(event);
this.composing = true;
}
/** @hidden @internal */
public handleInputClick(): void {
if (this.collapsed) {
this.open();
this.comboInput.focus();
}
}
/** @hidden @internal */
public override handleKeyDown(event: KeyboardEvent): void {
if (event.key === this.platformUtil.KEYMAP.ENTER) {
const filtered = this.filteredData.find(this.findAllMatches);
if (filtered === null || filtered === undefined) {
return;
}
this.select(this.dropdown.focusedItem.itemID);
event.preventDefault();
event.stopPropagation();
this.close();
// manually trigger text selection as it will not be triggered during editing
this.textSelection.trigger();
this.filterValue = this.getElementVal(filtered);
return;
}
if (event.key === this.platformUtil.KEYMAP.BACKSPACE
|| event.key === this.platformUtil.KEYMAP.DELETE) {
this._updateInput = false;
this.clearSelection(true);
}
if (!this.collapsed && event.key === this.platformUtil.KEYMAP.TAB) {
this.clearOnBlur();
this.close();
}
this.composing = false;
super.handleKeyDown(event);
}
/** @hidden @internal */
public handleKeyUp(event: KeyboardEvent): void {
if (event.key === this.platformUtil.KEYMAP.ARROW_DOWN) {
const firstItem = this.selectionService.first_item(this.id);
this.dropdown.focusedItem = firstItem && this.filteredData.length > 0
? this.dropdown.items.find(i => i.itemID === firstItem)
: this.dropdown.items[0];
this.dropdownContainer.nativeElement.focus();
}
}
/** @hidden @internal */
public handleItemKeyDown(event: KeyboardEvent): void {
if (event.key === this.platformUtil.KEYMAP.ARROW_UP && event.altKey) {
this.close();
this.comboInput.focus();
return;
}
if (event.key === this.platformUtil.KEYMAP.ENTER) {
this.comboInput.focus();
}
}
/** @hidden @internal */
public handleItemClick(): void {
this.close();
this.comboInput.focus();
}
/** @hidden @internal */
public override onBlur(): void {
// when clicking the toggle button to close the combo and immediately clicking outside of it
// the collapsed state is not modified as the dropdown is still not closed
if (this.collapsed || this._collapsing) {
this.clearOnBlur();
}
super.onBlur();
}
/** @hidden @internal */
public onFocus(): void {
this._internalFilter = this.comboInput.value || '';
}
/** @hidden @internal */
public getEditElement(): HTMLElement {
return this.comboInput.nativeElement;
}
/** @hidden @internal */
public handleClear(event: Event): void {
if (this.disabled) {
return;
}
this.clearSelection(true);
if(!this.collapsed){
this.focusSearchInput(true);
}
event.stopPropagation();
this.comboInput.value = this.filterValue = this.searchValue = '';
this.dropdown.focusedItem = null;
this.composing = false;
this.comboInput.focus();
}
/** @hidden @internal */
public handleOpened(): void {
this.triggerCheck();
if (!this.comboInput.focused) {
this.dropdownContainer.nativeElement.focus();
}
this.opened.emit({ owner: this });
}
/** @hidden @internal */
public override handleClosing(e: IBaseCancelableBrowserEventArgs): void {
const args: IBaseCancelableBrowserEventArgs = { owner: this, event: e.event, cancel: e.cancel };
this.closing.emit(args);
e.cancel = args.cancel;
if (e.cancel) {
return;
}
this.composing = false;
// explicitly update selection and trigger text selection so that we don't have to force CD
this.textSelection.selected = true;
this.textSelection.trigger();
}
/** @hidden @internal */
public focusSearchInput(opening?: boolean): void {
if (opening) {
this.dropdownContainer.nativeElement.focus();
} else {
this.comboInput.nativeElement.focus();
}
}
/** @hidden @internal */
public override onClick(event: Event): void {
super.onClick(event);
if (this.comboInput.value.length === 0) {
this.virtDir.scrollTo(0);
}
}
protected findAllMatches = (element: any): boolean => {
const value = this.displayKey ? element[this.displayKey] : element;
if (value === null || value === undefined || value === '') {
// we can accept null, undefined and empty strings as empty display values
return true;
}
const searchValue = this.searchValue || this.comboInput.value;
return !!searchValue && value.toString().toLowerCase().includes(searchValue.toLowerCase());
};
protected setSelection(newSelection: any): void {
const newSelectionAsArray = newSelection ? Array.from(newSelection) as IgxComboItemComponent[] : [];
const oldSelectionAsArray = Array.from(this.selectionService.get(this.id) || []);
const displayText = this.createDisplayText(newSelectionAsArray, oldSelectionAsArray);
const args: ISimpleComboSelectionChangingEventArgs = {
newSelection: newSelectionAsArray[0],
oldSelection: oldSelectionAsArray[0],
displayText,
owner: this,
cancel: false
};
if (args.newSelection !== args.oldSelection) {
this.selectionChanging.emit(args);
}
// TODO: refactor below code as it sets the selection and the display text
if (!args.cancel) {
let argsSelection = this.isValid(args.newSelection)
? args.newSelection
: [];
argsSelection = Array.isArray(argsSelection) ? argsSelection : [argsSelection];
this.selectionService.select_items(this.id, argsSelection, true);
if (this._updateInput) {
this.comboInput.value = this._internalFilter = this._value = this.searchValue = displayText !== args.displayText
? args.displayText
: this.createDisplayText(argsSelection, [args.oldSelection]);
}
this._onChangeCallback(args.newSelection);
this._updateInput = true;
} else if (this.isRemote) {
this.registerRemoteEntries(newSelectionAsArray, false);
}
}
protected createDisplayText(newSelection: any[], oldSelection: any[]): string {
if (this.isRemote) {
return this.getRemoteSelection(newSelection, oldSelection);
}
if (this.displayKey !== null
&& this.displayKey !== undefined
&& newSelection.length > 0) {
return this.convertKeysToItems(newSelection).filter(e => e).map(e => e[this.displayKey])[0]?.toString() || '';
}
return newSelection[0]?.toString() || '';
}
protected override getRemoteSelection(newSelection: any[], oldSelection: any[]): string {
if (!newSelection.length) {
this.registerRemoteEntries(oldSelection, false);
return '';
}
this.registerRemoteEntries(oldSelection, false);
this.registerRemoteEntries(newSelection);
return Object.keys(this._remoteSelection).map(e => this._remoteSelection[e])[0] || '';
}
/** Contains key-value pairs of the selected valueKeys and their resp. displayKeys */
protected override registerRemoteEntries(ids: any[], add = true) {
const selection = this.getValueDisplayPairs(ids)[0];
if (add && selection) {
this._remoteSelection[selection[this.valueKey]] = selection[this.displayKey].toString();
} else {
delete this._remoteSelection[ids[0]];
}
}
private clearSelection(ignoreFilter?: boolean): void {
let newSelection = this.selectionService.get_empty();
if (this.filteredData.length !== this.data.length && !ignoreFilter) {
newSelection = this.selectionService.delete_items(this.id, this.selectionService.get_all_ids(this.filteredData, this.valueKey));
}
this.setSelection(newSelection);
}
private clearOnBlur(): void {
if (this.isRemote) {
const searchValue = this.searchValue || this.comboInput.value;
const remoteValue = Object.keys(this._remoteSelection).map(e => this._remoteSelection[e])[0];
if (remoteValue && searchValue !== remoteValue) {
this.clear();
}
return;
}
const filtered = this.filteredData.find(this.findMatch);
// selecting null in primitive data returns undefined as the search text is '', but the item is null
if (filtered === undefined && this.selectedItem !== null || !this.selection.length) {
this.clear();
}
}
private getElementVal(element: any): string {
const elementVal = this.displayKey ? element[this.displayKey] : element;
return String(elementVal);
}
private clear(): void {
this.clearSelection(true);
this.comboInput.value = this._internalFilter = this._value = this.searchValue = '';
}
private isValid(value: any): boolean {
return this.required
? value !== null && value !== '' && value !== undefined
: value !== undefined;
}
}