@aquera/nile-elements 1.7.1 → 1.7.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.
Files changed (88) hide show
  1. package/README.md +7 -0
  2. package/dist/index.cjs.js +1 -1
  3. package/dist/index.esm.js +1 -1
  4. package/dist/index.js +1524 -419
  5. package/dist/nile-breadcrumb-item/nile-breadcrumb-item.cjs.js +1 -1
  6. package/dist/nile-breadcrumb-item/nile-breadcrumb-item.cjs.js.map +1 -1
  7. package/dist/nile-breadcrumb-item/nile-breadcrumb-item.esm.js +8 -6
  8. package/dist/nile-combobox/group-utils.cjs.js +2 -0
  9. package/dist/nile-combobox/group-utils.cjs.js.map +1 -0
  10. package/dist/nile-combobox/group-utils.esm.js +1 -0
  11. package/dist/nile-combobox/index.cjs.js +2 -0
  12. package/dist/nile-combobox/index.cjs.js.map +1 -0
  13. package/dist/nile-combobox/index.esm.js +1 -0
  14. package/dist/nile-combobox/nile-combobox.cjs.js +2 -0
  15. package/dist/nile-combobox/nile-combobox.cjs.js.map +1 -0
  16. package/dist/nile-combobox/nile-combobox.css.cjs.js +2 -0
  17. package/dist/nile-combobox/nile-combobox.css.cjs.js.map +1 -0
  18. package/dist/nile-combobox/nile-combobox.css.esm.js +715 -0
  19. package/dist/nile-combobox/nile-combobox.esm.js +238 -0
  20. package/dist/nile-combobox/portal-manager.cjs.js +2 -0
  21. package/dist/nile-combobox/portal-manager.cjs.js.map +1 -0
  22. package/dist/nile-combobox/portal-manager.esm.js +1 -0
  23. package/dist/nile-combobox/renderer.cjs.js +2 -0
  24. package/dist/nile-combobox/renderer.cjs.js.map +1 -0
  25. package/dist/nile-combobox/renderer.esm.js +147 -0
  26. package/dist/nile-combobox/search-manager.cjs.js +2 -0
  27. package/dist/nile-combobox/search-manager.cjs.js.map +1 -0
  28. package/dist/nile-combobox/search-manager.esm.js +1 -0
  29. package/dist/nile-combobox/selection-manager.cjs.js +2 -0
  30. package/dist/nile-combobox/selection-manager.cjs.js.map +1 -0
  31. package/dist/nile-combobox/selection-manager.esm.js +1 -0
  32. package/dist/nile-combobox/types.cjs.js +2 -0
  33. package/dist/nile-combobox/types.cjs.js.map +1 -0
  34. package/dist/nile-combobox/types.esm.js +1 -0
  35. package/dist/src/index.d.ts +1 -0
  36. package/dist/src/index.js +1 -0
  37. package/dist/src/index.js.map +1 -1
  38. package/dist/src/nile-breadcrumb-item/nile-breadcrumb-item.js +4 -2
  39. package/dist/src/nile-breadcrumb-item/nile-breadcrumb-item.js.map +1 -1
  40. package/dist/src/nile-combobox/group-utils.d.ts +26 -0
  41. package/dist/src/nile-combobox/group-utils.js +140 -0
  42. package/dist/src/nile-combobox/group-utils.js.map +1 -0
  43. package/dist/src/nile-combobox/index.d.ts +1 -0
  44. package/dist/src/nile-combobox/index.js +2 -0
  45. package/dist/src/nile-combobox/index.js.map +1 -0
  46. package/dist/src/nile-combobox/nile-combobox.css.d.ts +9 -0
  47. package/dist/src/nile-combobox/nile-combobox.css.js +724 -0
  48. package/dist/src/nile-combobox/nile-combobox.css.js.map +1 -0
  49. package/dist/src/nile-combobox/nile-combobox.d.ts +320 -0
  50. package/dist/src/nile-combobox/nile-combobox.js +1739 -0
  51. package/dist/src/nile-combobox/nile-combobox.js.map +1 -0
  52. package/dist/src/nile-combobox/nile-combobox.test.d.ts +1 -0
  53. package/dist/src/nile-combobox/nile-combobox.test.js +551 -0
  54. package/dist/src/nile-combobox/nile-combobox.test.js.map +1 -0
  55. package/dist/src/nile-combobox/portal-manager.d.ts +26 -0
  56. package/dist/src/nile-combobox/portal-manager.js +218 -0
  57. package/dist/src/nile-combobox/portal-manager.js.map +1 -0
  58. package/dist/src/nile-combobox/renderer.d.ts +24 -0
  59. package/dist/src/nile-combobox/renderer.js +279 -0
  60. package/dist/src/nile-combobox/renderer.js.map +1 -0
  61. package/dist/src/nile-combobox/search-manager.d.ts +15 -0
  62. package/dist/src/nile-combobox/search-manager.js +41 -0
  63. package/dist/src/nile-combobox/search-manager.js.map +1 -0
  64. package/dist/src/nile-combobox/selection-manager.d.ts +12 -0
  65. package/dist/src/nile-combobox/selection-manager.js +44 -0
  66. package/dist/src/nile-combobox/selection-manager.js.map +1 -0
  67. package/dist/src/nile-combobox/types.d.ts +53 -0
  68. package/dist/src/nile-combobox/types.js +8 -0
  69. package/dist/src/nile-combobox/types.js.map +1 -0
  70. package/dist/src/version.js +1 -1
  71. package/dist/src/version.js.map +1 -1
  72. package/dist/tsconfig.tsbuildinfo +1 -1
  73. package/package.json +3 -1
  74. package/src/index.ts +1 -0
  75. package/src/nile-breadcrumb-item/nile-breadcrumb-item.ts +4 -2
  76. package/src/nile-combobox/group-utils.ts +157 -0
  77. package/src/nile-combobox/index.ts +1 -0
  78. package/src/nile-combobox/nile-combobox.css.ts +726 -0
  79. package/src/nile-combobox/nile-combobox.test.ts +704 -0
  80. package/src/nile-combobox/nile-combobox.ts +1816 -0
  81. package/src/nile-combobox/portal-manager.ts +263 -0
  82. package/src/nile-combobox/renderer.ts +466 -0
  83. package/src/nile-combobox/search-manager.ts +53 -0
  84. package/src/nile-combobox/selection-manager.ts +57 -0
  85. package/src/nile-combobox/types.ts +63 -0
  86. package/vscode-html-custom-data.json +311 -4
  87. package/web-dev-server.config.mjs +9 -0
  88. package/web-test-runner.config.mjs +11 -0
@@ -0,0 +1,1816 @@
1
+ /**
2
+ * Copyright Aquera Inc 2025
3
+ *
4
+ * This source code is licensed under the BSD-3-Clause license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ */
7
+
8
+ import { html } from 'lit';
9
+ import { customElement, property, query, state } from 'lit/decorators.js';
10
+ import { classMap } from 'lit/directives/class-map.js';
11
+ import { ifDefined } from 'lit/directives/if-defined.js';
12
+ import { Ref, createRef, ref } from 'lit/directives/ref.js';
13
+ import { styles } from './nile-combobox.css';
14
+
15
+ import '../nile-icon';
16
+ import '../nile-popup/nile-popup';
17
+ import '../nile-tag/nile-tag';
18
+ import '../nile-option/nile-option';
19
+ import '../nile-checkbox/nile-checkbox';
20
+ import '../nile-loader/nile-loader';
21
+
22
+ import { animateTo, stopAnimations } from '../internal/animate';
23
+ import { defaultValue } from '../internal/default-value';
24
+ import { FormControlController } from '../internal/form';
25
+ import { getAnimation, setDefaultAnimation } from '../utilities/animation-registry';
26
+ import { HasSlotController } from '../internal/slot';
27
+ import { waitForEvent } from '../internal/event';
28
+ import { watch } from '../internal/watch';
29
+ import NileElement from '../internal/nile-element';
30
+ import type { CSSResultGroup, PropertyValues, TemplateResult } from 'lit';
31
+ import type { NileFormControl } from '../internal/nile-element';
32
+ import type NilePopup from '../nile-popup/nile-popup';
33
+ import { VirtualizerController } from '@tanstack/lit-virtual';
34
+
35
+ import type {
36
+ ComboboxOption,
37
+ ComboboxRenderItemConfig,
38
+ ComboboxTagLayout,
39
+ ComboboxSize,
40
+ ComboboxPlacement,
41
+ ComboboxRow,
42
+ NileRemoveEvent,
43
+ } from './types.js';
44
+ import { ComboboxSelectionManager } from './selection-manager.js';
45
+ import { ComboboxSearchManager } from './search-manager.js';
46
+ import { ComboboxRenderer } from './renderer.js';
47
+ import { ComboboxPortalManager } from './portal-manager.js';
48
+ import { hasGroups, flattenRows, filterRows, getOptionRows } from './group-utils.js';
49
+ import { VisibilityManager } from '../utilities/visibility-manager.js';
50
+
51
+ /**
52
+ * @summary A data-driven combobox with virtualized options, inline search, multi-select tags,
53
+ * custom value creation, and full WAI-ARIA Combobox keyboard navigation.
54
+ *
55
+ * @tag nile-combobox
56
+ * @status stable
57
+ * @since 2.0
58
+ *
59
+ * @dependency nile-icon
60
+ * @dependency nile-popup
61
+ * @dependency nile-tag
62
+ * @dependency nile-checkbox
63
+ * @dependency nile-loader
64
+ *
65
+ * @slot label - The input's label.
66
+ * @slot prefix - Prepend a presentational icon or element before the input.
67
+ * @slot clear-icon - An icon to use in lieu of the default clear icon.
68
+ * @slot expand-icon - The icon to show when the control is expanded/collapsed.
69
+ * @slot help-text - Text that describes how to use the input.
70
+ * @slot footer - Custom footer content (overrides default footer in multi-select mode).
71
+ * @slot no-results - Custom no-results content.
72
+ *
73
+ * @event nile-change - Emitted when the control's value changes.
74
+ * @event nile-clear - Emitted when the control's value is cleared.
75
+ * @event nile-input - Emitted when the control receives input.
76
+ * @event nile-focus - Emitted when the control gains focus.
77
+ * @event nile-blur - Emitted when the control loses focus.
78
+ * @event nile-show - Emitted when the dropdown opens.
79
+ * @event nile-after-show - Emitted after the dropdown opens and all animations complete.
80
+ * @event nile-hide - Emitted when the dropdown closes.
81
+ * @event nile-after-hide - Emitted after the dropdown closes and all animations complete.
82
+ * @event nile-search - Emitted (debounced) when the user types. Useful for API-driven filtering.
83
+ * @event nile-tag-remove - Emitted when a tag is removed in multi-select mode.
84
+ * @event nile-tag-add - Emitted when a custom value is added via allowCustomValue.
85
+ * @event nile-scroll-end - Emitted when scrolled to the bottom (for infinite loading).
86
+ * @event nile-invalid - Emitted when form validation constraints aren't satisfied.
87
+ * @event nile-select-all - Emitted when the Select all / Deselect all control toggles. Detail: { value, name, action: 'select-all' | 'deselect-all' }.
88
+ *
89
+ * @csspart form-control - The form control wrapper.
90
+ * @csspart form-control-label - The label wrapper.
91
+ * @csspart form-control-input - The input area wrapper.
92
+ * @csspart combobox - The combobox trigger (input + tags + icons).
93
+ * @csspart input - The text input element.
94
+ * @csspart listbox - The dropdown listbox.
95
+ * @csspart tags - The tags container in multi-select mode.
96
+ * @csspart tag - Each individual tag.
97
+ * @csspart clear-button - The clear button.
98
+ * @csspart expand-icon - The expand/collapse icon.
99
+ * @csspart top-actions - The sticky row above the option list that contains the Select all checkbox and the Selected / Show all filter toggle. Rendered only when multiple && selectAllEnabled.
100
+ * @csspart select-all - The Select all / Deselect all checkbox wrapper inside top-actions.
101
+ * @csspart show-toggle - The "Selected / Show all" filter button inside top-actions.
102
+ * @csspart no-results - The empty-state container shown when a search/filter returns no items. Contains no-results-title and no-results-subtitle.
103
+ * @csspart no-results-title - The title row of the no-results empty state.
104
+ * @csspart no-results-subtitle - The subtitle row of the no-results empty state.
105
+ * @csspart no-data - The empty-state container shown when the dataset is empty (no active search/filter).
106
+ * @csspart footer - The footer with "Show Selected" / "Clear All".
107
+ * @csspart no-results - The no-results message.
108
+ */
109
+ @customElement('nile-combobox')
110
+ export class NileCombobox extends NileElement implements NileFormControl {
111
+ static styles: CSSResultGroup = styles;
112
+
113
+ private readonly formControlController = new FormControlController(this, {
114
+ assumeInteractionOn: ['nile-blur', 'nile-input'],
115
+ });
116
+ private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label');
117
+ private readonly portalManager = new ComboboxPortalManager(this);
118
+ private readonly searchManager = new ComboboxSearchManager();
119
+
120
+ private scrollElementRef: Ref<HTMLDivElement> = createRef();
121
+ private virtualizerCtrl = new VirtualizerController<HTMLDivElement, Element>(this, {
122
+ getScrollElement: () => this.scrollElementRef.value ?? null,
123
+ count: 0,
124
+ estimateSize: () => 38,
125
+ overscan: 5,
126
+ });
127
+
128
+ private hScrollElementRef: Ref<HTMLDivElement> = createRef();
129
+ private hVirtualizerCtrl = new VirtualizerController<HTMLDivElement, Element>(this, {
130
+ getScrollElement: () => this.hScrollElementRef.value ?? null,
131
+ count: 0,
132
+ estimateSize: () => 160,
133
+ horizontal: true,
134
+ overscan: 3,
135
+ });
136
+
137
+ private scrollTimeout: number | undefined;
138
+ private scrolling = false;
139
+ private visibilityManager?: VisibilityManager;
140
+ private keyboardActiveIndex = -1;
141
+
142
+ @query('.combobox-popup') popup: NilePopup;
143
+ @query('.combobox__trigger') combobox: HTMLElement;
144
+ @query('.combobox__input') inputElement: HTMLInputElement;
145
+ @query('.combobox__value-input') valueInput: HTMLInputElement;
146
+
147
+ @state() private hasFocus = false;
148
+ @state() displayLabel = '';
149
+ @state() selectedOptions: ComboboxOption[] = [];
150
+
151
+ /** The items displayed after filtering. Renderer reads from this. */
152
+ @state() filteredData: any[] = [];
153
+
154
+ /**
155
+ * Mixed (header + option) row list, only populated when `data` contains
156
+ * group entries (`type: 'group'`). When non-empty, the listbox renders from
157
+ * this instead of `filteredData`. `filteredData` stays in sync as the
158
+ * option-only projection so existing select-all / strict-match / etc. logic
159
+ * keeps working unchanged.
160
+ */
161
+ @state() private filteredRows: ComboboxRow[] = [];
162
+
163
+ /** The complete unfiltered dataset (preserved for re-filtering). */
164
+ @state() private originalData: any[] = [];
165
+
166
+ @state() showNoResults = false;
167
+ @state() showListbox = false;
168
+ @state() searchValue = '';
169
+ @state() private showSelectedOnly = false;
170
+ @state() private selectAllChecked = false;
171
+ @state() private selectAllIndeterminate = false;
172
+
173
+ /**
174
+ * Index into `filteredRows` of the group header that should be pinned at
175
+ * the top of the (virtualized) listbox right now. -1 means none.
176
+ * Recomputed on scroll.
177
+ */
178
+ @state() private stickyHeaderIndex = -1;
179
+
180
+ // ── Public properties ──
181
+
182
+ @property() name = '';
183
+
184
+ @property({ type: Array }) data: any[] = [];
185
+
186
+ @property({
187
+ converter: {
188
+ fromAttribute: (value: string) => value.split(' '),
189
+ toAttribute: (value: string[]) => value.join(' '),
190
+ },
191
+ })
192
+ value: string | string[] = '';
193
+
194
+ @defaultValue() defaultValue: string | string[] = '';
195
+
196
+ @property() size: ComboboxSize = 'medium';
197
+
198
+ @property() placeholder = 'Type to search...';
199
+
200
+ @property({ type: Boolean, reflect: true }) multiple = false;
201
+
202
+ @property() label = '';
203
+
204
+ @property({ type: Boolean, reflect: true }) required = false;
205
+
206
+ @property({ type: Boolean, reflect: true }) disabled = false;
207
+
208
+ @property({ type: Boolean, reflect: true }) open = false;
209
+
210
+ @property({ type: Boolean, reflect: true }) clearable = false;
211
+
212
+ @property({ type: Boolean, reflect: true }) loading = false;
213
+
214
+ @property({ type: Boolean, reflect: true }) optionsLoading = false;
215
+
216
+ /** When true, skip local filtering and rely solely on the `nile-search` event for API-driven results. */
217
+ @property({ type: Boolean, reflect: true }) disableLocalSearch = false;
218
+
219
+ /** When true, displays a "+ Add [value]" option for values not in the data. */
220
+ @property({ type: Boolean, reflect: true }) allowCustomValue = false;
221
+
222
+ /** When true, typing free text and pressing Enter/Tab adds it as a tag (like nile-chip's acceptUserInput). */
223
+ @property({ type: Boolean, reflect: true }) acceptUserInput = false;
224
+
225
+ /** When true, custom values added via allowCustomValue or acceptUserInput are also appended to the suggestions list. */
226
+ @property({ type: Boolean, reflect: true }) addToSuggestions = false;
227
+
228
+ /** When true, value must match an option. On blur, reverts to the last valid value if text doesn't match. */
229
+ @property({ type: Boolean, reflect: true }) strict = false;
230
+
231
+ /** Max tags visible before showing "+N more" (0 = no limit). */
232
+ @property({ attribute: 'max-tags-visible', type: Number }) maxTagsVisible = 3;
233
+
234
+ /** Controls how tags wrap in multi-select mode. */
235
+ @property() tagLayout: ComboboxTagLayout = 'single-line';
236
+
237
+ /**
238
+ * Show footer with "Show Selected" and "Clear All" in multi-select mode.
239
+ * Automatically suppressed when `selectAllEnabled` is true, since the top
240
+ * actions row already provides the same controls.
241
+ */
242
+ @property({ type: Boolean, reflect: true }) showFooter = true;
243
+
244
+ /**
245
+ * When true (and `multiple` is true), renders a "Select all" / "Deselect all"
246
+ * checkbox at the top of the listbox. Operates on the currently visible,
247
+ * non-disabled options (respects the active search filter).
248
+ */
249
+ @property({ type: Boolean, reflect: true, attribute: 'select-all-enabled' }) selectAllEnabled = false;
250
+
251
+ /**
252
+ * When true (default), data-driven group headers stick to the top of the
253
+ * listbox while scrolling through that group's options (Atlassian-style).
254
+ * Works in both plain and virtualized rendering modes. Set to false for
255
+ * inline-only headers that scroll away with their options.
256
+ */
257
+ @property({ type: Boolean, reflect: true, attribute: 'sticky-group-header' }) stickyGroupHeader = true;
258
+
259
+ @property({ type: Boolean, reflect: true }) portal = false;
260
+
261
+ @property({ type: Boolean }) hoist = false;
262
+
263
+ @property({ reflect: true }) placement: ComboboxPlacement = 'bottom';
264
+
265
+ @property({ reflect: true }) form = '';
266
+
267
+ @property({ attribute: 'help-text', reflect: true }) helpText = '';
268
+
269
+ @property({ attribute: 'error-message', reflect: true }) errorMessage = '';
270
+
271
+ @property({ type: Boolean }) warning = false;
272
+ @property({ type: Boolean }) error = false;
273
+ @property({ type: Boolean }) success = false;
274
+
275
+ @property({ type: Boolean, reflect: true }) filled = false;
276
+ @property({ type: Boolean, reflect: true }) pill = false;
277
+
278
+ @property({ type: String }) noResultsMessage = 'No result found';
279
+ @property({ type: String, attribute: 'no-results-subtitle' }) noResultsSubtitle = 'Clear search or change filter';
280
+ @property({ type: String, attribute: 'no-data-message' }) noDataMessage = 'No data available';
281
+
282
+ /**
283
+ * Pre-defined autocomplete suggestions. Accepts a JSON array string attribute
284
+ * or a JS array property (like nile-chip). When `addToSuggestions` is true,
285
+ * custom values added by the user are appended to this list and persisted.
286
+ */
287
+ @property({
288
+ type: Array,
289
+ converter: {
290
+ fromAttribute: (value: string | null) => {
291
+ if (!value) return [];
292
+ try { return JSON.parse(value); } catch { return []; }
293
+ },
294
+ toAttribute: (value: any[]) => JSON.stringify(value),
295
+ },
296
+ })
297
+ autoCompleteOptions: any[] = [];
298
+
299
+ /** Debounce interval (ms) for the nile-search event. */
300
+ @property({ type: Number }) debounceMs = 300;
301
+
302
+ @property({ attribute: false }) renderItemConfig?: ComboboxRenderItemConfig;
303
+
304
+ @property({ type: Boolean, reflect: true }) allowHtmlLabel = true;
305
+
306
+ @property({ type: Boolean, reflect: true, attribute: true }) enableVisibilityEffect = false;
307
+ @property({ type: Boolean, reflect: true, attribute: true }) enableTabClose = false;
308
+ @property({ type: Boolean, reflect: true }) noWidthSync = false;
309
+
310
+ /** Number of columns in the dropdown grid (vertical scroll). When > 1, options render in a multi-column grid layout. */
311
+ @property({ attribute: 'grid-columns', type: Number, reflect: true }) gridColumns = 1;
312
+
313
+ /** Number of visible rows in horizontal grid mode. When > 0, enables horizontal virtual scroll with columns scrolling left/right. */
314
+ @property({ attribute: 'grid-rows', type: Number, reflect: true }) gridRows = 0;
315
+
316
+ /** Width of each column in horizontal grid mode (px). */
317
+ @property({ attribute: 'grid-column-width', type: Number }) gridColumnWidth = 160;
318
+
319
+ // ── Accessors ──
320
+
321
+ get validity() {
322
+ return this.valueInput?.validity;
323
+ }
324
+
325
+ get validationMessage() {
326
+ return this.valueInput?.validationMessage ?? '';
327
+ }
328
+
329
+ // ── Lifecycle ──
330
+
331
+ connectedCallback() {
332
+ super.connectedCallback();
333
+ this.open = false;
334
+ this.setupBoundHandlers();
335
+ this.emit('nile-init');
336
+
337
+ this.updateComplete.then(() => {
338
+ if (this.autoCompleteOptions.length > 0 && this.data.length === 0) {
339
+ this.data = [...this.autoCompleteOptions];
340
+ }
341
+ if (this.value && this.data.length > 0) {
342
+ this.syncSelection();
343
+ }
344
+ });
345
+ }
346
+
347
+ disconnectedCallback() {
348
+ this.removeOpenListeners();
349
+ this.visibilityManager?.cleanup();
350
+ this.searchManager.cancelDebounce();
351
+ if (this.scrollTimeout) {
352
+ clearTimeout(this.scrollTimeout);
353
+ this.scrollTimeout = undefined;
354
+ }
355
+ this.portalManager.cleanupPortal();
356
+ }
357
+
358
+ protected firstUpdated(_changed: PropertyValues) {
359
+ this.visibilityManager = new VisibilityManager({
360
+ host: this,
361
+ target: this.combobox,
362
+ enableVisibilityEffect: this.enableVisibilityEffect,
363
+ enableTabClose: this.enableTabClose,
364
+ isOpen: () => this.open,
365
+ onAnchorOutOfView: () => {
366
+ this.hide();
367
+ this.emit('nile-visibility-change', { visible: false, reason: 'anchor-out-of-view', name: this.name });
368
+ },
369
+ onDocumentHidden: () => {
370
+ this.hide();
371
+ this.emit('nile-visibility-change', { visible: false, reason: 'document-hidden', name: this.name });
372
+ },
373
+ emit: (event: string, detail: any) => this.emit(`nile-${event}`, detail),
374
+ });
375
+ }
376
+
377
+ protected updated(changedProperties: PropertyValues): void {
378
+ if (changedProperties.has('value')) {
379
+ this.syncSelection();
380
+ }
381
+ this.updateVirtualizerCount();
382
+ }
383
+
384
+ private get isHorizontalGrid(): boolean {
385
+ return this.gridRows > 0 && this.gridColumns <= 1;
386
+ }
387
+
388
+ /** True when the source data contains at least one group entry. */
389
+ private get hasGroupedData(): boolean {
390
+ const base = this.originalData.length > 0 ? this.originalData : this.data;
391
+ return hasGroups(base);
392
+ }
393
+
394
+ /**
395
+ * Walk filteredRows and find the index of the deepest group header whose
396
+ * virtual position is at or above `scrollTop`. That's the header that
397
+ * should be pinned at the top of the listbox right now.
398
+ */
399
+ private updateStickyHeader(scrollTop: number): void {
400
+ if (!this.stickyGroupHeader || !this.hasGroupedData) {
401
+ if (this.stickyHeaderIndex !== -1) this.stickyHeaderIndex = -1;
402
+ return;
403
+ }
404
+ let offset = 0;
405
+ let stuck = -1;
406
+ for (let i = 0; i < this.filteredRows.length; i++) {
407
+ const row = this.filteredRows[i];
408
+ const size = row.kind === 'header' ? 32 : 38;
409
+ if (offset > scrollTop) break;
410
+ if (row.kind === 'header') stuck = i;
411
+ offset += size;
412
+ }
413
+ if (stuck !== this.stickyHeaderIndex) this.stickyHeaderIndex = stuck;
414
+ }
415
+
416
+ /** Recursively keep only options whose value is in `selectedSet`; drop empty groups. */
417
+ private pruneTreeBySelection(items: any[], selectedSet: Set<string>): any[] {
418
+ const out: any[] = [];
419
+ for (const item of items) {
420
+ if (item && typeof item === 'object' && item.type === 'group' && Array.isArray(item.options)) {
421
+ const kept = this.pruneTreeBySelection(item.options, selectedSet);
422
+ if (kept.length > 0) out.push({ ...item, options: kept });
423
+ } else if (selectedSet.has(String(this.getItemValue(item)))) {
424
+ out.push(item);
425
+ }
426
+ }
427
+ return out;
428
+ }
429
+
430
+ private get hasActiveFilter(): boolean {
431
+ return !!this.searchValue || this.showSelectedOnly;
432
+ }
433
+
434
+ private renderEmptyState(): TemplateResult {
435
+ if (this.hasActiveFilter) {
436
+ return ComboboxRenderer.renderNoResults(this.noResultsMessage, this.noResultsSubtitle);
437
+ }
438
+ return ComboboxRenderer.renderNoData(this.noDataMessage);
439
+ }
440
+
441
+ private get isBidirectionalGrid(): boolean {
442
+ return this.gridRows > 0 && this.gridColumns > 1;
443
+ }
444
+
445
+ private get virtualRowCount(): number {
446
+ if (this.gridColumns > 1) {
447
+ return Math.ceil(this.filteredData.length / this.gridColumns);
448
+ }
449
+ if (this.hasGroupedData) {
450
+ return this.filteredRows.length;
451
+ }
452
+ return this.filteredData.length;
453
+ }
454
+
455
+ private get virtualColumnCount(): number {
456
+ return Math.ceil(this.filteredData.length / Math.max(this.gridRows, 1));
457
+ }
458
+
459
+ private updateVirtualizerCount(): void {
460
+ if (this.isHorizontalGrid) {
461
+ const hVirtualizer = this.hVirtualizerCtrl.getVirtualizer();
462
+ const colCount = this.virtualColumnCount;
463
+ if (hVirtualizer.options.count !== colCount || hVirtualizer.options.estimateSize(0) !== this.gridColumnWidth) {
464
+ hVirtualizer.setOptions({
465
+ ...hVirtualizer.options,
466
+ count: colCount,
467
+ estimateSize: () => this.gridColumnWidth,
468
+ });
469
+ hVirtualizer.measure();
470
+ }
471
+ } else {
472
+ const virtualizer = this.virtualizerCtrl.getVirtualizer();
473
+ const count = this.virtualRowCount;
474
+ const grouped = this.hasGroupedData;
475
+ const countChanged = virtualizer.options.count !== count;
476
+ const modeChanged = this.lastVirtualizerGrouped !== grouped;
477
+ if (countChanged || modeChanged) {
478
+ const estimateSize = grouped
479
+ ? (index: number) => (this.filteredRows[index]?.kind === 'header' ? 32 : 38)
480
+ : () => 38;
481
+ virtualizer.setOptions({
482
+ ...virtualizer.options,
483
+ count,
484
+ estimateSize,
485
+ });
486
+ virtualizer.measure();
487
+ this.lastVirtualizerGrouped = grouped;
488
+ }
489
+ }
490
+ }
491
+
492
+ private lastVirtualizerGrouped = false;
493
+
494
+ // ── Data helpers ──
495
+
496
+ private getDisplayText(item: any): string {
497
+ if (this.renderItemConfig?.getDisplayText) return this.renderItemConfig.getDisplayText(item);
498
+ return item?.label || item?.name || item?.toString?.() || '';
499
+ }
500
+
501
+ private getItemValue(item: any): string {
502
+ if (this.renderItemConfig?.getValue) return this.renderItemConfig.getValue(item);
503
+ return item?.value ?? item;
504
+ }
505
+
506
+ private getSearchText(item: any): string {
507
+ if (this.renderItemConfig?.getSearchText) return this.renderItemConfig.getSearchText(item);
508
+ return this.getDisplayText(item);
509
+ }
510
+
511
+ private getItemDescription(item: any): string {
512
+ return this.renderItemConfig?.getDescription?.(item) ?? item?.description ?? '';
513
+ }
514
+
515
+ private getItemPrefix(item: any): string {
516
+ return this.renderItemConfig?.getPrefix?.(item) ?? item?.prefix ?? '';
517
+ }
518
+
519
+ private getItemSuffix(item: any): string {
520
+ return this.renderItemConfig?.getSuffix?.(item) ?? item?.suffix ?? '';
521
+ }
522
+
523
+ // ── Selection ──
524
+
525
+ private syncSelection(): void {
526
+ const baseData = this.originalData.length > 0 ? this.originalData : this.data;
527
+ const items = this.hasGroupedData
528
+ ? getOptionRows(flattenRows(baseData)).map(r => r.item)
529
+ : baseData;
530
+ this.selectedOptions = ComboboxSelectionManager.createOptionsFromValues(
531
+ this.value,
532
+ items,
533
+ this.getDisplayText.bind(this),
534
+ this.renderItemConfig?.getValue,
535
+ );
536
+
537
+ if (!this.multiple) {
538
+ const label = this.selectedOptions[0]?.getTextLabel() ?? '';
539
+ this.displayLabel = label;
540
+ if (label) {
541
+ this.searchValue = label;
542
+ } else if (!this.hasFocus) {
543
+ this.searchValue = '';
544
+ }
545
+ } else {
546
+ this.displayLabel = '';
547
+ }
548
+
549
+ this.updateValidity();
550
+ this.updateSelectAllState();
551
+ }
552
+
553
+ // ── Select All ──
554
+
555
+ /**
556
+ * Returns the options eligible for Select All — the currently filtered data
557
+ * (so the active search/filter is respected) minus any `disabled` items.
558
+ * Mirrors nile-select's `getSelectableOptions()` contract.
559
+ */
560
+ private getSelectableData(): any[] {
561
+ return this.filteredData.filter((item: any) => !item?.disabled);
562
+ }
563
+
564
+ /**
565
+ * Derives `selectAllChecked` + `selectAllIndeterminate` from the current
566
+ * selection vs. the selectable set. No-op when `multiple` is false.
567
+ *
568
+ * options=0 → both false (nothing to select, e.g. empty search)
569
+ * selected=0 → checked=false, indeterminate=false
570
+ * selected=options → checked=true, indeterminate=false
571
+ * otherwise (partial) → checked=false, indeterminate=true
572
+ */
573
+ private updateSelectAllState(): void {
574
+ if (!this.multiple) {
575
+ this.selectAllChecked = false;
576
+ this.selectAllIndeterminate = false;
577
+ return;
578
+ }
579
+ const options = this.getSelectableData();
580
+ if (options.length === 0) {
581
+ this.selectAllChecked = false;
582
+ this.selectAllIndeterminate = false;
583
+ return;
584
+ }
585
+ const selectedValues = Array.isArray(this.value) ? this.value : [];
586
+ const selectedSet = new Set(selectedValues.map(String));
587
+ const selectedInView = options.reduce((n, item) => {
588
+ return n + (selectedSet.has(String(this.getItemValue(item))) ? 1 : 0);
589
+ }, 0);
590
+
591
+ if (selectedInView === 0) {
592
+ this.selectAllChecked = false;
593
+ this.selectAllIndeterminate = false;
594
+ } else if (selectedInView === options.length) {
595
+ this.selectAllChecked = true;
596
+ this.selectAllIndeterminate = false;
597
+ } else {
598
+ this.selectAllChecked = false;
599
+ this.selectAllIndeterminate = true;
600
+ }
601
+ }
602
+
603
+ /**
604
+ * Toggles all selectable, visible options. Indeterminate → clears selection
605
+ * (matches the "Deselect All" label flip), empty → selects all,
606
+ * fully-selected → clears.
607
+ */
608
+ private handleSelectAllToggle(event: Event): void {
609
+ event.stopPropagation();
610
+ event.preventDefault();
611
+ if (!this.multiple || !this.selectAllEnabled) return;
612
+
613
+ const options = this.getSelectableData();
614
+ if (options.length === 0) return;
615
+
616
+ const shouldUnselectAll = this.selectAllChecked || this.selectAllIndeterminate;
617
+ const current = Array.isArray(this.value) ? [...this.value] : [];
618
+ const inViewValues = options.map(item => String(this.getItemValue(item)));
619
+ const inViewSet = new Set(inViewValues);
620
+
621
+ let next: string[];
622
+ if (shouldUnselectAll) {
623
+ // Deselect only the in-view options; preserve selections outside the filter.
624
+ next = current.filter(v => !inViewSet.has(String(v)));
625
+ } else {
626
+ // Select all in-view options on top of any existing (out-of-view) selection.
627
+ const merged = new Set(current.map(String));
628
+ inViewValues.forEach(v => merged.add(v));
629
+ next = Array.from(merged);
630
+ }
631
+
632
+ this.value = next;
633
+ this.searchValue = '';
634
+ this.syncSelection();
635
+ this.filterOptions('', true);
636
+
637
+ this.updateComplete.then(() => {
638
+ this.emit('nile-input', { value: this.value, name: this.name });
639
+ this.emit('nile-change', { value: this.value, name: this.name });
640
+ this.emit('nile-select-all', {
641
+ value: this.value,
642
+ name: this.name,
643
+ action: shouldUnselectAll ? 'deselect-all' : 'select-all',
644
+ });
645
+ });
646
+ }
647
+
648
+ // ── Event handler setup ──
649
+
650
+ private handleDocumentFocusIn!: (e: FocusEvent) => void;
651
+ private handleDocumentKeyDown!: (e: KeyboardEvent) => void;
652
+ private handleDocumentMouseDown!: (e: MouseEvent) => void;
653
+ private handleWindowError!: (e: ErrorEvent) => void;
654
+ private handleWindowResize!: () => void;
655
+ private handleWindowScroll!: () => void;
656
+
657
+ private setupBoundHandlers(): void {
658
+ this.handleDocumentFocusIn = (event: FocusEvent) => {
659
+ if (!this.open) return;
660
+ const path = event.composedPath();
661
+ const hitSelf = path.includes(this);
662
+ const hitPopup = this.popup && path.includes(this.popup);
663
+ const hitPortal = this.portal && this.portalManager.portalContainerElement && path.includes(this.portalManager.portalContainerElement);
664
+ if (!hitSelf && !hitPopup && !hitPortal) this.hide();
665
+ };
666
+
667
+ this.handleDocumentKeyDown = (event: KeyboardEvent) => {
668
+ if (!this.open && event.key !== 'ArrowDown' && event.key !== 'ArrowUp' && event.key !== 'Enter') return;
669
+ this.onGlobalKeyDown(event);
670
+ };
671
+
672
+ this.handleDocumentMouseDown = (event: MouseEvent) => {
673
+ if (!this.open) return;
674
+ const path = event.composedPath();
675
+ const hitSelf = path.includes(this);
676
+ const hitPopup = this.popup && path.includes(this.popup);
677
+ const hitPortal = this.portal && this.portalManager.portalContainerElement && path.includes(this.portalManager.portalContainerElement);
678
+ if (!hitSelf && !hitPopup && !hitPortal) this.hide();
679
+ };
680
+
681
+ this.handleWindowError = (event: ErrorEvent) => {
682
+ const msg = event.error?.message || event.message || '';
683
+ if (msg.includes("Cannot read properties of null (reading 'insertBefore')")) {
684
+ event.preventDefault();
685
+ }
686
+ };
687
+
688
+ this.handleWindowResize = () => this.portalManager.updatePosition();
689
+ this.handleWindowScroll = () => this.portalManager.updatePosition();
690
+ }
691
+
692
+ private addOpenListeners(): void {
693
+ document.addEventListener('focusin', this.handleDocumentFocusIn);
694
+ document.addEventListener('keydown', this.handleDocumentKeyDown);
695
+ document.addEventListener('mousedown', this.handleDocumentMouseDown);
696
+ window.addEventListener('error', this.handleWindowError);
697
+ if (this.portal) {
698
+ window.addEventListener('resize', this.handleWindowResize);
699
+ window.addEventListener('scroll', this.handleWindowScroll, true);
700
+ }
701
+ }
702
+
703
+ private removeOpenListeners(): void {
704
+ document.removeEventListener('focusin', this.handleDocumentFocusIn);
705
+ document.removeEventListener('keydown', this.handleDocumentKeyDown);
706
+ document.removeEventListener('mousedown', this.handleDocumentMouseDown);
707
+ window.removeEventListener('error', this.handleWindowError);
708
+ window.removeEventListener('resize', this.handleWindowResize);
709
+ window.removeEventListener('scroll', this.handleWindowScroll, true);
710
+ }
711
+
712
+ // ── Keyboard navigation (WAI-ARIA Combobox) ──
713
+
714
+ private onInputKeyDown(event: KeyboardEvent): void {
715
+ switch (event.key) {
716
+ case 'ArrowDown':
717
+ event.preventDefault();
718
+ if (!this.open) {
719
+ this.show();
720
+ } else {
721
+ this.moveActiveOption(this.gridColumns > 1 ? this.gridColumns : 1);
722
+ }
723
+ break;
724
+ case 'ArrowUp':
725
+ event.preventDefault();
726
+ if (!this.open) {
727
+ this.show();
728
+ } else {
729
+ this.moveActiveOption(this.gridColumns > 1 ? -this.gridColumns : -1);
730
+ }
731
+ break;
732
+ case 'ArrowRight':
733
+ if (this.gridColumns > 1 && this.open) {
734
+ event.preventDefault();
735
+ this.moveActiveOption(1);
736
+ }
737
+ break;
738
+ case 'ArrowLeft':
739
+ if (this.gridColumns > 1 && this.open) {
740
+ event.preventDefault();
741
+ this.moveActiveOption(-1);
742
+ }
743
+ break;
744
+ case 'Enter':
745
+ event.preventDefault();
746
+ if (this.open) {
747
+ this.selectActiveOption();
748
+ } else if (this.searchValue.trim() && (this.acceptUserInput || this.allowCustomValue)) {
749
+ this.addCustomValue(this.searchValue.trim());
750
+ } else {
751
+ this.show();
752
+ }
753
+ break;
754
+ case 'Escape':
755
+ if (this.open) {
756
+ event.preventDefault();
757
+ event.stopPropagation();
758
+ this.hide();
759
+ }
760
+ break;
761
+ case 'Home':
762
+ if (this.open) {
763
+ event.preventDefault();
764
+ this.setActiveOption(0);
765
+ }
766
+ break;
767
+ case 'End':
768
+ if (this.open) {
769
+ event.preventDefault();
770
+ this.setActiveOption(this.filteredData.length - 1);
771
+ }
772
+ break;
773
+ case 'Backspace':
774
+ if (this.multiple && this.searchValue === '' && this.selectedOptions.length > 0) {
775
+ const last = this.selectedOptions[this.selectedOptions.length - 1];
776
+ this.removeTag(last);
777
+ }
778
+ break;
779
+ case 'Tab':
780
+ if (this.multiple && this.acceptUserInput && this.searchValue.trim()) {
781
+ this.addCustomValue(this.searchValue.trim());
782
+ }
783
+ if (this.open) this.hide();
784
+ break;
785
+ }
786
+ }
787
+
788
+ private onGlobalKeyDown(event: KeyboardEvent): void {
789
+ if (event.key === 'Escape' && this.open) {
790
+ event.preventDefault();
791
+ event.stopPropagation();
792
+ this.hide();
793
+ this.inputElement?.focus({ preventScroll: true });
794
+ }
795
+ }
796
+
797
+ private getVisibleOptions(): NodeListOf<Element> | Element[] {
798
+ const root = this.portal ? this.portalManager.portalContainerElement : this.shadowRoot;
799
+ if (!root) return [];
800
+ return root.querySelectorAll('nile-option:not([disabled])');
801
+ }
802
+
803
+ private moveActiveOption(delta: number): void {
804
+ const options = this.getVisibleOptions();
805
+ if (!options.length) return;
806
+ let next = this.keyboardActiveIndex + delta;
807
+ if (next < 0) next = options.length - 1;
808
+ if (next >= options.length) next = 0;
809
+ this.setActiveOption(next);
810
+ }
811
+
812
+ private setActiveOption(index: number): void {
813
+ const options = this.getVisibleOptions();
814
+ if (!options.length) return;
815
+
816
+ options.forEach((opt, i) => {
817
+ const el = opt as HTMLElement;
818
+ if (i === index) {
819
+ el.setAttribute('aria-current', 'true');
820
+ el.classList.add('combobox__option--active');
821
+ el.scrollIntoView?.({ block: 'nearest' });
822
+ } else {
823
+ el.removeAttribute('aria-current');
824
+ el.classList.remove('combobox__option--active');
825
+ }
826
+ });
827
+
828
+ this.keyboardActiveIndex = index;
829
+ }
830
+
831
+ private selectActiveOption(): void {
832
+ const options = this.getVisibleOptions();
833
+ if (this.keyboardActiveIndex >= 0 && this.keyboardActiveIndex < options.length) {
834
+ const opt = options[this.keyboardActiveIndex] as any;
835
+ if (opt && !opt.disabled) {
836
+ this.handleOptionSelection(opt.value);
837
+ }
838
+ } else if (this.searchValue.trim() && (this.allowCustomValue || this.acceptUserInput)) {
839
+ this.addCustomValue(this.searchValue.trim());
840
+ }
841
+ }
842
+
843
+ // ── Input handling ──
844
+
845
+ private onInputChange(event: Event): void {
846
+ event.stopPropagation();
847
+ }
848
+
849
+ private onInputHandler(event: Event): void {
850
+ const target = event.target as HTMLInputElement;
851
+ this.searchValue = target.value;
852
+
853
+ if (!this.open) this.show();
854
+
855
+ this.emit('nile-input', { value: this.searchValue, name: this.name });
856
+
857
+ if (this.disableLocalSearch) {
858
+ this.searchManager.debounceSearch(
859
+ (q: string) => this.emit('nile-search', { query: q, name: this.name }),
860
+ this.searchValue,
861
+ this.debounceMs,
862
+ );
863
+ } else {
864
+ this.filterOptions(this.searchValue);
865
+ }
866
+
867
+ this.keyboardActiveIndex = -1;
868
+ }
869
+
870
+ private filterOptions(search: string, preserveScroll = false): void {
871
+ const baseData = this.originalData.length > 0 ? this.originalData : this.data;
872
+
873
+ if (this.hasGroupedData) {
874
+ // Grouped path: filter the tree, derive options-only projection.
875
+ let tree = baseData;
876
+ if (this.showSelectedOnly) {
877
+ const selectedValues = Array.isArray(this.value) ? this.value : [this.value];
878
+ const selectedSet = new Set(selectedValues.map(v => String(v)));
879
+ tree = this.pruneTreeBySelection(baseData, selectedSet);
880
+ }
881
+ const { rows } = filterRows(tree, search, this.getSearchText.bind(this));
882
+ this.filteredRows = rows;
883
+ this.filteredData = getOptionRows(rows).map(r => r.item);
884
+ this.showNoResults = this.filteredData.length === 0;
885
+ // Recompute sticky header at current scroll (defaults to top on filter).
886
+ const st = this.scrollElementRef.value?.scrollTop ?? 0;
887
+ this.updateStickyHeader(st);
888
+ } else {
889
+ let source = baseData;
890
+ if (this.showSelectedOnly) {
891
+ const selectedValues = Array.isArray(this.value) ? this.value : [this.value];
892
+ const selectedSet = new Set(selectedValues.map(v => String(v)));
893
+ source = baseData.filter((item: any) => selectedSet.has(String(this.getItemValue(item))));
894
+ }
895
+ const { filteredItems, showNoResults } = this.searchManager.filter(
896
+ search,
897
+ source,
898
+ this.getSearchText.bind(this),
899
+ );
900
+ this.filteredData = filteredItems;
901
+ this.filteredRows = [];
902
+ this.showNoResults = showNoResults;
903
+ }
904
+
905
+ this.portalManager.resetMeasuredHeight();
906
+ if (!preserveScroll) {
907
+ this.resetScrollPosition();
908
+ }
909
+ this.updateSelectAllState();
910
+ this.requestUpdate();
911
+ }
912
+
913
+ // ── Focus ──
914
+
915
+ private onFocusIn(): void {
916
+ this.hasFocus = true;
917
+ this.emit('nile-focus');
918
+ if (!this.multiple && this.displayLabel) {
919
+ this.searchValue = this.displayLabel;
920
+ this.requestUpdate();
921
+ this.updateComplete.then(() => {
922
+ this.inputElement?.select();
923
+ });
924
+ }
925
+ }
926
+
927
+ private onFocusOut(): void {
928
+ this.hasFocus = false;
929
+ this.emit('nile-blur');
930
+
931
+ if (this.open) return;
932
+
933
+ if (!this.multiple) {
934
+ if (this.strict) {
935
+ const baseData = this.originalData.length > 0 ? this.originalData : this.data;
936
+ const allItems = this.hasGroupedData
937
+ ? getOptionRows(flattenRows(baseData)).map(r => r.item)
938
+ : baseData;
939
+ const match = allItems.find(
940
+ (item: any) => this.getDisplayText(item).toLowerCase() === this.searchValue.toLowerCase(),
941
+ );
942
+ if (match) {
943
+ const val = this.getItemValue(match);
944
+ if (this.value !== val) {
945
+ this.value = val;
946
+ this.syncSelection();
947
+ this.emit('nile-change', { value: this.value, name: this.name });
948
+ }
949
+ } else {
950
+ this.searchValue = this.displayLabel;
951
+ }
952
+ } else {
953
+ this.searchValue = this.displayLabel;
954
+ }
955
+ }
956
+ }
957
+
958
+ // ── Combobox trigger interaction ──
959
+
960
+ private onTriggerMouseDown(event: MouseEvent): void {
961
+ if (this.disabled) return;
962
+ const path = event.composedPath();
963
+ if (path.some(el => el instanceof Element && el.tagName.toLowerCase() === 'nile-icon-button')) return;
964
+
965
+ event.preventDefault();
966
+ this.inputElement?.focus({ preventScroll: true });
967
+ if (this.open) {
968
+ this.hide();
969
+ } else {
970
+ this.show();
971
+ }
972
+ }
973
+
974
+ // ── Option click ──
975
+
976
+ private onOptionClick(event: MouseEvent): void {
977
+ // Use composedPath to find nile-option across shadow boundaries
978
+ const path = event.composedPath();
979
+ const option = path.find((el: EventTarget) =>
980
+ (el as HTMLElement).tagName === 'NILE-OPTION'
981
+ ) as any;
982
+
983
+ if (!option || option.disabled) return;
984
+ this.handleOptionSelection(option.value);
985
+ }
986
+
987
+ private handleOptionSelection(optionValue: string): void {
988
+ const oldValue = this.value;
989
+
990
+ if (this.multiple) {
991
+ const current = Array.isArray(this.value) ? this.value : [];
992
+ this.value = ComboboxSelectionManager.toggleMultiValue(current, optionValue);
993
+ this.searchValue = '';
994
+ this.syncSelection();
995
+ this.filterOptions('', true);
996
+ } else {
997
+ this.value = optionValue;
998
+ this.syncSelection();
999
+ this.searchValue = this.displayLabel;
1000
+ this.hide();
1001
+ }
1002
+
1003
+ if (JSON.stringify(this.value) !== JSON.stringify(oldValue)) {
1004
+ this.updateComplete.then(() => {
1005
+ this.emit('nile-input', { value: this.value, name: this.name });
1006
+ this.emit('nile-change', { value: this.value, name: this.name });
1007
+ });
1008
+ }
1009
+
1010
+ this.inputElement?.focus({ preventScroll: true });
1011
+ }
1012
+
1013
+ private addCustomValue(val: string): void {
1014
+ if (this.addToSuggestions) {
1015
+ const allItems = this.originalData.length > 0 ? this.originalData : this.data;
1016
+ const alreadyExists = allItems.some((item: any) =>
1017
+ String(this.getItemValue(item)) === val || this.getDisplayText(item) === val,
1018
+ );
1019
+ if (!alreadyExists) {
1020
+ const newItem = this.renderItemConfig?.getValue
1021
+ ? { value: val, label: val }
1022
+ : val;
1023
+ this.originalData = [...allItems, newItem];
1024
+ this.data = [...this.originalData];
1025
+ this.autoCompleteOptions = [...this.originalData];
1026
+ this.filteredData = [...this.originalData];
1027
+ }
1028
+ }
1029
+
1030
+ if (this.multiple) {
1031
+ const current = Array.isArray(this.value) ? this.value : [];
1032
+ if (!current.includes(val)) {
1033
+ this.value = [...current, val];
1034
+ }
1035
+ } else {
1036
+ this.value = val;
1037
+ this.hide();
1038
+ }
1039
+
1040
+ this.searchValue = this.multiple ? '' : val;
1041
+ this.syncSelection();
1042
+ this.filterOptions(this.searchValue);
1043
+
1044
+ this.emit('nile-tag-add', { value: val, name: this.name });
1045
+ this.emit('nile-change', { value: this.value, name: this.name });
1046
+ }
1047
+
1048
+ // ── Clear ──
1049
+
1050
+ private onClearClick(event: MouseEvent): void {
1051
+ event.stopPropagation();
1052
+ this.value = this.multiple ? [] : '';
1053
+ this.searchValue = '';
1054
+ this.syncSelection();
1055
+ this.filterOptions('');
1056
+ this.emit('nile-clear', { value: this.value, name: this.name });
1057
+ this.emit('nile-change', { value: this.value, name: this.name });
1058
+ }
1059
+
1060
+ // ── Tags ──
1061
+
1062
+ private removeTag(option: ComboboxOption): void {
1063
+ if (this.disabled) return;
1064
+ const current = Array.isArray(this.value) ? this.value : [];
1065
+ this.value = ComboboxSelectionManager.removeValue(current, option.value);
1066
+ this.syncSelection();
1067
+ this.emit('nile-tag-remove', { value: this.value, name: this.name, removedtagvalue: option.value });
1068
+ this.emit('nile-change', { value: this.value, name: this.name });
1069
+ }
1070
+
1071
+ // ── Footer ──
1072
+
1073
+ private onFooterClick(event: MouseEvent): void {
1074
+ event.stopPropagation();
1075
+ event.preventDefault();
1076
+ }
1077
+
1078
+ private toggleShowSelected(event: Event): void {
1079
+ event.stopPropagation();
1080
+ event.preventDefault();
1081
+ if (this.selectedOptions.length === 0) return;
1082
+
1083
+ this.showSelectedOnly = !this.showSelectedOnly;
1084
+ if (this.showSelectedOnly) this.searchValue = '';
1085
+ this.filterOptions(this.searchValue);
1086
+ }
1087
+
1088
+ private clearAll(): void {
1089
+ this.showSelectedOnly = false;
1090
+ this.value = this.multiple ? [] : '';
1091
+ this.filterOptions('');
1092
+ this.syncSelection();
1093
+ this.emit('nile-change', { value: this.value, name: this.name });
1094
+ this.emit('nile-clear', { value: this.value, name: this.name });
1095
+ this.resetScrollPosition();
1096
+ }
1097
+
1098
+ // ── Scroll ──
1099
+
1100
+ private onScroll(e: Event): void {
1101
+ const target = e.target as HTMLElement;
1102
+ this.updateStickyHeader(target.scrollTop);
1103
+ if (this.showSelectedOnly) return;
1104
+
1105
+ this.emit('nile-scroll', { scrollTop: target.scrollTop, scrollLeft: target.scrollLeft, name: this.name });
1106
+
1107
+ if (!this.scrolling) {
1108
+ this.scrolling = true;
1109
+ this.emit('nile-scroll-start', { scrollTop: target.scrollTop, scrollLeft: target.scrollLeft, name: this.name });
1110
+ }
1111
+
1112
+ clearTimeout(this.scrollTimeout);
1113
+ this.scrollTimeout = window.setTimeout(() => {
1114
+ this.scrolling = false;
1115
+ }, 300);
1116
+
1117
+ const isAtBottom = Math.ceil(target.scrollTop) >= Math.floor(target.scrollHeight - target.offsetHeight);
1118
+ if (isAtBottom) {
1119
+ this.emit('nile-scroll-end', { scrollTop: target.scrollTop, scrollLeft: target.scrollLeft, name: this.name, isAtBottom: true });
1120
+ }
1121
+ }
1122
+
1123
+ // ── Show / Hide ──
1124
+
1125
+ async show(): Promise<unknown> {
1126
+ if (this.open || this.disabled) return undefined;
1127
+ this.open = true;
1128
+ return waitForEvent(this, 'nile-after-show');
1129
+ }
1130
+
1131
+ async hide(): Promise<unknown> {
1132
+ if (!this.open) return undefined;
1133
+ this.open = false;
1134
+ return waitForEvent(this, 'nile-after-hide');
1135
+ }
1136
+
1137
+ @watch('open', { waitUntilFirstUpdate: true })
1138
+ async handleOpenChange(): Promise<void> {
1139
+ if (this.open && !this.disabled) {
1140
+ this.visibilityManager?.setup();
1141
+ this.showListbox = true;
1142
+ await this.updateComplete;
1143
+ await this.doOpen();
1144
+
1145
+ if (this.portal) this.portalManager.setupPortal();
1146
+ } else {
1147
+ this.visibilityManager?.cleanup();
1148
+ await this.doClose();
1149
+ this.showListbox = false;
1150
+
1151
+ if (this.portal) this.portalManager.cleanupPortal();
1152
+ }
1153
+ }
1154
+
1155
+ private async doOpen(): Promise<void> {
1156
+ this.emit('nile-show', { value: this.value, name: this.name });
1157
+ this.addOpenListeners();
1158
+ this.keyboardActiveIndex = -1;
1159
+
1160
+ if (this.originalData.length === 0 && this.data.length > 0) {
1161
+ this.originalData = [...this.data];
1162
+ }
1163
+ if (this.hasGroupedData) {
1164
+ this.filterOptions(this.searchValue);
1165
+ } else {
1166
+ this.filteredData = [...(this.originalData.length > 0 ? this.originalData : this.data)];
1167
+ this.filteredRows = [];
1168
+ this.showNoResults = this.filteredData.length === 0;
1169
+ }
1170
+
1171
+ await stopAnimations(this);
1172
+
1173
+ if (this.popup?.popup) {
1174
+ this.popup.popup.style.visibility = 'hidden';
1175
+ }
1176
+
1177
+ this.popup.active = true;
1178
+
1179
+ await new Promise(r => requestAnimationFrame(r));
1180
+ await new Promise(r => requestAnimationFrame(r));
1181
+
1182
+ if (this.popup?.popup) {
1183
+ this.popup.popup.style.visibility = '';
1184
+ }
1185
+
1186
+ const { keyframes, options } = getAnimation(this, 'combobox.show', { dir: 'ltr' });
1187
+ await animateTo(this.popup.popup, keyframes, options);
1188
+
1189
+ this.resetScrollPosition();
1190
+ this.emit('nile-after-show', { value: this.value, name: this.name });
1191
+ }
1192
+
1193
+ private async doClose(): Promise<void> {
1194
+ this.emit('nile-hide', { value: this.value, name: this.name });
1195
+ this.removeOpenListeners();
1196
+
1197
+ await stopAnimations(this);
1198
+ const { keyframes, options } = getAnimation(this, 'combobox.hide', { dir: 'ltr' });
1199
+ await animateTo(this.popup.popup, keyframes, options);
1200
+ this.popup.active = false;
1201
+
1202
+ if (this.popup?.popup) this.popup.popup.style.visibility = '';
1203
+
1204
+ this.showSelectedOnly = false;
1205
+ this.portalManager.resetMeasuredHeight();
1206
+
1207
+ if (!this.multiple) {
1208
+ this.searchValue = this.displayLabel;
1209
+ }
1210
+
1211
+ this.emit('nile-after-hide', { value: this.value, name: this.name });
1212
+ }
1213
+
1214
+ // ── Watchers ──
1215
+
1216
+ @watch('disabled', { waitUntilFirstUpdate: true })
1217
+ handleDisabledChange(): void {
1218
+ if (this.disabled) {
1219
+ this.open = false;
1220
+ this.handleOpenChange();
1221
+ }
1222
+ }
1223
+
1224
+ @watch('value', { waitUntilFirstUpdate: true })
1225
+ handleValueChange(): void {
1226
+ this.syncSelection();
1227
+ this.requestUpdate();
1228
+ if (this.portal && this.portalManager.portalContainerElement) {
1229
+ this.portalManager.updatePosition();
1230
+ }
1231
+ }
1232
+
1233
+ @watch('data', { waitUntilFirstUpdate: true })
1234
+ handleDataChange(): void {
1235
+ if (this.data.length > 0 && !this.showSelectedOnly) {
1236
+ this.originalData = [...this.data];
1237
+ }
1238
+ if (this.hasGroupedData) {
1239
+ this.filterOptions(this.searchValue);
1240
+ } else {
1241
+ this.filteredData = [...this.data];
1242
+ this.filteredRows = [];
1243
+ }
1244
+ this.syncSelection();
1245
+ this.updateSelectAllState();
1246
+
1247
+ if (!this.optionsLoading && !this.loading && this.data.length === 0) {
1248
+ this.showNoResults = true;
1249
+ } else if (this.data.length > 0) {
1250
+ this.showNoResults = false;
1251
+ }
1252
+
1253
+ this.portalManager.resetMeasuredHeight();
1254
+ if (this.portal && this.portalManager.portalContainerElement) {
1255
+ this.portalManager.updatePosition();
1256
+ }
1257
+ this.requestUpdate();
1258
+ }
1259
+
1260
+ @watch('autoCompleteOptions', { waitUntilFirstUpdate: true })
1261
+ handleAutoCompleteOptionsChange(): void {
1262
+ if (this.autoCompleteOptions.length > 0) {
1263
+ this.data = [...this.autoCompleteOptions];
1264
+ }
1265
+ }
1266
+
1267
+ @watch('multiple', { waitUntilFirstUpdate: true })
1268
+ @watch('selectAllEnabled', { waitUntilFirstUpdate: true })
1269
+ handleSelectAllConfigChange(): void {
1270
+ this.updateSelectAllState();
1271
+ }
1272
+
1273
+ @watch('portal', { waitUntilFirstUpdate: true })
1274
+ handlePortalChange(): void {
1275
+ if (this.open) {
1276
+ if (this.portal) this.portalManager.setupPortal();
1277
+ else this.portalManager.cleanupPortal();
1278
+ }
1279
+ }
1280
+
1281
+ // ── Form integration ──
1282
+
1283
+ checkValidity(): boolean { return this.valueInput.checkValidity(); }
1284
+ getForm(): HTMLFormElement | null { return this.formControlController.getForm(); }
1285
+ reportValidity(): boolean { return this.valueInput.reportValidity(); }
1286
+ setCustomValidity(message: string): void {
1287
+ this.valueInput.setCustomValidity(message);
1288
+ this.formControlController.updateValidity();
1289
+ }
1290
+
1291
+ focus(options?: FocusOptions): void { this.inputElement?.focus(options); }
1292
+ blur(): void { this.inputElement?.blur(); }
1293
+
1294
+ private handleInvalid(event: Event): void {
1295
+ this.formControlController.setValidity(false);
1296
+ this.formControlController.emitInvalidEvent(event);
1297
+ }
1298
+
1299
+ private updateValidity(): void {
1300
+ this.updateComplete.then(() => this.formControlController.updateValidity());
1301
+ }
1302
+
1303
+ private async resetScrollPosition(): Promise<void> {
1304
+ await this.portalManager.resetScrollPosition();
1305
+ }
1306
+
1307
+ // ── Render ──
1308
+
1309
+ render(): TemplateResult {
1310
+ const hasLabelSlot = this.hasSlotController.test('label');
1311
+ const hasLabel = this.label ? true : !!hasLabelSlot;
1312
+ const hasValue = this.multiple
1313
+ ? (Array.isArray(this.value) && this.value.length > 0)
1314
+ : (typeof this.value === 'string' && this.value !== '');
1315
+ const hasClearIcon = this.clearable && !this.disabled && hasValue;
1316
+ const hasHelpText = !!this.helpText;
1317
+ const hasErrorMessage = !!this.errorMessage;
1318
+ const isEmpty = !hasValue;
1319
+
1320
+ return html`
1321
+ <div
1322
+ part="form-control"
1323
+ class=${classMap({
1324
+ 'form-control': true,
1325
+ 'form-control--small': this.size === 'small',
1326
+ 'form-control--medium': this.size === 'medium',
1327
+ 'form-control--large': this.size === 'large',
1328
+ 'form-control--has-label': hasLabel,
1329
+ 'form-control--has-help-text': hasHelpText,
1330
+ })}
1331
+ >
1332
+ <label
1333
+ id="label"
1334
+ part="form-control-label"
1335
+ class="form-control__label"
1336
+ aria-hidden=${hasLabel ? 'false' : 'true'}
1337
+ @click=${() => this.inputElement?.focus()}
1338
+ >
1339
+ <slot name="label">${this.label}</slot>
1340
+ </label>
1341
+
1342
+ <div part="form-control-input" class="form-control-input">
1343
+ <nile-popup
1344
+ class=${classMap({
1345
+ 'combobox-popup': true,
1346
+ 'combobox--open': this.open,
1347
+ 'combobox--closed': !this.open,
1348
+ 'combobox--disabled': this.disabled,
1349
+ 'combobox--focused': this.hasFocus,
1350
+ 'combobox--multiple': this.multiple,
1351
+ 'combobox--empty': isEmpty,
1352
+ 'combobox--small': this.size === 'small',
1353
+ 'combobox--medium': this.size === 'medium',
1354
+ 'combobox--large': this.size === 'large',
1355
+ 'combobox--warning': this.warning,
1356
+ 'combobox--error': this.error,
1357
+ 'combobox--success': this.success,
1358
+ 'combobox--filled': this.filled,
1359
+ 'combobox--pill': this.pill,
1360
+ })}
1361
+ placement=${this.placement}
1362
+ strategy=${this.portal ? 'fixed' : (this.hoist ? 'fixed' : 'absolute')}
1363
+ distance="6"
1364
+ ?flip=${!this.portal}
1365
+ ?shift=${!this.portal}
1366
+ sync=${ifDefined(!this.noWidthSync ? 'width' : undefined)}
1367
+ auto-size="vertical"
1368
+ auto-size-padding="10"
1369
+ >
1370
+ ${this.renderTrigger(hasClearIcon)}
1371
+ ${this.showListbox ? this.renderListbox() : html``}
1372
+ </nile-popup>
1373
+
1374
+ ${hasHelpText ? html`<nile-form-help-text>${this.helpText}</nile-form-help-text>` : html``}
1375
+ ${hasErrorMessage ? html`<nile-form-error-message>${this.errorMessage}</nile-form-error-message>` : html``}
1376
+ </div>
1377
+ </div>
1378
+ `;
1379
+ }
1380
+
1381
+ private renderTrigger(hasClearIcon: boolean): TemplateResult {
1382
+ const hasTags = this.multiple && this.selectedOptions.length > 0;
1383
+ const inputPlaceholder = hasTags ? '' : this.placeholder;
1384
+
1385
+ return html`
1386
+ <div
1387
+ part="combobox"
1388
+ class="combobox__trigger"
1389
+ slot="anchor"
1390
+ @mousedown=${this.onTriggerMouseDown}
1391
+ >
1392
+ <slot part="prefix" name="prefix" class="combobox__prefix"></slot>
1393
+
1394
+ <div class=${classMap({
1395
+ 'combobox__scroll-area': true,
1396
+ 'combobox__scroll-area--single-line': this.multiple && this.tagLayout === 'single-line',
1397
+ 'combobox__scroll-area--wrap': this.multiple && (this.tagLayout === 'wrap' || this.tagLayout === 'fixed-height'),
1398
+ })}>
1399
+ ${hasTags ? this.renderInlineTags() : html``}
1400
+
1401
+ <div class="combobox__input-wrapper">
1402
+ <input
1403
+ part="input"
1404
+ class="combobox__input"
1405
+ type="text"
1406
+ placeholder=${inputPlaceholder}
1407
+ .disabled=${this.disabled}
1408
+ .value=${this.searchValue}
1409
+ autocomplete="off"
1410
+ spellcheck="false"
1411
+ autocapitalize="off"
1412
+ aria-controls="listbox"
1413
+ aria-expanded=${this.open ? 'true' : 'false'}
1414
+ aria-haspopup="listbox"
1415
+ aria-labelledby="label"
1416
+ aria-disabled=${this.disabled ? 'true' : 'false'}
1417
+ aria-describedby="help-text"
1418
+ aria-activedescendant=${this.keyboardActiveIndex >= 0 ? `option-${this.keyboardActiveIndex}` : ''}
1419
+ role="combobox"
1420
+ tabindex="0"
1421
+ @input=${this.onInputHandler}
1422
+ @keydown=${this.onInputKeyDown}
1423
+ @focus=${this.onFocusIn}
1424
+ @blur=${this.onFocusOut}
1425
+ @change=${this.onInputChange}
1426
+ />
1427
+ </div>
1428
+ </div>
1429
+
1430
+ <input
1431
+ class="combobox__value-input"
1432
+ type="text"
1433
+ ?disabled=${this.disabled}
1434
+ ?required=${this.required}
1435
+ .value=${Array.isArray(this.value) ? this.value.join(', ') : this.value}
1436
+ tabindex="-1"
1437
+ aria-hidden="true"
1438
+ @focus=${() => this.focus()}
1439
+ @invalid=${this.handleInvalid}
1440
+ />
1441
+
1442
+ <div class="combobox__actions">
1443
+ ${hasClearIcon ? this.renderClearButton() : html``}
1444
+
1445
+ <slot name="expand-icon" part="expand-icon" class="combobox__expand-icon">
1446
+ <nile-icon
1447
+ library="system"
1448
+ name="var(--nile-icon-arrow-down, var(--ng-icon-chevron-down))"
1449
+ method="var(--nile-svg-method-fill, var(--ng-svg-method-stroke))"
1450
+ color="var(--nile-colors-dark-500, var(--ng-colors-fg-quaternary-400))"
1451
+ ></nile-icon>
1452
+ </slot>
1453
+ </div>
1454
+ </div>
1455
+ `;
1456
+ }
1457
+
1458
+ private renderInlineTags(): TemplateResult[] {
1459
+ const tags: TemplateResult[] = [];
1460
+
1461
+ this.selectedOptions.forEach((option, index) => {
1462
+ if (this.maxTagsVisible > 0 && index >= this.maxTagsVisible) {
1463
+ if (index === this.maxTagsVisible) {
1464
+ tags.push(html`<span class="combobox__tags-count">+${this.selectedOptions.length - this.maxTagsVisible} More</span>`);
1465
+ }
1466
+ return;
1467
+ }
1468
+ tags.push(html`
1469
+ <nile-tag
1470
+ part="tag"
1471
+ exportparts="base:tag__base, content:tag__content, remove-button:tag__remove-button"
1472
+ ?pill=${this.pill}
1473
+ size=${this.size}
1474
+ removable
1475
+ ?disabled=${this.disabled}
1476
+ @nile-remove=${(e: NileRemoveEvent) => { e.stopPropagation(); this.removeTag(option); }}
1477
+ >
1478
+ ${option.getTextLabel()}
1479
+ </nile-tag>
1480
+ `);
1481
+ });
1482
+
1483
+ return tags;
1484
+ }
1485
+
1486
+ private renderClearButton(): TemplateResult {
1487
+ return html`
1488
+ <button
1489
+ part="clear-button"
1490
+ class="combobox__clear"
1491
+ type="button"
1492
+ aria-label="Clear"
1493
+ @mousedown=${(e: MouseEvent) => e.stopPropagation()}
1494
+ @click=${this.onClearClick}
1495
+ tabindex="-1"
1496
+ >
1497
+ <slot name="clear-icon">
1498
+ <nile-icon
1499
+ name="var(--nile-icon-close, var(--ng-icon-x-close))"
1500
+ size="var(--nile-height-14px, var(--ng-height-16px))"
1501
+ method="var(--nile-svg-method-fill, var(--ng-svg-method-stroke))"
1502
+ library="system"
1503
+ color="var(--nile-colors-dark-500, var(--ng-colors-fg-quaternary-400))"
1504
+ ></nile-icon>
1505
+ </slot>
1506
+ </button>
1507
+ `;
1508
+ }
1509
+
1510
+ private renderStickyHeaderOverlay(grouped: boolean, useVirtual: boolean): TemplateResult {
1511
+ if (!this.stickyGroupHeader || !grouped || !useVirtual) return html``;
1512
+ const idx = this.stickyHeaderIndex;
1513
+ if (idx < 0 || idx >= this.filteredRows.length) return html``;
1514
+ const row = this.filteredRows[idx];
1515
+ if (row.kind !== 'header') return html``;
1516
+ return html`
1517
+ <div class="combobox__group-sticky-overlay">
1518
+ ${ComboboxRenderer.renderGroupHeader(row)}
1519
+ </div>
1520
+ `;
1521
+ }
1522
+
1523
+ private renderListbox(): TemplateResult {
1524
+ const showAddOption = this.allowCustomValue
1525
+ && this.searchValue.trim()
1526
+ && !this.filteredData.some((item: any) => {
1527
+ const v = this.getItemValue(item);
1528
+ return String(v).toLowerCase() === this.searchValue.trim().toLowerCase();
1529
+ })
1530
+ && !this.filteredData.some((item: any) => {
1531
+ const t = this.getDisplayText(item);
1532
+ return t.toLowerCase() === this.searchValue.trim().toLowerCase();
1533
+ });
1534
+
1535
+ if (this.isHorizontalGrid) {
1536
+ return this.renderHorizontalListbox(!!showAddOption);
1537
+ }
1538
+
1539
+ const isGrid = this.gridColumns > 1 || this.isBidirectionalGrid;
1540
+ const grouped = this.hasGroupedData && !isGrid;
1541
+ const useVirtual = grouped
1542
+ ? this.filteredRows.length >= 5
1543
+ : ComboboxRenderer.shouldUseVirtualizer(this.filteredData, this.gridColumns);
1544
+ const virtualizer = this.virtualizerCtrl.getVirtualizer();
1545
+ const virtualItems = (useVirtual || isGrid) ? virtualizer.getVirtualItems() : [];
1546
+ const totalSize = (useVirtual || isGrid) ? virtualizer.getTotalSize() : 0;
1547
+
1548
+ return html`
1549
+ <div
1550
+ id="listbox"
1551
+ role="listbox"
1552
+ aria-expanded=${this.open ? 'true' : 'false'}
1553
+ aria-multiselectable=${this.multiple ? 'true' : 'false'}
1554
+ aria-labelledby="label"
1555
+ part="listbox"
1556
+ class="combobox__listbox ${this.isBidirectionalGrid ? 'combobox__listbox--bidirectional' : ''} ${this.portal ? 'combobox__portal-hidden' : ''}"
1557
+ tabindex="-1"
1558
+ @mouseup=${this.onOptionClick}
1559
+ @scroll=${this.onScroll}
1560
+ style="${this.portal ? 'display: none;' : ''}${this.isBidirectionalGrid ? `max-height:${this.gridRows * 38 + 16}px;` : ''}"
1561
+ ${ref(this.scrollElementRef)}
1562
+ >
1563
+ ${this.renderLoader()}
1564
+ ${this.renderSelectAll()}
1565
+ ${this.renderStickyHeaderOverlay(grouped, useVirtual)}
1566
+ ${this.showNoResults && !this.optionsLoading && !this.loading
1567
+ ? this.renderEmptyState()
1568
+ : isGrid
1569
+ ? ComboboxRenderer.renderVirtualizedGrid(
1570
+ virtualItems,
1571
+ totalSize,
1572
+ this.filteredData,
1573
+ this.value,
1574
+ this.multiple,
1575
+ this.gridColumns,
1576
+ this.getDisplayText.bind(this),
1577
+ this.getItemValue.bind(this),
1578
+ this.optionsLoading || this.loading,
1579
+ this.allowHtmlLabel,
1580
+ this.renderItemConfig?.getDescription ? this.getItemDescription.bind(this) : undefined,
1581
+ this.renderItemConfig?.getPrefix ? this.getItemPrefix.bind(this) : undefined,
1582
+ this.renderItemConfig?.getSuffix ? this.getItemSuffix.bind(this) : undefined,
1583
+ this.isBidirectionalGrid ? this.gridColumnWidth : undefined,
1584
+ )
1585
+ : grouped
1586
+ ? (useVirtual
1587
+ ? ComboboxRenderer.renderRowsVirtualized(
1588
+ virtualItems,
1589
+ totalSize,
1590
+ this.filteredRows,
1591
+ this.value,
1592
+ this.multiple,
1593
+ this.getDisplayText.bind(this),
1594
+ this.getItemValue.bind(this),
1595
+ this.optionsLoading || this.loading,
1596
+ this.allowHtmlLabel,
1597
+ this.renderItemConfig?.getDescription ? this.getItemDescription.bind(this) : undefined,
1598
+ this.renderItemConfig?.getPrefix ? this.getItemPrefix.bind(this) : undefined,
1599
+ this.renderItemConfig?.getSuffix ? this.getItemSuffix.bind(this) : undefined,
1600
+ )
1601
+ : ComboboxRenderer.renderRowsPlain(
1602
+ this.filteredRows,
1603
+ this.value,
1604
+ this.multiple,
1605
+ this.getDisplayText.bind(this),
1606
+ this.getItemValue.bind(this),
1607
+ this.showNoResults,
1608
+ this.noResultsMessage,
1609
+ this.optionsLoading || this.loading,
1610
+ this.onScroll.bind(this),
1611
+ this.allowHtmlLabel,
1612
+ this.renderItemConfig?.getDescription ? this.getItemDescription.bind(this) : undefined,
1613
+ this.renderItemConfig?.getPrefix ? this.getItemPrefix.bind(this) : undefined,
1614
+ this.renderItemConfig?.getSuffix ? this.getItemSuffix.bind(this) : undefined,
1615
+ undefined,
1616
+ this.hasActiveFilter ? this.noResultsSubtitle : undefined,
1617
+ ))
1618
+ : useVirtual
1619
+ ? ComboboxRenderer.renderVirtualizedOptions(
1620
+ virtualItems,
1621
+ totalSize,
1622
+ this.filteredData,
1623
+ this.value,
1624
+ this.multiple,
1625
+ this.getDisplayText.bind(this),
1626
+ this.getItemValue.bind(this),
1627
+ this.optionsLoading || this.loading,
1628
+ this.allowHtmlLabel,
1629
+ virtualizer.measureElement,
1630
+ this.renderItemConfig?.getDescription ? this.getItemDescription.bind(this) : undefined,
1631
+ this.renderItemConfig?.getPrefix ? this.getItemPrefix.bind(this) : undefined,
1632
+ this.renderItemConfig?.getSuffix ? this.getItemSuffix.bind(this) : undefined,
1633
+ )
1634
+ : ComboboxRenderer.renderPlainOptions(
1635
+ this.filteredData,
1636
+ this.value,
1637
+ this.multiple,
1638
+ this.getDisplayText.bind(this),
1639
+ this.getItemValue.bind(this),
1640
+ this.showNoResults,
1641
+ this.noResultsMessage,
1642
+ this.optionsLoading || this.loading,
1643
+ this.onScroll.bind(this),
1644
+ this.allowHtmlLabel,
1645
+ this.renderItemConfig?.getDescription ? this.getItemDescription.bind(this) : undefined,
1646
+ this.renderItemConfig?.getPrefix ? this.getItemPrefix.bind(this) : undefined,
1647
+ this.renderItemConfig?.getSuffix ? this.getItemSuffix.bind(this) : undefined,
1648
+ undefined,
1649
+ this.hasActiveFilter ? this.noResultsSubtitle : undefined,
1650
+ )
1651
+ }
1652
+ ${showAddOption ? html`
1653
+ <div @mouseup=${(e: MouseEvent) => { e.stopPropagation(); this.addCustomValue(this.searchValue.trim()); }}>
1654
+ ${ComboboxRenderer.renderAddCustomOption(this.searchValue.trim(), this.multiple)}
1655
+ </div>
1656
+ ` : ''}
1657
+ ${this.multiple && this.showFooter && !this.selectAllEnabled ? this.renderFooter() : ''}
1658
+ </div>
1659
+ `;
1660
+ }
1661
+
1662
+ private renderHorizontalListbox(showAddOption: boolean): TemplateResult {
1663
+ const hVirtualizer = this.hVirtualizerCtrl.getVirtualizer();
1664
+ const virtualItems = hVirtualizer.getVirtualItems();
1665
+ const totalSize = hVirtualizer.getTotalSize();
1666
+
1667
+ return html`
1668
+ <div
1669
+ id="listbox"
1670
+ role="listbox"
1671
+ aria-expanded=${this.open ? 'true' : 'false'}
1672
+ aria-multiselectable=${this.multiple ? 'true' : 'false'}
1673
+ aria-labelledby="label"
1674
+ part="listbox"
1675
+ class="combobox__listbox combobox__listbox--horizontal ${this.portal ? 'combobox__portal-hidden' : ''}"
1676
+ tabindex="-1"
1677
+ @mouseup=${this.onOptionClick}
1678
+ style=${this.portal ? 'display: none;' : ''}
1679
+ ${ref(this.hScrollElementRef)}
1680
+ >
1681
+ ${this.renderLoader()}
1682
+ ${this.renderSelectAll()}
1683
+ ${this.showNoResults && !this.optionsLoading && !this.loading
1684
+ ? this.renderEmptyState()
1685
+ : ComboboxRenderer.renderHorizontalGrid(
1686
+ virtualItems,
1687
+ totalSize,
1688
+ this.filteredData,
1689
+ this.value,
1690
+ this.multiple,
1691
+ this.gridRows,
1692
+ this.gridColumnWidth,
1693
+ this.getDisplayText.bind(this),
1694
+ this.getItemValue.bind(this),
1695
+ this.optionsLoading || this.loading,
1696
+ this.allowHtmlLabel,
1697
+ this.renderItemConfig?.getDescription ? this.getItemDescription.bind(this) : undefined,
1698
+ this.renderItemConfig?.getPrefix ? this.getItemPrefix.bind(this) : undefined,
1699
+ this.renderItemConfig?.getSuffix ? this.getItemSuffix.bind(this) : undefined,
1700
+ )
1701
+ }
1702
+ ${showAddOption ? html`
1703
+ <div @mouseup=${(e: MouseEvent) => { e.stopPropagation(); this.addCustomValue(this.searchValue.trim()); }}>
1704
+ ${ComboboxRenderer.renderAddCustomOption(this.searchValue.trim(), this.multiple)}
1705
+ </div>
1706
+ ` : ''}
1707
+ ${this.multiple && this.showFooter && !this.selectAllEnabled ? this.renderFooter() : ''}
1708
+ </div>
1709
+ `;
1710
+ }
1711
+
1712
+ private renderLoader(): TemplateResult {
1713
+ if (this.loading) {
1714
+ return html`<span class="combobox__loader--center"><nile-loader size="sm"></nile-loader></span>`;
1715
+ }
1716
+ if (this.optionsLoading) {
1717
+ return html`<span class="combobox__loader"><nile-icon class="combobox__loader--icon" name="button-loading-blue"></nile-icon></span>`;
1718
+ }
1719
+ return html``;
1720
+ }
1721
+
1722
+ private renderSelectAll(): TemplateResult {
1723
+ if (!this.multiple || !this.selectAllEnabled) return html``;
1724
+ const disabled = this.showNoResults || this.optionsLoading || this.loading;
1725
+ const anySelected = this.selectAllChecked || this.selectAllIndeterminate;
1726
+ const label = anySelected ? 'Deselect all' : 'Select all';
1727
+ const showToggleDisabled = this.selectedOptions.length === 0 || this.showNoResults;
1728
+ const iconColor = showToggleDisabled
1729
+ ? 'var(--nile-colors-primary-500, var(--ng-colors-fg-quaternary-400))'
1730
+ : 'var(--nile-colors-primary-600, var(--ng-colors-fg-brand-secondary-600))';
1731
+
1732
+ return html`
1733
+ <div
1734
+ part="top-actions"
1735
+ class="combobox__top-actions ${disabled ? 'combobox__top-actions--disabled' : ''}"
1736
+ @mousedown=${(e: MouseEvent) => e.preventDefault()}
1737
+ >
1738
+ <div
1739
+ class="combobox__select-all"
1740
+ @click=${this.handleSelectAllToggle}
1741
+ >
1742
+ <nile-checkbox
1743
+ ?checked=${this.selectAllChecked}
1744
+ ?indeterminate=${this.selectAllIndeterminate}
1745
+ ?disabled=${disabled}
1746
+ >
1747
+ ${label}
1748
+ </nile-checkbox>
1749
+ </div>
1750
+
1751
+ <button
1752
+ part="show-toggle"
1753
+ class="combobox__show-toggle"
1754
+ type="button"
1755
+ ?disabled=${showToggleDisabled}
1756
+ @click=${this.toggleShowSelected}
1757
+ >
1758
+ <nile-icon
1759
+ library="system"
1760
+ name="filter"
1761
+ color=${iconColor}
1762
+ ></nile-icon>
1763
+ ${this.showSelectedOnly ? 'Show all' : 'Selected'}
1764
+ </button>
1765
+ </div>
1766
+ `;
1767
+ }
1768
+
1769
+ private renderFooter(): TemplateResult {
1770
+ return html`
1771
+ <div
1772
+ part="footer"
1773
+ class="combobox__footer ${this.loading ? 'loading' : ''}"
1774
+ @click=${this.onFooterClick}
1775
+ >
1776
+ <span @click=${this.toggleShowSelected} style="cursor: pointer;">
1777
+ <nile-checkbox
1778
+ ?disabled=${this.selectedOptions.length === 0}
1779
+ ?checked=${this.showSelectedOnly}
1780
+ >
1781
+ &nbsp; Show Selected
1782
+ </nile-checkbox>
1783
+ </span>
1784
+ ${this.selectedOptions.length > 0
1785
+ ? html`<span class="combobox__footer-clear" @click=${this.clearAll}>Clear All</span>`
1786
+ : ''}
1787
+ </div>
1788
+ `;
1789
+ }
1790
+ }
1791
+
1792
+ // ── Default animations ──
1793
+
1794
+ setDefaultAnimation('combobox.show', {
1795
+ keyframes: [
1796
+ { opacity: 0, scale: 0.9 },
1797
+ { opacity: 1, scale: 1 },
1798
+ ],
1799
+ options: { duration: 100, easing: 'ease' },
1800
+ });
1801
+
1802
+ setDefaultAnimation('combobox.hide', {
1803
+ keyframes: [
1804
+ { opacity: 1, scale: 1 },
1805
+ { opacity: 0, scale: 0.9 },
1806
+ ],
1807
+ options: { duration: 100, easing: 'ease' },
1808
+ });
1809
+
1810
+ export default NileCombobox;
1811
+
1812
+ declare global {
1813
+ interface HTMLElementTagNameMap {
1814
+ 'nile-combobox': NileCombobox;
1815
+ }
1816
+ }