@smilodon/core 1.0.0

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 (34) hide show
  1. package/dist/index.cjs +3685 -0
  2. package/dist/index.cjs.map +1 -0
  3. package/dist/index.js +3643 -0
  4. package/dist/index.js.map +1 -0
  5. package/dist/index.min.js +2 -0
  6. package/dist/index.min.js.map +1 -0
  7. package/dist/index.umd.js +3691 -0
  8. package/dist/index.umd.js.map +1 -0
  9. package/dist/index.umd.min.js +2 -0
  10. package/dist/index.umd.min.js.map +1 -0
  11. package/dist/types/src/components/enhanced-select.d.ts +136 -0
  12. package/dist/types/src/components/native-select.d.ts +41 -0
  13. package/dist/types/src/components/select-option.d.ts +77 -0
  14. package/dist/types/src/config/global-config.d.ts +194 -0
  15. package/dist/types/src/index.d.ts +14 -0
  16. package/dist/types/src/renderers/contracts.d.ts +5 -0
  17. package/dist/types/src/themes/importer.d.ts +112 -0
  18. package/dist/types/src/types.d.ts +85 -0
  19. package/dist/types/src/utils/csp-styles.d.ts +74 -0
  20. package/dist/types/src/utils/data-source.d.ts +37 -0
  21. package/dist/types/src/utils/dom-pool.d.ts +79 -0
  22. package/dist/types/src/utils/fenwick-tree.d.ts +71 -0
  23. package/dist/types/src/utils/security.d.ts +131 -0
  24. package/dist/types/src/utils/telemetry.d.ts +95 -0
  25. package/dist/types/src/utils/virtualizer.d.ts +53 -0
  26. package/dist/types/src/utils/worker-manager.d.ts +106 -0
  27. package/dist/types/tests/csp.spec.d.ts +5 -0
  28. package/dist/types/tests/interaction.spec.d.ts +1 -0
  29. package/dist/types/tests/setup.d.ts +6 -0
  30. package/dist/types/tests/shadow-dom.spec.d.ts +5 -0
  31. package/dist/types/tests/smoke.spec.d.ts +1 -0
  32. package/dist/types/tests/stress.spec.d.ts +5 -0
  33. package/dist/types/tests/virtualizer.spec.d.ts +1 -0
  34. package/package.json +52 -0
@@ -0,0 +1,3691 @@
1
+ (function (global, factory) {
2
+ typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
3
+ typeof define === 'function' && define.amd ? define(['exports'], factory) :
4
+ (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.NativeSelect = {}));
5
+ })(this, (function (exports) { 'use strict';
6
+
7
+ const createRendererHelpers = (onSelect) => ({
8
+ onSelect,
9
+ getIndex: (node) => {
10
+ const el = node?.closest?.('[data-selectable]');
11
+ if (!el)
12
+ return null;
13
+ const idx = Number(el.dataset.index);
14
+ return Number.isFinite(idx) ? idx : null;
15
+ },
16
+ keyboardFocus: (index) => {
17
+ const el = document.querySelector(`[data-selectable][data-index="${index}"]`);
18
+ el?.focus?.();
19
+ }
20
+ });
21
+ // Fast template render path: string -> DOM via DocumentFragment, with delegation markers
22
+ function renderTemplate(container, items, optionTemplate) {
23
+ const frag = document.createDocumentFragment();
24
+ for (let i = 0; i < items.length; i++) {
25
+ const item = items[i];
26
+ const wrapper = document.createElement('div');
27
+ wrapper.innerHTML = optionTemplate(item, i);
28
+ let el = wrapper.firstElementChild;
29
+ if (!el) {
30
+ // Edge case: template produced multiple or zero root nodes; wrap children
31
+ const wrap = document.createElement('div');
32
+ wrap.setAttribute('data-selectable', '');
33
+ wrap.setAttribute('data-index', String(i));
34
+ while (wrapper.firstChild)
35
+ wrap.appendChild(wrapper.firstChild);
36
+ frag.appendChild(wrap);
37
+ continue;
38
+ }
39
+ // Allow override if developer sets data-smilodon-handled="true"
40
+ if (!el.hasAttribute('data-smilodon-handled')) {
41
+ el.setAttribute('data-selectable', '');
42
+ el.setAttribute('data-index', String(i));
43
+ }
44
+ frag.appendChild(el);
45
+ }
46
+ container.replaceChildren(frag);
47
+ }
48
+
49
+ /**
50
+ * Fenwick Tree (Binary Indexed Tree) for O(log n) prefix sum queries and updates.
51
+ * Used for cumulative height calculations in variable-height virtualization.
52
+ *
53
+ * Space: O(n)
54
+ * Time: O(log n) for both query and update
55
+ *
56
+ * @see https://en.wikipedia.org/wiki/Fenwick_tree
57
+ */
58
+ class FenwickTree {
59
+ constructor(size) {
60
+ this.size = size;
61
+ // 1-indexed for cleaner bit manipulation
62
+ this.tree = new Float64Array(size + 1);
63
+ }
64
+ /**
65
+ * Add delta to index i (0-indexed input)
66
+ * Complexity: O(log n)
67
+ */
68
+ add(index, delta) {
69
+ // Convert to 1-indexed
70
+ let i = index + 1;
71
+ while (i <= this.size) {
72
+ this.tree[i] += delta;
73
+ // Move to next relevant index: i + LSB(i)
74
+ i += i & -i;
75
+ }
76
+ }
77
+ /**
78
+ * Get prefix sum from 0 to index (inclusive, 0-indexed input)
79
+ * Complexity: O(log n)
80
+ */
81
+ sum(index) {
82
+ if (index < 0)
83
+ return 0;
84
+ // Convert to 1-indexed
85
+ let i = index + 1;
86
+ let result = 0;
87
+ while (i > 0) {
88
+ result += this.tree[i];
89
+ // Move to parent: i - LSB(i)
90
+ i -= i & -i;
91
+ }
92
+ return result;
93
+ }
94
+ /**
95
+ * Get sum in range [left, right] (inclusive, 0-indexed)
96
+ * Complexity: O(log n)
97
+ */
98
+ rangeSum(left, right) {
99
+ if (left > right)
100
+ return 0;
101
+ return this.sum(right) - (left > 0 ? this.sum(left - 1) : 0);
102
+ }
103
+ /**
104
+ * Update value at index to newValue (handles delta internally)
105
+ * Requires tracking current values externally or use add() directly
106
+ * Complexity: O(log n)
107
+ */
108
+ update(index, oldValue, newValue) {
109
+ this.add(index, newValue - oldValue);
110
+ }
111
+ /**
112
+ * Binary search to find the smallest index where sum(index) >= target
113
+ * Useful for finding scroll position -> item index mapping
114
+ * Complexity: O(log² n) or O(log n) with optimization
115
+ *
116
+ * @returns index (0-indexed) or -1 if not found
117
+ */
118
+ lowerBound(target) {
119
+ if (target <= 0)
120
+ return 0;
121
+ // Optimized binary search on Fenwick tree structure
122
+ let pos = 0;
123
+ let sum = 0;
124
+ // Start from highest power of 2 <= size
125
+ let bit = 1 << Math.floor(Math.log2(this.size));
126
+ while (bit > 0) {
127
+ if (pos + bit <= this.size) {
128
+ const nextSum = sum + this.tree[pos + bit];
129
+ if (nextSum < target) {
130
+ pos += bit;
131
+ sum = nextSum;
132
+ }
133
+ }
134
+ bit >>= 1;
135
+ }
136
+ // Convert back to 0-indexed
137
+ return pos;
138
+ }
139
+ /**
140
+ * Reset all values to 0
141
+ * Complexity: O(n)
142
+ */
143
+ reset() {
144
+ this.tree.fill(0);
145
+ }
146
+ /**
147
+ * Resize the tree (useful for dynamic item lists)
148
+ * Complexity: O(n)
149
+ */
150
+ resize(newSize) {
151
+ if (newSize === this.size)
152
+ return;
153
+ const oldTree = this.tree;
154
+ const oldSize = this.size;
155
+ this.size = newSize;
156
+ this.tree = new Float64Array(newSize + 1);
157
+ // Copy existing values up to min(oldSize, newSize)
158
+ const copySize = Math.min(oldSize, newSize);
159
+ for (let i = 1; i <= copySize; i++) {
160
+ this.tree[i] = oldTree[i];
161
+ }
162
+ }
163
+ /**
164
+ * Get current size
165
+ */
166
+ getSize() {
167
+ return this.size;
168
+ }
169
+ /**
170
+ * Export state for serialization (e.g., to SharedArrayBuffer)
171
+ */
172
+ exportState() {
173
+ return {
174
+ size: this.size,
175
+ tree: Array.from(this.tree),
176
+ };
177
+ }
178
+ /**
179
+ * Import state from serialization
180
+ */
181
+ static fromState(state) {
182
+ const ft = new FenwickTree(state.size);
183
+ ft.tree = new Float64Array(state.tree);
184
+ return ft;
185
+ }
186
+ }
187
+
188
+ /**
189
+ * DOM Node Pool with LRU eviction and aggressive cleanup.
190
+ * Reuses nodes to minimize GC pressure and layout thrashing.
191
+ *
192
+ * Features:
193
+ * - LRU eviction when pool exceeds maxSize
194
+ * - Style reset to avoid style pollution
195
+ * - Event listener cleanup
196
+ * - Performance telemetry integration
197
+ */
198
+ class DOMPool {
199
+ constructor(options) {
200
+ this.pool = [];
201
+ // Telemetry
202
+ this.hits = 0;
203
+ this.misses = 0;
204
+ this.evictions = 0;
205
+ this.factory = options.factory;
206
+ this.reset = options.reset || this.defaultReset.bind(this);
207
+ this.maxSize = options.maxSize || 64;
208
+ this.telemetry = options.telemetry || false;
209
+ }
210
+ /**
211
+ * Acquire a node from pool or create new one
212
+ * Complexity: O(n) for LRU scan, amortized O(1) for small pools
213
+ */
214
+ acquire() {
215
+ const now = performance.now();
216
+ // Find first available node
217
+ for (let i = 0; i < this.pool.length; i++) {
218
+ const poolNode = this.pool[i];
219
+ if (!poolNode.inUse) {
220
+ poolNode.inUse = true;
221
+ poolNode.lastUsed = now;
222
+ this.reset(poolNode.node);
223
+ if (this.telemetry)
224
+ this.hits++;
225
+ return poolNode.node;
226
+ }
227
+ }
228
+ // No available nodes, create new one
229
+ const node = this.factory();
230
+ this.pool.push({
231
+ node,
232
+ lastUsed: now,
233
+ inUse: true,
234
+ });
235
+ if (this.telemetry)
236
+ this.misses++;
237
+ // Evict if pool too large
238
+ if (this.pool.length > this.maxSize) {
239
+ this.evictLRU();
240
+ }
241
+ return node;
242
+ }
243
+ /**
244
+ * Release node back to pool
245
+ * Complexity: O(n) to find node
246
+ */
247
+ release(node) {
248
+ const poolNode = this.pool.find(p => p.node === node);
249
+ if (poolNode) {
250
+ poolNode.inUse = false;
251
+ poolNode.lastUsed = performance.now();
252
+ // Aggressive cleanup
253
+ this.reset(poolNode.node);
254
+ }
255
+ }
256
+ /**
257
+ * Evict least recently used node
258
+ * Complexity: O(n)
259
+ */
260
+ evictLRU() {
261
+ // Find LRU node that's not in use
262
+ let lruIndex = -1;
263
+ let oldestTime = Infinity;
264
+ for (let i = 0; i < this.pool.length; i++) {
265
+ const poolNode = this.pool[i];
266
+ if (!poolNode.inUse && poolNode.lastUsed < oldestTime) {
267
+ lruIndex = i;
268
+ oldestTime = poolNode.lastUsed;
269
+ }
270
+ }
271
+ if (lruIndex >= 0) {
272
+ const evicted = this.pool.splice(lruIndex, 1)[0];
273
+ // Full cleanup before GC
274
+ this.deepCleanup(evicted.node);
275
+ if (this.telemetry)
276
+ this.evictions++;
277
+ }
278
+ }
279
+ /**
280
+ * Default reset: clear content, remove inline styles, remove attributes
281
+ */
282
+ defaultReset(node) {
283
+ // Clear content
284
+ node.textContent = '';
285
+ // Remove inline styles
286
+ node.removeAttribute('style');
287
+ // Remove data attributes (preserve data-index, data-selectable for delegation)
288
+ const attrs = Array.from(node.attributes);
289
+ for (const attr of attrs) {
290
+ if (attr.name.startsWith('data-') &&
291
+ attr.name !== 'data-index' &&
292
+ attr.name !== 'data-selectable') {
293
+ node.removeAttribute(attr.name);
294
+ }
295
+ }
296
+ // Remove ARIA attributes that might pollute reuse
297
+ const ariaAttrs = ['aria-selected', 'aria-checked', 'aria-disabled'];
298
+ ariaAttrs.forEach(attr => node.removeAttribute(attr));
299
+ }
300
+ /**
301
+ * Deep cleanup before eviction/destruction
302
+ */
303
+ deepCleanup(node) {
304
+ // Remove all event listeners by cloning (nuclear option)
305
+ const clone = node.cloneNode(true);
306
+ node.parentNode?.replaceChild(clone, node);
307
+ // Clear references
308
+ node.textContent = '';
309
+ }
310
+ /**
311
+ * Clear entire pool
312
+ */
313
+ clear() {
314
+ for (const poolNode of this.pool) {
315
+ this.deepCleanup(poolNode.node);
316
+ }
317
+ this.pool = [];
318
+ this.resetTelemetry();
319
+ }
320
+ /**
321
+ * Adjust max pool size dynamically
322
+ */
323
+ setMaxSize(size) {
324
+ this.maxSize = size;
325
+ // Evict excess nodes
326
+ while (this.pool.length > this.maxSize) {
327
+ this.evictLRU();
328
+ }
329
+ }
330
+ /**
331
+ * Get pool statistics
332
+ */
333
+ getStats() {
334
+ const available = this.pool.filter(p => !p.inUse).length;
335
+ const hitRate = this.hits + this.misses > 0
336
+ ? this.hits / (this.hits + this.misses)
337
+ : 0;
338
+ return {
339
+ total: this.pool.length,
340
+ available,
341
+ inUse: this.pool.length - available,
342
+ maxSize: this.maxSize,
343
+ hits: this.hits,
344
+ misses: this.misses,
345
+ evictions: this.evictions,
346
+ hitRate,
347
+ };
348
+ }
349
+ /**
350
+ * Reset telemetry counters
351
+ */
352
+ resetTelemetry() {
353
+ this.hits = 0;
354
+ this.misses = 0;
355
+ this.evictions = 0;
356
+ }
357
+ }
358
+
359
+ class Virtualizer {
360
+ constructor(container, itemsLength, itemGetter, options) {
361
+ this.measuredHeights = new Map();
362
+ this.activeNodes = new Map(); // index -> node mapping
363
+ this.container = container;
364
+ this.itemsLength = itemsLength;
365
+ this.itemGetter = itemGetter;
366
+ this.options = options;
367
+ this.averageHeight = options.estimatedItemHeight;
368
+ // Initialize DOM pool with LRU eviction
369
+ const maxPoolSize = Math.ceil((options.buffer * 2 + 10) * 1.5) + (options.maxPoolExtra ?? 32);
370
+ this.pool = new DOMPool({
371
+ maxSize: maxPoolSize,
372
+ factory: () => {
373
+ const div = document.createElement('div');
374
+ div.setAttribute('data-selectable', '');
375
+ return div;
376
+ },
377
+ reset: (node) => {
378
+ // Clear content but preserve data-selectable
379
+ node.textContent = '';
380
+ node.removeAttribute('style');
381
+ // Remove aria attributes
382
+ node.removeAttribute('aria-selected');
383
+ node.removeAttribute('aria-checked');
384
+ },
385
+ telemetry: options.enableTelemetry || false,
386
+ });
387
+ // Use Fenwick tree for large lists (>5000 items)
388
+ if (itemsLength > 5000) {
389
+ this.fenwick = new FenwickTree(itemsLength);
390
+ // Initialize with estimated heights
391
+ for (let i = 0; i < itemsLength; i++) {
392
+ this.fenwick.add(i, options.estimatedItemHeight);
393
+ }
394
+ }
395
+ this.container.style.willChange = 'transform';
396
+ }
397
+ computeWindow(scrollTop, viewportHeight) {
398
+ const { buffer } = this.options;
399
+ const startIndex = Math.max(Math.floor(scrollTop / this.averageHeight) - buffer, 0);
400
+ const endIndex = Math.min(Math.ceil((scrollTop + viewportHeight) / this.averageHeight) + buffer, this.itemsLength - 1);
401
+ const windowSize = Math.max(0, endIndex - startIndex + 1);
402
+ // Adjust pool size dynamically based on window
403
+ const newPoolSize = windowSize + (this.options.maxPoolExtra ?? 32);
404
+ this.pool.setMaxSize(newPoolSize);
405
+ return { startIndex, endIndex, windowSize };
406
+ }
407
+ cumulativeOffset(index) {
408
+ if (index <= 0)
409
+ return 0;
410
+ // Use Fenwick tree for O(log n) prefix sum
411
+ if (this.fenwick) {
412
+ return this.fenwick.sum(index - 1);
413
+ }
414
+ // Fallback: approximate using average height with sparse adjustments
415
+ let total = 0;
416
+ for (let i = 0; i < index; i++) {
417
+ total += this.measuredHeights.get(i) ?? this.averageHeight;
418
+ }
419
+ return total;
420
+ }
421
+ acquireNode(index) {
422
+ // Check if we already have this node active (reuse)
423
+ const existing = this.activeNodes.get(index);
424
+ if (existing)
425
+ return existing;
426
+ const node = this.pool.acquire();
427
+ node.setAttribute('data-index', String(index));
428
+ this.activeNodes.set(index, node);
429
+ return node;
430
+ }
431
+ releaseNode(index) {
432
+ const node = this.activeNodes.get(index);
433
+ if (node) {
434
+ this.pool.release(node);
435
+ this.activeNodes.delete(index);
436
+ }
437
+ }
438
+ releaseExcess(activeIndices) {
439
+ // Release nodes not in active set
440
+ for (const [index, node] of this.activeNodes) {
441
+ if (!activeIndices.has(index)) {
442
+ this.pool.release(node);
443
+ this.activeNodes.delete(index);
444
+ }
445
+ }
446
+ }
447
+ render(startIndex, endIndex, updateNode) {
448
+ const frag = document.createDocumentFragment();
449
+ const activeIndices = new Set();
450
+ for (let i = startIndex; i <= endIndex; i++) {
451
+ const node = this.acquireNode(i);
452
+ updateNode(node, this.itemGetter(i), i);
453
+ frag.appendChild(node);
454
+ activeIndices.add(i);
455
+ // Measure on appear (deferred)
456
+ queueMicrotask(() => this.measureOnAppear(node, i));
457
+ }
458
+ // Translate container by cumulative offset of startIndex
459
+ const topOffset = this.cumulativeOffset(startIndex);
460
+ this.container.style.transform = `translate3d(0, ${Math.round(topOffset * 100) / 100}px, 0)`;
461
+ this.container.replaceChildren(frag);
462
+ // Recycle nodes not in use
463
+ requestAnimationFrame(() => this.releaseExcess(activeIndices));
464
+ }
465
+ measureOnAppear(node, index) {
466
+ const h = node.offsetHeight;
467
+ const prev = this.measuredHeights.get(index);
468
+ const threshold = this.options.measurementThreshold ?? 5;
469
+ if (prev === undefined || Math.abs(prev - h) > threshold) {
470
+ const oldValue = prev ?? this.averageHeight;
471
+ this.measuredHeights.set(index, h);
472
+ // Update Fenwick tree with new measurement
473
+ if (this.fenwick) {
474
+ this.fenwick.update(index, oldValue, h);
475
+ }
476
+ // Update running average (weighted by measured count)
477
+ const count = this.measuredHeights.size;
478
+ const total = Array.from(this.measuredHeights.values()).reduce((a, b) => a + b, 0);
479
+ this.averageHeight = Math.round((total / count) * 100) / 100;
480
+ }
481
+ }
482
+ /**
483
+ * Get pool statistics (if telemetry enabled)
484
+ */
485
+ getPoolStats() {
486
+ return this.pool.getStats();
487
+ }
488
+ /**
489
+ * Update items length and resize Fenwick tree if needed
490
+ */
491
+ setItemsLength(newLength) {
492
+ this.itemsLength = newLength;
493
+ if (this.fenwick) {
494
+ this.fenwick.resize(newLength);
495
+ }
496
+ else if (newLength > 5000 && !this.fenwick) {
497
+ // Activate Fenwick tree when crossing threshold
498
+ this.fenwick = new FenwickTree(newLength);
499
+ // Populate with existing measurements
500
+ for (const [index, height] of this.measuredHeights) {
501
+ this.fenwick.update(index, this.averageHeight, height);
502
+ }
503
+ }
504
+ }
505
+ /**
506
+ * Cleanup resources
507
+ */
508
+ destroy() {
509
+ this.pool.clear();
510
+ this.activeNodes.clear();
511
+ this.measuredHeights.clear();
512
+ if (this.fenwick) {
513
+ this.fenwick.reset();
514
+ }
515
+ }
516
+ }
517
+
518
+ class NativeSelectElement extends HTMLElement {
519
+ static get observedAttributes() {
520
+ return ['placement', 'strategy', 'portal'];
521
+ }
522
+ constructor() {
523
+ super();
524
+ this._options = {};
525
+ this._items = [];
526
+ // Multi-select & interaction state
527
+ this._selectedSet = new Set(); // indices
528
+ this._selectedItems = new Map(); // index -> item
529
+ this._activeIndex = -1;
530
+ this._multi = false;
531
+ this._typeBuffer = '';
532
+ this._shadow = this.attachShadow({ mode: 'open' });
533
+ this._listRoot = document.createElement('div');
534
+ this._listRoot.setAttribute('role', 'listbox');
535
+ this._listRoot.setAttribute('tabindex', '0');
536
+ this._shadow.appendChild(this._listRoot);
537
+ // Live region for screen reader announcements
538
+ this._liveRegion = document.createElement('div');
539
+ this._liveRegion.setAttribute('role', 'status');
540
+ this._liveRegion.setAttribute('aria-live', 'polite');
541
+ this._liveRegion.style.cssText = 'position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden;';
542
+ this._shadow.appendChild(this._liveRegion);
543
+ this._helpers = createRendererHelpers((item, index) => this._onSelect(item, index));
544
+ // Delegated click
545
+ this._listRoot.addEventListener('click', (e) => {
546
+ const el = e.target.closest('[data-selectable]');
547
+ if (!el)
548
+ return;
549
+ const idx = Number(el.dataset.index);
550
+ const item = this._items[idx];
551
+ this._onSelect(item, idx);
552
+ });
553
+ // Keyboard navigation
554
+ this._listRoot.addEventListener('keydown', (e) => this._onKeydown(e));
555
+ }
556
+ connectedCallback() {
557
+ // Initialize ARIA roles and open event
558
+ this._listRoot.setAttribute('role', 'listbox');
559
+ this._listRoot.setAttribute('aria-label', 'Options list');
560
+ if (this._multi)
561
+ this._listRoot.setAttribute('aria-multiselectable', 'true');
562
+ this._emit('open', {});
563
+ }
564
+ disconnectedCallback() {
565
+ this._emit('close', {});
566
+ // Cleanup: remove listeners if any were added outside constructor
567
+ if (this._typeTimeout)
568
+ window.clearTimeout(this._typeTimeout);
569
+ }
570
+ attributeChangedCallback(name, _oldValue, newValue) {
571
+ switch (name) {
572
+ case 'placement':
573
+ this._options.placement = (newValue ?? undefined);
574
+ break;
575
+ case 'strategy':
576
+ this._options.strategy = (newValue ?? undefined);
577
+ break;
578
+ case 'portal':
579
+ this._options.portal = newValue === 'true' ? true : newValue === 'false' ? false : undefined;
580
+ break;
581
+ }
582
+ }
583
+ set items(items) {
584
+ this._items = items ?? [];
585
+ // initialize virtualizer with estimated height (default 48) and buffer
586
+ this._virtualizer = new Virtualizer(this._listRoot, this._items.length, (i) => this._items[i], { estimatedItemHeight: 48, buffer: 5 });
587
+ this.render();
588
+ }
589
+ get items() {
590
+ return this._items;
591
+ }
592
+ set multi(value) {
593
+ this._multi = value;
594
+ if (value) {
595
+ this._listRoot.setAttribute('aria-multiselectable', 'true');
596
+ }
597
+ else {
598
+ this._listRoot.removeAttribute('aria-multiselectable');
599
+ }
600
+ }
601
+ get multi() {
602
+ return this._multi;
603
+ }
604
+ get selectedIndices() {
605
+ return Array.from(this._selectedSet);
606
+ }
607
+ get selectedItems() {
608
+ return Array.from(this._selectedItems.values());
609
+ }
610
+ set optionTemplate(template) {
611
+ this._options.optionTemplate = template;
612
+ this.render();
613
+ }
614
+ set optionRenderer(renderer) {
615
+ this._options.optionRenderer = renderer;
616
+ this.render();
617
+ }
618
+ render() {
619
+ const { optionTemplate, optionRenderer } = this._options;
620
+ const viewportHeight = this.getBoundingClientRect().height || 300;
621
+ const scrollTop = this.scrollTop || 0;
622
+ // Update aria-activedescendant
623
+ if (this._activeIndex >= 0) {
624
+ this._listRoot.setAttribute('aria-activedescendant', `option-${this._activeIndex}`);
625
+ }
626
+ else {
627
+ this._listRoot.removeAttribute('aria-activedescendant');
628
+ }
629
+ if (this._virtualizer) {
630
+ const { startIndex, endIndex } = this._virtualizer.computeWindow(scrollTop, viewportHeight);
631
+ this._virtualizer.render(startIndex, endIndex, (node, item, i) => {
632
+ this._applyOptionAttrs(node, i);
633
+ if (optionRenderer) {
634
+ const el = optionRenderer(item, i, this._helpers);
635
+ // replace node contents
636
+ node.replaceChildren(el);
637
+ }
638
+ else if (optionTemplate) {
639
+ const wrapper = document.createElement('div');
640
+ wrapper.innerHTML = optionTemplate(item, i);
641
+ const el = wrapper.firstElementChild;
642
+ node.replaceChildren(el ?? document.createTextNode(String(item)));
643
+ }
644
+ else {
645
+ node.textContent = String(item);
646
+ }
647
+ });
648
+ return;
649
+ }
650
+ const frag = document.createDocumentFragment();
651
+ for (let i = 0; i < this._items.length; i++) {
652
+ const item = this._items[i];
653
+ if (optionRenderer) {
654
+ const el = optionRenderer(item, i, this._helpers);
655
+ if (!el.hasAttribute('data-selectable')) {
656
+ el.setAttribute('data-selectable', '');
657
+ el.setAttribute('data-index', String(i));
658
+ }
659
+ this._applyOptionAttrs(el, i);
660
+ frag.appendChild(el);
661
+ }
662
+ else if (optionTemplate) {
663
+ // Fast path: render all via DocumentFragment in one call
664
+ renderTemplate(this._listRoot, this._items, optionTemplate);
665
+ // Apply ARIA attrs after template render
666
+ this._applyAriaToAll();
667
+ return; // rendering complete
668
+ }
669
+ else {
670
+ const el = document.createElement('div');
671
+ el.textContent = String(item);
672
+ el.setAttribute('data-selectable', '');
673
+ el.setAttribute('data-index', String(i));
674
+ this._applyOptionAttrs(el, i);
675
+ frag.appendChild(el);
676
+ }
677
+ }
678
+ this._listRoot.replaceChildren(frag);
679
+ }
680
+ _applyOptionAttrs(node, index) {
681
+ node.setAttribute('role', 'option');
682
+ node.id = `option-${index}`;
683
+ if (this._selectedSet.has(index)) {
684
+ node.setAttribute('aria-selected', 'true');
685
+ }
686
+ else {
687
+ node.setAttribute('aria-selected', 'false');
688
+ }
689
+ }
690
+ _applyAriaToAll() {
691
+ const children = Array.from(this._listRoot.children);
692
+ for (const child of children) {
693
+ const idx = Number(child.dataset.index);
694
+ if (Number.isFinite(idx))
695
+ this._applyOptionAttrs(child, idx);
696
+ }
697
+ }
698
+ _emit(name, detail) {
699
+ this.dispatchEvent(new CustomEvent(name, { detail, bubbles: true }));
700
+ }
701
+ // Multi-select and interaction methods
702
+ _onSelect(item, index) {
703
+ if (this._multi) {
704
+ if (this._selectedSet.has(index)) {
705
+ this._selectedSet.delete(index);
706
+ this._selectedItems.delete(index);
707
+ }
708
+ else {
709
+ this._selectedSet.add(index);
710
+ this._selectedItems.set(index, item);
711
+ }
712
+ }
713
+ else {
714
+ this._selectedSet.clear();
715
+ this._selectedItems.clear();
716
+ this._selectedSet.add(index);
717
+ this._selectedItems.set(index, item);
718
+ }
719
+ this._activeIndex = index;
720
+ this.render();
721
+ // Emit with all required fields
722
+ const selected = this._selectedSet.has(index);
723
+ this._emit('select', {
724
+ item,
725
+ index,
726
+ value: item?.value ?? item,
727
+ label: item?.label ?? String(item),
728
+ selected,
729
+ multi: this._multi
730
+ });
731
+ this._announce(`Selected ${String(item)}`);
732
+ }
733
+ _onKeydown(e) {
734
+ switch (e.key) {
735
+ case 'ArrowDown':
736
+ e.preventDefault();
737
+ this._moveActive(1);
738
+ break;
739
+ case 'ArrowUp':
740
+ e.preventDefault();
741
+ this._moveActive(-1);
742
+ break;
743
+ case 'Home':
744
+ e.preventDefault();
745
+ this._setActive(0);
746
+ break;
747
+ case 'End':
748
+ e.preventDefault();
749
+ this._setActive(this._items.length - 1);
750
+ break;
751
+ case 'PageDown':
752
+ e.preventDefault();
753
+ this._moveActive(10);
754
+ break;
755
+ case 'PageUp':
756
+ e.preventDefault();
757
+ this._moveActive(-10);
758
+ break;
759
+ case 'Enter':
760
+ case ' ':
761
+ e.preventDefault();
762
+ if (this._activeIndex >= 0) {
763
+ const item = this._items[this._activeIndex];
764
+ this._onSelect(item, this._activeIndex);
765
+ }
766
+ break;
767
+ case 'Escape':
768
+ e.preventDefault();
769
+ this._emit('close', {});
770
+ break;
771
+ default:
772
+ // Type-ahead buffer
773
+ if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey) {
774
+ this._onType(e.key);
775
+ }
776
+ break;
777
+ }
778
+ }
779
+ _moveActive(delta) {
780
+ const next = Math.max(0, Math.min(this._items.length - 1, this._activeIndex + delta));
781
+ this._setActive(next);
782
+ }
783
+ _setActive(index) {
784
+ this._activeIndex = index;
785
+ this.render();
786
+ this._scrollToActive();
787
+ this._announce(`Navigated to ${String(this._items[index])}`);
788
+ }
789
+ _scrollToActive() {
790
+ const el = this._shadow.getElementById(`option-${this._activeIndex}`);
791
+ el?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
792
+ }
793
+ _onType(char) {
794
+ if (this._typeTimeout)
795
+ window.clearTimeout(this._typeTimeout);
796
+ this._typeBuffer += char.toLowerCase();
797
+ this._typeTimeout = window.setTimeout(() => {
798
+ this._typeBuffer = '';
799
+ }, 400);
800
+ // Find first matching item
801
+ const match = this._items.findIndex((item) => String(item).toLowerCase().startsWith(this._typeBuffer));
802
+ if (match >= 0) {
803
+ this._setActive(match);
804
+ }
805
+ }
806
+ _announce(msg) {
807
+ if (this._liveRegion) {
808
+ this._liveRegion.textContent = msg;
809
+ setTimeout(() => {
810
+ if (this._liveRegion)
811
+ this._liveRegion.textContent = '';
812
+ }, 1000);
813
+ }
814
+ }
815
+ // Focus management
816
+ focus() {
817
+ this._listRoot.focus();
818
+ }
819
+ }
820
+ customElements.define('smilodon-select', NativeSelectElement);
821
+
822
+ /**
823
+ * Global Configuration System for Select Components
824
+ * Allows users to define default behaviors that can be overridden at component level
825
+ */
826
+ /**
827
+ * Default global configuration
828
+ */
829
+ const defaultConfig = {
830
+ selection: {
831
+ mode: 'single',
832
+ allowDeselect: false,
833
+ maxSelections: 0,
834
+ showRemoveButton: true,
835
+ closeOnSelect: true,
836
+ },
837
+ scrollToSelected: {
838
+ enabled: true,
839
+ multiSelectTarget: 'first',
840
+ behavior: 'smooth',
841
+ block: 'nearest',
842
+ },
843
+ loadMore: {
844
+ enabled: false,
845
+ itemsPerLoad: 3,
846
+ threshold: 100,
847
+ showLoader: true,
848
+ },
849
+ busyBucket: {
850
+ enabled: true,
851
+ showSpinner: true,
852
+ message: 'Loading...',
853
+ minDisplayTime: 200,
854
+ },
855
+ styles: {
856
+ classNames: {},
857
+ },
858
+ serverSide: {
859
+ enabled: false,
860
+ getValueFromItem: (item) => item?.value ?? item,
861
+ getLabelFromItem: (item) => item?.label ?? String(item),
862
+ },
863
+ infiniteScroll: {
864
+ enabled: false,
865
+ pageSize: 20,
866
+ initialPage: 1,
867
+ cachePages: true,
868
+ maxCachedPages: 10,
869
+ preloadAdjacent: true,
870
+ scrollRestoration: 'auto',
871
+ },
872
+ callbacks: {},
873
+ enabled: true,
874
+ searchable: false,
875
+ placeholder: 'Select an option...',
876
+ virtualize: true,
877
+ estimatedItemHeight: 48,
878
+ };
879
+ /**
880
+ * Global configuration instance
881
+ */
882
+ class SelectConfigManager {
883
+ constructor() {
884
+ this.config = this.deepClone(defaultConfig);
885
+ }
886
+ /**
887
+ * Get current global configuration
888
+ */
889
+ getConfig() {
890
+ return this.config;
891
+ }
892
+ /**
893
+ * Update global configuration (deep merge)
894
+ */
895
+ updateConfig(updates) {
896
+ this.config = this.deepMerge(this.config, updates);
897
+ }
898
+ /**
899
+ * Reset to default configuration
900
+ */
901
+ resetConfig() {
902
+ this.config = this.deepClone(defaultConfig);
903
+ }
904
+ /**
905
+ * Merge component-level config with global config
906
+ * Component-level config takes precedence
907
+ */
908
+ mergeWithComponentConfig(componentConfig) {
909
+ return this.deepMerge(this.deepClone(this.config), componentConfig);
910
+ }
911
+ deepClone(obj) {
912
+ return JSON.parse(JSON.stringify(obj));
913
+ }
914
+ deepMerge(target, source) {
915
+ const result = { ...target };
916
+ for (const key in source) {
917
+ if (source.hasOwnProperty(key)) {
918
+ const sourceValue = source[key];
919
+ const targetValue = result[key];
920
+ if (sourceValue && typeof sourceValue === 'object' && !Array.isArray(sourceValue)) {
921
+ result[key] = this.deepMerge(targetValue && typeof targetValue === 'object' ? targetValue : {}, sourceValue);
922
+ }
923
+ else {
924
+ result[key] = sourceValue;
925
+ }
926
+ }
927
+ }
928
+ return result;
929
+ }
930
+ }
931
+ /**
932
+ * Singleton instance
933
+ */
934
+ const selectConfig = new SelectConfigManager();
935
+ /**
936
+ * Helper function to configure select globally
937
+ */
938
+ function configureSelect(config) {
939
+ selectConfig.updateConfig(config);
940
+ }
941
+ /**
942
+ * Helper function to reset select configuration
943
+ */
944
+ function resetSelectConfig() {
945
+ selectConfig.resetConfig();
946
+ }
947
+
948
+ /**
949
+ * Enhanced Select Component
950
+ * Implements all advanced features: infinite scroll, load more, busy state,
951
+ * server-side selection, and full customization
952
+ */
953
+ class EnhancedSelect extends HTMLElement {
954
+ constructor() {
955
+ super();
956
+ this._pageCache = {};
957
+ this._typeBuffer = '';
958
+ this._hasError = false;
959
+ this._errorMessage = '';
960
+ this._boundArrowClick = null;
961
+ this._shadow = this.attachShadow({ mode: 'open' });
962
+ this._uniqueId = `enhanced-select-${Math.random().toString(36).substr(2, 9)}`;
963
+ // Merge global config with component-level config
964
+ this._config = selectConfig.getConfig();
965
+ // Initialize state
966
+ this._state = {
967
+ isOpen: false,
968
+ isBusy: false,
969
+ isSearching: false,
970
+ currentPage: this._config.infiniteScroll.initialPage || 1,
971
+ totalPages: 1,
972
+ selectedIndices: new Set(),
973
+ selectedItems: new Map(),
974
+ activeIndex: -1,
975
+ searchQuery: '',
976
+ loadedItems: [],
977
+ groupedItems: [],
978
+ preserveScrollPosition: false,
979
+ lastScrollPosition: 0,
980
+ lastNotifiedQuery: null,
981
+ lastNotifiedResultCount: 0,
982
+ };
983
+ // Create DOM structure
984
+ this._container = this._createContainer();
985
+ this._inputContainer = this._createInputContainer();
986
+ this._input = this._createInput();
987
+ this._arrowContainer = this._createArrowContainer();
988
+ this._dropdown = this._createDropdown();
989
+ this._optionsContainer = this._createOptionsContainer();
990
+ this._liveRegion = this._createLiveRegion();
991
+ this._assembleDOM();
992
+ this._initializeStyles();
993
+ this._attachEventListeners();
994
+ this._initializeObservers();
995
+ }
996
+ connectedCallback() {
997
+ // Load initial data if server-side is enabled
998
+ if (this._config.serverSide.enabled && this._config.serverSide.initialSelectedValues) {
999
+ this._loadInitialSelectedItems();
1000
+ }
1001
+ // Emit open event if configured to start open
1002
+ if (this._config.callbacks.onOpen) {
1003
+ this._config.callbacks.onOpen();
1004
+ }
1005
+ }
1006
+ disconnectedCallback() {
1007
+ // Cleanup observers
1008
+ this._resizeObserver?.disconnect();
1009
+ this._intersectionObserver?.disconnect();
1010
+ if (this._busyTimeout)
1011
+ clearTimeout(this._busyTimeout);
1012
+ if (this._typeTimeout)
1013
+ clearTimeout(this._typeTimeout);
1014
+ if (this._searchTimeout)
1015
+ clearTimeout(this._searchTimeout);
1016
+ // Cleanup arrow click listener
1017
+ if (this._boundArrowClick && this._arrowContainer) {
1018
+ this._arrowContainer.removeEventListener('click', this._boundArrowClick);
1019
+ }
1020
+ }
1021
+ _createContainer() {
1022
+ const container = document.createElement('div');
1023
+ container.className = 'select-container';
1024
+ if (this._config.styles.classNames?.container) {
1025
+ container.className += ' ' + this._config.styles.classNames.container;
1026
+ }
1027
+ if (this._config.styles.container) {
1028
+ Object.assign(container.style, this._config.styles.container);
1029
+ }
1030
+ return container;
1031
+ }
1032
+ _createInputContainer() {
1033
+ const container = document.createElement('div');
1034
+ container.className = 'input-container';
1035
+ return container;
1036
+ }
1037
+ _createInput() {
1038
+ const input = document.createElement('input');
1039
+ input.type = 'text';
1040
+ input.className = 'select-input';
1041
+ input.placeholder = this._config.placeholder || 'Select an option...';
1042
+ input.disabled = !this._config.enabled;
1043
+ input.readOnly = !this._config.searchable;
1044
+ // Update readonly when input is focused if searchable
1045
+ input.addEventListener('focus', () => {
1046
+ if (this._config.searchable) {
1047
+ input.readOnly = false;
1048
+ }
1049
+ });
1050
+ if (this._config.styles.classNames?.input) {
1051
+ input.className += ' ' + this._config.styles.classNames.input;
1052
+ }
1053
+ if (this._config.styles.input) {
1054
+ Object.assign(input.style, this._config.styles.input);
1055
+ }
1056
+ input.setAttribute('role', 'combobox');
1057
+ input.setAttribute('aria-expanded', 'false');
1058
+ input.setAttribute('aria-haspopup', 'listbox');
1059
+ input.setAttribute('aria-autocomplete', this._config.searchable ? 'list' : 'none');
1060
+ return input;
1061
+ }
1062
+ _createDropdown() {
1063
+ const dropdown = document.createElement('div');
1064
+ dropdown.className = 'select-dropdown';
1065
+ dropdown.style.display = 'none';
1066
+ if (this._config.styles.classNames?.dropdown) {
1067
+ dropdown.className += ' ' + this._config.styles.classNames.dropdown;
1068
+ }
1069
+ if (this._config.styles.dropdown) {
1070
+ Object.assign(dropdown.style, this._config.styles.dropdown);
1071
+ }
1072
+ dropdown.setAttribute('role', 'listbox');
1073
+ if (this._config.selection.mode === 'multi') {
1074
+ dropdown.setAttribute('aria-multiselectable', 'true');
1075
+ }
1076
+ return dropdown;
1077
+ }
1078
+ _createOptionsContainer() {
1079
+ const container = document.createElement('div');
1080
+ container.className = 'options-container';
1081
+ return container;
1082
+ }
1083
+ _createLiveRegion() {
1084
+ const liveRegion = document.createElement('div');
1085
+ liveRegion.setAttribute('role', 'status');
1086
+ liveRegion.setAttribute('aria-live', 'polite');
1087
+ liveRegion.setAttribute('aria-atomic', 'true');
1088
+ liveRegion.style.cssText = 'position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0;';
1089
+ return liveRegion;
1090
+ }
1091
+ _createArrowContainer() {
1092
+ const container = document.createElement('div');
1093
+ container.className = 'dropdown-arrow-container';
1094
+ container.innerHTML = `
1095
+ <svg class="dropdown-arrow" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
1096
+ <path d="M4 6L8 10L12 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
1097
+ </svg>
1098
+ `;
1099
+ return container;
1100
+ }
1101
+ _assembleDOM() {
1102
+ this._inputContainer.appendChild(this._input);
1103
+ if (this._arrowContainer) {
1104
+ this._inputContainer.appendChild(this._arrowContainer);
1105
+ }
1106
+ this._container.appendChild(this._inputContainer);
1107
+ this._dropdown.appendChild(this._optionsContainer);
1108
+ this._container.appendChild(this._dropdown);
1109
+ this._shadow.appendChild(this._container);
1110
+ if (this._liveRegion) {
1111
+ this._shadow.appendChild(this._liveRegion);
1112
+ }
1113
+ // Set ARIA relationships
1114
+ const listboxId = `${this._uniqueId}-listbox`;
1115
+ this._dropdown.id = listboxId;
1116
+ this._input.setAttribute('aria-controls', listboxId);
1117
+ this._input.setAttribute('aria-owns', listboxId);
1118
+ }
1119
+ _initializeStyles() {
1120
+ const style = document.createElement('style');
1121
+ style.textContent = `
1122
+ :host {
1123
+ display: block;
1124
+ position: relative;
1125
+ width: 100%;
1126
+ }
1127
+
1128
+ .select-container {
1129
+ position: relative;
1130
+ width: 100%;
1131
+ }
1132
+
1133
+ .input-container {
1134
+ position: relative;
1135
+ width: 100%;
1136
+ display: flex;
1137
+ align-items: center;
1138
+ flex-wrap: wrap;
1139
+ gap: 6px;
1140
+ padding: 6px 52px 6px 8px;
1141
+ min-height: 44px;
1142
+ background: white;
1143
+ border: 1px solid #d1d5db;
1144
+ border-radius: 6px;
1145
+ box-sizing: border-box;
1146
+ transition: all 0.2s ease;
1147
+ }
1148
+
1149
+ .input-container:focus-within {
1150
+ border-color: #667eea;
1151
+ box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
1152
+ }
1153
+
1154
+ /* Gradient separator before arrow */
1155
+ .input-container::after {
1156
+ content: '';
1157
+ position: absolute;
1158
+ top: 50%;
1159
+ right: 40px;
1160
+ transform: translateY(-50%);
1161
+ width: 1px;
1162
+ height: 60%;
1163
+ background: linear-gradient(
1164
+ to bottom,
1165
+ transparent 0%,
1166
+ rgba(0, 0, 0, 0.1) 20%,
1167
+ rgba(0, 0, 0, 0.1) 80%,
1168
+ transparent 100%
1169
+ );
1170
+ pointer-events: none;
1171
+ z-index: 1;
1172
+ }
1173
+
1174
+ .dropdown-arrow-container {
1175
+ position: absolute;
1176
+ top: 0;
1177
+ right: 0;
1178
+ bottom: 0;
1179
+ width: 40px;
1180
+ display: flex;
1181
+ align-items: center;
1182
+ justify-content: center;
1183
+ cursor: pointer;
1184
+ transition: background-color 0.2s ease;
1185
+ border-radius: 0 4px 4px 0;
1186
+ z-index: 2;
1187
+ }
1188
+
1189
+ .dropdown-arrow-container:hover {
1190
+ background-color: rgba(102, 126, 234, 0.08);
1191
+ }
1192
+
1193
+ .dropdown-arrow {
1194
+ width: 16px;
1195
+ height: 16px;
1196
+ color: #667eea;
1197
+ transition: transform 0.2s ease, color 0.2s ease;
1198
+ transform: translateY(0);
1199
+ }
1200
+
1201
+ .dropdown-arrow-container:hover .dropdown-arrow {
1202
+ color: #667eea;
1203
+ }
1204
+
1205
+ .dropdown-arrow.open {
1206
+ transform: rotate(180deg);
1207
+ }
1208
+
1209
+ .select-input {
1210
+ flex: 1;
1211
+ min-width: 120px;
1212
+ padding: 4px;
1213
+ border: none;
1214
+ font-size: 14px;
1215
+ line-height: 1.5;
1216
+ color: #1f2937;
1217
+ background: transparent;
1218
+ box-sizing: border-box;
1219
+ outline: none;
1220
+ }
1221
+
1222
+ .select-input::placeholder {
1223
+ color: #9ca3af;
1224
+ }
1225
+
1226
+ .selection-badge {
1227
+ display: inline-flex;
1228
+ align-items: center;
1229
+ gap: 4px;
1230
+ padding: 4px 8px;
1231
+ margin: 2px;
1232
+ background: #667eea;
1233
+ color: white;
1234
+ border-radius: 4px;
1235
+ font-size: 13px;
1236
+ line-height: 1;
1237
+ }
1238
+
1239
+ .badge-remove {
1240
+ display: inline-flex;
1241
+ align-items: center;
1242
+ justify-content: center;
1243
+ width: 16px;
1244
+ height: 16px;
1245
+ padding: 0;
1246
+ margin-left: 4px;
1247
+ background: rgba(255, 255, 255, 0.3);
1248
+ border: none;
1249
+ border-radius: 50%;
1250
+ color: white;
1251
+ font-size: 16px;
1252
+ line-height: 1;
1253
+ cursor: pointer;
1254
+ transition: background 0.2s;
1255
+ }
1256
+
1257
+ .badge-remove:hover {
1258
+ background: rgba(255, 255, 255, 0.5);
1259
+ }
1260
+
1261
+ .select-input:disabled {
1262
+ background-color: var(--select-disabled-bg, #f5f5f5);
1263
+ cursor: not-allowed;
1264
+ }
1265
+
1266
+ .select-dropdown {
1267
+ position: absolute;
1268
+ scroll-behavior: smooth;
1269
+ top: 100%;
1270
+ left: 0;
1271
+ right: 0;
1272
+ margin-top: 4px;
1273
+ max-height: 300px;
1274
+ overflow-y: auto;
1275
+ background: var(--select-dropdown-bg, white);
1276
+ border: 1px solid var(--select-dropdown-border, #ccc);
1277
+ border-radius: var(--select-border-radius, 4px);
1278
+ box-shadow: var(--select-dropdown-shadow, 0 4px 6px rgba(0,0,0,0.1));
1279
+ z-index: var(--select-dropdown-z-index, 1000);
1280
+ }
1281
+
1282
+ .options-container {
1283
+ position: relative;
1284
+ transition: opacity 0.2s ease-in-out;
1285
+ }
1286
+
1287
+ .option {
1288
+ padding: 8px 12px;
1289
+ cursor: pointer;
1290
+ color: inherit;
1291
+ transition: background-color 0.15s ease;
1292
+ user-select: none;
1293
+ }
1294
+
1295
+ .option:hover {
1296
+ background-color: #f3f4f6;
1297
+ }
1298
+
1299
+ .option.selected {
1300
+ background-color: #e0e7ff;
1301
+ color: #4338ca;
1302
+ font-weight: 500;
1303
+ }
1304
+
1305
+ .option.active {
1306
+ background-color: #f3f4f6;
1307
+ }
1308
+
1309
+ .load-more-container {
1310
+ padding: 12px;
1311
+ text-align: center;
1312
+ border-top: 1px solid var(--select-divider-color, #e0e0e0);
1313
+ }
1314
+
1315
+ .load-more-button {
1316
+ padding: 8px 16px;
1317
+ border: 1px solid var(--select-button-border, #1976d2);
1318
+ background: var(--select-button-bg, white);
1319
+ color: var(--select-button-color, #1976d2);
1320
+ border-radius: 4px;
1321
+ cursor: pointer;
1322
+ font-size: 14px;
1323
+ transition: all 0.2s ease;
1324
+ }
1325
+
1326
+ .load-more-button:hover {
1327
+ background: var(--select-button-hover-bg, #1976d2);
1328
+ color: var(--select-button-hover-color, white);
1329
+ }
1330
+
1331
+ .load-more-button:disabled {
1332
+ opacity: 0.5;
1333
+ cursor: not-allowed;
1334
+ }
1335
+
1336
+ .busy-bucket {
1337
+ padding: 16px;
1338
+ text-align: center;
1339
+ color: var(--select-busy-color, #666);
1340
+ }
1341
+
1342
+ .spinner {
1343
+ display: inline-block;
1344
+ width: 20px;
1345
+ height: 20px;
1346
+ border: 2px solid var(--select-spinner-color, #ccc);
1347
+ border-top-color: var(--select-spinner-active-color, #1976d2);
1348
+ border-radius: 50%;
1349
+ animation: spin 0.6s linear infinite;
1350
+ }
1351
+
1352
+ @keyframes spin {
1353
+ to { transform: rotate(360deg); }
1354
+ }
1355
+
1356
+ .empty-state {
1357
+ padding: 24px;
1358
+ text-align: center;
1359
+ color: var(--select-empty-color, #999);
1360
+ }
1361
+
1362
+ .searching-state {
1363
+ padding: 24px;
1364
+ text-align: center;
1365
+ color: #667eea;
1366
+ font-style: italic;
1367
+ animation: pulse 1.5s ease-in-out infinite;
1368
+ }
1369
+
1370
+ @keyframes pulse {
1371
+ 0%, 100% { opacity: 1; }
1372
+ 50% { opacity: 0.5; }
1373
+ }
1374
+
1375
+ /* Error states */
1376
+ .select-input[aria-invalid="true"] {
1377
+ border-color: var(--select-error-border, #dc2626);
1378
+ }
1379
+
1380
+ .select-input[aria-invalid="true"]:focus {
1381
+ border-color: var(--select-error-border, #dc2626);
1382
+ box-shadow: 0 0 0 2px var(--select-error-shadow, rgba(220, 38, 38, 0.1));
1383
+ outline-color: var(--select-error-border, #dc2626);
1384
+ }
1385
+
1386
+ /* Accessibility: Reduced motion */
1387
+ @media (prefers-reduced-motion: reduce) {
1388
+ * {
1389
+ animation-duration: 0.01ms !important;
1390
+ animation-iteration-count: 1 !important;
1391
+ transition-duration: 0.01ms !important;
1392
+ }
1393
+ }
1394
+
1395
+ /* Accessibility: Dark mode */
1396
+ @media (prefers-color-scheme: dark) {
1397
+ .select-input {
1398
+ background: var(--select-dark-bg, #1f2937);
1399
+ color: var(--select-dark-text, #f9fafb);
1400
+ border-color: var(--select-dark-border, #4b5563);
1401
+ }
1402
+
1403
+ .select-dropdown {
1404
+ background: var(--select-dark-dropdown-bg, #1f2937);
1405
+ border-color: var(--select-dark-dropdown-border, #4b5563);
1406
+ color: var(--select-dark-text, #f9fafb);
1407
+ }
1408
+
1409
+ .option:hover {
1410
+ background-color: var(--select-dark-option-hover-bg, #374151);
1411
+ }
1412
+
1413
+ .option.selected {
1414
+ background-color: var(--select-dark-option-selected-bg, #3730a3);
1415
+ color: var(--select-dark-option-selected-text, #e0e7ff);
1416
+ }
1417
+
1418
+ .option.active {
1419
+ background-color: var(--select-dark-option-active-bg, #374151);
1420
+ }
1421
+
1422
+ .busy-bucket {
1423
+ color: var(--select-dark-busy-color, #9ca3af);
1424
+ }
1425
+ }
1426
+
1427
+ /* Accessibility: High contrast mode */
1428
+ @media (prefers-contrast: high) {
1429
+ .select-input:focus {
1430
+ outline-width: 3px;
1431
+ outline-color: Highlight;
1432
+ }
1433
+
1434
+ .select-input {
1435
+ border-width: 2px;
1436
+ }
1437
+ }
1438
+
1439
+ /* Touch targets (WCAG 2.5.5) */
1440
+ .load-more-button,
1441
+ select-option {
1442
+ min-height: 44px;
1443
+ }
1444
+ `;
1445
+ this._shadow.appendChild(style);
1446
+ }
1447
+ _attachEventListeners() {
1448
+ // Arrow click handler
1449
+ if (this._arrowContainer) {
1450
+ this._boundArrowClick = (e) => {
1451
+ e.stopPropagation();
1452
+ e.preventDefault();
1453
+ const wasOpen = this._state.isOpen;
1454
+ this._state.isOpen = !this._state.isOpen;
1455
+ this._updateDropdownVisibility();
1456
+ this._updateArrowRotation();
1457
+ if (this._state.isOpen && this._config.callbacks.onOpen) {
1458
+ this._config.callbacks.onOpen();
1459
+ }
1460
+ else if (!this._state.isOpen && this._config.callbacks.onClose) {
1461
+ this._config.callbacks.onClose();
1462
+ }
1463
+ // Scroll to selected when opening
1464
+ if (!wasOpen && this._state.isOpen && this._state.selectedIndices.size > 0) {
1465
+ setTimeout(() => this._scrollToSelected(), 50);
1466
+ }
1467
+ };
1468
+ this._arrowContainer.addEventListener('click', this._boundArrowClick);
1469
+ }
1470
+ // Input container click - prevent event from reaching document listener
1471
+ this._container.addEventListener('click', (e) => {
1472
+ e.stopPropagation();
1473
+ });
1474
+ // Input focus/blur
1475
+ this._input.addEventListener('focus', () => this._handleOpen());
1476
+ this._input.addEventListener('blur', (e) => {
1477
+ // Delay to allow option click
1478
+ setTimeout(() => {
1479
+ if (!this._dropdown.contains(document.activeElement)) {
1480
+ this._handleClose();
1481
+ }
1482
+ }, 200);
1483
+ });
1484
+ // Input search
1485
+ this._input.addEventListener('input', (e) => {
1486
+ if (!this._config.searchable)
1487
+ return;
1488
+ console.log('[EnhancedSelect] Input event fired', e.target.value);
1489
+ const query = e.target.value;
1490
+ this._handleSearch(query);
1491
+ });
1492
+ // Keyboard navigation
1493
+ this._input.addEventListener('keydown', (e) => this._handleKeydown(e));
1494
+ // Click outside to close
1495
+ document.addEventListener('click', (e) => {
1496
+ const target = e.target;
1497
+ // Check if click is outside shadow root
1498
+ if (!this._shadow.contains(target) && !this._container.contains(target)) {
1499
+ this._handleClose();
1500
+ }
1501
+ });
1502
+ }
1503
+ _initializeObservers() {
1504
+ // Disconnect existing observer if any
1505
+ if (this._intersectionObserver) {
1506
+ this._intersectionObserver.disconnect();
1507
+ this._intersectionObserver = undefined;
1508
+ }
1509
+ // Intersection observer for infinite scroll
1510
+ if (this._config.infiniteScroll.enabled) {
1511
+ this._intersectionObserver = new IntersectionObserver((entries) => {
1512
+ entries.forEach((entry) => {
1513
+ if (entry.isIntersecting) {
1514
+ console.log('[InfiniteScroll] Sentinel intersected. isBusy:', this._state.isBusy);
1515
+ if (!this._state.isBusy) {
1516
+ this._loadMoreItems();
1517
+ }
1518
+ }
1519
+ });
1520
+ }, { threshold: 0.1 });
1521
+ }
1522
+ }
1523
+ async _loadInitialSelectedItems() {
1524
+ if (!this._config.serverSide.fetchSelectedItems || !this._config.serverSide.initialSelectedValues) {
1525
+ return;
1526
+ }
1527
+ this._setBusy(true);
1528
+ try {
1529
+ const items = await this._config.serverSide.fetchSelectedItems(this._config.serverSide.initialSelectedValues);
1530
+ // Add to state
1531
+ items.forEach((item, index) => {
1532
+ this._state.selectedItems.set(index, item);
1533
+ this._state.selectedIndices.add(index);
1534
+ });
1535
+ this._updateInputDisplay();
1536
+ }
1537
+ catch (error) {
1538
+ this._handleError(error);
1539
+ }
1540
+ finally {
1541
+ this._setBusy(false);
1542
+ }
1543
+ }
1544
+ _handleOpen() {
1545
+ if (!this._config.enabled || this._state.isOpen)
1546
+ return;
1547
+ this._state.isOpen = true;
1548
+ this._dropdown.style.display = 'block';
1549
+ this._input.setAttribute('aria-expanded', 'true');
1550
+ this._updateArrowRotation();
1551
+ // Clear search query when opening to show all options
1552
+ // This ensures we can scroll to selected item
1553
+ if (this._config.searchable) {
1554
+ this._state.searchQuery = '';
1555
+ // Don't clear input value if it represents selection
1556
+ // But if we want to search, we might want to clear it?
1557
+ // Standard behavior: input keeps value (label), but dropdown shows all options
1558
+ // until user types.
1559
+ // However, our filtering logic uses _state.searchQuery.
1560
+ // So clearing it here resets the filter.
1561
+ }
1562
+ // Render options when opening
1563
+ this._renderOptions();
1564
+ this._emit('open', {});
1565
+ this._config.callbacks.onOpen?.();
1566
+ // Scroll to selected if configured
1567
+ if (this._config.scrollToSelected.enabled) {
1568
+ // Use setTimeout to allow render to complete
1569
+ setTimeout(() => this._scrollToSelected(), 0);
1570
+ }
1571
+ }
1572
+ _handleClose() {
1573
+ if (!this._state.isOpen)
1574
+ return;
1575
+ this._state.isOpen = false;
1576
+ this._dropdown.style.display = 'none';
1577
+ this._input.setAttribute('aria-expanded', 'false');
1578
+ this._updateArrowRotation();
1579
+ this._emit('close', {});
1580
+ this._config.callbacks.onClose?.();
1581
+ }
1582
+ _updateDropdownVisibility() {
1583
+ if (this._state.isOpen) {
1584
+ this._dropdown.style.display = 'block';
1585
+ this._input.setAttribute('aria-expanded', 'true');
1586
+ }
1587
+ else {
1588
+ this._dropdown.style.display = 'none';
1589
+ this._input.setAttribute('aria-expanded', 'false');
1590
+ }
1591
+ }
1592
+ _updateArrowRotation() {
1593
+ if (this._arrowContainer) {
1594
+ const arrow = this._arrowContainer.querySelector('.dropdown-arrow');
1595
+ if (arrow) {
1596
+ if (this._state.isOpen) {
1597
+ arrow.classList.add('open');
1598
+ }
1599
+ else {
1600
+ arrow.classList.remove('open');
1601
+ }
1602
+ }
1603
+ }
1604
+ }
1605
+ _handleSearch(query) {
1606
+ console.log('[EnhancedSelect] _handleSearch called with:', JSON.stringify(query));
1607
+ this._state.searchQuery = query;
1608
+ // Clear previous search timeout
1609
+ if (this._searchTimeout) {
1610
+ clearTimeout(this._searchTimeout);
1611
+ }
1612
+ // Search immediately - no debouncing for better responsiveness
1613
+ // Users expect instant feedback as they type
1614
+ this._state.isSearching = false;
1615
+ // Ensure dropdown is open when searching
1616
+ if (!this._state.isOpen) {
1617
+ console.log('[EnhancedSelect] Opening dropdown for search');
1618
+ this._handleOpen();
1619
+ }
1620
+ else {
1621
+ // Filter and render options immediately
1622
+ console.log('[EnhancedSelect] Dropdown already open, re-rendering options');
1623
+ this._renderOptions();
1624
+ }
1625
+ // Get filtered items based on search query - searches ENTIRE phrase
1626
+ const getLabel = this._config.serverSide.getLabelFromItem || ((item) => item?.label ?? String(item));
1627
+ // FIX: Do not trim query to allow searching for phrases with spaces
1628
+ const searchQuery = query.toLowerCase();
1629
+ const filteredItems = searchQuery
1630
+ ? this._state.loadedItems.filter((item) => {
1631
+ try {
1632
+ const label = String(getLabel(item)).toLowerCase();
1633
+ // Match the entire search phrase
1634
+ return label.includes(searchQuery);
1635
+ }
1636
+ catch (e) {
1637
+ return false;
1638
+ }
1639
+ })
1640
+ : this._state.loadedItems;
1641
+ const count = filteredItems.length;
1642
+ console.log(`[EnhancedSelect] Search results: ${count} items found for query "${searchQuery}"`);
1643
+ // Announce search results for accessibility
1644
+ if (searchQuery) {
1645
+ this._announce(`${count} result${count !== 1 ? 's' : ''} found for "${query}"`);
1646
+ }
1647
+ // Only notify if query or result count changed to prevent infinite loops
1648
+ if (query !== this._state.lastNotifiedQuery || count !== this._state.lastNotifiedResultCount) {
1649
+ this._state.lastNotifiedQuery = query;
1650
+ this._state.lastNotifiedResultCount = count;
1651
+ // Use setTimeout to avoid synchronous state updates during render
1652
+ setTimeout(() => {
1653
+ this._emit('search', { query, results: filteredItems, count });
1654
+ this._config.callbacks.onSearch?.(query);
1655
+ }, 0);
1656
+ }
1657
+ }
1658
+ _handleKeydown(e) {
1659
+ switch (e.key) {
1660
+ case 'ArrowDown':
1661
+ e.preventDefault();
1662
+ if (!this._state.isOpen) {
1663
+ this._handleOpen();
1664
+ }
1665
+ else {
1666
+ this._moveActive(1);
1667
+ }
1668
+ break;
1669
+ case 'ArrowUp':
1670
+ e.preventDefault();
1671
+ if (!this._state.isOpen) {
1672
+ this._handleOpen();
1673
+ }
1674
+ else {
1675
+ this._moveActive(-1);
1676
+ }
1677
+ break;
1678
+ case 'Home':
1679
+ e.preventDefault();
1680
+ if (this._state.isOpen) {
1681
+ this._setActive(0);
1682
+ }
1683
+ break;
1684
+ case 'End':
1685
+ e.preventDefault();
1686
+ if (this._state.isOpen) {
1687
+ const options = Array.from(this._optionsContainer.children);
1688
+ this._setActive(options.length - 1);
1689
+ }
1690
+ break;
1691
+ case 'PageDown':
1692
+ e.preventDefault();
1693
+ if (this._state.isOpen) {
1694
+ this._moveActive(10);
1695
+ }
1696
+ break;
1697
+ case 'PageUp':
1698
+ e.preventDefault();
1699
+ if (this._state.isOpen) {
1700
+ this._moveActive(-10);
1701
+ }
1702
+ break;
1703
+ case 'Enter':
1704
+ e.preventDefault();
1705
+ if (this._state.activeIndex >= 0) {
1706
+ this._selectOption(this._state.activeIndex);
1707
+ }
1708
+ break;
1709
+ case 'Escape':
1710
+ e.preventDefault();
1711
+ this._handleClose();
1712
+ break;
1713
+ case 'a':
1714
+ case 'A':
1715
+ if ((e.ctrlKey || e.metaKey) && this._config.selection.mode === 'multi') {
1716
+ e.preventDefault();
1717
+ this._selectAll();
1718
+ }
1719
+ break;
1720
+ default:
1721
+ // Type-ahead search
1722
+ if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey) {
1723
+ this._handleTypeAhead(e.key);
1724
+ }
1725
+ break;
1726
+ }
1727
+ }
1728
+ _moveActive(delta) {
1729
+ const options = Array.from(this._optionsContainer.children);
1730
+ const next = Math.max(0, Math.min(options.length - 1, this._state.activeIndex + delta));
1731
+ this._setActive(next);
1732
+ }
1733
+ _setActive(index) {
1734
+ const options = Array.from(this._optionsContainer.children);
1735
+ // Clear previous active state
1736
+ if (this._state.activeIndex >= 0 && options[this._state.activeIndex]) {
1737
+ options[this._state.activeIndex].setActive(false);
1738
+ }
1739
+ this._state.activeIndex = index;
1740
+ // Set new active state
1741
+ if (options[index]) {
1742
+ options[index].setActive(true);
1743
+ options[index].scrollIntoView({ block: 'nearest', behavior: 'smooth' });
1744
+ // Announce position for screen readers
1745
+ const total = options.length;
1746
+ this._announce(`Item ${index + 1} of ${total}`);
1747
+ // Update aria-activedescendant
1748
+ const optionId = `${this._uniqueId}-option-${index}`;
1749
+ this._input.setAttribute('aria-activedescendant', optionId);
1750
+ }
1751
+ }
1752
+ _handleTypeAhead(char) {
1753
+ if (this._typeTimeout)
1754
+ clearTimeout(this._typeTimeout);
1755
+ this._typeBuffer += char.toLowerCase();
1756
+ this._typeTimeout = window.setTimeout(() => {
1757
+ this._typeBuffer = '';
1758
+ }, 500);
1759
+ // Find first matching option
1760
+ const getValue = this._config.serverSide.getLabelFromItem || ((item) => item?.label ?? String(item));
1761
+ const matchIndex = this._state.loadedItems.findIndex((item) => getValue(item).toLowerCase().startsWith(this._typeBuffer));
1762
+ if (matchIndex >= 0) {
1763
+ this._setActive(matchIndex);
1764
+ }
1765
+ }
1766
+ _selectAll() {
1767
+ if (this._config.selection.mode !== 'multi')
1768
+ return;
1769
+ const options = Array.from(this._optionsContainer.children);
1770
+ const maxSelections = this._config.selection.maxSelections || 0;
1771
+ options.forEach((option, index) => {
1772
+ if (maxSelections > 0 && this._state.selectedIndices.size >= maxSelections) {
1773
+ return;
1774
+ }
1775
+ if (!this._state.selectedIndices.has(index)) {
1776
+ const config = option.getConfig();
1777
+ this._state.selectedIndices.add(index);
1778
+ this._state.selectedItems.set(index, config.item);
1779
+ option.setSelected(true);
1780
+ }
1781
+ });
1782
+ this._updateInputDisplay();
1783
+ this._emitChange();
1784
+ this._announce(`Selected all ${options.length} items`);
1785
+ }
1786
+ _announce(message) {
1787
+ if (this._liveRegion) {
1788
+ this._liveRegion.textContent = message;
1789
+ setTimeout(() => {
1790
+ if (this._liveRegion)
1791
+ this._liveRegion.textContent = '';
1792
+ }, 1000);
1793
+ }
1794
+ }
1795
+ _selectOption(index) {
1796
+ // FIX: Do not rely on this._optionsContainer.children[index] because filtering changes the children
1797
+ // Instead, use the index to update state directly
1798
+ const item = this._state.loadedItems[index];
1799
+ if (!item)
1800
+ return;
1801
+ const isCurrentlySelected = this._state.selectedIndices.has(index);
1802
+ if (this._config.selection.mode === 'single') {
1803
+ // Single select: clear previous and select new
1804
+ const wasSelected = this._state.selectedIndices.has(index);
1805
+ this._state.selectedIndices.clear();
1806
+ this._state.selectedItems.clear();
1807
+ if (!wasSelected) {
1808
+ // Select this option
1809
+ this._state.selectedIndices.add(index);
1810
+ this._state.selectedItems.set(index, item);
1811
+ }
1812
+ // Re-render to update all option styles
1813
+ this._renderOptions();
1814
+ if (this._config.selection.closeOnSelect) {
1815
+ this._handleClose();
1816
+ }
1817
+ }
1818
+ else {
1819
+ // Multi select with toggle
1820
+ const maxSelections = this._config.selection.maxSelections || 0;
1821
+ if (isCurrentlySelected) {
1822
+ // Deselect (toggle off)
1823
+ this._state.selectedIndices.delete(index);
1824
+ this._state.selectedItems.delete(index);
1825
+ }
1826
+ else {
1827
+ // Select (toggle on)
1828
+ if (maxSelections > 0 && this._state.selectedIndices.size >= maxSelections) {
1829
+ this._announce(`Maximum ${maxSelections} selections allowed`);
1830
+ return; // Max selections reached
1831
+ }
1832
+ this._state.selectedIndices.add(index);
1833
+ this._state.selectedItems.set(index, item);
1834
+ }
1835
+ // Re-render to update styles (safer than trying to find the element in filtered list)
1836
+ this._renderOptions();
1837
+ }
1838
+ this._updateInputDisplay();
1839
+ this._emitChange();
1840
+ // Call user callback
1841
+ const getValue = this._config.serverSide.getValueFromItem || ((item) => item?.value ?? item);
1842
+ const getLabel = this._config.serverSide.getLabelFromItem || ((item) => item?.label ?? String(item));
1843
+ this._config.callbacks.onSelect?.({
1844
+ item: item,
1845
+ index,
1846
+ value: getValue(item),
1847
+ label: getLabel(item),
1848
+ selected: this._state.selectedIndices.has(index),
1849
+ });
1850
+ }
1851
+ _handleOptionRemove(index) {
1852
+ const option = this._optionsContainer.children[index];
1853
+ if (!option)
1854
+ return;
1855
+ this._state.selectedIndices.delete(index);
1856
+ this._state.selectedItems.delete(index);
1857
+ option.setSelected(false);
1858
+ this._updateInputDisplay();
1859
+ this._emitChange();
1860
+ const config = option.getConfig();
1861
+ this._emit('remove', { item: config.item, index });
1862
+ }
1863
+ _updateInputDisplay() {
1864
+ const selectedItems = Array.from(this._state.selectedItems.values());
1865
+ const getLabel = this._config.serverSide.getLabelFromItem || ((item) => item?.label ?? String(item));
1866
+ if (selectedItems.length === 0) {
1867
+ this._input.value = '';
1868
+ this._input.placeholder = this._config.placeholder || 'Select an option...';
1869
+ // Clear any badges
1870
+ const existingBadges = this._inputContainer.querySelectorAll('.selection-badge');
1871
+ existingBadges.forEach(badge => badge.remove());
1872
+ }
1873
+ else if (this._config.selection.mode === 'single') {
1874
+ this._input.value = getLabel(selectedItems[0]);
1875
+ }
1876
+ else {
1877
+ // Multi-select: show badges instead of text in input
1878
+ this._input.value = '';
1879
+ this._input.placeholder = '';
1880
+ // Clear existing badges
1881
+ const existingBadges = this._inputContainer.querySelectorAll('.selection-badge');
1882
+ existingBadges.forEach(badge => badge.remove());
1883
+ // Create badges for each selected item
1884
+ const selectedEntries = Array.from(this._state.selectedItems.entries());
1885
+ selectedEntries.forEach(([index, item]) => {
1886
+ const badge = document.createElement('span');
1887
+ badge.className = 'selection-badge';
1888
+ badge.textContent = getLabel(item);
1889
+ // Add remove button to badge
1890
+ const removeBtn = document.createElement('button');
1891
+ removeBtn.className = 'badge-remove';
1892
+ removeBtn.innerHTML = '×';
1893
+ removeBtn.setAttribute('aria-label', `Remove ${getLabel(item)}`);
1894
+ removeBtn.addEventListener('click', (e) => {
1895
+ e.stopPropagation();
1896
+ this._state.selectedIndices.delete(index);
1897
+ this._state.selectedItems.delete(index);
1898
+ this._updateInputDisplay();
1899
+ this._renderOptions();
1900
+ this._emitChange();
1901
+ });
1902
+ badge.appendChild(removeBtn);
1903
+ this._inputContainer.insertBefore(badge, this._input);
1904
+ });
1905
+ }
1906
+ }
1907
+ _renderOptionsWithAnimation() {
1908
+ // Add fade-out animation
1909
+ this._optionsContainer.style.opacity = '0';
1910
+ this._optionsContainer.style.transition = 'opacity 0.15s ease-out';
1911
+ setTimeout(() => {
1912
+ this._renderOptions();
1913
+ // Fade back in
1914
+ this._optionsContainer.style.opacity = '1';
1915
+ this._optionsContainer.style.transition = 'opacity 0.2s ease-in';
1916
+ }, 150);
1917
+ }
1918
+ _scrollToSelected() {
1919
+ if (this._state.selectedIndices.size === 0)
1920
+ return;
1921
+ const target = this._config.scrollToSelected.multiSelectTarget;
1922
+ const indices = Array.from(this._state.selectedIndices).sort((a, b) => a - b);
1923
+ const targetIndex = target === 'first' ? indices[0] : indices[indices.length - 1];
1924
+ // FIX: Find the option element by ID instead of index in children
1925
+ // because children list might be filtered or reordered
1926
+ const optionId = `${this._uniqueId}-option-${targetIndex}`;
1927
+ // We need to search in shadow root or options container
1928
+ // Since options are custom elements, we can find them by ID if we set it (we do)
1929
+ // But wait, we set ID on the element instance, but is it in the DOM?
1930
+ // If filtered out, it won't be in the DOM.
1931
+ // If we are searching, we might not want to scroll to selected if it's not visible
1932
+ // But if we just opened the dropdown, we usually want to see the selected item.
1933
+ // If the selected item is filtered out, we can't scroll to it.
1934
+ // Try to find the element in the options container
1935
+ // Note: querySelector on shadowRoot works if we set the ID attribute
1936
+ // In _renderOptions we set: option.id = ...
1937
+ const option = this._optionsContainer.querySelector(`[id="${optionId}"]`);
1938
+ if (option) {
1939
+ option.scrollIntoView({
1940
+ block: this._config.scrollToSelected.block || 'center',
1941
+ behavior: 'smooth',
1942
+ });
1943
+ }
1944
+ }
1945
+ async _loadMoreItems() {
1946
+ if (this._state.isBusy)
1947
+ return;
1948
+ console.log('[InfiniteScroll] _loadMoreItems triggered');
1949
+ this._setBusy(true);
1950
+ // Save scroll position before loading
1951
+ if (this._dropdown) {
1952
+ this._state.lastScrollPosition = this._dropdown.scrollTop;
1953
+ this._state.preserveScrollPosition = true;
1954
+ // Update dropdown to show loading indicator but keep the
1955
+ // same scrollTop so the visible items don't move.
1956
+ this._renderOptions();
1957
+ this._dropdown.scrollTop = this._state.lastScrollPosition;
1958
+ }
1959
+ try {
1960
+ // Emit event for parent to handle
1961
+ this._state.currentPage++;
1962
+ console.log(`[InfiniteScroll] Emitting loadMore event for page ${this._state.currentPage}`);
1963
+ this._emit('loadMore', { page: this._state.currentPage, items: [] });
1964
+ this._config.callbacks.onLoadMore?.(this._state.currentPage);
1965
+ // NOTE: We do NOT set isBusy = false here.
1966
+ // The parent component MUST call setItems() or similar to clear the busy state.
1967
+ // This prevents the sentinel from reappearing before new items are loaded.
1968
+ }
1969
+ catch (error) {
1970
+ this._handleError(error);
1971
+ this._setBusy(false); // Only clear on error
1972
+ }
1973
+ }
1974
+ _setBusy(busy) {
1975
+ this._state.isBusy = busy;
1976
+ // Trigger re-render to show/hide busy indicator
1977
+ // We use _renderOptions to handle the UI update
1978
+ this._renderOptions();
1979
+ }
1980
+ _showBusyBucket() {
1981
+ // Deprecated: Logic moved to _renderOptions
1982
+ }
1983
+ _hideBusyBucket() {
1984
+ // Deprecated: Logic moved to _renderOptions
1985
+ }
1986
+ _handleError(error) {
1987
+ this._emit('error', { message: error.message, cause: error });
1988
+ this._config.callbacks.onError?.(error);
1989
+ }
1990
+ _emit(name, detail) {
1991
+ this.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }));
1992
+ }
1993
+ _emitChange() {
1994
+ const selectedItems = Array.from(this._state.selectedItems.values());
1995
+ const getValue = this._config.serverSide.getValueFromItem || ((item) => item?.value ?? item);
1996
+ const selectedValues = selectedItems.map(getValue);
1997
+ const selectedIndices = Array.from(this._state.selectedIndices);
1998
+ this._emit('change', { selectedItems, selectedValues, selectedIndices });
1999
+ this._config.callbacks.onChange?.(selectedItems, selectedValues);
2000
+ }
2001
+ // Public API
2002
+ /**
2003
+ * Set items to display in the select
2004
+ */
2005
+ setItems(items) {
2006
+ const previousLength = this._state.loadedItems.length;
2007
+ this._state.loadedItems = items;
2008
+ // If grouped items exist, flatten them to items
2009
+ if (this._state.groupedItems.length > 0) {
2010
+ this._state.loadedItems = this._state.groupedItems.flatMap(group => group.options);
2011
+ }
2012
+ const newLength = this._state.loadedItems.length;
2013
+ // When infinite scroll is active (preserveScrollPosition = true),
2014
+ // we need to maintain scroll position during the update
2015
+ if (this._state.preserveScrollPosition && this._dropdown) {
2016
+ const targetScrollTop = this._state.lastScrollPosition;
2017
+ console.log('[InfiniteScroll] setItems: before render', {
2018
+ previousLength,
2019
+ newLength,
2020
+ lastScrollPosition: this._state.lastScrollPosition,
2021
+ scrollTop: this._dropdown.scrollTop,
2022
+ scrollHeight: this._dropdown.scrollHeight,
2023
+ clientHeight: this._dropdown.clientHeight
2024
+ });
2025
+ // Only clear loading if we actually got more items
2026
+ if (newLength > previousLength) {
2027
+ this._state.isBusy = false;
2028
+ }
2029
+ this._renderOptions();
2030
+ // Restore the exact scrollTop we had before loading
2031
+ // so the previously visible items stay in place and
2032
+ // new ones simply appear below.
2033
+ this._dropdown.scrollTop = targetScrollTop;
2034
+ // Ensure it sticks after layout
2035
+ requestAnimationFrame(() => {
2036
+ if (this._dropdown) {
2037
+ this._dropdown.scrollTop = targetScrollTop;
2038
+ console.log('[InfiniteScroll] setItems: after render', {
2039
+ newLength,
2040
+ lastScrollPosition: this._state.lastScrollPosition,
2041
+ scrollTop: this._dropdown.scrollTop,
2042
+ scrollHeight: this._dropdown.scrollHeight,
2043
+ clientHeight: this._dropdown.clientHeight
2044
+ });
2045
+ }
2046
+ });
2047
+ // Only clear preserveScrollPosition if we got new items
2048
+ if (newLength > previousLength) {
2049
+ this._state.preserveScrollPosition = false;
2050
+ }
2051
+ }
2052
+ else {
2053
+ // Normal update - just render normally
2054
+ this._state.isBusy = false;
2055
+ this._renderOptions();
2056
+ }
2057
+ }
2058
+ /**
2059
+ * Set grouped items
2060
+ */
2061
+ setGroupedItems(groupedItems) {
2062
+ this._state.groupedItems = groupedItems;
2063
+ this._state.loadedItems = groupedItems.flatMap(group => group.options);
2064
+ this._renderOptions();
2065
+ }
2066
+ /**
2067
+ * Get currently selected items
2068
+ */
2069
+ getSelectedItems() {
2070
+ return Array.from(this._state.selectedItems.values());
2071
+ }
2072
+ /**
2073
+ * Get all loaded items
2074
+ */
2075
+ get loadedItems() {
2076
+ return this._state.loadedItems;
2077
+ }
2078
+ /**
2079
+ * Get currently selected values
2080
+ */
2081
+ getSelectedValues() {
2082
+ const getValue = this._config.serverSide.getValueFromItem || ((item) => item?.value ?? item);
2083
+ return this.getSelectedItems().map(getValue);
2084
+ }
2085
+ /**
2086
+ * Set selected items by value
2087
+ */
2088
+ async setSelectedValues(values) {
2089
+ if (this._config.serverSide.enabled && this._config.serverSide.fetchSelectedItems) {
2090
+ await this._loadSelectedItemsByValues(values);
2091
+ }
2092
+ else {
2093
+ // Select from loaded items
2094
+ const getValue = this._config.serverSide.getValueFromItem || ((item) => item?.value ?? item);
2095
+ this._state.selectedIndices.clear();
2096
+ this._state.selectedItems.clear();
2097
+ this._state.loadedItems.forEach((item, index) => {
2098
+ if (values.includes(getValue(item))) {
2099
+ this._state.selectedIndices.add(index);
2100
+ this._state.selectedItems.set(index, item);
2101
+ }
2102
+ });
2103
+ this._renderOptions();
2104
+ this._updateInputDisplay();
2105
+ this._emitChange();
2106
+ }
2107
+ }
2108
+ /**
2109
+ * Load and select items by their values (for infinite scroll scenario)
2110
+ */
2111
+ async _loadSelectedItemsByValues(values) {
2112
+ if (!this._config.serverSide.fetchSelectedItems)
2113
+ return;
2114
+ this._setBusy(true);
2115
+ try {
2116
+ const items = await this._config.serverSide.fetchSelectedItems(values);
2117
+ this._state.selectedIndices.clear();
2118
+ this._state.selectedItems.clear();
2119
+ items.forEach((item, index) => {
2120
+ this._state.selectedIndices.add(index);
2121
+ this._state.selectedItems.set(index, item);
2122
+ });
2123
+ this._renderOptions();
2124
+ this._updateInputDisplay();
2125
+ this._emitChange();
2126
+ // Scroll to selected if configured
2127
+ if (this._config.scrollToSelected.enabled) {
2128
+ this._scrollToSelected();
2129
+ }
2130
+ }
2131
+ catch (error) {
2132
+ this._handleError(error);
2133
+ }
2134
+ finally {
2135
+ this._setBusy(false);
2136
+ }
2137
+ }
2138
+ /**
2139
+ * Clear all selections
2140
+ */
2141
+ clear() {
2142
+ this._state.selectedIndices.clear();
2143
+ this._state.selectedItems.clear();
2144
+ this._renderOptions();
2145
+ this._updateInputDisplay();
2146
+ this._emitChange();
2147
+ }
2148
+ /**
2149
+ * Open dropdown
2150
+ */
2151
+ open() {
2152
+ this._handleOpen();
2153
+ }
2154
+ /**
2155
+ * Close dropdown
2156
+ */
2157
+ close() {
2158
+ this._handleClose();
2159
+ }
2160
+ /**
2161
+ * Update component configuration
2162
+ */
2163
+ updateConfig(config) {
2164
+ this._config = selectConfig.mergeWithComponentConfig(config);
2165
+ // Update input state based on new config
2166
+ if (this._input) {
2167
+ this._input.readOnly = !this._config.searchable;
2168
+ this._input.setAttribute('aria-autocomplete', this._config.searchable ? 'list' : 'none');
2169
+ }
2170
+ // Re-initialize observers in case infinite scroll was enabled/disabled
2171
+ this._initializeObservers();
2172
+ this._renderOptions();
2173
+ }
2174
+ /**
2175
+ * Set error state
2176
+ */
2177
+ setError(message) {
2178
+ this._hasError = true;
2179
+ this._errorMessage = message;
2180
+ this._input.setAttribute('aria-invalid', 'true');
2181
+ this._announce(`Error: ${message}`);
2182
+ }
2183
+ /**
2184
+ * Clear error state
2185
+ */
2186
+ clearError() {
2187
+ this._hasError = false;
2188
+ this._errorMessage = '';
2189
+ this._input.removeAttribute('aria-invalid');
2190
+ }
2191
+ /**
2192
+ * Set required state
2193
+ */
2194
+ setRequired(required) {
2195
+ if (required) {
2196
+ this._input.setAttribute('aria-required', 'true');
2197
+ this._input.setAttribute('required', '');
2198
+ }
2199
+ else {
2200
+ this._input.removeAttribute('aria-required');
2201
+ this._input.removeAttribute('required');
2202
+ }
2203
+ }
2204
+ /**
2205
+ * Validate selection (for required fields)
2206
+ */
2207
+ validate() {
2208
+ const isRequired = this._input.hasAttribute('required');
2209
+ if (isRequired && this._state.selectedIndices.size === 0) {
2210
+ this.setError('Selection is required');
2211
+ return false;
2212
+ }
2213
+ this.clearError();
2214
+ return true;
2215
+ }
2216
+ /**
2217
+ * Render options based on current state
2218
+ */
2219
+ _renderOptions() {
2220
+ console.log('[EnhancedSelect] _renderOptions called');
2221
+ // Cleanup observer
2222
+ if (this._loadMoreTrigger && this._intersectionObserver) {
2223
+ this._intersectionObserver.unobserve(this._loadMoreTrigger);
2224
+ }
2225
+ // Clear options container
2226
+ this._optionsContainer.innerHTML = '';
2227
+ // Ensure dropdown only contains options container (cleanup legacy direct children)
2228
+ // We need to preserve optionsContainer, so we can't just clear dropdown.innerHTML
2229
+ // But we can check if there are other children and remove them
2230
+ Array.from(this._dropdown.children).forEach(child => {
2231
+ if (child !== this._optionsContainer) {
2232
+ this._dropdown.removeChild(child);
2233
+ }
2234
+ });
2235
+ // Ensure dropdown is visible if we are rendering options
2236
+ if (this._state.isOpen && this._dropdown.style.display === 'none') {
2237
+ this._dropdown.style.display = 'block';
2238
+ }
2239
+ // Show searching state (exclusive state)
2240
+ if (this._state.isSearching) {
2241
+ const searching = document.createElement('div');
2242
+ searching.className = 'searching-state';
2243
+ searching.textContent = 'Searching...';
2244
+ this._optionsContainer.appendChild(searching);
2245
+ return;
2246
+ }
2247
+ const getValue = this._config.serverSide.getValueFromItem || ((item) => item?.value ?? item);
2248
+ const getLabel = this._config.serverSide.getLabelFromItem || ((item) => item?.label ?? String(item));
2249
+ // Filter items by search query
2250
+ const query = this._state.searchQuery.toLowerCase();
2251
+ // Handle Grouped Items Rendering (when no search query)
2252
+ if (this._state.groupedItems.length > 0 && !query) {
2253
+ this._state.groupedItems.forEach(group => {
2254
+ const header = document.createElement('div');
2255
+ header.className = 'group-header';
2256
+ header.textContent = group.label;
2257
+ Object.assign(header.style, {
2258
+ padding: '8px 12px',
2259
+ fontWeight: '600',
2260
+ color: '#6b7280',
2261
+ backgroundColor: '#f3f4f6',
2262
+ fontSize: '12px',
2263
+ textTransform: 'uppercase',
2264
+ letterSpacing: '0.05em',
2265
+ position: 'sticky',
2266
+ top: '0',
2267
+ zIndex: '1',
2268
+ borderBottom: '1px solid #e5e7eb'
2269
+ });
2270
+ this._optionsContainer.appendChild(header);
2271
+ group.options.forEach(item => {
2272
+ this._renderSingleOption(item, getValue, getLabel);
2273
+ });
2274
+ });
2275
+ }
2276
+ else {
2277
+ // Normal rendering (flat list or filtered)
2278
+ const itemsToRender = query
2279
+ ? this._state.loadedItems.filter((item) => {
2280
+ try {
2281
+ const label = String(getLabel(item)).toLowerCase();
2282
+ return label.includes(query);
2283
+ }
2284
+ catch (e) {
2285
+ return false;
2286
+ }
2287
+ })
2288
+ : this._state.loadedItems;
2289
+ if (itemsToRender.length === 0 && !this._state.isBusy) {
2290
+ const empty = document.createElement('div');
2291
+ empty.className = 'empty-state';
2292
+ if (query) {
2293
+ empty.textContent = `No results found for "${this._state.searchQuery}"`;
2294
+ }
2295
+ else {
2296
+ empty.textContent = 'No options available';
2297
+ }
2298
+ this._optionsContainer.appendChild(empty);
2299
+ }
2300
+ else {
2301
+ itemsToRender.forEach((item) => {
2302
+ this._renderSingleOption(item, getValue, getLabel);
2303
+ });
2304
+ }
2305
+ }
2306
+ // Append Busy Indicator if busy
2307
+ if (this._state.isBusy && this._config.busyBucket.enabled) {
2308
+ const busyBucket = document.createElement('div');
2309
+ busyBucket.className = 'busy-bucket';
2310
+ if (this._config.busyBucket.showSpinner) {
2311
+ const spinner = document.createElement('div');
2312
+ spinner.className = 'spinner';
2313
+ busyBucket.appendChild(spinner);
2314
+ }
2315
+ if (this._config.busyBucket.message) {
2316
+ const message = document.createElement('div');
2317
+ message.textContent = this._config.busyBucket.message;
2318
+ busyBucket.appendChild(message);
2319
+ }
2320
+ this._optionsContainer.appendChild(busyBucket);
2321
+ }
2322
+ // Append Load More Trigger (Button or Sentinel) if enabled and not busy
2323
+ else if ((this._config.loadMore.enabled || this._config.infiniteScroll.enabled) && this._state.loadedItems.length > 0) {
2324
+ this._addLoadMoreTrigger();
2325
+ }
2326
+ }
2327
+ _renderSingleOption(item, getValue, getLabel) {
2328
+ const option = document.createElement('div');
2329
+ option.className = 'option';
2330
+ const value = getValue(item);
2331
+ const label = getLabel(item);
2332
+ option.textContent = label;
2333
+ option.dataset.value = String(value);
2334
+ // Check if selected using selectedItems map
2335
+ const isSelected = Array.from(this._state.selectedItems.values()).some(selectedItem => {
2336
+ const selectedValue = getValue(selectedItem);
2337
+ return selectedValue === value;
2338
+ });
2339
+ if (isSelected) {
2340
+ option.classList.add('selected');
2341
+ }
2342
+ option.addEventListener('click', () => {
2343
+ const index = this._state.loadedItems.indexOf(item);
2344
+ if (index !== -1) {
2345
+ this._selectOption(index);
2346
+ }
2347
+ });
2348
+ this._optionsContainer.appendChild(option);
2349
+ }
2350
+ _addLoadMoreTrigger() {
2351
+ const container = document.createElement('div');
2352
+ container.className = 'load-more-container';
2353
+ if (this._config.infiniteScroll.enabled) {
2354
+ // Infinite Scroll: Render an invisible sentinel
2355
+ // It must have some height to be intersected
2356
+ const sentinel = document.createElement('div');
2357
+ sentinel.className = 'infinite-scroll-sentinel';
2358
+ sentinel.style.height = '10px';
2359
+ sentinel.style.width = '100%';
2360
+ sentinel.style.opacity = '0'; // Invisible
2361
+ this._loadMoreTrigger = sentinel;
2362
+ container.appendChild(sentinel);
2363
+ }
2364
+ else {
2365
+ // Manual Load More: Render a button
2366
+ const button = document.createElement('button');
2367
+ button.className = 'load-more-button';
2368
+ button.textContent = `Load ${this._config.loadMore.itemsPerLoad} more`;
2369
+ button.addEventListener('click', () => this._loadMoreItems());
2370
+ this._loadMoreTrigger = button;
2371
+ container.appendChild(button);
2372
+ }
2373
+ this._optionsContainer.appendChild(container);
2374
+ // Setup intersection observer for auto-load
2375
+ if (this._intersectionObserver && this._loadMoreTrigger) {
2376
+ console.log('[InfiniteScroll] Observing sentinel');
2377
+ this._intersectionObserver.observe(this._loadMoreTrigger);
2378
+ }
2379
+ }
2380
+ }
2381
+ // Register custom element
2382
+ if (!customElements.get('enhanced-select')) {
2383
+ customElements.define('enhanced-select', EnhancedSelect);
2384
+ }
2385
+
2386
+ /**
2387
+ * Independent Option Component
2388
+ * High cohesion, low coupling - handles its own selection state and events
2389
+ */
2390
+ class SelectOption extends HTMLElement {
2391
+ constructor(config) {
2392
+ super();
2393
+ this._config = config;
2394
+ this._shadow = this.attachShadow({ mode: 'open' });
2395
+ this._container = document.createElement('div');
2396
+ this._container.className = 'option-container';
2397
+ this._initializeStyles();
2398
+ this._render();
2399
+ this._attachEventListeners();
2400
+ this._shadow.appendChild(this._container);
2401
+ }
2402
+ _initializeStyles() {
2403
+ const style = document.createElement('style');
2404
+ style.textContent = `
2405
+ :host {
2406
+ display: block;
2407
+ position: relative;
2408
+ }
2409
+
2410
+ .option-container {
2411
+ display: flex;
2412
+ align-items: center;
2413
+ justify-content: space-between;
2414
+ padding: 8px 12px;
2415
+ cursor: pointer;
2416
+ user-select: none;
2417
+ transition: background-color 0.2s ease;
2418
+ }
2419
+
2420
+ .option-container:hover {
2421
+ background-color: var(--select-option-hover-bg, #f0f0f0);
2422
+ }
2423
+
2424
+ .option-container.selected {
2425
+ background-color: var(--select-option-selected-bg, #e3f2fd);
2426
+ color: var(--select-option-selected-color, #1976d2);
2427
+ }
2428
+
2429
+ .option-container.active {
2430
+ outline: 2px solid var(--select-option-active-outline, #1976d2);
2431
+ outline-offset: -2px;
2432
+ }
2433
+
2434
+ .option-container.disabled {
2435
+ opacity: 0.5;
2436
+ cursor: not-allowed;
2437
+ pointer-events: none;
2438
+ }
2439
+
2440
+ .option-content {
2441
+ flex: 1;
2442
+ overflow: hidden;
2443
+ text-overflow: ellipsis;
2444
+ white-space: nowrap;
2445
+ }
2446
+
2447
+ .remove-button {
2448
+ margin-left: 8px;
2449
+ padding: 2px 6px;
2450
+ border: none;
2451
+ background-color: var(--select-remove-btn-bg, transparent);
2452
+ color: var(--select-remove-btn-color, #666);
2453
+ cursor: pointer;
2454
+ border-radius: 3px;
2455
+ font-size: 16px;
2456
+ line-height: 1;
2457
+ transition: all 0.2s ease;
2458
+ }
2459
+
2460
+ .remove-button:hover {
2461
+ background-color: var(--select-remove-btn-hover-bg, #ffebee);
2462
+ color: var(--select-remove-btn-hover-color, #c62828);
2463
+ }
2464
+
2465
+ .remove-button:focus {
2466
+ outline: 2px solid var(--select-remove-btn-focus-outline, #1976d2);
2467
+ outline-offset: 2px;
2468
+ }
2469
+ `;
2470
+ this._shadow.appendChild(style);
2471
+ }
2472
+ _render() {
2473
+ const { item, index, selected, disabled, active, render, showRemoveButton } = this._config;
2474
+ // Clear container
2475
+ this._container.innerHTML = '';
2476
+ // Apply state classes
2477
+ this._container.classList.toggle('selected', selected);
2478
+ this._container.classList.toggle('disabled', disabled || false);
2479
+ this._container.classList.toggle('active', active || false);
2480
+ // Custom class name
2481
+ if (this._config.className) {
2482
+ this._container.className += ' ' + this._config.className;
2483
+ }
2484
+ // Apply custom styles
2485
+ if (this._config.style) {
2486
+ Object.assign(this._container.style, this._config.style);
2487
+ }
2488
+ // Render content
2489
+ const contentDiv = document.createElement('div');
2490
+ contentDiv.className = 'option-content';
2491
+ if (render) {
2492
+ const rendered = render(item, index);
2493
+ if (typeof rendered === 'string') {
2494
+ contentDiv.innerHTML = rendered;
2495
+ }
2496
+ else {
2497
+ contentDiv.appendChild(rendered);
2498
+ }
2499
+ }
2500
+ else {
2501
+ const label = this._getLabel();
2502
+ contentDiv.textContent = label;
2503
+ }
2504
+ this._container.appendChild(contentDiv);
2505
+ // Add remove button if needed
2506
+ if (showRemoveButton && selected) {
2507
+ this._removeButton = document.createElement('button');
2508
+ this._removeButton.className = 'remove-button';
2509
+ this._removeButton.innerHTML = '×';
2510
+ this._removeButton.setAttribute('aria-label', 'Remove option');
2511
+ this._removeButton.setAttribute('type', 'button');
2512
+ this._container.appendChild(this._removeButton);
2513
+ }
2514
+ // Set ARIA attributes
2515
+ this.setAttribute('role', 'option');
2516
+ this.setAttribute('aria-selected', String(selected));
2517
+ if (disabled)
2518
+ this.setAttribute('aria-disabled', 'true');
2519
+ this.id = `select-option-${index}`;
2520
+ }
2521
+ _attachEventListeners() {
2522
+ // Click handler for selection
2523
+ this._container.addEventListener('click', (e) => {
2524
+ // Don't trigger selection if clicking remove button
2525
+ if (e.target === this._removeButton) {
2526
+ return;
2527
+ }
2528
+ if (!this._config.disabled) {
2529
+ this._handleSelect();
2530
+ }
2531
+ });
2532
+ // Remove button handler
2533
+ if (this._removeButton) {
2534
+ this._removeButton.addEventListener('click', (e) => {
2535
+ e.stopPropagation();
2536
+ this._handleRemove();
2537
+ });
2538
+ }
2539
+ // Keyboard handler
2540
+ this.addEventListener('keydown', (e) => {
2541
+ if (this._config.disabled)
2542
+ return;
2543
+ if (e.key === 'Enter' || e.key === ' ') {
2544
+ e.preventDefault();
2545
+ this._handleSelect();
2546
+ }
2547
+ else if (e.key === 'Delete' || e.key === 'Backspace') {
2548
+ if (this._config.selected && this._config.showRemoveButton) {
2549
+ e.preventDefault();
2550
+ this._handleRemove();
2551
+ }
2552
+ }
2553
+ });
2554
+ }
2555
+ _handleSelect() {
2556
+ const detail = {
2557
+ item: this._config.item,
2558
+ index: this._config.index,
2559
+ value: this._getValue(),
2560
+ label: this._getLabel(),
2561
+ selected: !this._config.selected,
2562
+ };
2563
+ this.dispatchEvent(new CustomEvent('optionSelect', {
2564
+ detail,
2565
+ bubbles: true,
2566
+ composed: true,
2567
+ }));
2568
+ }
2569
+ _handleRemove() {
2570
+ const detail = {
2571
+ item: this._config.item,
2572
+ index: this._config.index,
2573
+ value: this._getValue(),
2574
+ label: this._getLabel(),
2575
+ selected: false,
2576
+ };
2577
+ this.dispatchEvent(new CustomEvent('optionRemove', {
2578
+ detail,
2579
+ bubbles: true,
2580
+ composed: true,
2581
+ }));
2582
+ }
2583
+ _getValue() {
2584
+ if (this._config.getValue) {
2585
+ return this._config.getValue(this._config.item);
2586
+ }
2587
+ return this._config.item?.value ?? this._config.item;
2588
+ }
2589
+ _getLabel() {
2590
+ if (this._config.getLabel) {
2591
+ return this._config.getLabel(this._config.item);
2592
+ }
2593
+ return this._config.item?.label ?? String(this._config.item);
2594
+ }
2595
+ /**
2596
+ * Update option configuration and re-render
2597
+ */
2598
+ updateConfig(updates) {
2599
+ this._config = { ...this._config, ...updates };
2600
+ this._render();
2601
+ this._attachEventListeners();
2602
+ }
2603
+ /**
2604
+ * Get current configuration
2605
+ */
2606
+ getConfig() {
2607
+ return this._config;
2608
+ }
2609
+ /**
2610
+ * Get option value
2611
+ */
2612
+ getValue() {
2613
+ return this._getValue();
2614
+ }
2615
+ /**
2616
+ * Get option label
2617
+ */
2618
+ getLabel() {
2619
+ return this._getLabel();
2620
+ }
2621
+ /**
2622
+ * Set selected state
2623
+ */
2624
+ setSelected(selected) {
2625
+ this._config.selected = selected;
2626
+ this._render();
2627
+ }
2628
+ /**
2629
+ * Set active state
2630
+ */
2631
+ setActive(active) {
2632
+ this._config.active = active;
2633
+ this._render();
2634
+ }
2635
+ /**
2636
+ * Set disabled state
2637
+ */
2638
+ setDisabled(disabled) {
2639
+ this._config.disabled = disabled;
2640
+ this._render();
2641
+ }
2642
+ }
2643
+ // Register custom element
2644
+ if (!customElements.get('select-option')) {
2645
+ customElements.define('select-option', SelectOption);
2646
+ }
2647
+
2648
+ /**
2649
+ * Web Worker protocol types for off-main-thread processing.
2650
+ * Supports heavy transforms, fuzzy search, and data processing.
2651
+ */
2652
+ /**
2653
+ * Worker manager for handling off-main-thread processing.
2654
+ * Automatically falls back if workers not available or blocked by COOP/COEP.
2655
+ */
2656
+ class WorkerManager {
2657
+ constructor() {
2658
+ this.worker = null;
2659
+ this.pending = new Map();
2660
+ this.supportsWorkers = false;
2661
+ this.supportsSharedArrayBuffer = false;
2662
+ this.nextId = 0;
2663
+ this.detectFeatures();
2664
+ this.initWorker();
2665
+ }
2666
+ /**
2667
+ * Detect browser capabilities
2668
+ */
2669
+ detectFeatures() {
2670
+ this.supportsWorkers = typeof Worker !== 'undefined';
2671
+ this.supportsSharedArrayBuffer = typeof SharedArrayBuffer !== 'undefined';
2672
+ }
2673
+ /**
2674
+ * Initialize worker (inline blob URL for simplicity)
2675
+ */
2676
+ initWorker() {
2677
+ if (!this.supportsWorkers)
2678
+ return;
2679
+ try {
2680
+ const workerCode = this.generateWorkerCode();
2681
+ const blob = new Blob([workerCode], { type: 'application/javascript' });
2682
+ const url = URL.createObjectURL(blob);
2683
+ this.worker = new Worker(url);
2684
+ this.worker.onmessage = this.handleMessage.bind(this);
2685
+ this.worker.onerror = this.handleError.bind(this);
2686
+ // Cleanup blob URL after worker loads
2687
+ URL.revokeObjectURL(url);
2688
+ }
2689
+ catch (err) {
2690
+ console.warn('Worker initialization failed, falling back to main thread', err);
2691
+ this.worker = null;
2692
+ }
2693
+ }
2694
+ /**
2695
+ * Generate worker code as string
2696
+ */
2697
+ generateWorkerCode() {
2698
+ return `
2699
+ // Worker code
2700
+ self.onmessage = function(e) {
2701
+ const start = performance.now();
2702
+ const { id, type, payload } = e.data;
2703
+
2704
+ try {
2705
+ let result;
2706
+
2707
+ switch (type) {
2708
+ case 'transform':
2709
+ result = handleTransform(payload);
2710
+ break;
2711
+ case 'search':
2712
+ result = handleSearch(payload);
2713
+ break;
2714
+ case 'filter':
2715
+ result = handleFilter(payload);
2716
+ break;
2717
+ case 'sort':
2718
+ result = handleSort(payload);
2719
+ break;
2720
+ default:
2721
+ throw new Error('Unknown operation: ' + type);
2722
+ }
2723
+
2724
+ const duration = performance.now() - start;
2725
+ self.postMessage({
2726
+ id,
2727
+ success: true,
2728
+ data: result,
2729
+ duration,
2730
+ });
2731
+ } catch (error) {
2732
+ self.postMessage({
2733
+ id,
2734
+ success: false,
2735
+ error: error.message,
2736
+ });
2737
+ }
2738
+ };
2739
+
2740
+ function handleTransform({ items, transformer }) {
2741
+ const fn = new Function('item', 'index', 'return (' + transformer + ')(item, index)');
2742
+ return items.map((item, i) => fn(item, i));
2743
+ }
2744
+
2745
+ function handleSearch({ items, query, fuzzy, maxResults }) {
2746
+ const lowerQuery = query.toLowerCase();
2747
+ const results = [];
2748
+
2749
+ for (let i = 0; i < items.length; i++) {
2750
+ const item = items[i];
2751
+ const text = String(item).toLowerCase();
2752
+
2753
+ if (fuzzy) {
2754
+ if (fuzzyMatch(text, lowerQuery)) {
2755
+ results.push({ item, index: i, score: fuzzyScore(text, lowerQuery) });
2756
+ }
2757
+ } else {
2758
+ if (text.includes(lowerQuery)) {
2759
+ results.push({ item, index: i });
2760
+ }
2761
+ }
2762
+
2763
+ if (maxResults && results.length >= maxResults) break;
2764
+ }
2765
+
2766
+ if (fuzzy) {
2767
+ results.sort((a, b) => b.score - a.score);
2768
+ }
2769
+
2770
+ return results;
2771
+ }
2772
+
2773
+ function handleFilter({ items, predicate }) {
2774
+ const fn = new Function('item', 'index', 'return (' + predicate + ')(item, index)');
2775
+ return items.filter((item, i) => fn(item, i));
2776
+ }
2777
+
2778
+ function handleSort({ items, comparator }) {
2779
+ const fn = new Function('a', 'b', 'return (' + comparator + ')(a, b)');
2780
+ return [...items].sort(fn);
2781
+ }
2782
+
2783
+ // Simple fuzzy matching (Levenshtein-inspired)
2784
+ function fuzzyMatch(text, query) {
2785
+ let qi = 0;
2786
+ for (let ti = 0; ti < text.length && qi < query.length; ti++) {
2787
+ if (text[ti] === query[qi]) qi++;
2788
+ }
2789
+ return qi === query.length;
2790
+ }
2791
+
2792
+ function fuzzyScore(text, query) {
2793
+ let score = 0;
2794
+ let qi = 0;
2795
+ for (let ti = 0; ti < text.length && qi < query.length; ti++) {
2796
+ if (text[ti] === query[qi]) {
2797
+ score += 100 - ti; // Earlier matches score higher
2798
+ qi++;
2799
+ }
2800
+ }
2801
+ return score;
2802
+ }
2803
+ `;
2804
+ }
2805
+ /**
2806
+ * Handle worker message
2807
+ */
2808
+ handleMessage(e) {
2809
+ const { id, success, data, error } = e.data;
2810
+ const pending = this.pending.get(id);
2811
+ if (!pending)
2812
+ return;
2813
+ clearTimeout(pending.timeout);
2814
+ this.pending.delete(id);
2815
+ if (success) {
2816
+ pending.resolve(data);
2817
+ }
2818
+ else {
2819
+ pending.reject(new Error(error || 'Worker error'));
2820
+ }
2821
+ }
2822
+ /**
2823
+ * Handle worker error
2824
+ */
2825
+ handleError(err) {
2826
+ console.error('Worker error:', err);
2827
+ // Reject all pending requests
2828
+ for (const [id, pending] of this.pending) {
2829
+ clearTimeout(pending.timeout);
2830
+ pending.reject(new Error('Worker crashed'));
2831
+ }
2832
+ this.pending.clear();
2833
+ }
2834
+ /**
2835
+ * Execute operation (worker or fallback)
2836
+ */
2837
+ async execute(type, payload, timeout = 5000) {
2838
+ // Fallback to main thread if no worker
2839
+ if (!this.worker) {
2840
+ return this.executeFallback(type, payload);
2841
+ }
2842
+ const id = `req_${this.nextId++}`;
2843
+ return new Promise((resolve, reject) => {
2844
+ const timeoutId = setTimeout(() => {
2845
+ this.pending.delete(id);
2846
+ reject(new Error('Worker timeout'));
2847
+ }, timeout);
2848
+ this.pending.set(id, { resolve, reject, timeout: timeoutId });
2849
+ this.worker.postMessage({ id, type, payload });
2850
+ });
2851
+ }
2852
+ /**
2853
+ * Fallback to main thread execution
2854
+ */
2855
+ async executeFallback(type, payload) {
2856
+ // Simple synchronous fallback implementations
2857
+ switch (type) {
2858
+ case 'transform': {
2859
+ const { items, transformer } = payload;
2860
+ const fn = new Function('item', 'index', `return (${transformer})(item, index)`);
2861
+ return items.map((item, i) => fn(item, i));
2862
+ }
2863
+ case 'search': {
2864
+ const { items, query, fuzzy } = payload;
2865
+ const lowerQuery = query.toLowerCase();
2866
+ const results = [];
2867
+ items.forEach((item, index) => {
2868
+ const itemStr = String(item).toLowerCase();
2869
+ if (fuzzy) {
2870
+ // Simple fuzzy match - check if all query chars appear in order
2871
+ let queryIndex = 0;
2872
+ for (let i = 0; i < itemStr.length && queryIndex < lowerQuery.length; i++) {
2873
+ if (itemStr[i] === lowerQuery[queryIndex]) {
2874
+ queryIndex++;
2875
+ }
2876
+ }
2877
+ if (queryIndex === lowerQuery.length) {
2878
+ results.push({ item, index });
2879
+ }
2880
+ }
2881
+ else {
2882
+ // Exact substring match
2883
+ if (itemStr.includes(lowerQuery)) {
2884
+ results.push({ item, index });
2885
+ }
2886
+ }
2887
+ });
2888
+ return results;
2889
+ }
2890
+ case 'filter': {
2891
+ const { items, predicate } = payload;
2892
+ const fn = new Function('item', 'index', `return (${predicate})(item, index)`);
2893
+ return items.filter((item, i) => fn(item, i));
2894
+ }
2895
+ case 'sort': {
2896
+ const { items, comparator } = payload;
2897
+ const fn = new Function('a', 'b', `return (${comparator})(a, b)`);
2898
+ return [...items].sort(fn);
2899
+ }
2900
+ default:
2901
+ throw new Error(`Unknown operation: ${type}`);
2902
+ }
2903
+ }
2904
+ /**
2905
+ * Transform items with custom function
2906
+ */
2907
+ async transform(items, transformer) {
2908
+ return this.execute('transform', {
2909
+ items,
2910
+ transformer: transformer.toString(),
2911
+ });
2912
+ }
2913
+ /**
2914
+ * Search items with optional fuzzy matching
2915
+ */
2916
+ async search(items, query, fuzzy = false) {
2917
+ return this.execute('search', { items, query, fuzzy });
2918
+ }
2919
+ /**
2920
+ * Filter items with predicate
2921
+ */
2922
+ async filter(items, predicate) {
2923
+ return this.execute('filter', {
2924
+ items,
2925
+ predicate: predicate.toString(),
2926
+ });
2927
+ }
2928
+ /**
2929
+ * Sort items with comparator
2930
+ */
2931
+ async sort(items, comparator) {
2932
+ return this.execute('sort', {
2933
+ items,
2934
+ comparator: comparator.toString(),
2935
+ });
2936
+ }
2937
+ /**
2938
+ * Check if workers are supported
2939
+ */
2940
+ get hasWorkerSupport() {
2941
+ return this.supportsWorkers && this.worker !== null;
2942
+ }
2943
+ /**
2944
+ * Check if SharedArrayBuffer is supported
2945
+ */
2946
+ get hasSharedArrayBuffer() {
2947
+ return this.supportsSharedArrayBuffer;
2948
+ }
2949
+ /**
2950
+ * Terminate worker
2951
+ */
2952
+ destroy() {
2953
+ if (this.worker) {
2954
+ // Reject all pending
2955
+ for (const [id, pending] of this.pending) {
2956
+ clearTimeout(pending.timeout);
2957
+ pending.reject(new Error('Worker terminated'));
2958
+ }
2959
+ this.pending.clear();
2960
+ this.worker.terminate();
2961
+ this.worker = null;
2962
+ }
2963
+ }
2964
+ }
2965
+ /**
2966
+ * Singleton instance
2967
+ */
2968
+ let workerManager = null;
2969
+ function getWorkerManager() {
2970
+ if (!workerManager) {
2971
+ workerManager = new WorkerManager();
2972
+ }
2973
+ return workerManager;
2974
+ }
2975
+
2976
+ /**
2977
+ * Performance telemetry module for tracking frame timing and performance metrics.
2978
+ * Uses PerformanceObserver for precise measurements.
2979
+ *
2980
+ * Tracks:
2981
+ * - Frame time (should be < 16.67ms for 60fps)
2982
+ * - Main thread work per frame (target < 8ms, ideal 4-6ms)
2983
+ * - Long tasks (> 50ms blocks)
2984
+ * - Memory usage (if available)
2985
+ */
2986
+ class PerformanceTelemetry {
2987
+ constructor() {
2988
+ this.frameTimes = [];
2989
+ this.longTasks = 0;
2990
+ this.lastFrameTime = 0;
2991
+ this.rafId = 0;
2992
+ this.measuring = false;
2993
+ this.observer = null;
2994
+ this.workTimes = [];
2995
+ /**
2996
+ * Measure frame timing via requestAnimationFrame
2997
+ */
2998
+ this.measureFrame = () => {
2999
+ if (!this.measuring)
3000
+ return;
3001
+ const now = performance.now();
3002
+ const frameTime = now - this.lastFrameTime;
3003
+ this.frameTimes.push(frameTime);
3004
+ // Keep last 60 frames (1 second at 60fps)
3005
+ if (this.frameTimes.length > 60) {
3006
+ this.frameTimes.shift();
3007
+ }
3008
+ this.lastFrameTime = now;
3009
+ this.rafId = requestAnimationFrame(this.measureFrame);
3010
+ };
3011
+ this.setupObserver();
3012
+ }
3013
+ /**
3014
+ * Setup PerformanceObserver for long tasks
3015
+ */
3016
+ setupObserver() {
3017
+ if (typeof PerformanceObserver === 'undefined')
3018
+ return;
3019
+ try {
3020
+ this.observer = new PerformanceObserver((list) => {
3021
+ for (const entry of list.getEntries()) {
3022
+ if (entry.entryType === 'longtask' && entry.duration > 50) {
3023
+ this.longTasks++;
3024
+ }
3025
+ if (entry.entryType === 'measure') {
3026
+ this.workTimes.push(entry.duration);
3027
+ }
3028
+ }
3029
+ });
3030
+ // Observe long tasks if supported
3031
+ try {
3032
+ this.observer.observe({ entryTypes: ['longtask', 'measure'] });
3033
+ }
3034
+ catch {
3035
+ // Fallback to just measure if longtask not supported
3036
+ this.observer.observe({ entryTypes: ['measure'] });
3037
+ }
3038
+ }
3039
+ catch (err) {
3040
+ console.warn('PerformanceObserver not available', err);
3041
+ }
3042
+ }
3043
+ /**
3044
+ * Start measuring frame performance
3045
+ */
3046
+ start() {
3047
+ if (this.measuring)
3048
+ return;
3049
+ this.measuring = true;
3050
+ this.frameTimes = [];
3051
+ this.workTimes = [];
3052
+ this.longTasks = 0;
3053
+ this.lastFrameTime = performance.now();
3054
+ this.measureFrame();
3055
+ }
3056
+ /**
3057
+ * Stop measuring
3058
+ */
3059
+ stop() {
3060
+ this.measuring = false;
3061
+ if (this.rafId) {
3062
+ cancelAnimationFrame(this.rafId);
3063
+ this.rafId = 0;
3064
+ }
3065
+ }
3066
+ /**
3067
+ * Mark start of work for custom measurement
3068
+ */
3069
+ markStart(label) {
3070
+ performance.mark(`${label}-start`);
3071
+ }
3072
+ /**
3073
+ * Mark end of work and measure duration
3074
+ */
3075
+ markEnd(label) {
3076
+ performance.mark(`${label}-end`);
3077
+ performance.measure(label, `${label}-start`, `${label}-end`);
3078
+ const measure = performance.getEntriesByName(label, 'measure')[0];
3079
+ return measure ? measure.duration : 0;
3080
+ }
3081
+ /**
3082
+ * Get current metrics snapshot
3083
+ */
3084
+ getMetrics() {
3085
+ const avgFrameTime = this.frameTimes.length > 0
3086
+ ? this.frameTimes.reduce((a, b) => a + b, 0) / this.frameTimes.length
3087
+ : 0;
3088
+ const fps = avgFrameTime > 0 ? 1000 / avgFrameTime : 0;
3089
+ const longFrames = this.frameTimes.filter(t => t > 16.67).length;
3090
+ const droppedFrames = this.frameTimes.filter(t => t > 33.33).length; // > 2 frames
3091
+ const avgMainThreadWork = this.workTimes.length > 0
3092
+ ? this.workTimes.reduce((a, b) => a + b, 0) / this.workTimes.length
3093
+ : 0;
3094
+ const metrics = {
3095
+ frames: {
3096
+ frameTime: avgFrameTime,
3097
+ fps,
3098
+ longTasks: longFrames,
3099
+ droppedFrames,
3100
+ },
3101
+ mainThreadWork: avgMainThreadWork,
3102
+ longTaskCount: this.longTasks,
3103
+ };
3104
+ // Add memory info if available
3105
+ if ('memory' in performance && performance.memory) {
3106
+ const mem = performance.memory;
3107
+ metrics.memory = {
3108
+ usedJSHeapSize: mem.usedJSHeapSize,
3109
+ totalJSHeapSize: mem.totalJSHeapSize,
3110
+ jsHeapSizeLimit: mem.jsHeapSizeLimit,
3111
+ };
3112
+ }
3113
+ return metrics;
3114
+ }
3115
+ /**
3116
+ * Check if performance is acceptable
3117
+ */
3118
+ isPerformanceGood() {
3119
+ const metrics = this.getMetrics();
3120
+ // Target: 60fps (16.67ms), < 8ms main thread work, minimal long tasks
3121
+ return (metrics.frames.fps >= 55 && // Allow some variance
3122
+ metrics.mainThreadWork < 8 &&
3123
+ metrics.frames.longTasks < 3 // Max 5% long frames
3124
+ );
3125
+ }
3126
+ /**
3127
+ * Get detailed report as string
3128
+ */
3129
+ getReport() {
3130
+ const metrics = this.getMetrics();
3131
+ let report = '=== Performance Report ===\n';
3132
+ report += `FPS: ${metrics.frames.fps.toFixed(2)}\n`;
3133
+ report += `Avg Frame Time: ${metrics.frames.frameTime.toFixed(2)}ms\n`;
3134
+ report += `Long Frames (>16.67ms): ${metrics.frames.longTasks}\n`;
3135
+ report += `Dropped Frames (>33ms): ${metrics.frames.droppedFrames}\n`;
3136
+ report += `Avg Main Thread Work: ${metrics.mainThreadWork.toFixed(2)}ms\n`;
3137
+ report += `Long Tasks (>50ms): ${metrics.longTaskCount}\n`;
3138
+ if (metrics.memory) {
3139
+ const usedMB = (metrics.memory.usedJSHeapSize / 1024 / 1024).toFixed(2);
3140
+ const limitMB = (metrics.memory.jsHeapSizeLimit / 1024 / 1024).toFixed(2);
3141
+ report += `Memory: ${usedMB}MB / ${limitMB}MB\n`;
3142
+ }
3143
+ report += `Status: ${this.isPerformanceGood() ? '✓ GOOD' : '✗ POOR'}\n`;
3144
+ return report;
3145
+ }
3146
+ /**
3147
+ * Reset all metrics
3148
+ */
3149
+ reset() {
3150
+ this.frameTimes = [];
3151
+ this.workTimes = [];
3152
+ this.longTasks = 0;
3153
+ }
3154
+ /**
3155
+ * Cleanup
3156
+ */
3157
+ destroy() {
3158
+ this.stop();
3159
+ if (this.observer) {
3160
+ this.observer.disconnect();
3161
+ this.observer = null;
3162
+ }
3163
+ }
3164
+ }
3165
+ /**
3166
+ * Singleton instance
3167
+ */
3168
+ let telemetry = null;
3169
+ function getTelemetry() {
3170
+ if (!telemetry) {
3171
+ telemetry = new PerformanceTelemetry();
3172
+ }
3173
+ return telemetry;
3174
+ }
3175
+ /**
3176
+ * Helper to measure async function execution
3177
+ */
3178
+ async function measureAsync(label, fn) {
3179
+ const start = performance.now();
3180
+ const result = await fn();
3181
+ const duration = performance.now() - start;
3182
+ console.log(`[Perf] ${label}: ${duration.toFixed(2)}ms`);
3183
+ return { result, duration };
3184
+ }
3185
+ /**
3186
+ * Helper to measure sync function execution
3187
+ */
3188
+ function measureSync(label, fn) {
3189
+ const start = performance.now();
3190
+ const result = fn();
3191
+ const duration = performance.now() - start;
3192
+ console.log(`[Perf] ${label}: ${duration.toFixed(2)}ms`);
3193
+ return { result, duration };
3194
+ }
3195
+
3196
+ /**
3197
+ * Security utilities for CSP-compliant environments.
3198
+ * All utilities are opt-in and do not run by default.
3199
+ */
3200
+ /**
3201
+ * No-op sanitizer - DOES NOT sanitize HTML.
3202
+ * Used by default to avoid forcing a dependency on sanitizer libraries.
3203
+ * Developers using untrusted data MUST provide a real sanitizer.
3204
+ */
3205
+ class NoOpSanitizer {
3206
+ sanitize(html) {
3207
+ return html;
3208
+ }
3209
+ }
3210
+ /**
3211
+ * Registry for custom sanitizer implementation.
3212
+ * Defaults to no-op - developers must explicitly set a sanitizer.
3213
+ */
3214
+ let globalSanitizer = new NoOpSanitizer();
3215
+ /**
3216
+ * Set a custom HTML sanitizer (e.g., DOMPurify).
3217
+ *
3218
+ * @example
3219
+ * import DOMPurify from 'dompurify';
3220
+ * setHTMLSanitizer({
3221
+ * sanitize: (html) => DOMPurify.sanitize(html)
3222
+ * });
3223
+ */
3224
+ function setHTMLSanitizer(sanitizer) {
3225
+ globalSanitizer = sanitizer;
3226
+ }
3227
+ /**
3228
+ * Get the current HTML sanitizer.
3229
+ */
3230
+ function getHTMLSanitizer() {
3231
+ return globalSanitizer;
3232
+ }
3233
+ /**
3234
+ * Sanitize HTML using the registered sanitizer.
3235
+ * WARNING: Default is no-op! Set a real sanitizer for untrusted content.
3236
+ *
3237
+ * @param html - HTML string to sanitize
3238
+ * @returns Sanitized HTML string
3239
+ */
3240
+ function sanitizeHTML(html) {
3241
+ return globalSanitizer.sanitize(html);
3242
+ }
3243
+ /**
3244
+ * Create a text node (CSP-safe alternative to innerHTML for text content).
3245
+ *
3246
+ * @param text - Text content
3247
+ * @returns Text node
3248
+ */
3249
+ function createTextNode(text) {
3250
+ return document.createTextNode(text);
3251
+ }
3252
+ /**
3253
+ * Set text content safely (CSP-compliant).
3254
+ *
3255
+ * @param element - Target element
3256
+ * @param text - Text content
3257
+ */
3258
+ function setTextContent(element, text) {
3259
+ element.textContent = text;
3260
+ }
3261
+ /**
3262
+ * Create an element with attributes (CSP-safe).
3263
+ * No inline styles or event handlers.
3264
+ *
3265
+ * @param tagName - HTML tag name
3266
+ * @param attributes - Element attributes (excluding style and on* handlers)
3267
+ * @param textContent - Optional text content
3268
+ * @returns Created element
3269
+ */
3270
+ function createElement(tagName, attributes, textContent) {
3271
+ const el = document.createElement(tagName);
3272
+ if (attributes) {
3273
+ for (const [key, value] of Object.entries(attributes)) {
3274
+ // Block dangerous attributes
3275
+ if (key.startsWith('on') || key === 'style') {
3276
+ console.warn(`[NativeSelect Security] Blocked attribute: ${key}`);
3277
+ continue;
3278
+ }
3279
+ el.setAttribute(key, value);
3280
+ }
3281
+ }
3282
+ if (textContent !== undefined) {
3283
+ el.textContent = textContent;
3284
+ }
3285
+ return el;
3286
+ }
3287
+ /**
3288
+ * Set CSS custom properties (CSP-safe way to style elements).
3289
+ *
3290
+ * @param element - Target element
3291
+ * @param properties - CSS custom properties (e.g., { '--color': 'red' })
3292
+ */
3293
+ function setCSSProperties(element, properties) {
3294
+ for (const [key, value] of Object.entries(properties)) {
3295
+ if (!key.startsWith('--')) {
3296
+ console.warn(`[NativeSelect Security] CSS property must start with --: ${key}`);
3297
+ continue;
3298
+ }
3299
+ element.style.setProperty(key, value);
3300
+ }
3301
+ }
3302
+ /**
3303
+ * Feature detection utilities for CSP-restricted environments.
3304
+ */
3305
+ const CSPFeatures = {
3306
+ /**
3307
+ * Check if inline scripts are allowed (CSP: script-src 'unsafe-inline').
3308
+ */
3309
+ hasInlineScripts() {
3310
+ try {
3311
+ // Try to create a function (blocked by CSP: script-src without 'unsafe-eval')
3312
+ new Function('return true');
3313
+ return true;
3314
+ }
3315
+ catch {
3316
+ return false;
3317
+ }
3318
+ },
3319
+ /**
3320
+ * Check if eval is allowed (CSP: script-src 'unsafe-eval').
3321
+ */
3322
+ hasEval() {
3323
+ try {
3324
+ eval('true');
3325
+ return true;
3326
+ }
3327
+ catch {
3328
+ return false;
3329
+ }
3330
+ },
3331
+ /**
3332
+ * Check if SharedArrayBuffer is available.
3333
+ * Required for some Worker features, blocked without COOP/COEP headers.
3334
+ */
3335
+ hasSharedArrayBuffer() {
3336
+ return typeof SharedArrayBuffer !== 'undefined';
3337
+ },
3338
+ /**
3339
+ * Check if Web Workers are available and functional.
3340
+ */
3341
+ hasWorkers() {
3342
+ try {
3343
+ return typeof Worker !== 'undefined';
3344
+ }
3345
+ catch {
3346
+ return false;
3347
+ }
3348
+ },
3349
+ /**
3350
+ * Check if we're in a sandboxed iframe.
3351
+ */
3352
+ isSandboxed() {
3353
+ try {
3354
+ // Sandboxed iframes may not have access to parent
3355
+ return window !== window.parent && !window.parent;
3356
+ }
3357
+ catch {
3358
+ return true;
3359
+ }
3360
+ },
3361
+ /**
3362
+ * Check CSP policy via meta tag or SecurityPolicyViolationEvent.
3363
+ */
3364
+ getCSPDirectives() {
3365
+ const metaTags = document.querySelectorAll('meta[http-equiv="Content-Security-Policy"]');
3366
+ const directives = [];
3367
+ metaTags.forEach((meta) => {
3368
+ const content = meta.getAttribute('content');
3369
+ if (content) {
3370
+ directives.push(content);
3371
+ }
3372
+ });
3373
+ return directives;
3374
+ }
3375
+ };
3376
+ /**
3377
+ * Detect environment capabilities once at initialization.
3378
+ */
3379
+ function detectEnvironment() {
3380
+ return {
3381
+ canUseWorkers: CSPFeatures.hasWorkers(),
3382
+ canUseSharedArrayBuffer: CSPFeatures.hasSharedArrayBuffer(),
3383
+ canUseInlineScripts: CSPFeatures.hasInlineScripts(),
3384
+ canUseEval: CSPFeatures.hasEval(),
3385
+ isSandboxed: CSPFeatures.isSandboxed(),
3386
+ cspDirectives: CSPFeatures.getCSPDirectives()
3387
+ };
3388
+ }
3389
+ /**
3390
+ * Validation for untrusted HTML in templates.
3391
+ * Logs warning if no sanitizer is configured.
3392
+ */
3393
+ function validateTemplate(html, source) {
3394
+ if (globalSanitizer instanceof NoOpSanitizer) {
3395
+ console.warn(`[NativeSelect Security] Template from "${source}" contains HTML but no sanitizer is configured. ` +
3396
+ `This may be unsafe if the content is untrusted. ` +
3397
+ `Call setHTMLSanitizer() with a sanitizer like DOMPurify.`);
3398
+ }
3399
+ return sanitizeHTML(html);
3400
+ }
3401
+ /**
3402
+ * Check if a string looks like it might contain executable code.
3403
+ */
3404
+ function containsSuspiciousPatterns(str) {
3405
+ const patterns = [
3406
+ /<script/i,
3407
+ /javascript:/i,
3408
+ /on\w+\s*=/i, // event handlers like onclick=
3409
+ /eval\s*\(/i,
3410
+ /Function\s*\(/i,
3411
+ /setTimeout\s*\(/i,
3412
+ /setInterval\s*\(/i
3413
+ ];
3414
+ return patterns.some(pattern => pattern.test(str));
3415
+ }
3416
+ /**
3417
+ * Escape HTML entities for text content.
3418
+ */
3419
+ function escapeHTML(text) {
3420
+ const div = document.createElement('div');
3421
+ div.textContent = text;
3422
+ return div.innerHTML;
3423
+ }
3424
+ /**
3425
+ * Escape attribute values.
3426
+ */
3427
+ function escapeAttribute(attr) {
3428
+ return attr
3429
+ .replace(/&/g, '&amp;')
3430
+ .replace(/"/g, '&quot;')
3431
+ .replace(/'/g, '&#39;')
3432
+ .replace(/</g, '&lt;')
3433
+ .replace(/>/g, '&gt;');
3434
+ }
3435
+
3436
+ /**
3437
+ * CSP-compliant styling utilities.
3438
+ * No inline styles or runtime style tag injection.
3439
+ * Uses CSS custom properties and class names only.
3440
+ */
3441
+ /**
3442
+ * Apply CSS classes to an element (CSP-safe).
3443
+ */
3444
+ function applyClasses(element, classes) {
3445
+ element.className = classes.filter(Boolean).join(' ');
3446
+ }
3447
+ /**
3448
+ * Toggle a CSS class (CSP-safe).
3449
+ */
3450
+ function toggleClass(element, className, force) {
3451
+ element.classList.toggle(className, force);
3452
+ }
3453
+ /**
3454
+ * Set CSS custom properties (CSP-safe dynamic styling).
3455
+ * All properties must start with -- (CSS variables).
3456
+ *
3457
+ * @example
3458
+ * setCustomProperties(element, {
3459
+ * '--item-height': '48px',
3460
+ * '--selected-bg': '#0066cc'
3461
+ * });
3462
+ */
3463
+ function setCustomProperties(element, properties) {
3464
+ for (const [key, value] of Object.entries(properties)) {
3465
+ if (!key.startsWith('--')) {
3466
+ if (process.env.NODE_ENV !== 'production') {
3467
+ console.warn(`[NativeSelect CSP] Custom property must start with --: "${key}". Skipping.`);
3468
+ }
3469
+ continue;
3470
+ }
3471
+ element.style.setProperty(key, value);
3472
+ }
3473
+ }
3474
+ /**
3475
+ * Get a CSS custom property value.
3476
+ */
3477
+ function getCustomProperty(element, propertyName) {
3478
+ return getComputedStyle(element).getPropertyValue(propertyName).trim();
3479
+ }
3480
+ /**
3481
+ * Remove a CSS custom property.
3482
+ */
3483
+ function removeCustomProperty(element, propertyName) {
3484
+ element.style.removeProperty(propertyName);
3485
+ }
3486
+ /**
3487
+ * Predefined theme as CSS custom properties.
3488
+ * Can be overridden by consumers via CSS.
3489
+ */
3490
+ const defaultTheme = {
3491
+ '--ns-item-height': '40px',
3492
+ '--ns-item-padding': '8px 12px',
3493
+ '--ns-item-bg': 'transparent',
3494
+ '--ns-item-hover-bg': 'rgba(0, 0, 0, 0.05)',
3495
+ '--ns-item-selected-bg': 'rgba(0, 102, 204, 0.1)',
3496
+ '--ns-item-active-bg': 'rgba(0, 102, 204, 0.2)',
3497
+ '--ns-item-color': 'inherit',
3498
+ '--ns-item-selected-color': '#0066cc',
3499
+ '--ns-border-color': '#ccc',
3500
+ '--ns-border-radius': '4px',
3501
+ '--ns-focus-outline': '2px solid #0066cc',
3502
+ '--ns-font-size': '14px',
3503
+ '--ns-font-family': 'system-ui, -apple-system, sans-serif'
3504
+ };
3505
+ /**
3506
+ * Apply the default theme to an element.
3507
+ */
3508
+ function applyDefaultTheme(element) {
3509
+ setCustomProperties(element, defaultTheme);
3510
+ }
3511
+ /**
3512
+ * Static CSS for shadow DOM (no runtime injection).
3513
+ * This CSS is CSP-safe because it's defined at build time as a string constant,
3514
+ * not generated at runtime.
3515
+ */
3516
+ const shadowDOMStyles = `
3517
+ :host {
3518
+ display: block;
3519
+ box-sizing: border-box;
3520
+ font-family: var(--ns-font-family, system-ui, -apple-system, sans-serif);
3521
+ font-size: var(--ns-font-size, 14px);
3522
+ }
3523
+
3524
+ * {
3525
+ box-sizing: border-box;
3526
+ }
3527
+
3528
+ .ns-list {
3529
+ position: relative;
3530
+ overflow: auto;
3531
+ max-height: var(--ns-max-height, 300px);
3532
+ border: 1px solid var(--ns-border-color, #ccc);
3533
+ border-radius: var(--ns-border-radius, 4px);
3534
+ background: var(--ns-bg, white);
3535
+ outline: none;
3536
+ }
3537
+
3538
+ .ns-list:focus {
3539
+ outline: var(--ns-focus-outline, 2px solid #0066cc);
3540
+ outline-offset: -2px;
3541
+ }
3542
+
3543
+ .ns-item {
3544
+ padding: var(--ns-item-padding, 8px 12px);
3545
+ min-height: var(--ns-item-height, 40px);
3546
+ color: var(--ns-item-color, inherit);
3547
+ background: var(--ns-item-bg, transparent);
3548
+ cursor: pointer;
3549
+ user-select: none;
3550
+ display: flex;
3551
+ align-items: center;
3552
+ transition: background-color 0.15s ease;
3553
+ }
3554
+
3555
+ .ns-item:hover {
3556
+ background: var(--ns-item-hover-bg, rgba(0, 0, 0, 0.05));
3557
+ }
3558
+
3559
+ .ns-item[aria-selected="true"] {
3560
+ background: var(--ns-item-selected-bg, rgba(0, 102, 204, 0.1));
3561
+ color: var(--ns-item-selected-color, #0066cc);
3562
+ }
3563
+
3564
+ .ns-item[data-active="true"] {
3565
+ background: var(--ns-item-active-bg, rgba(0, 102, 204, 0.2));
3566
+ }
3567
+
3568
+ .ns-item[aria-disabled="true"] {
3569
+ opacity: 0.5;
3570
+ cursor: not-allowed;
3571
+ }
3572
+
3573
+ .ns-viewport {
3574
+ position: relative;
3575
+ overflow: auto;
3576
+ -webkit-overflow-scrolling: touch;
3577
+ }
3578
+
3579
+ .ns-spacer {
3580
+ position: absolute;
3581
+ top: 0;
3582
+ left: 0;
3583
+ width: 1px;
3584
+ pointer-events: none;
3585
+ }
3586
+
3587
+ /* Screen reader only */
3588
+ .ns-sr-only {
3589
+ position: absolute;
3590
+ left: -9999px;
3591
+ width: 1px;
3592
+ height: 1px;
3593
+ overflow: hidden;
3594
+ clip: rect(0, 0, 0, 0);
3595
+ white-space: nowrap;
3596
+ }
3597
+
3598
+ /* Portal mode (teleported outside shadow DOM) */
3599
+ .ns-portal {
3600
+ position: fixed;
3601
+ z-index: var(--ns-portal-z-index, 9999);
3602
+ }
3603
+
3604
+ /* CSP warning banner (only shown in development) */
3605
+ .ns-csp-warning {
3606
+ padding: 8px;
3607
+ background: #fff3cd;
3608
+ border: 1px solid #ffc107;
3609
+ border-radius: 4px;
3610
+ color: #856404;
3611
+ font-size: 12px;
3612
+ margin-bottom: 8px;
3613
+ }
3614
+ `;
3615
+ /**
3616
+ * Inject static styles into shadow root (CSP-safe).
3617
+ * Only called once during component initialization.
3618
+ */
3619
+ function injectShadowStyles(shadowRoot) {
3620
+ const style = document.createElement('style');
3621
+ style.textContent = shadowDOMStyles;
3622
+ shadowRoot.appendChild(style);
3623
+ }
3624
+ /**
3625
+ * Check if element has overflow:hidden ancestors (portal fallback detection).
3626
+ */
3627
+ function hasOverflowHiddenAncestor(element) {
3628
+ let current = element.parentElement;
3629
+ while (current && current !== document.body) {
3630
+ const overflow = getComputedStyle(current).overflow;
3631
+ if (overflow === 'hidden' || overflow === 'clip') {
3632
+ return true;
3633
+ }
3634
+ current = current.parentElement;
3635
+ }
3636
+ return false;
3637
+ }
3638
+ /**
3639
+ * Runtime warning for CSP violations (development only).
3640
+ */
3641
+ function warnCSPViolation(feature, fallback) {
3642
+ if (process.env.NODE_ENV !== 'production') {
3643
+ console.warn(`[NativeSelect CSP] Feature "${feature}" blocked by Content Security Policy. ` +
3644
+ `Falling back to "${fallback}".`);
3645
+ }
3646
+ }
3647
+
3648
+ exports.CSPFeatures = CSPFeatures;
3649
+ exports.DOMPool = DOMPool;
3650
+ exports.EnhancedSelect = EnhancedSelect;
3651
+ exports.FenwickTree = FenwickTree;
3652
+ exports.NativeSelectElement = NativeSelectElement;
3653
+ exports.PerformanceTelemetry = PerformanceTelemetry;
3654
+ exports.SelectOption = SelectOption;
3655
+ exports.Virtualizer = Virtualizer;
3656
+ exports.WorkerManager = WorkerManager;
3657
+ exports.applyClasses = applyClasses;
3658
+ exports.applyDefaultTheme = applyDefaultTheme;
3659
+ exports.configureSelect = configureSelect;
3660
+ exports.containsSuspiciousPatterns = containsSuspiciousPatterns;
3661
+ exports.createElement = createElement;
3662
+ exports.createRendererHelpers = createRendererHelpers;
3663
+ exports.createTextNode = createTextNode;
3664
+ exports.defaultTheme = defaultTheme;
3665
+ exports.detectEnvironment = detectEnvironment;
3666
+ exports.escapeAttribute = escapeAttribute;
3667
+ exports.escapeHTML = escapeHTML;
3668
+ exports.getCustomProperty = getCustomProperty;
3669
+ exports.getHTMLSanitizer = getHTMLSanitizer;
3670
+ exports.getTelemetry = getTelemetry;
3671
+ exports.getWorkerManager = getWorkerManager;
3672
+ exports.hasOverflowHiddenAncestor = hasOverflowHiddenAncestor;
3673
+ exports.injectShadowStyles = injectShadowStyles;
3674
+ exports.measureAsync = measureAsync;
3675
+ exports.measureSync = measureSync;
3676
+ exports.removeCustomProperty = removeCustomProperty;
3677
+ exports.renderTemplate = renderTemplate;
3678
+ exports.resetSelectConfig = resetSelectConfig;
3679
+ exports.sanitizeHTML = sanitizeHTML;
3680
+ exports.selectConfig = selectConfig;
3681
+ exports.setCSSProperties = setCSSProperties;
3682
+ exports.setCustomProperties = setCustomProperties;
3683
+ exports.setHTMLSanitizer = setHTMLSanitizer;
3684
+ exports.setTextContent = setTextContent;
3685
+ exports.shadowDOMStyles = shadowDOMStyles;
3686
+ exports.toggleClass = toggleClass;
3687
+ exports.validateTemplate = validateTemplate;
3688
+ exports.warnCSPViolation = warnCSPViolation;
3689
+
3690
+ }));
3691
+ //# sourceMappingURL=index.umd.js.map