2 * <eg-combobox [allowFreeText]="true" [entries]="comboboxEntryList"/>
3 * <!-- see also <eg-combobox-entry> -->
6 import {Component, OnInit, Input, Output, ViewChild, EventEmitter, ElementRef} from '@angular/core';
7 import {Observable, of, Subject} from 'rxjs';
8 import {map, tap, reduce, mergeMap, mapTo, debounceTime, distinctUntilChanged, merge, filter} from 'rxjs/operators';
9 import {NgbTypeahead, NgbTypeaheadSelectItemEvent} from '@ng-bootstrap/ng-bootstrap';
10 import {StoreService} from '@eg/core/store.service';
12 export interface ComboboxEntry {
19 selector: 'eg-combobox',
20 templateUrl: './combobox.component.html',
22 .icons {margin-left:-18px}
23 .material-icons {font-size: 16px;font-weight:bold}
26 export class ComboboxComponent implements OnInit {
28 selected: ComboboxEntry;
29 click$: Subject<string>;
30 entrylist: ComboboxEntry[];
32 @ViewChild('instance') instance: NgbTypeahead;
34 // Applies a name attribute to the input.
36 @Input() name: string;
38 // Placeholder text for selector input
39 @Input() placeholder = '';
41 @Input() persistKey: string; // TODO
43 @Input() allowFreeText = false;
45 // Add a 'required' attribute to the input
47 @Input() set required(r: boolean) {
53 @Input() set disabled(d: boolean) {
57 // Entry ID of the default entry to select (optional)
58 // onChange() is NOT fired when applying the default value,
59 // unless startIdFiresOnChange is set to true.
60 @Input() startId: any;
61 @Input() startIdFiresOnChange: boolean;
63 @Input() asyncDataSource: (term: string) => Observable<ComboboxEntry>;
65 // Useful for efficiently preventing duplicate async entries
66 asyncIds: {[idx: string]: boolean};
68 // True if a default selection has been made.
69 defaultSelectionApplied: boolean;
71 @Input() set entries(el: ComboboxEntry[]) {
73 this.applySelection();
76 // Emitted when the value is changed via UI.
77 // When the UI value is cleared, null is emitted.
78 @Output() onChange: EventEmitter<ComboboxEntry>;
80 // Useful for massaging the match string prior to comparison
81 // and display. Default version trims leading/trailing spaces.
82 formatDisplayString: (ComboboxEntry) => string;
85 private elm: ElementRef,
86 private store: StoreService,
90 this.click$ = new Subject<string>();
91 this.onChange = new EventEmitter<ComboboxEntry>();
92 this.defaultSelectionApplied = false;
94 this.formatDisplayString = (result: ComboboxEntry) => {
95 return result.label.trim();
103 // Give the input a chance to focus then fire the click
104 // handler to force open the typeahead
105 this.elm.nativeElement.getElementsByTagName('input')[0].focus();
106 setTimeout(() => this.click$.next(''));
109 // Apply a default selection where needed
113 this.entrylist && !this.defaultSelectionApplied) {
116 this.entrylist.filter(e => e.id === this.startId)[0];
119 this.selected = entry;
120 this.defaultSelectionApplied = true;
121 if (this.startIdFiresOnChange) {
122 this.selectorChanged(
123 {item: this.selected, preventDefault: () => true});
129 // Called by combobox-entry.component
130 addEntry(entry: ComboboxEntry) {
131 this.entrylist.push(entry);
132 this.applySelection();
135 // Manually set the selected value by ID.
136 // This does NOT fire the onChange handler.
137 applyEntryId(entryId: any) {
138 this.selected = this.entrylist.filter(e => e.id === entryId)[0];
142 // When the selected value is a string it means we have either
143 // no value (user cleared the input) or a free-text value.
145 if (typeof this.selected === 'string') {
147 if (this.allowFreeText && this.selected !== '') {
148 // Free text entered which does not match a known entry
149 // translate it into a dummy ComboboxEntry
152 label: this.selected,
158 this.selected = null;
161 // Manually fire the onchange since NgbTypeahead fails
162 // to fire the onchange when the value is cleared.
163 this.selectorChanged(
164 {item: this.selected, preventDefault: () => true});
168 // Fired by the typeahead to inform us of a change.
169 selectorChanged(selEvent: NgbTypeaheadSelectItemEvent) {
170 this.onChange.emit(selEvent.item);
173 // Adds matching async entries to the entry list
174 // and propagates the search term for pipelining.
175 addAsyncEntries(term: string): Observable<string> {
177 if (!term || !this.asyncDataSource) {
181 return new Observable(observer => {
182 this.asyncDataSource(term).subscribe(
183 (entry: ComboboxEntry) => {
184 if (!this.asyncIds['' + entry.id]) {
185 this.asyncIds['' + entry.id] = true;
186 this.addEntry(entry);
198 filter = (text$: Observable<string>): Observable<ComboboxEntry[]> => {
201 distinctUntilChanged(),
203 // Merge click actions in with the stream of text entry
205 // Inject a specifier indicating the source of the
206 // action is a user click instead of a text entry.
207 // This tells the filter to show all values in sync mode.
208 this.click$.pipe(filter(() =>
209 !this.instance.isPopupOpen() && !this.asyncDataSource
210 )).pipe(mapTo('_CLICK_'))
213 // mergeMap coalesces an observable into our stream.
214 mergeMap(term => this.addAsyncEntries(term)),
215 map((term: string) => {
217 if (term === '' || term === '_CLICK_') {
218 if (this.asyncDataSource) {
221 // In sync mode, a post-focus empty search or
222 // click event displays the whole list.
223 return this.entrylist;
227 // Filter entrylist whose labels substring-match the
229 return this.entrylist.filter(entry =>
230 entry.label.toLowerCase().indexOf(term.toLowerCase()) > -1