quang 19.3.15-2 → 19.3.15-3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { NgClass, NgStyle } from '@angular/common';
|
|
2
2
|
import * as i0 from '@angular/core';
|
|
3
|
-
import { input, viewChild, signal, computed,
|
|
3
|
+
import { input, output, viewChild, signal, computed, effect, forwardRef, ChangeDetectionStrategy, Component } from '@angular/core';
|
|
4
4
|
import { toObservable, takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
5
5
|
import { NG_VALUE_ACCESSOR } from '@angular/forms';
|
|
6
6
|
import { TranslocoPipe } from '@jsverse/transloco';
|
|
@@ -17,206 +17,369 @@ import { QuangBaseComponent, OptionListParentType, QuangOptionListComponent } fr
|
|
|
17
17
|
* `searchTextDebounce` is by default set to 300ms.
|
|
18
18
|
*/
|
|
19
19
|
class QuangAutocompleteComponent extends QuangBaseComponent {
|
|
20
|
+
/**
|
|
21
|
+
* Finds an option whose label exactly matches the given text (case-insensitive, trimmed).
|
|
22
|
+
* @param text The text to match against option labels
|
|
23
|
+
* @returns The matching option, or undefined if no match
|
|
24
|
+
*/
|
|
25
|
+
findMatchingOption(text) {
|
|
26
|
+
if (!text)
|
|
27
|
+
return undefined;
|
|
28
|
+
const searchTextLower = text.trim().toLowerCase();
|
|
29
|
+
return this.selectOptions().find((x) => x.label.trim().toLowerCase() === searchTextLower);
|
|
30
|
+
}
|
|
31
|
+
// ============================================
|
|
32
|
+
// CONSTRUCTOR
|
|
33
|
+
// ============================================
|
|
20
34
|
constructor() {
|
|
21
35
|
super();
|
|
22
|
-
//
|
|
36
|
+
// ============================================
|
|
37
|
+
// INPUTS - Configuration properties
|
|
38
|
+
// ============================================
|
|
39
|
+
/**
|
|
40
|
+
* The list of options to display in the autocomplete dropdown.
|
|
41
|
+
*/
|
|
42
|
+
this.selectOptions = input.required();
|
|
43
|
+
/**
|
|
44
|
+
* When true, allows any text input as a valid form value, not just option values.
|
|
45
|
+
* The form value will sync with whatever text the user types.
|
|
46
|
+
* When false (default), the form value must match one of the option values.
|
|
47
|
+
* @default false
|
|
48
|
+
*/
|
|
49
|
+
this.allowFreeText = input(false);
|
|
50
|
+
/**
|
|
51
|
+
* When true and allowFreeText is false, automatically selects an option if the user's
|
|
52
|
+
* input text matches an option's label exactly (case-insensitive, trimmed).
|
|
53
|
+
* This provides a better UX by auto-selecting when users type a complete option label.
|
|
54
|
+
* @default true
|
|
55
|
+
*/
|
|
56
|
+
this.autoSelectOnExactMatch = input(true);
|
|
57
|
+
/**
|
|
58
|
+
* When true, updates the form value as the user types (after debounce).
|
|
59
|
+
* When false (default), the form value is only updated when:
|
|
60
|
+
* - User selects an option from the dropdown
|
|
61
|
+
* - User stops typing and the input loses focus (blur)
|
|
62
|
+
* @default false
|
|
63
|
+
*/
|
|
64
|
+
this.updateValueOnType = input(false);
|
|
65
|
+
/**
|
|
66
|
+
* Whether the form value can be any text or must match one of the options.
|
|
67
|
+
* When true, the form value syncs with the input text.
|
|
68
|
+
* When false (default), the form value must be one of the option values.
|
|
69
|
+
* @default false
|
|
70
|
+
* @deprecated Use `allowFreeText` instead. This input will be removed in a future version.
|
|
71
|
+
*/
|
|
23
72
|
this.syncFormWithText = input(false);
|
|
73
|
+
/**
|
|
74
|
+
* Maximum height of the option list before scrolling.
|
|
75
|
+
* @default '200px'
|
|
76
|
+
*/
|
|
24
77
|
this.optionListMaxHeight = input('200px');
|
|
25
|
-
|
|
78
|
+
/**
|
|
79
|
+
* Whether to translate option labels.
|
|
80
|
+
* @default true
|
|
81
|
+
*/
|
|
26
82
|
this.translateValue = input(true);
|
|
83
|
+
/**
|
|
84
|
+
* Scroll behavior when the option list opens.
|
|
85
|
+
* @default 'smooth'
|
|
86
|
+
*/
|
|
27
87
|
this.scrollBehaviorOnOpen = input('smooth');
|
|
28
88
|
/**
|
|
29
|
-
*
|
|
89
|
+
* When true, only emits the value without saving it to ngControl.
|
|
90
|
+
* @default false
|
|
30
91
|
*/
|
|
31
92
|
this.emitOnly = input(false);
|
|
93
|
+
/**
|
|
94
|
+
* Enable multiple selection mode with chips.
|
|
95
|
+
* @default false
|
|
96
|
+
*/
|
|
32
97
|
this.multiple = input(false);
|
|
33
98
|
/**
|
|
34
|
-
*
|
|
99
|
+
* Maximum length in characters for chip display text.
|
|
100
|
+
* When set, chips will be truncated and show a tooltip with full text.
|
|
101
|
+
* @default 0 (no limit)
|
|
35
102
|
*/
|
|
36
103
|
this.chipMaxLength = input(0);
|
|
104
|
+
/**
|
|
105
|
+
* Layout direction for chips in multiple selection mode.
|
|
106
|
+
* @default 'vertical'
|
|
107
|
+
*/
|
|
37
108
|
this.multiSelectDisplayMode = input('vertical');
|
|
109
|
+
/**
|
|
110
|
+
* Debounce time in milliseconds for search text changes.
|
|
111
|
+
* @default 300
|
|
112
|
+
*/
|
|
113
|
+
this.searchTextDebounce = input(300);
|
|
114
|
+
/**
|
|
115
|
+
* Whether to filter options internally based on input text.
|
|
116
|
+
* When false, filtering should be handled externally via searchTextChange event.
|
|
117
|
+
* @default true
|
|
118
|
+
*/
|
|
119
|
+
this.internalFilterOptions = input(true);
|
|
120
|
+
// ============================================
|
|
121
|
+
// OUTPUTS - Event emitters
|
|
122
|
+
// ============================================
|
|
123
|
+
/**
|
|
124
|
+
* Emitted when an option is selected.
|
|
125
|
+
* Emits the selected option's value, or null when cleared.
|
|
126
|
+
*/
|
|
127
|
+
this.selectedOption = output();
|
|
128
|
+
/**
|
|
129
|
+
* Emitted when the search text changes (after debounce).
|
|
130
|
+
* Useful for external filtering or API calls.
|
|
131
|
+
*/
|
|
132
|
+
this.searchTextChange = output();
|
|
133
|
+
// ============================================
|
|
134
|
+
// VIEW CHILDREN - Template references
|
|
135
|
+
// ============================================
|
|
136
|
+
/** Reference to the option list component */
|
|
38
137
|
this.optionList = viewChild('optionList');
|
|
39
|
-
|
|
40
|
-
this.
|
|
41
|
-
|
|
42
|
-
this.
|
|
43
|
-
|
|
44
|
-
this.
|
|
45
|
-
|
|
46
|
-
|
|
138
|
+
/** Reference to the input element */
|
|
139
|
+
this.selectInput = viewChild('selectInput');
|
|
140
|
+
/** Reference to the chip container element */
|
|
141
|
+
this.chipContainer = viewChild('chipContainer');
|
|
142
|
+
/** Reference to the main autocomplete container */
|
|
143
|
+
this.autocompleteContainer = viewChild('autocompleteContainer');
|
|
144
|
+
// ============================================
|
|
145
|
+
// PUBLIC STATE - Used in template
|
|
146
|
+
// ============================================
|
|
147
|
+
/** Constant for option list parent type */
|
|
148
|
+
this.ParentType = OptionListParentType.AUTOCOMPLETE;
|
|
149
|
+
/** Height of the input element (used for positioning) */
|
|
150
|
+
this.inputHeight = signal(0);
|
|
151
|
+
/**
|
|
152
|
+
* The display text for the input field.
|
|
153
|
+
* - When searching: shows what the user typed
|
|
154
|
+
* - When allowFreeText/syncFormWithText is true and no matching option: shows the raw value
|
|
155
|
+
* - When not searching: shows the selected option's label (derived from _value)
|
|
156
|
+
*/
|
|
157
|
+
this._inputValue = computed(() => {
|
|
158
|
+
if (this._isSearching()) {
|
|
159
|
+
return this._userSearchText();
|
|
160
|
+
}
|
|
161
|
+
// Derive display text from _value by finding the matching option
|
|
47
162
|
const value = this._value();
|
|
48
|
-
if (
|
|
49
|
-
|
|
163
|
+
if (value === null || value === undefined || value === '' || Array.isArray(value)) {
|
|
164
|
+
return '';
|
|
50
165
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
166
|
+
const option = this.selectOptions().find((x) => x.value === value);
|
|
167
|
+
// When free text is allowed and no matching option found, display the value itself
|
|
168
|
+
// (since the value IS the text the user typed)
|
|
169
|
+
if (!option && this._allowFreeTextInternal()) {
|
|
170
|
+
return String(value);
|
|
55
171
|
}
|
|
172
|
+
return option?.label ?? '';
|
|
56
173
|
});
|
|
174
|
+
/** Whether the option list is currently visible */
|
|
175
|
+
this._showOptions = signal(null);
|
|
176
|
+
/** List of selected chip values (for multiple mode) */
|
|
177
|
+
this._chipList = signal([]);
|
|
178
|
+
/** List of selected option objects (for multiple mode) */
|
|
179
|
+
this._selectedOptions = signal([]);
|
|
180
|
+
/** Filtered options based on search text and chip selection */
|
|
57
181
|
this._filteredOptions = computed(() => {
|
|
58
|
-
const
|
|
182
|
+
const searchText = this._isSearching() ? this._userSearchText() : '';
|
|
59
183
|
if (this.multiple()) {
|
|
60
|
-
return this.filterOptions(
|
|
184
|
+
return this.filterOptions(searchText).filter((x) => !this._chipList().some((chip) => chip === x.value));
|
|
61
185
|
}
|
|
62
|
-
|
|
63
|
-
|
|
186
|
+
return searchText?.length ? this.filterOptions(searchText) : this.selectOptions();
|
|
187
|
+
});
|
|
188
|
+
/**
|
|
189
|
+
* The value to use for highlighting in the option list.
|
|
190
|
+
* When searching: shows the matched option (if any) based on exact label match
|
|
191
|
+
* When not searching: shows the current form value
|
|
192
|
+
* This keeps highlighting in sync with what will be selected on blur.
|
|
193
|
+
*/
|
|
194
|
+
this._highlightedValue = computed(() => {
|
|
195
|
+
if (this._isSearching() && !this.multiple()) {
|
|
196
|
+
const searchText = this._userSearchText();
|
|
197
|
+
if (!searchText?.trim()) {
|
|
198
|
+
// If search text is empty, don't highlight anything
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
// Find exact match for highlighting
|
|
202
|
+
const matchingOption = this.findMatchingOption(searchText);
|
|
203
|
+
if (matchingOption && this.autoSelectOnExactMatch()) {
|
|
204
|
+
return matchingOption.value;
|
|
205
|
+
}
|
|
206
|
+
// If free text is allowed, don't highlight any option
|
|
207
|
+
// (the typed text itself will be the value)
|
|
208
|
+
return null;
|
|
64
209
|
}
|
|
210
|
+
// When not searching, use the current value
|
|
211
|
+
return this._value();
|
|
65
212
|
});
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
this.
|
|
71
|
-
|
|
72
|
-
this.
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
213
|
+
// ============================================
|
|
214
|
+
// PROTECTED STATE - Internal but accessible to subclasses
|
|
215
|
+
// ============================================
|
|
216
|
+
/** Whether the user is actively typing/searching */
|
|
217
|
+
this._isSearching = signal(false);
|
|
218
|
+
/** The text the user is currently typing while searching */
|
|
219
|
+
this._userSearchText = signal('');
|
|
220
|
+
/**
|
|
221
|
+
* Internal computed that returns true if free text input is allowed.
|
|
222
|
+
* Combines both `allowFreeText` and deprecated `syncFormWithText` inputs.
|
|
223
|
+
*/
|
|
224
|
+
this._allowFreeTextInternal = computed(() => {
|
|
225
|
+
return this.allowFreeText() || this.syncFormWithText();
|
|
226
|
+
});
|
|
227
|
+
// ============================================
|
|
228
|
+
// PRIVATE STATE - Internal implementation details
|
|
229
|
+
// ============================================
|
|
230
|
+
/** Timer for search text debounce */
|
|
76
231
|
this._searchDebounceTimer = null;
|
|
232
|
+
/** Last emitted search text (for distinctUntilChanged behavior) */
|
|
77
233
|
this._lastEmittedSearchText = null;
|
|
234
|
+
/** Whether the component has been destroyed */
|
|
78
235
|
this._isDestroyed = false;
|
|
79
|
-
|
|
236
|
+
/** Subscription to form value changes */
|
|
237
|
+
this.formValueChangeSubscription = undefined;
|
|
238
|
+
// ============================================
|
|
239
|
+
// EFFECTS - Reactive side effects
|
|
240
|
+
// ============================================
|
|
241
|
+
/** Effect to handle input element setup and keyboard events */
|
|
242
|
+
this.onChangeSelectInputEffect = effect(() => {
|
|
80
243
|
const selectInput = this.selectInput();
|
|
81
244
|
if (selectInput) {
|
|
82
|
-
this.inputHeight.set(selectInput
|
|
245
|
+
this.inputHeight.set(selectInput.nativeElement.getBoundingClientRect().height);
|
|
83
246
|
selectInput.nativeElement.addEventListener('keydown', (e) => {
|
|
84
|
-
|
|
85
|
-
e.preventDefault();
|
|
86
|
-
// this.deleteChip(this._chipList()[this._chipList().length - 1])
|
|
87
|
-
const chipContainer = this.chipContainer()?.nativeElement;
|
|
88
|
-
if (chipContainer) {
|
|
89
|
-
const chips = chipContainer.querySelectorAll('.chip button.btn-chip');
|
|
90
|
-
if (chips.length > 0) {
|
|
91
|
-
const focusChip = chips[chips.length - 1];
|
|
92
|
-
focusChip.focus();
|
|
93
|
-
focusChip.addEventListener('keydown', (event) => {
|
|
94
|
-
if (event.key === 'Backspace') {
|
|
95
|
-
event.preventDefault();
|
|
96
|
-
this.deleteChip(this._chipList()[this._chipList().length - 1]);
|
|
97
|
-
selectInput.nativeElement.focus();
|
|
98
|
-
}
|
|
99
|
-
else {
|
|
100
|
-
event.preventDefault();
|
|
101
|
-
}
|
|
102
|
-
});
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
}
|
|
247
|
+
this.handleInputKeydown(e, selectInput.nativeElement);
|
|
106
248
|
});
|
|
107
249
|
}
|
|
108
250
|
});
|
|
109
|
-
|
|
110
|
-
this.
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
251
|
+
/** Subscription to options changes */
|
|
252
|
+
this.selectOptionsChangeSubscription = toObservable(this.selectOptions)
|
|
253
|
+
.pipe(takeUntilDestroyed())
|
|
254
|
+
.subscribe(() => {
|
|
255
|
+
this.handleOptionsChange();
|
|
115
256
|
});
|
|
116
|
-
|
|
257
|
+
/** Subscription to show options changes */
|
|
258
|
+
this.showOptionsChangeSubscription = toObservable(this._showOptions)
|
|
117
259
|
.pipe(takeUntilDestroyed())
|
|
118
260
|
.subscribe((data) => {
|
|
119
|
-
|
|
120
|
-
|
|
261
|
+
// Note: Form value processing is now handled directly in onBlurHandler
|
|
262
|
+
// for immediate processing. This subscription is kept for backwards compatibility
|
|
263
|
+
// but the _isSearching check prevents double-processing since onBlurHandler
|
|
264
|
+
// already sets _isSearching to false before this subscription fires.
|
|
265
|
+
if (!data && data !== null && this._isSearching()) {
|
|
266
|
+
// Only process if still in search mode (which means onBlurHandler didn't run)
|
|
267
|
+
this.processTextToFormValue(this._userSearchText(), {
|
|
268
|
+
exitSearchMode: true,
|
|
269
|
+
updateOnMatch: true,
|
|
270
|
+
clearSearchText: true,
|
|
271
|
+
});
|
|
121
272
|
}
|
|
122
273
|
});
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
*/
|
|
128
|
-
emitDebouncedSearchText(value) {
|
|
129
|
-
// Clear any pending debounce timer
|
|
130
|
-
if (this._searchDebounceTimer) {
|
|
131
|
-
clearTimeout(this._searchDebounceTimer);
|
|
132
|
-
}
|
|
133
|
-
// Set up new debounce timer
|
|
134
|
-
this._searchDebounceTimer = setTimeout(() => {
|
|
135
|
-
if (this._isDestroyed)
|
|
136
|
-
return;
|
|
137
|
-
// Check distinctUntilChanged
|
|
138
|
-
if (value !== this._lastEmittedSearchText) {
|
|
139
|
-
this._lastEmittedSearchText = value;
|
|
140
|
-
this.searchTextChange.emit(value || '');
|
|
141
|
-
if (this.syncFormWithText()) {
|
|
142
|
-
this.onValueChange(value, false);
|
|
143
|
-
}
|
|
144
|
-
if (!value?.length && !this.emitOnly() && !this.multiple()) {
|
|
145
|
-
this._ngControl()?.control?.patchValue(null);
|
|
146
|
-
}
|
|
274
|
+
this.destroyRef.onDestroy(() => {
|
|
275
|
+
this._isDestroyed = true;
|
|
276
|
+
if (this._searchDebounceTimer) {
|
|
277
|
+
clearTimeout(this._searchDebounceTimer);
|
|
147
278
|
}
|
|
148
|
-
}
|
|
279
|
+
});
|
|
149
280
|
}
|
|
281
|
+
// ============================================
|
|
282
|
+
// LIFECYCLE HOOKS / OVERRIDES
|
|
283
|
+
// ============================================
|
|
150
284
|
setupFormControl() {
|
|
151
285
|
super.setupFormControl();
|
|
152
286
|
const formControl = this._ngControl()?.control;
|
|
153
|
-
if (this.
|
|
154
|
-
this.
|
|
155
|
-
this.
|
|
287
|
+
if (this.formValueChangeSubscription) {
|
|
288
|
+
this.formValueChangeSubscription.unsubscribe();
|
|
289
|
+
this.formValueChangeSubscription = undefined;
|
|
156
290
|
}
|
|
157
291
|
if (formControl) {
|
|
158
|
-
this.
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
value.forEach((x) => {
|
|
163
|
-
this.onSelectValue(x);
|
|
164
|
-
});
|
|
165
|
-
}
|
|
166
|
-
else if (!this.multiple() && (typeof value === 'string' || typeof value === 'number')) {
|
|
167
|
-
// When syncFormWithText is true and user is actively searching (options visible),
|
|
168
|
-
// don't overwrite their typed text with the option label from an externally set value
|
|
169
|
-
if (!(this.syncFormWithText() && this._showOptions())) {
|
|
170
|
-
this.setInputValue();
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
if (!this.syncFormWithText() && !value) {
|
|
174
|
-
this._inputValue.set('');
|
|
175
|
-
}
|
|
292
|
+
this.formValueChangeSubscription = formControl.valueChanges
|
|
293
|
+
.pipe(takeUntilDestroyed(this.destroyRef))
|
|
294
|
+
.subscribe((value) => {
|
|
295
|
+
this.handleFormValueChange(value);
|
|
176
296
|
});
|
|
177
297
|
}
|
|
178
298
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
299
|
+
writeValue(val) {
|
|
300
|
+
// Simply update the value - _inputValue is computed and will automatically
|
|
301
|
+
// show the correct display text based on _value and _isSearching state.
|
|
302
|
+
super.writeValue(val);
|
|
303
|
+
// Handle array values for multiple mode
|
|
304
|
+
if (Array.isArray(val)) {
|
|
305
|
+
val.forEach((x) => {
|
|
306
|
+
this.handleSelectValue(x);
|
|
307
|
+
});
|
|
183
308
|
}
|
|
184
|
-
this._showOptions.set(true);
|
|
185
309
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
310
|
+
onChangedHandler(value) {
|
|
311
|
+
super.onChangedHandler(value);
|
|
312
|
+
// Exit search mode - _inputValue will now derive from _value
|
|
313
|
+
// Note: Don't clear _userSearchText here - it's needed for checkInputValue matching
|
|
314
|
+
this._isSearching.set(false);
|
|
315
|
+
}
|
|
316
|
+
onBlurHandler() {
|
|
317
|
+
// Process form value and exit search mode immediately to avoid visual glitches
|
|
318
|
+
this.processTextToFormValue(this._userSearchText(), {
|
|
319
|
+
exitSearchMode: true,
|
|
320
|
+
updateOnMatch: true,
|
|
321
|
+
clearSearchText: true,
|
|
322
|
+
});
|
|
323
|
+
// Hide dropdown immediately - click events on options are protected by onBlurInput
|
|
324
|
+
// which checks if focus moved to the option list before calling this handler
|
|
325
|
+
this.hideOptionVisibility();
|
|
326
|
+
super.onBlurHandler();
|
|
327
|
+
}
|
|
328
|
+
// ============================================
|
|
329
|
+
// PUBLIC METHODS - Used in template
|
|
330
|
+
// ============================================
|
|
331
|
+
/**
|
|
332
|
+
* Shows the option list dropdown.
|
|
333
|
+
*/
|
|
334
|
+
showOptionVisibility() {
|
|
335
|
+
this._showOptions.set(true);
|
|
336
|
+
// Initialize _userSearchText with current input value when showing options
|
|
337
|
+
// This ensures that if user focuses and blurs without typing, the value is preserved
|
|
338
|
+
// Also enter search mode to enable filtering
|
|
339
|
+
if (!this._isSearching()) {
|
|
340
|
+
const currentInputValue = this._inputValue();
|
|
341
|
+
this._userSearchText.set(currentInputValue || '');
|
|
342
|
+
this._isSearching.set(true);
|
|
189
343
|
}
|
|
190
|
-
this._optionHideTimeout.set(setTimeout(() => {
|
|
191
|
-
this._showOptions.set(false);
|
|
192
|
-
}, skipTimeout ? 0 : 50));
|
|
193
344
|
}
|
|
345
|
+
/**
|
|
346
|
+
* Hides the option list dropdown.
|
|
347
|
+
*/
|
|
348
|
+
hideOptionVisibility() {
|
|
349
|
+
this._showOptions.set(false);
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Handles input text changes (typing).
|
|
353
|
+
* @param event The input event
|
|
354
|
+
*/
|
|
194
355
|
onChangeInput(event) {
|
|
195
356
|
this.showOptionVisibility();
|
|
196
357
|
const value = event.target?.value ?? '';
|
|
197
|
-
this.
|
|
358
|
+
this._isSearching.set(true);
|
|
359
|
+
this._userSearchText.set(value);
|
|
198
360
|
this.emitDebouncedSearchText(value);
|
|
199
361
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
return options;
|
|
206
|
-
}
|
|
207
|
-
onChangedHandler(value) {
|
|
208
|
-
super.onChangedHandler(value);
|
|
209
|
-
this.setInputValue();
|
|
210
|
-
}
|
|
362
|
+
/**
|
|
363
|
+
* Handles option selection from the dropdown.
|
|
364
|
+
* @param value The selected option's value
|
|
365
|
+
* @param hideOptions Whether to hide the dropdown after selection
|
|
366
|
+
*/
|
|
211
367
|
onValueChange(value, hideOptions = true) {
|
|
212
368
|
if (this.multiple()) {
|
|
213
|
-
this.
|
|
369
|
+
this.handleSelectValue(value);
|
|
214
370
|
this.onChangedHandler(this._chipList());
|
|
215
371
|
if (this._chipList().some((x) => x === value)) {
|
|
216
|
-
this.
|
|
372
|
+
this._userSearchText.set('');
|
|
373
|
+
this._isSearching.set(false);
|
|
217
374
|
}
|
|
218
375
|
}
|
|
219
376
|
else {
|
|
377
|
+
// Update _userSearchText to the selected option's label
|
|
378
|
+
// This enables checkInputValue to match correctly on blur
|
|
379
|
+
const selectedOption = this.selectOptions().find((x) => x.value === value);
|
|
380
|
+
if (selectedOption) {
|
|
381
|
+
this._userSearchText.set(selectedOption.label);
|
|
382
|
+
}
|
|
220
383
|
this.onChangedHandler(value);
|
|
221
384
|
if (hideOptions) {
|
|
222
385
|
this.hideOptionVisibility();
|
|
@@ -224,91 +387,235 @@ class QuangAutocompleteComponent extends QuangBaseComponent {
|
|
|
224
387
|
this.selectedOption.emit(value);
|
|
225
388
|
}
|
|
226
389
|
}
|
|
227
|
-
|
|
228
|
-
|
|
390
|
+
/**
|
|
391
|
+
* Handles input blur event.
|
|
392
|
+
* @param event The focus event
|
|
393
|
+
*/
|
|
394
|
+
onBlurInput(event) {
|
|
395
|
+
const relatedTarget = event.relatedTarget;
|
|
396
|
+
const optionListId = this.optionList()?.optionListContainer()?.nativeElement?.id;
|
|
397
|
+
if (relatedTarget?.id !== optionListId) {
|
|
398
|
+
this.onBlurHandler();
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Handles blur event on the option list.
|
|
403
|
+
* @param event The blur event (truthy if should hide)
|
|
404
|
+
*/
|
|
405
|
+
onBlurOptionList(event) {
|
|
406
|
+
if (event) {
|
|
407
|
+
this.hideOptionVisibility();
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Gets the display description for a chip value.
|
|
412
|
+
* @param chipValue The chip's value
|
|
413
|
+
* @returns The chip's display label
|
|
414
|
+
*/
|
|
415
|
+
getDescription(chipValue) {
|
|
416
|
+
const option = this.selectOptions().find((x) => x.value === chipValue);
|
|
417
|
+
return option?.label?.toString() ?? '';
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Removes a chip from the selection (multiple mode).
|
|
421
|
+
* @param chipValue The chip value to remove
|
|
422
|
+
*/
|
|
423
|
+
deleteChip(chipValue) {
|
|
424
|
+
const stringChipValue = chipValue?.toString();
|
|
425
|
+
const index = this._chipList().findIndex((x) => x.toString() === stringChipValue);
|
|
426
|
+
if (index >= 0) {
|
|
427
|
+
this._chipList.update((list) => list.filter((_, i) => i !== index));
|
|
428
|
+
this.onChangedHandler(this._chipList());
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
// ============================================
|
|
432
|
+
// PROTECTED METHODS - Internal logic, accessible to subclasses
|
|
433
|
+
// ============================================
|
|
434
|
+
/**
|
|
435
|
+
* Filters options based on input text.
|
|
436
|
+
* @param value The search text
|
|
437
|
+
* @returns Filtered options
|
|
438
|
+
*/
|
|
439
|
+
filterOptions(value) {
|
|
440
|
+
const options = this.selectOptions();
|
|
441
|
+
if (this.internalFilterOptions()) {
|
|
442
|
+
return options.filter((x) => x.label.toLowerCase().includes(value.toLowerCase()));
|
|
443
|
+
}
|
|
444
|
+
return options;
|
|
445
|
+
}
|
|
446
|
+
// ============================================
|
|
447
|
+
// PRIVATE METHODS - Internal implementation
|
|
448
|
+
// ============================================
|
|
449
|
+
/**
|
|
450
|
+
* Core method that processes text input and updates form value accordingly.
|
|
451
|
+
*
|
|
452
|
+
* Matching logic:
|
|
453
|
+
* - If text matches an option label (case-insensitive, trimmed) and autoSelectOnExactMatch is true, select that option
|
|
454
|
+
* - If no match and allowFreeText is true, use the typed text as value
|
|
455
|
+
* - If no match and allowFreeText is false, clear the value
|
|
456
|
+
*
|
|
457
|
+
* @param text The text to process
|
|
458
|
+
* @param options Configuration options:
|
|
459
|
+
* - exitSearchMode: If true, uses onChangedHandler which exits search mode. If false, stays in search mode.
|
|
460
|
+
* - updateOnMatch: If true, updates form when match found. If false, only clears on no-match.
|
|
461
|
+
* - clearSearchText: If true, clears _userSearchText after processing.
|
|
462
|
+
*/
|
|
463
|
+
processTextToFormValue(text, options) {
|
|
464
|
+
const searchText = text?.trim();
|
|
465
|
+
// Find matching option: exact match (case-insensitive, trimmed)
|
|
466
|
+
const matchingOption = this.findMatchingOption(text);
|
|
229
467
|
if (!this.multiple()) {
|
|
230
|
-
|
|
468
|
+
// If the found option is already selected, nothing to do except exit search mode
|
|
469
|
+
if (matchingOption?.value === this._value()) {
|
|
470
|
+
if (options.exitSearchMode) {
|
|
471
|
+
this._isSearching.set(false);
|
|
472
|
+
}
|
|
473
|
+
if (options.clearSearchText) {
|
|
474
|
+
this._userSearchText.set('');
|
|
475
|
+
}
|
|
231
476
|
return;
|
|
232
|
-
if (option) {
|
|
233
|
-
this.onChangedHandler(option.value ?? '');
|
|
234
477
|
}
|
|
235
|
-
|
|
478
|
+
// Determine what action to take based on match status and settings
|
|
479
|
+
const shouldAutoSelect = matchingOption && this.autoSelectOnExactMatch() && options.updateOnMatch;
|
|
480
|
+
const shouldUseFreeText = this._allowFreeTextInternal() && searchText && options.updateOnMatch;
|
|
481
|
+
// Clear logic differs between typing and blur:
|
|
482
|
+
// - On blur (exitSearchMode=true): clear when no valid selection and free text not allowed
|
|
483
|
+
// - During typing (exitSearchMode=false): only clear when updateOnMatch is true and text doesn't match
|
|
484
|
+
const shouldClearOnBlur = options.exitSearchMode && !this._allowFreeTextInternal() && (!matchingOption || !this.autoSelectOnExactMatch());
|
|
485
|
+
const shouldClearWhileTyping = !options.exitSearchMode && options.updateOnMatch && !matchingOption && !this._allowFreeTextInternal();
|
|
486
|
+
if (shouldAutoSelect) {
|
|
487
|
+
// Auto-select the matching option
|
|
488
|
+
if (options.exitSearchMode) {
|
|
489
|
+
this.onChangedHandler(matchingOption.value ?? '');
|
|
490
|
+
}
|
|
491
|
+
else {
|
|
492
|
+
this.onValueChange(matchingOption.value ?? '', false);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
else if (shouldUseFreeText) {
|
|
496
|
+
// Free text allowed: use the typed text as value
|
|
497
|
+
if (options.exitSearchMode) {
|
|
498
|
+
this.onChangedHandler(searchText);
|
|
499
|
+
}
|
|
500
|
+
else {
|
|
501
|
+
this.onValueChange(searchText, false);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
else if (shouldClearOnBlur) {
|
|
505
|
+
// On blur, no valid selection possible: clear the value
|
|
236
506
|
this.onChangedHandler('');
|
|
237
507
|
}
|
|
508
|
+
else if (shouldClearWhileTyping) {
|
|
509
|
+
// While typing, text doesn't match any option: clear the value but stay in search mode
|
|
510
|
+
this.updateValueWithoutExitingSearchMode('');
|
|
511
|
+
}
|
|
238
512
|
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
// When syncFormWithText is true and user is actively typing/searching,
|
|
242
|
-
// don't let external writeValue calls (e.g., from state management) overwrite the input.
|
|
243
|
-
// This prevents the issue where selecting an option, then typing to search for another,
|
|
244
|
-
// causes the input to revert to the previously selected option's label.
|
|
245
|
-
if (this.syncFormWithText() && this._showOptions() && !Array.isArray(val)) {
|
|
246
|
-
// User is actively searching - preserve their typed text
|
|
247
|
-
// Only update _value without touching _inputValue
|
|
248
|
-
super.writeValue(val);
|
|
249
|
-
return;
|
|
250
|
-
}
|
|
251
|
-
super.writeValue(val);
|
|
252
|
-
this.setInputValue(true);
|
|
253
|
-
if (Array.isArray(val)) {
|
|
254
|
-
val.forEach((x) => {
|
|
255
|
-
this.onSelectValue(x);
|
|
256
|
-
});
|
|
513
|
+
if (options.clearSearchText) {
|
|
514
|
+
this._userSearchText.set('');
|
|
257
515
|
}
|
|
258
516
|
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
this.
|
|
266
|
-
if (
|
|
267
|
-
|
|
517
|
+
/**
|
|
518
|
+
* Handles keyboard events on the input element.
|
|
519
|
+
*/
|
|
520
|
+
handleInputKeydown(e, inputElement) {
|
|
521
|
+
if (this.multiple() && this._chipList().length > 0 && !this._inputValue()?.length && e.key === 'Backspace') {
|
|
522
|
+
e.preventDefault();
|
|
523
|
+
const chipContainerEl = this.chipContainer()?.nativeElement;
|
|
524
|
+
if (chipContainerEl) {
|
|
525
|
+
const chips = chipContainerEl.querySelectorAll('.chip button.btn-chip');
|
|
526
|
+
if (chips.length > 0) {
|
|
527
|
+
const lastChip = chips[chips.length - 1];
|
|
528
|
+
lastChip.focus();
|
|
529
|
+
lastChip.addEventListener('keydown', (event) => {
|
|
530
|
+
if (event.key === 'Backspace') {
|
|
531
|
+
event.preventDefault();
|
|
532
|
+
this.deleteChip(this._chipList()[this._chipList().length - 1]);
|
|
533
|
+
inputElement.focus();
|
|
534
|
+
}
|
|
535
|
+
else {
|
|
536
|
+
event.preventDefault();
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
}
|
|
268
540
|
}
|
|
269
|
-
|
|
270
|
-
}, 100);
|
|
271
|
-
}
|
|
272
|
-
onBlurOptionList(event) {
|
|
273
|
-
if (event)
|
|
274
|
-
this.hideOptionVisibility();
|
|
541
|
+
}
|
|
275
542
|
}
|
|
276
|
-
|
|
277
|
-
|
|
543
|
+
/**
|
|
544
|
+
* Handles changes to the select options input.
|
|
545
|
+
*/
|
|
546
|
+
handleOptionsChange() {
|
|
547
|
+
const value = this._value();
|
|
548
|
+
if (this.multiple() && Array.isArray(value) && value.length > 0) {
|
|
549
|
+
for (const valueElement of value) {
|
|
550
|
+
this.handleSelectValue(valueElement);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
// For single mode: _inputValue is computed, so it automatically updates
|
|
278
554
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
555
|
+
/**
|
|
556
|
+
* Handles form value changes from external sources.
|
|
557
|
+
*/
|
|
558
|
+
handleFormValueChange(value) {
|
|
559
|
+
if (this.multiple() && Array.isArray(value)) {
|
|
560
|
+
this._chipList.set([]);
|
|
561
|
+
this._selectedOptions.set([]);
|
|
562
|
+
value.forEach((x) => {
|
|
563
|
+
this.handleSelectValue(x);
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
// Note: Don't clear _userSearchText here - it's managed by checkInputValue
|
|
567
|
+
// which runs when options are hidden and needs _userSearchText for matching.
|
|
282
568
|
}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
569
|
+
/**
|
|
570
|
+
* Handles selecting a value (adding to chip list in multiple mode).
|
|
571
|
+
*/
|
|
572
|
+
handleSelectValue(value) {
|
|
573
|
+
const option = this.selectOptions().find((x) => x.value === value);
|
|
574
|
+
if (option && !this._chipList().some((x) => x === option.value)) {
|
|
575
|
+
this._chipList.update((list) => [...list, option.value]);
|
|
576
|
+
this._selectedOptions.update((list) => [...list, option]);
|
|
288
577
|
}
|
|
289
578
|
}
|
|
290
579
|
/**
|
|
291
|
-
*
|
|
292
|
-
*
|
|
580
|
+
* Emits search text change after debounce.
|
|
581
|
+
* When `updateValueOnType` is true, also updates the form value using the same
|
|
582
|
+
* matching logic as checkInputValue (auto-select matching options, or use free text).
|
|
293
583
|
*/
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
if (i >= 0) {
|
|
298
|
-
const currentList = this._chipList();
|
|
299
|
-
if (Array.isArray(currentList) && currentList.length > 0) {
|
|
300
|
-
this._chipList.update((list) => list.filter((_, index) => index !== i));
|
|
301
|
-
this.onChangedHandler(this._chipList());
|
|
302
|
-
}
|
|
584
|
+
emitDebouncedSearchText(value) {
|
|
585
|
+
if (this._searchDebounceTimer) {
|
|
586
|
+
clearTimeout(this._searchDebounceTimer);
|
|
303
587
|
}
|
|
588
|
+
this._searchDebounceTimer = setTimeout(() => {
|
|
589
|
+
if (this._isDestroyed) {
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
if (value !== this._lastEmittedSearchText) {
|
|
593
|
+
this._lastEmittedSearchText = value;
|
|
594
|
+
this.searchTextChange.emit(value || '');
|
|
595
|
+
// Update form value based on what the user typed
|
|
596
|
+
// - When updateValueOnType is true: update on both match and no-match
|
|
597
|
+
// - When updateValueOnType is false: only clear the value when text doesn't match
|
|
598
|
+
this.processTextToFormValue(value, {
|
|
599
|
+
exitSearchMode: false,
|
|
600
|
+
updateOnMatch: this.updateValueOnType(),
|
|
601
|
+
clearSearchText: false,
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
}, this.searchTextDebounce());
|
|
304
605
|
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
606
|
+
/**
|
|
607
|
+
* Updates the form value and internal _value signal without exiting search mode.
|
|
608
|
+
* This is used when clearing the value during typing - we want to update the form
|
|
609
|
+
* but keep the user in search mode so they can continue typing.
|
|
610
|
+
*/
|
|
611
|
+
updateValueWithoutExitingSearchMode(value) {
|
|
612
|
+
this._value.set(value);
|
|
613
|
+
if (this.onChange) {
|
|
614
|
+
this.onChange(value);
|
|
308
615
|
}
|
|
309
616
|
}
|
|
310
617
|
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.15", ngImport: i0, type: QuangAutocompleteComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
|
|
311
|
-
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.2.15", type: QuangAutocompleteComponent, isStandalone: true, selector: "quang-autocomplete", inputs: {
|
|
618
|
+
static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.2.15", type: QuangAutocompleteComponent, isStandalone: true, selector: "quang-autocomplete", inputs: { selectOptions: { classPropertyName: "selectOptions", publicName: "selectOptions", isSignal: true, isRequired: true, transformFunction: null }, allowFreeText: { classPropertyName: "allowFreeText", publicName: "allowFreeText", isSignal: true, isRequired: false, transformFunction: null }, autoSelectOnExactMatch: { classPropertyName: "autoSelectOnExactMatch", publicName: "autoSelectOnExactMatch", isSignal: true, isRequired: false, transformFunction: null }, updateValueOnType: { classPropertyName: "updateValueOnType", publicName: "updateValueOnType", isSignal: true, isRequired: false, transformFunction: null }, syncFormWithText: { classPropertyName: "syncFormWithText", publicName: "syncFormWithText", isSignal: true, isRequired: false, transformFunction: null }, optionListMaxHeight: { classPropertyName: "optionListMaxHeight", publicName: "optionListMaxHeight", isSignal: true, isRequired: false, transformFunction: null }, translateValue: { classPropertyName: "translateValue", publicName: "translateValue", isSignal: true, isRequired: false, transformFunction: null }, scrollBehaviorOnOpen: { classPropertyName: "scrollBehaviorOnOpen", publicName: "scrollBehaviorOnOpen", isSignal: true, isRequired: false, transformFunction: null }, emitOnly: { classPropertyName: "emitOnly", publicName: "emitOnly", isSignal: true, isRequired: false, transformFunction: null }, multiple: { classPropertyName: "multiple", publicName: "multiple", isSignal: true, isRequired: false, transformFunction: null }, chipMaxLength: { classPropertyName: "chipMaxLength", publicName: "chipMaxLength", isSignal: true, isRequired: false, transformFunction: null }, multiSelectDisplayMode: { classPropertyName: "multiSelectDisplayMode", publicName: "multiSelectDisplayMode", isSignal: true, isRequired: false, transformFunction: null }, searchTextDebounce: { classPropertyName: "searchTextDebounce", publicName: "searchTextDebounce", isSignal: true, isRequired: false, transformFunction: null }, internalFilterOptions: { classPropertyName: "internalFilterOptions", publicName: "internalFilterOptions", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { selectedOption: "selectedOption", searchTextChange: "searchTextChange" }, providers: [
|
|
312
619
|
{
|
|
313
620
|
provide: NG_VALUE_ACCESSOR,
|
|
314
621
|
useExisting: forwardRef(() => QuangAutocompleteComponent),
|
|
@@ -318,7 +625,7 @@ class QuangAutocompleteComponent extends QuangBaseComponent {
|
|
|
318
625
|
provide: QuangOptionListComponent,
|
|
319
626
|
multi: false,
|
|
320
627
|
},
|
|
321
|
-
], viewQueries: [{ propertyName: "optionList", first: true, predicate: ["optionList"], descendants: true, isSignal: true }, { propertyName: "selectInput", first: true, predicate: ["selectInput"], descendants: true, isSignal: true }, { propertyName: "chipContainer", first: true, predicate: ["chipContainer"], descendants: true, isSignal: true }, { propertyName: "autocompleteContainer", first: true, predicate: ["autocompleteContainer"], descendants: true, isSignal: true }], usesInheritance: true, ngImport: i0, template: "<div\n [ngStyle]=\"{ '--chip-max-length': chipMaxLength() ? chipMaxLength() + 'ch' : 'none' }\"\n #autocompleteContainer\n class=\"autocomplete-container\"\n>\n @if (componentLabel()) {\n <label\n [htmlFor]=\"componentId()\"\n class=\"form-label\"\n >\n {{ componentLabel() | transloco }}\n <span [hidden]=\"!_isRequired()\">*</span>\n </label>\n }\n <div\n [ngClass]=\"multiSelectDisplayMode() === 'horizontal' ? 'horizontal form-control' : ''\"\n #chipContainer\n class=\"container-wrap\"\n >\n @if (multiple() && _chipList().length > 0) {\n @for (chip of _chipList(); track chip) {\n @if (getDescription(chip)) {\n <div\n [quangTooltip]=\"chipMaxLength() ? getDescription(chip) : ''\"\n class=\"chip chip-hover\"\n >\n <p [ngClass]=\"{ 'm-0': isReadonly() || _isDisabled() }\">\n {{ getDescription(chip) }}\n </p>\n @if (!isReadonly() && !_isDisabled()) {\n <button\n [tabIndex]=\"$index + 1\"\n (click)=\"deleteChip(chip)\"\n class=\"btn btn-chip\"\n type=\"button\"\n >\n <svg\n class=\"ionicon\"\n fill=\"currentColor\"\n height=\"24\"\n viewBox=\"0 0 512 512\"\n width=\"24\"\n xmlns=\"http://www.w3.org/2000/svg\"\n >\n <path\n d=\"M368 368L144 144M368 144L144 368\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n stroke-width=\"32\"\n />\n </svg>\n </button>\n }\n </div>\n }\n }\n }\n\n <input\n [attr.required]=\"getIsRequiredControl()\"\n [class.form-control]=\"multiSelectDisplayMode() !== 'horizontal'\"\n [class.is-invalid]=\"_showErrors()\"\n [class.is-valid]=\"_showSuccess()\"\n [disabled]=\"_isDisabled() || isReadonly()\"\n [id]=\"componentId()\"\n [ngClass]=\"componentClass()\"\n [placeholder]=\"componentPlaceholder() | transloco\"\n [tabIndex]=\"componentTabIndex()\"\n [value]=\"_inputValue()\"\n (blur)=\"onBlurInput($event)\"\n (input)=\"onChangeInput($event)\"\n (mousedown)=\"showOptionVisibility()\"\n #selectInput\n autocomplete=\"off\"\n type=\"text\"\n />\n </div>\n @if (_showOptions()) {\n <quang-option-list\n [_isDisabled]=\"_isDisabled()\"\n [_value]=\"
|
|
628
|
+
], viewQueries: [{ propertyName: "optionList", first: true, predicate: ["optionList"], descendants: true, isSignal: true }, { propertyName: "selectInput", first: true, predicate: ["selectInput"], descendants: true, isSignal: true }, { propertyName: "chipContainer", first: true, predicate: ["chipContainer"], descendants: true, isSignal: true }, { propertyName: "autocompleteContainer", first: true, predicate: ["autocompleteContainer"], descendants: true, isSignal: true }], usesInheritance: true, ngImport: i0, template: "<div\n [ngStyle]=\"{ '--chip-max-length': chipMaxLength() ? chipMaxLength() + 'ch' : 'none' }\"\n #autocompleteContainer\n class=\"autocomplete-container\"\n>\n @if (componentLabel()) {\n <label\n [htmlFor]=\"componentId()\"\n class=\"form-label\"\n >\n {{ componentLabel() | transloco }}\n <span [hidden]=\"!_isRequired()\">*</span>\n </label>\n }\n <div\n [ngClass]=\"multiSelectDisplayMode() === 'horizontal' ? 'horizontal form-control' : ''\"\n #chipContainer\n class=\"container-wrap\"\n >\n @if (multiple() && _chipList().length > 0) {\n @for (chip of _chipList(); track chip) {\n @if (getDescription(chip)) {\n <div\n [quangTooltip]=\"chipMaxLength() ? getDescription(chip) : ''\"\n class=\"chip chip-hover\"\n >\n <p [ngClass]=\"{ 'm-0': isReadonly() || _isDisabled() }\">\n {{ getDescription(chip) }}\n </p>\n @if (!isReadonly() && !_isDisabled()) {\n <button\n [tabIndex]=\"$index + 1\"\n (click)=\"deleteChip(chip)\"\n class=\"btn btn-chip\"\n type=\"button\"\n >\n <svg\n class=\"ionicon\"\n fill=\"currentColor\"\n height=\"24\"\n viewBox=\"0 0 512 512\"\n width=\"24\"\n xmlns=\"http://www.w3.org/2000/svg\"\n >\n <path\n d=\"M368 368L144 144M368 144L144 368\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n stroke-width=\"32\"\n />\n </svg>\n </button>\n }\n </div>\n }\n }\n }\n\n <input\n [attr.required]=\"getIsRequiredControl()\"\n [class.form-control]=\"multiSelectDisplayMode() !== 'horizontal'\"\n [class.is-invalid]=\"_showErrors()\"\n [class.is-valid]=\"_showSuccess()\"\n [disabled]=\"_isDisabled() || isReadonly()\"\n [id]=\"componentId()\"\n [ngClass]=\"componentClass()\"\n [placeholder]=\"componentPlaceholder() | transloco\"\n [tabIndex]=\"componentTabIndex()\"\n [value]=\"_inputValue()\"\n (blur)=\"onBlurInput($event)\"\n (input)=\"onChangeInput($event)\"\n (mousedown)=\"showOptionVisibility()\"\n #selectInput\n autocomplete=\"off\"\n type=\"text\"\n />\n </div>\n @if (_showOptions()) {\n <quang-option-list\n [_isDisabled]=\"_isDisabled()\"\n [_value]=\"_highlightedValue()\"\n [componentClass]=\"componentClass()\"\n [componentLabel]=\"componentLabel()\"\n [componentTabIndex]=\"componentTabIndex()\"\n [nullOption]=\"false\"\n [optionListMaxHeight]=\"optionListMaxHeight()\"\n [parentID]=\"componentId()\"\n [parentType]=\"ParentType\"\n [scrollBehaviorOnOpen]=\"scrollBehaviorOnOpen()\"\n [selectButtonRef]=\"autocompleteContainer\"\n [selectOptions]=\"_filteredOptions()\"\n [translateValue]=\"translateValue()\"\n (blurHandler)=\"onBlurOptionList($event)\"\n (changedHandler)=\"onValueChange($event)\"\n #optionList\n selectionMode=\"single\"\n />\n }\n <div class=\"valid-feedback\">\n {{ successMessage() | transloco }}\n </div>\n <div class=\"invalid-feedback\">\n {{ _currentErrorMessage() | transloco: _currentErrorMessageExtraData() }}\n </div>\n @if (helpMessage()) {\n <small\n [hidden]=\"_showSuccess() || _showErrors()\"\n aria-live=\"assertive\"\n class=\"form-text text-muted\"\n >\n {{ helpMessage() | transloco }}\n </small>\n }\n</div>\n", styles: [":host{display:block;--chip-max-length: none}.autocomplete-container{margin-bottom:1rem;position:relative}.chip:has(.btn-chip:disabled):hover{filter:unset;cursor:unset}.container-wrap{display:flex;flex-wrap:wrap;gap:.5rem}.container-wrap.horizontal{display:flex}.container-wrap.horizontal .chip-container{max-width:70%;margin-bottom:0;margin-left:.5rem;flex-wrap:nowrap;white-space:nowrap;overflow-x:auto;position:absolute;align-items:center}.container-wrap.horizontal .chip-container .chip{white-space:nowrap}.container-wrap.horizontal input{min-width:30%;flex:1 1 0;width:auto;border:none}.container-wrap.horizontal input:focus-visible{outline:none}.chip{display:flex;justify-content:space-between;align-items:center;padding:.25rem .5rem;border-radius:16px;color:var(--bs-btn-color);background-color:rgba(var(--bs-primary-rgb),.1);border-width:1px;border-style:solid;border-color:var(--bs-primary-border-subtle);height:2rem}.chip p{margin:0;max-width:var(--chip-max-length);white-space:nowrap;text-overflow:ellipsis;overflow:hidden}.chip .btn-chip{text-align:end;padding:0;min-width:unset}.chip .btn-chip:hover{opacity:80%}.chip .btn-chip:active{border-color:transparent}.chip .btn-chip svg{color:var(--bs-primary);vertical-align:sub}.chip:has(.btn-chip:focus-visible){border-width:2px;filter:brightness(80%)}\n"], dependencies: [{ kind: "pipe", type: TranslocoPipe, name: "transloco" }, { kind: "directive", type: NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "component", type: QuangOptionListComponent, selector: "quang-option-list", inputs: ["selectionMode", "optionListMaxHeight", "selectOptions", "selectButtonRef", "_value", "_isDisabled", "componentClass", "componentLabel", "componentTabIndex", "translateValue", "nullOption", "scrollBehaviorOnOpen", "parentType", "parentID"], outputs: ["changedHandler", "blurHandler"] }, { kind: "directive", type: NgStyle, selector: "[ngStyle]", inputs: ["ngStyle"] }, { kind: "directive", type: QuangTooltipDirective, selector: "[quangTooltip]", inputs: ["quangTooltip", "showMethod"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); }
|
|
322
629
|
}
|
|
323
630
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.15", ngImport: i0, type: QuangAutocompleteComponent, decorators: [{
|
|
324
631
|
type: Component,
|
|
@@ -332,7 +639,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.15", ngImpo
|
|
|
332
639
|
provide: QuangOptionListComponent,
|
|
333
640
|
multi: false,
|
|
334
641
|
},
|
|
335
|
-
], template: "<div\n [ngStyle]=\"{ '--chip-max-length': chipMaxLength() ? chipMaxLength() + 'ch' : 'none' }\"\n #autocompleteContainer\n class=\"autocomplete-container\"\n>\n @if (componentLabel()) {\n <label\n [htmlFor]=\"componentId()\"\n class=\"form-label\"\n >\n {{ componentLabel() | transloco }}\n <span [hidden]=\"!_isRequired()\">*</span>\n </label>\n }\n <div\n [ngClass]=\"multiSelectDisplayMode() === 'horizontal' ? 'horizontal form-control' : ''\"\n #chipContainer\n class=\"container-wrap\"\n >\n @if (multiple() && _chipList().length > 0) {\n @for (chip of _chipList(); track chip) {\n @if (getDescription(chip)) {\n <div\n [quangTooltip]=\"chipMaxLength() ? getDescription(chip) : ''\"\n class=\"chip chip-hover\"\n >\n <p [ngClass]=\"{ 'm-0': isReadonly() || _isDisabled() }\">\n {{ getDescription(chip) }}\n </p>\n @if (!isReadonly() && !_isDisabled()) {\n <button\n [tabIndex]=\"$index + 1\"\n (click)=\"deleteChip(chip)\"\n class=\"btn btn-chip\"\n type=\"button\"\n >\n <svg\n class=\"ionicon\"\n fill=\"currentColor\"\n height=\"24\"\n viewBox=\"0 0 512 512\"\n width=\"24\"\n xmlns=\"http://www.w3.org/2000/svg\"\n >\n <path\n d=\"M368 368L144 144M368 144L144 368\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n stroke-width=\"32\"\n />\n </svg>\n </button>\n }\n </div>\n }\n }\n }\n\n <input\n [attr.required]=\"getIsRequiredControl()\"\n [class.form-control]=\"multiSelectDisplayMode() !== 'horizontal'\"\n [class.is-invalid]=\"_showErrors()\"\n [class.is-valid]=\"_showSuccess()\"\n [disabled]=\"_isDisabled() || isReadonly()\"\n [id]=\"componentId()\"\n [ngClass]=\"componentClass()\"\n [placeholder]=\"componentPlaceholder() | transloco\"\n [tabIndex]=\"componentTabIndex()\"\n [value]=\"_inputValue()\"\n (blur)=\"onBlurInput($event)\"\n (input)=\"onChangeInput($event)\"\n (mousedown)=\"showOptionVisibility()\"\n #selectInput\n autocomplete=\"off\"\n type=\"text\"\n />\n </div>\n @if (_showOptions()) {\n <quang-option-list\n [_isDisabled]=\"_isDisabled()\"\n [_value]=\"
|
|
642
|
+
], template: "<div\n [ngStyle]=\"{ '--chip-max-length': chipMaxLength() ? chipMaxLength() + 'ch' : 'none' }\"\n #autocompleteContainer\n class=\"autocomplete-container\"\n>\n @if (componentLabel()) {\n <label\n [htmlFor]=\"componentId()\"\n class=\"form-label\"\n >\n {{ componentLabel() | transloco }}\n <span [hidden]=\"!_isRequired()\">*</span>\n </label>\n }\n <div\n [ngClass]=\"multiSelectDisplayMode() === 'horizontal' ? 'horizontal form-control' : ''\"\n #chipContainer\n class=\"container-wrap\"\n >\n @if (multiple() && _chipList().length > 0) {\n @for (chip of _chipList(); track chip) {\n @if (getDescription(chip)) {\n <div\n [quangTooltip]=\"chipMaxLength() ? getDescription(chip) : ''\"\n class=\"chip chip-hover\"\n >\n <p [ngClass]=\"{ 'm-0': isReadonly() || _isDisabled() }\">\n {{ getDescription(chip) }}\n </p>\n @if (!isReadonly() && !_isDisabled()) {\n <button\n [tabIndex]=\"$index + 1\"\n (click)=\"deleteChip(chip)\"\n class=\"btn btn-chip\"\n type=\"button\"\n >\n <svg\n class=\"ionicon\"\n fill=\"currentColor\"\n height=\"24\"\n viewBox=\"0 0 512 512\"\n width=\"24\"\n xmlns=\"http://www.w3.org/2000/svg\"\n >\n <path\n d=\"M368 368L144 144M368 144L144 368\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n stroke-width=\"32\"\n />\n </svg>\n </button>\n }\n </div>\n }\n }\n }\n\n <input\n [attr.required]=\"getIsRequiredControl()\"\n [class.form-control]=\"multiSelectDisplayMode() !== 'horizontal'\"\n [class.is-invalid]=\"_showErrors()\"\n [class.is-valid]=\"_showSuccess()\"\n [disabled]=\"_isDisabled() || isReadonly()\"\n [id]=\"componentId()\"\n [ngClass]=\"componentClass()\"\n [placeholder]=\"componentPlaceholder() | transloco\"\n [tabIndex]=\"componentTabIndex()\"\n [value]=\"_inputValue()\"\n (blur)=\"onBlurInput($event)\"\n (input)=\"onChangeInput($event)\"\n (mousedown)=\"showOptionVisibility()\"\n #selectInput\n autocomplete=\"off\"\n type=\"text\"\n />\n </div>\n @if (_showOptions()) {\n <quang-option-list\n [_isDisabled]=\"_isDisabled()\"\n [_value]=\"_highlightedValue()\"\n [componentClass]=\"componentClass()\"\n [componentLabel]=\"componentLabel()\"\n [componentTabIndex]=\"componentTabIndex()\"\n [nullOption]=\"false\"\n [optionListMaxHeight]=\"optionListMaxHeight()\"\n [parentID]=\"componentId()\"\n [parentType]=\"ParentType\"\n [scrollBehaviorOnOpen]=\"scrollBehaviorOnOpen()\"\n [selectButtonRef]=\"autocompleteContainer\"\n [selectOptions]=\"_filteredOptions()\"\n [translateValue]=\"translateValue()\"\n (blurHandler)=\"onBlurOptionList($event)\"\n (changedHandler)=\"onValueChange($event)\"\n #optionList\n selectionMode=\"single\"\n />\n }\n <div class=\"valid-feedback\">\n {{ successMessage() | transloco }}\n </div>\n <div class=\"invalid-feedback\">\n {{ _currentErrorMessage() | transloco: _currentErrorMessageExtraData() }}\n </div>\n @if (helpMessage()) {\n <small\n [hidden]=\"_showSuccess() || _showErrors()\"\n aria-live=\"assertive\"\n class=\"form-text text-muted\"\n >\n {{ helpMessage() | transloco }}\n </small>\n }\n</div>\n", styles: [":host{display:block;--chip-max-length: none}.autocomplete-container{margin-bottom:1rem;position:relative}.chip:has(.btn-chip:disabled):hover{filter:unset;cursor:unset}.container-wrap{display:flex;flex-wrap:wrap;gap:.5rem}.container-wrap.horizontal{display:flex}.container-wrap.horizontal .chip-container{max-width:70%;margin-bottom:0;margin-left:.5rem;flex-wrap:nowrap;white-space:nowrap;overflow-x:auto;position:absolute;align-items:center}.container-wrap.horizontal .chip-container .chip{white-space:nowrap}.container-wrap.horizontal input{min-width:30%;flex:1 1 0;width:auto;border:none}.container-wrap.horizontal input:focus-visible{outline:none}.chip{display:flex;justify-content:space-between;align-items:center;padding:.25rem .5rem;border-radius:16px;color:var(--bs-btn-color);background-color:rgba(var(--bs-primary-rgb),.1);border-width:1px;border-style:solid;border-color:var(--bs-primary-border-subtle);height:2rem}.chip p{margin:0;max-width:var(--chip-max-length);white-space:nowrap;text-overflow:ellipsis;overflow:hidden}.chip .btn-chip{text-align:end;padding:0;min-width:unset}.chip .btn-chip:hover{opacity:80%}.chip .btn-chip:active{border-color:transparent}.chip .btn-chip svg{color:var(--bs-primary);vertical-align:sub}.chip:has(.btn-chip:focus-visible){border-width:2px;filter:brightness(80%)}\n"] }]
|
|
336
643
|
}], ctorParameters: () => [] });
|
|
337
644
|
|
|
338
645
|
/**
|