@liwe3/webcomponents 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.
@@ -0,0 +1,775 @@
1
+ /**
2
+ * SmartSelect Web Component
3
+ * A customizable select dropdown with search, multi-select, and keyboard navigation
4
+ */
5
+
6
+ export interface SelectOption {
7
+ value: string;
8
+ label: string;
9
+ }
10
+
11
+ export class SmartSelectElement extends HTMLElement {
12
+ declare shadowRoot: ShadowRoot;
13
+ private isOpen: boolean = false;
14
+ private selectedOptions: SelectOption[] = [];
15
+ private filteredOptions: SelectOption[] = [];
16
+ private focusedIndex: number = -1;
17
+ private searchValue: string = '';
18
+ private keyboardNavigating: boolean = false;
19
+ private keyboardTimer?: number;
20
+
21
+ constructor() {
22
+ super();
23
+ this.attachShadow({ mode: 'open' });
24
+
25
+ // Make component focusable
26
+ if (!this.hasAttribute('tabindex')) {
27
+ this.setAttribute('tabindex', '0');
28
+ }
29
+
30
+ this.render();
31
+ this.bindEvents();
32
+ }
33
+
34
+ static get observedAttributes(): string[] {
35
+ return ['multiple', 'searchable', 'placeholder', 'disabled', 'value', 'options'];
36
+ }
37
+
38
+ attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void {
39
+ if (oldValue !== newValue) {
40
+ if (name === 'options') {
41
+ this.filteredOptions = [...this.options];
42
+ }
43
+ this.render();
44
+ }
45
+ }
46
+
47
+ get multiple(): boolean {
48
+ return this.hasAttribute('multiple');
49
+ }
50
+
51
+ set multiple(value: boolean) {
52
+ if (value) {
53
+ this.setAttribute('multiple', '');
54
+ } else {
55
+ this.removeAttribute('multiple');
56
+ }
57
+ }
58
+
59
+ get searchable(): boolean {
60
+ return this.hasAttribute('searchable');
61
+ }
62
+
63
+ set searchable(value: boolean) {
64
+ if (value) {
65
+ this.setAttribute('searchable', '');
66
+ } else {
67
+ this.removeAttribute('searchable');
68
+ }
69
+ }
70
+
71
+ get placeholder(): string {
72
+ return this.getAttribute('placeholder') || 'Select an option';
73
+ }
74
+
75
+ set placeholder(value: string) {
76
+ this.setAttribute('placeholder', value);
77
+ }
78
+
79
+ get disabled(): boolean {
80
+ return this.hasAttribute('disabled');
81
+ }
82
+
83
+ set disabled(value: boolean) {
84
+ if (value) {
85
+ this.setAttribute('disabled', '');
86
+ } else {
87
+ this.removeAttribute('disabled');
88
+ }
89
+ }
90
+
91
+ get value(): string | string[] {
92
+ if (this.multiple) {
93
+ return this.selectedOptions.map(opt => opt.value);
94
+ }
95
+ return this.selectedOptions.length > 0 ? this.selectedOptions[0].value : '';
96
+ }
97
+
98
+ set value(val: string | string[]) {
99
+ if (this.multiple && Array.isArray(val)) {
100
+ this.selectedOptions = this.options.filter(opt => val.includes(opt.value));
101
+ } else {
102
+ const option = this.options.find(opt => opt.value === val);
103
+ this.selectedOptions = option ? [option] : [];
104
+ }
105
+ this.render();
106
+ }
107
+
108
+ get options(): SelectOption[] {
109
+ const optionsAttr = this.getAttribute('options');
110
+ if (optionsAttr) {
111
+ try {
112
+ return JSON.parse(optionsAttr);
113
+ } catch (e) {
114
+ console.error('Invalid options format:', e);
115
+ return [];
116
+ }
117
+ }
118
+ return [];
119
+ }
120
+
121
+ set options(opts: SelectOption[]) {
122
+ this.setAttribute('options', JSON.stringify(opts));
123
+ }
124
+
125
+ /**
126
+ * Opens the dropdown
127
+ */
128
+ open(): void {
129
+ if (this.disabled) return;
130
+ this.isOpen = true;
131
+ this.focusedIndex = -1;
132
+ if (this.options.length > 0) {
133
+ this.filteredOptions = [...this.options];
134
+ }
135
+ this.render();
136
+
137
+ // Update dropdown position based on viewport
138
+ this._updateDropdownPosition();
139
+
140
+ // Focus search input if searchable
141
+ if (this.searchable) {
142
+ requestAnimationFrame(() => {
143
+ const searchInput = this.shadowRoot.querySelector('.search-input') as HTMLInputElement;
144
+ if (searchInput) {
145
+ searchInput.focus();
146
+ }
147
+ });
148
+ }
149
+
150
+ this.dispatchEvent(new CustomEvent('open'));
151
+ }
152
+
153
+ /**
154
+ * Closes the dropdown
155
+ */
156
+ close(): void {
157
+ this.isOpen = false;
158
+ this.focusedIndex = -1;
159
+ this.searchValue = '';
160
+
161
+ // Reset filtered options when closing
162
+ if (this.searchable && this.options.length > 0) {
163
+ this.filteredOptions = [...this.options];
164
+ }
165
+
166
+ // Clear any inline positioning styles
167
+ const dropdown = this.shadowRoot.querySelector('.dropdown') as HTMLElement;
168
+ if (dropdown) {
169
+ dropdown.style.top = '';
170
+ dropdown.style.left = '';
171
+ dropdown.style.width = '';
172
+ dropdown.style.maxHeight = '';
173
+ }
174
+
175
+ this.render();
176
+ this.dispatchEvent(new CustomEvent('close'));
177
+ }
178
+
179
+ /**
180
+ * Toggles the dropdown open/closed state
181
+ */
182
+ toggle(): void {
183
+ if (this.isOpen) {
184
+ this.close();
185
+ } else {
186
+ this.open();
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Selects an option by its value
192
+ */
193
+ selectOption(value: string): void {
194
+ const option = this.options.find(opt => opt.value === value);
195
+ if (!option) return;
196
+
197
+ if (this.multiple) {
198
+ if (!this.selectedOptions.find(opt => opt.value === value)) {
199
+ this.selectedOptions.push(option);
200
+ }
201
+ } else {
202
+ this.selectedOptions = [option];
203
+ this.close();
204
+ }
205
+
206
+ this.render();
207
+ this.dispatchEvent(new CustomEvent('change', { detail: { value: this.value } }));
208
+ }
209
+
210
+ /**
211
+ * Deselects an option by its value
212
+ */
213
+ deselectOption(value: string): void {
214
+ this.selectedOptions = this.selectedOptions.filter(opt => opt.value !== value);
215
+ this.render();
216
+ this.dispatchEvent(new CustomEvent('change', { detail: { value: this.value } }));
217
+ }
218
+
219
+ /**
220
+ * Returns an array of currently selected options
221
+ */
222
+ getSelectedOptions(): SelectOption[] {
223
+ return [...this.selectedOptions];
224
+ }
225
+
226
+ /**
227
+ * Sets the options for the select component
228
+ */
229
+ setOptions(options: SelectOption[]): void {
230
+ this.options = options;
231
+ this.filteredOptions = [...options];
232
+ this.selectedOptions = [];
233
+ this.render();
234
+ }
235
+
236
+ /**
237
+ * Handles search functionality
238
+ */
239
+ private handleSearch(query: string): void {
240
+ this.searchValue = query;
241
+ this.filteredOptions = this.options.filter(option =>
242
+ option.label.toLowerCase().includes(query.toLowerCase())
243
+ );
244
+ this.focusedIndex = -1;
245
+ this.render();
246
+ this.dispatchEvent(new CustomEvent('search', { detail: { query } }));
247
+ }
248
+
249
+ /**
250
+ * Updates the visual focus state without full re-render
251
+ */
252
+ private updateFocusedOption(): void {
253
+ const options = this.shadowRoot.querySelectorAll('.option');
254
+
255
+ // Remove focused class from all options
256
+ options.forEach(option => option.classList.remove('focused'));
257
+
258
+ // Add focused class to current option
259
+ if (this.focusedIndex >= 0 && this.focusedIndex < options.length) {
260
+ options[this.focusedIndex].classList.add('focused');
261
+ }
262
+
263
+ this.scrollToFocusedOption();
264
+ }
265
+
266
+ /**
267
+ * Scrolls the focused option into view
268
+ */
269
+ private scrollToFocusedOption(): void {
270
+ if (this.focusedIndex < 0) return;
271
+
272
+ requestAnimationFrame(() => {
273
+ const dropdown = this.shadowRoot.querySelector('.dropdown') as HTMLElement;
274
+ const focusedOption = this.shadowRoot.querySelector('.option.focused') as HTMLElement;
275
+
276
+ if (dropdown && focusedOption) {
277
+ const dropdownRect = dropdown.getBoundingClientRect();
278
+ const optionRect = focusedOption.getBoundingClientRect();
279
+
280
+ // Check if option is above visible area
281
+ if (optionRect.top < dropdownRect.top) {
282
+ dropdown.scrollTop -= (dropdownRect.top - optionRect.top);
283
+ }
284
+ // Check if option is below visible area
285
+ else if (optionRect.bottom > dropdownRect.bottom) {
286
+ dropdown.scrollTop += (optionRect.bottom - dropdownRect.bottom);
287
+ }
288
+ }
289
+ });
290
+ }
291
+
292
+ /**
293
+ * Calculates the optimal dropdown position based on viewport constraints
294
+ */
295
+ private _calculateDropdownPosition(): { top: number; left: number; width: number; maxHeight: number } | null {
296
+ const trigger = this.shadowRoot.querySelector('.select-trigger') as HTMLElement;
297
+ if (!trigger) return null;
298
+
299
+ const triggerRect = trigger.getBoundingClientRect();
300
+ const viewportHeight = window.innerHeight;
301
+ const viewportWidth = window.innerWidth;
302
+ const dropdownMaxHeight = 200;
303
+ const dropdownPadding = 10;
304
+ const margin = 2;
305
+
306
+ // Calculate available space
307
+ const spaceBelow = viewportHeight - triggerRect.bottom;
308
+ const spaceAbove = triggerRect.top;
309
+
310
+ // Determine if dropdown should open upward
311
+ const shouldOpenUpward = spaceBelow < dropdownMaxHeight + dropdownPadding && spaceAbove > spaceBelow;
312
+
313
+ // Calculate dimensions
314
+ const width = triggerRect.width;
315
+ const left = Math.max(0, Math.min(triggerRect.left, viewportWidth - width));
316
+
317
+ let top: number;
318
+ let maxHeight: number;
319
+
320
+ if (shouldOpenUpward) {
321
+ // Position above the trigger
322
+ maxHeight = Math.min(dropdownMaxHeight, spaceAbove - dropdownPadding);
323
+ top = triggerRect.top - maxHeight - margin;
324
+ } else {
325
+ // Position below the trigger
326
+ maxHeight = Math.min(dropdownMaxHeight, spaceBelow - dropdownPadding);
327
+ top = triggerRect.bottom + margin;
328
+ }
329
+
330
+ return {
331
+ top: Math.max(0, top),
332
+ left,
333
+ width,
334
+ maxHeight: Math.max(100, maxHeight) // Ensure minimum height
335
+ };
336
+ }
337
+
338
+ /**
339
+ * Updates dropdown position using fixed positioning relative to viewport
340
+ */
341
+ private _updateDropdownPosition(): void {
342
+ requestAnimationFrame(() => {
343
+ const dropdown = this.shadowRoot.querySelector('.dropdown') as HTMLElement;
344
+ if (!dropdown) return;
345
+
346
+ const position = this._calculateDropdownPosition();
347
+ if (!position) return;
348
+
349
+ // Apply calculated position as inline styles
350
+ dropdown.style.top = `${position.top}px`;
351
+ dropdown.style.left = `${position.left}px`;
352
+ dropdown.style.width = `${position.width}px`;
353
+ dropdown.style.maxHeight = `${position.maxHeight}px`;
354
+ });
355
+ }
356
+
357
+ /**
358
+ * Handles keyboard navigation
359
+ */
360
+ private handleKeydown(event: KeyboardEvent): void {
361
+ if (this.disabled) return;
362
+
363
+ // Prevent double execution if event has already been handled
364
+ if ((event as any)._smartSelectHandled) return;
365
+ (event as any)._smartSelectHandled = true;
366
+
367
+ switch (event.key) {
368
+ case 'ArrowDown':
369
+ event.preventDefault();
370
+ this.keyboardNavigating = true;
371
+ clearTimeout(this.keyboardTimer);
372
+ this.keyboardTimer = window.setTimeout(() => { this.keyboardNavigating = false; }, 100);
373
+
374
+ if (!this.isOpen) {
375
+ this.open();
376
+ } else {
377
+ // If searchable and search input is focused, move to first option
378
+ const searchInput = this.shadowRoot.querySelector('.search-input') as HTMLInputElement;
379
+ const isSearchFocused = this.searchable && searchInput === this.shadowRoot.activeElement;
380
+
381
+ if (isSearchFocused) {
382
+ this.focusedIndex = 0;
383
+ searchInput.blur(); // Blur search input to allow normal navigation
384
+ // Focus the component to ensure it receives keyboard events
385
+ this.focus();
386
+ this.updateFocusedOption();
387
+ return;
388
+ }
389
+ // Navigate through options
390
+ const newIndex = Math.min(this.focusedIndex + 1, this.filteredOptions.length - 1);
391
+ this.focusedIndex = newIndex;
392
+ this.updateFocusedOption();
393
+ }
394
+ break;
395
+
396
+ case 'ArrowUp':
397
+ event.preventDefault();
398
+ this.keyboardNavigating = true;
399
+ clearTimeout(this.keyboardTimer);
400
+ this.keyboardTimer = window.setTimeout(() => { this.keyboardNavigating = false; }, 100);
401
+
402
+ if (this.isOpen) {
403
+ // If at first option and searchable, focus search input
404
+ if (this.focusedIndex === 0 && this.searchable) {
405
+ this.focusedIndex = -1;
406
+ this.updateFocusedOption();
407
+ requestAnimationFrame(() => {
408
+ const searchInput = this.shadowRoot.querySelector('.search-input') as HTMLInputElement;
409
+ if (searchInput) {
410
+ searchInput.focus();
411
+ searchInput.setSelectionRange(searchInput.value.length, searchInput.value.length);
412
+ }
413
+ });
414
+ return;
415
+ }
416
+ // If searchable and search input is focused, do nothing
417
+ const searchInput = this.shadowRoot.querySelector('.search-input') as HTMLInputElement;
418
+ const isSearchFocused = this.searchable && searchInput === this.shadowRoot.activeElement;
419
+
420
+ if (isSearchFocused) {
421
+ return;
422
+ }
423
+ // Navigate through options
424
+ const newIndex = Math.max(this.focusedIndex - 1, -1);
425
+ this.focusedIndex = newIndex;
426
+ this.updateFocusedOption();
427
+ }
428
+ break;
429
+
430
+ case 'Enter':
431
+ event.preventDefault();
432
+ if (this.isOpen && this.focusedIndex >= 0 && this.focusedIndex < this.filteredOptions.length) {
433
+ this.selectOption(this.filteredOptions[this.focusedIndex].value);
434
+ } else if (!this.isOpen) {
435
+ this.open();
436
+ }
437
+ break;
438
+
439
+ case 'Escape':
440
+ event.preventDefault();
441
+ this.close();
442
+ break;
443
+
444
+ case 'Tab':
445
+ this.close();
446
+ break;
447
+ }
448
+ }
449
+
450
+ /**
451
+ * Binds all event listeners
452
+ */
453
+ private bindEvents(): void {
454
+ // Listen for keydown events on both the component and shadow root
455
+ const keydownHandler = this.handleKeydown.bind(this);
456
+ this.addEventListener('keydown', keydownHandler);
457
+ this.shadowRoot.addEventListener('keydown', keydownHandler as EventListener);
458
+
459
+ // Use event delegation on the shadow root
460
+ this.shadowRoot.addEventListener('click', (e) => {
461
+ e.stopPropagation();
462
+ const target = e.target as HTMLElement;
463
+
464
+ if (target.closest('.remove-tag')) {
465
+ const value = (target.closest('.remove-tag') as HTMLElement).dataset.value;
466
+ if (value) this.deselectOption(value);
467
+ } else if (target.closest('.option')) {
468
+ const value = (target.closest('.option') as HTMLElement).dataset.value;
469
+ if (value) this.selectOption(value);
470
+ } else if (target.closest('.select-trigger')) {
471
+ this.toggle();
472
+ }
473
+ });
474
+
475
+ // Handle mouse hover on options to update focused index
476
+ this.shadowRoot.addEventListener('mouseover', (e) => {
477
+ // Don't interfere with keyboard navigation
478
+ if (this.keyboardNavigating) return;
479
+
480
+ const target = e.target as HTMLElement;
481
+ if (target.closest('.option')) {
482
+ const option = target.closest('.option') as HTMLElement;
483
+ const options = Array.from(this.shadowRoot.querySelectorAll('.option'));
484
+ const newFocusedIndex = options.indexOf(option);
485
+
486
+ // Only update if the focused index actually changed
487
+ if (this.focusedIndex !== newFocusedIndex) {
488
+ // Remove focused class from current option
489
+ const currentFocused = this.shadowRoot.querySelector('.option.focused');
490
+ if (currentFocused) {
491
+ currentFocused.classList.remove('focused');
492
+ }
493
+
494
+ // Add focused class to new option
495
+ option.classList.add('focused');
496
+ this.focusedIndex = newFocusedIndex;
497
+ }
498
+ }
499
+ });
500
+
501
+ // Handle mouse leaving dropdown to clear focus
502
+ this.shadowRoot.addEventListener('mouseleave', (e) => {
503
+ // Don't interfere with keyboard navigation
504
+ if (this.keyboardNavigating) return;
505
+
506
+ const target = e.target as HTMLElement;
507
+ if (target.closest('.dropdown')) {
508
+ const currentFocused = this.shadowRoot.querySelector('.option.focused');
509
+ if (currentFocused) {
510
+ currentFocused.classList.remove('focused');
511
+ }
512
+ this.focusedIndex = -1;
513
+ }
514
+ });
515
+
516
+ // Handle search input
517
+ this.shadowRoot.addEventListener('input', (e) => {
518
+ const target = e.target as HTMLInputElement;
519
+ if (target.classList.contains('search-input')) {
520
+ this.handleSearch(target.value);
521
+ }
522
+ });
523
+
524
+ // Close dropdown when clicking outside
525
+ document.addEventListener('click', (e) => {
526
+ if (!this.contains(e.target as Node)) {
527
+ this.close();
528
+ }
529
+ });
530
+
531
+ // Update dropdown position on window resize or scroll
532
+ window.addEventListener('resize', () => {
533
+ if (this.isOpen) {
534
+ this._updateDropdownPosition();
535
+ }
536
+ });
537
+
538
+ window.addEventListener('scroll', () => {
539
+ if (this.isOpen) {
540
+ this._updateDropdownPosition();
541
+ }
542
+ }, true); // Use capture to catch all scroll events
543
+ }
544
+
545
+ /**
546
+ * Renders the component
547
+ */
548
+ private render(): void {
549
+ // Initialize filteredOptions if not set
550
+ if (this.filteredOptions.length === 0 && this.options.length > 0) {
551
+ this.filteredOptions = [...this.options];
552
+ }
553
+
554
+ // Remember if search input was focused before render
555
+ const wasSearchFocused = this.shadowRoot.querySelector('.search-input') === this.shadowRoot.activeElement;
556
+
557
+ const displayText = this.selectedOptions.length > 0
558
+ ? (this.multiple
559
+ ? `${this.selectedOptions.length} selected`
560
+ : this.selectedOptions[0].label)
561
+ : this.placeholder;
562
+
563
+ this.shadowRoot.innerHTML = `
564
+ <style>
565
+ :host {
566
+ display: inline-block;
567
+ position: relative;
568
+ min-width: 200px;
569
+ font-family: var(--font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
570
+ font-size: var(--font-size, 14px);
571
+ outline: none;
572
+ }
573
+
574
+ :host(:focus) .select-trigger {
575
+ border-color: var(--focus-color, #007bff);
576
+ box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
577
+ }
578
+
579
+ :host([disabled]) {
580
+ opacity: 0.6;
581
+ cursor: not-allowed;
582
+ }
583
+
584
+ .select-container {
585
+ position: relative;
586
+ }
587
+
588
+ .select-trigger {
589
+ display: flex;
590
+ align-items: center;
591
+ justify-content: space-between;
592
+ padding: var(--padding, 8px 12px);
593
+ border: var(--border, 1px solid #ccc);
594
+ border-radius: var(--border-radius, 4px);
595
+ background: var(--background, white);
596
+ cursor: pointer;
597
+ min-height: 36px;
598
+ box-sizing: border-box;
599
+ color: #333;
600
+ }
601
+
602
+ .select-trigger:focus {
603
+ outline: none;
604
+ border-color: var(--focus-color, #007bff);
605
+ box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
606
+ }
607
+
608
+ .select-trigger[disabled] {
609
+ cursor: not-allowed;
610
+ }
611
+
612
+ .selected-content {
613
+ display: flex;
614
+ align-items: center;
615
+ gap: 4px;
616
+ flex-wrap: wrap;
617
+ flex: 1;
618
+ }
619
+
620
+ .tag {
621
+ display: inline-flex;
622
+ align-items: center;
623
+ gap: 4px;
624
+ padding: 2px 8px;
625
+ background: var(--tag-background, #e9ecef);
626
+ border-radius: var(--tag-border-radius, 12px);
627
+ font-size: 12px;
628
+ color: var(--tag-color, #495057);
629
+ }
630
+
631
+ .remove-tag {
632
+ cursor: pointer;
633
+ color: var(--remove-color, #6c757d);
634
+ font-weight: bold;
635
+ font-size: 14px;
636
+ }
637
+
638
+ .remove-tag:hover {
639
+ color: var(--remove-hover-color, #dc3545);
640
+ }
641
+
642
+ .arrow {
643
+ width: 0;
644
+ height: 0;
645
+ border-left: 5px solid transparent;
646
+ border-right: 5px solid transparent;
647
+ border-top: 5px solid var(--arrow-color, #666);
648
+ transition: transform 0.2s;
649
+ }
650
+
651
+ .arrow.open {
652
+ transform: rotate(180deg);
653
+ }
654
+
655
+ .dropdown {
656
+ position: fixed;
657
+ z-index: 99999;
658
+ background: var(--dropdown-background, white);
659
+ border: var(--dropdown-border, 1px solid #ccc);
660
+ border-radius: var(--dropdown-border-radius, 4px);
661
+ box-shadow: var(--dropdown-shadow, 0 2px 8px rgba(0, 0, 0, 0.1));
662
+ max-height: 200px;
663
+ overflow-y: auto;
664
+ scroll-behavior: smooth;
665
+ color: #333;
666
+ }
667
+
668
+ .search-input {
669
+ width: 100%;
670
+ padding: 8px 12px;
671
+ border: none;
672
+ border-bottom: 1px solid #eee;
673
+ font-size: 14px;
674
+ outline: none;
675
+ box-sizing: border-box;
676
+ }
677
+
678
+ .option {
679
+ padding: 8px 12px;
680
+ cursor: pointer;
681
+ color: var(--option-color, #333);
682
+ transition: background-color 0.2s;
683
+ }
684
+
685
+ .option:hover {
686
+ background-color: var(--option-hover-background, #f8f9fa);
687
+ }
688
+
689
+ .option.focused {
690
+ background-color: var(--option-focused-background, #007bff);
691
+ color: var(--option-focused-color, white);
692
+ }
693
+
694
+ .option.selected {
695
+ background-color: var(--option-selected-background, #e3f2fd);
696
+ color: var(--option-selected-color, #1976d2);
697
+ }
698
+
699
+ .no-options {
700
+ padding: 8px 12px;
701
+ color: var(--no-options-color, #6c757d);
702
+ font-style: italic;
703
+ }
704
+ </style>
705
+
706
+ <div class="select-container">
707
+ <div class="select-trigger" tabindex="-1">
708
+ <div class="selected-content">
709
+ ${this.multiple && this.selectedOptions.length > 0
710
+ ? this.selectedOptions.map(option => `
711
+ <span class="tag">
712
+ ${option.label}
713
+ <span class="remove-tag" data-value="${option.value}">×</span>
714
+ </span>
715
+ `).join('')
716
+ : `<span>${displayText}</span>`
717
+ }
718
+ </div>
719
+ <div class="arrow ${this.isOpen ? 'open' : ''}"></div>
720
+ </div>
721
+
722
+ ${this.isOpen ? `
723
+ <div class="dropdown">
724
+ ${this.searchable ? `
725
+ <input
726
+ type="text"
727
+ class="search-input"
728
+ placeholder="Search options..."
729
+ value="${this.searchValue}"
730
+ >
731
+ ` : ''}
732
+
733
+ ${this.filteredOptions.length > 0
734
+ ? this.filteredOptions.map((option, index) => `
735
+ <div
736
+ class="option ${this.selectedOptions.find(selected => selected.value === option.value) ? 'selected' : ''} ${index === this.focusedIndex ? 'focused' : ''}"
737
+ data-value="${option.value}"
738
+ >
739
+ ${option.label}
740
+ </div>
741
+ `).join('')
742
+ : '<div class="no-options">No options available</div>'
743
+ }
744
+ </div>
745
+ ` : ''}
746
+ </div>
747
+ `;
748
+
749
+ // Re-focus search input if it was previously focused
750
+ if (wasSearchFocused && this.searchable && this.isOpen) {
751
+ requestAnimationFrame(() => {
752
+ const searchInput = this.shadowRoot.querySelector('.search-input') as HTMLInputElement;
753
+ if (searchInput) {
754
+ searchInput.focus();
755
+ // Restore cursor position to the end
756
+ searchInput.setSelectionRange(searchInput.value.length, searchInput.value.length);
757
+ }
758
+ });
759
+ }
760
+ }
761
+ }
762
+
763
+ /**
764
+ * Conditionally defines the custom element if in a browser environment.
765
+ */
766
+ const defineSmartSelect = (tagName: string = 'liwe3-select'): void => {
767
+ if (typeof window !== 'undefined' && !window.customElements.get(tagName)) {
768
+ customElements.define(tagName, SmartSelectElement);
769
+ }
770
+ };
771
+
772
+ // Auto-register with default tag name
773
+ defineSmartSelect();
774
+
775
+ export { defineSmartSelect };