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, output, effect, forwardRef, ChangeDetectionStrategy, Component } from '@angular/core';
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
- // the form can't be a random text but must be one of the options if this is false
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
- this.selectOptions = input.required();
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
- * Only emits the value without saving it in ngControl
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
- * Set the maximum length in characters of the single chip.
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
- this._showOptions = signal(null);
40
- this._inputValue = signal('');
41
- this._optionHideTimeout = signal(undefined);
42
- this._chipList = signal([]);
43
- this._selectedOptions = signal([]);
44
- this.selectOptionsChange = toObservable(this.selectOptions)
45
- .pipe(takeUntilDestroyed())
46
- .subscribe(() => {
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 (!this.multiple() && !this._inputValue()) {
49
- this.setInputValue();
163
+ if (value === null || value === undefined || value === '' || Array.isArray(value)) {
164
+ return '';
50
165
  }
51
- else if (this.multiple() && Array.isArray(value) && value.length > 0) {
52
- for (const valueElement of value) {
53
- this.onSelectValue(valueElement);
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 text = this._inputValue();
182
+ const searchText = this._isSearching() ? this._userSearchText() : '';
59
183
  if (this.multiple()) {
60
- return this.filterOptions(text).filter((x) => !this._chipList().some((chip) => chip === x.value));
184
+ return this.filterOptions(searchText).filter((x) => !this._chipList().some((chip) => chip === x.value));
61
185
  }
62
- else {
63
- return text?.length ? this.filterOptions(text) : this.selectOptions();
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
- this.selectedOption = output();
67
- this.searchTextChange = output();
68
- this.searchTextDebounce = input(300);
69
- this.internalFilterOptions = input(true);
70
- this.ParentType = OptionListParentType.AUTOCOMPLETE;
71
- this.formValueChange$ = undefined;
72
- this.selectInput = viewChild('selectInput');
73
- this.chipContainer = viewChild('chipContainer');
74
- this.autocompleteContainer = viewChild('autocompleteContainer');
75
- this.inputHeight = signal(0);
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
- this.onChangeSelectInput = effect(() => {
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?.nativeElement?.getBoundingClientRect().height);
245
+ this.inputHeight.set(selectInput.nativeElement.getBoundingClientRect().height);
83
246
  selectInput.nativeElement.addEventListener('keydown', (e) => {
84
- if (this.multiple() && this._chipList().length > 0 && !this._inputValue()?.length && e.key === 'Backspace') {
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
- // Cleanup on destroy
110
- this.destroyRef.onDestroy(() => {
111
- this._isDestroyed = true;
112
- if (this._searchDebounceTimer) {
113
- clearTimeout(this._searchDebounceTimer);
114
- }
251
+ /** Subscription to options changes */
252
+ this.selectOptionsChangeSubscription = toObservable(this.selectOptions)
253
+ .pipe(takeUntilDestroyed())
254
+ .subscribe(() => {
255
+ this.handleOptionsChange();
115
256
  });
116
- toObservable(this._showOptions)
257
+ /** Subscription to show options changes */
258
+ this.showOptionsChangeSubscription = toObservable(this._showOptions)
117
259
  .pipe(takeUntilDestroyed())
118
260
  .subscribe((data) => {
119
- if (!data && data !== null) {
120
- this.checkInputValue();
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
- * Handle debounced search text change emission
126
- * @param value The input value to emit after debounce
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
- }, this.searchTextDebounce());
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.formValueChange$) {
154
- this.formValueChange$.unsubscribe();
155
- this.formValueChange$ = undefined;
287
+ if (this.formValueChangeSubscription) {
288
+ this.formValueChangeSubscription.unsubscribe();
289
+ this.formValueChangeSubscription = undefined;
156
290
  }
157
291
  if (formControl) {
158
- this.formValueChange$ = formControl.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((value) => {
159
- if (this.multiple() && Array.isArray(value)) {
160
- this._chipList.set([]);
161
- this._selectedOptions.set([]);
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
- showOptionVisibility() {
180
- if (this._optionHideTimeout()) {
181
- clearTimeout(this._optionHideTimeout());
182
- this._optionHideTimeout.set(null);
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
- hideOptionVisibility(skipTimeout = false) {
187
- if (this._optionHideTimeout()) {
188
- clearTimeout(this._optionHideTimeout());
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._inputValue.set(value);
358
+ this._isSearching.set(true);
359
+ this._userSearchText.set(value);
198
360
  this.emitDebouncedSearchText(value);
199
361
  }
200
- filterOptions(value) {
201
- const options = this.selectOptions();
202
- if (this.internalFilterOptions()) {
203
- return options.filter((x) => x.label.toLowerCase().includes(value.toLowerCase()));
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.onSelectValue(value);
369
+ this.handleSelectValue(value);
214
370
  this.onChangedHandler(this._chipList());
215
371
  if (this._chipList().some((x) => x === value)) {
216
- this._inputValue.set('');
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
- checkInputValue() {
228
- const option = this.selectOptions().find((x) => x.label.toLowerCase() === this._inputValue()?.toLowerCase());
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
- if (option?.value === this._value())
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
- else if (!this.syncFormWithText()) {
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
- writeValue(val) {
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
- onBlurInput(event) {
260
- if (event?.relatedTarget?.id !== this.optionList()?.optionListContainer()?.nativeElement?.id)
261
- this.onBlurHandler();
262
- }
263
- onBlurHandler() {
264
- setTimeout(() => {
265
- this.hideOptionVisibility();
266
- if (!this._inputValue()?.length && !this.emitOnly() && !this.multiple()) {
267
- this._ngControl()?.control?.patchValue(null);
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
- super.onBlurHandler();
270
- }, 100);
271
- }
272
- onBlurOptionList(event) {
273
- if (event)
274
- this.hideOptionVisibility();
541
+ }
275
542
  }
276
- setInputValue(resetOnMiss = false) {
277
- this._inputValue.set(this.selectOptions().find((x) => x.value === this._value())?.label ?? (resetOnMiss ? '' : this._inputValue()));
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
- getDescription(chip) {
280
- const valueChip = this.selectOptions().find((x) => x.value === chip);
281
- return valueChip ? valueChip.label.toString() : '';
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
- onSelectValue(value) {
284
- const newChip = this.selectOptions().find((x) => x.value === value);
285
- if (newChip && !this._chipList().some((x) => x === newChip?.value)) {
286
- this.createChipList(newChip);
287
- this._selectedOptions.update((list) => [...list, newChip]);
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
- * remove chip from chips list
292
- * @param chipValue chip to delete
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
- deleteChip(chipValue) {
295
- const stringChipValue = chipValue?.toString();
296
- const i = this._chipList()?.findIndex((x) => x.toString() === stringChipValue);
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
- createChipList(chip) {
306
- if (chip) {
307
- this._chipList.update((list) => [...list, chip.value]);
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: { syncFormWithText: { classPropertyName: "syncFormWithText", publicName: "syncFormWithText", isSignal: true, isRequired: false, transformFunction: null }, optionListMaxHeight: { classPropertyName: "optionListMaxHeight", publicName: "optionListMaxHeight", isSignal: true, isRequired: false, transformFunction: null }, selectOptions: { classPropertyName: "selectOptions", publicName: "selectOptions", isSignal: true, isRequired: true, 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: [
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]=\"_value()\"\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 }); }
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]=\"_value()\"\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"] }]
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
  /**