snice 1.14.3 → 2.1.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 (185) hide show
  1. package/bin/templates/base/tsconfig.json +5 -4
  2. package/components/accordion/demo.html +403 -0
  3. package/components/accordion/snice-accordion-item.css +85 -0
  4. package/components/accordion/snice-accordion-item.ts +226 -0
  5. package/components/accordion/snice-accordion.css +31 -0
  6. package/components/accordion/snice-accordion.ts +182 -0
  7. package/components/accordion/snice-accordion.types.ts +32 -0
  8. package/components/alert/demo.html +445 -0
  9. package/components/alert/snice-alert.css +195 -0
  10. package/components/alert/snice-alert.ts +141 -0
  11. package/components/alert/snice-alert.types.ts +12 -0
  12. package/components/avatar/demo.html +598 -0
  13. package/components/avatar/snice-avatar.css +131 -0
  14. package/components/avatar/snice-avatar.ts +136 -0
  15. package/components/avatar/snice-avatar.types.ts +13 -0
  16. package/components/badge/demo.html +523 -0
  17. package/components/badge/snice-badge.css +161 -0
  18. package/components/badge/snice-badge.ts +117 -0
  19. package/components/badge/snice-badge.types.ts +16 -0
  20. package/components/breadcrumbs/demo.html +404 -0
  21. package/components/breadcrumbs/snice-breadcrumbs.css +133 -0
  22. package/components/breadcrumbs/snice-breadcrumbs.ts +191 -0
  23. package/components/breadcrumbs/snice-breadcrumbs.types.ts +26 -0
  24. package/components/breadcrumbs/snice-crumb.ts +26 -0
  25. package/components/button/demo.html +42 -0
  26. package/components/button/snice-button.css +230 -0
  27. package/components/button/snice-button.ts +169 -0
  28. package/components/button/snice-button.types.ts +25 -0
  29. package/components/card/demo.html +525 -0
  30. package/components/card/snice-card.css +140 -0
  31. package/components/card/snice-card.ts +102 -0
  32. package/components/card/snice-card.types.ts +10 -0
  33. package/components/checkbox/demo.html +253 -0
  34. package/components/checkbox/snice-checkbox.css +164 -0
  35. package/components/checkbox/snice-checkbox.ts +223 -0
  36. package/components/checkbox/snice-checkbox.types.ts +22 -0
  37. package/components/chip/demo.html +383 -0
  38. package/components/chip/snice-chip.css +195 -0
  39. package/components/chip/snice-chip.ts +139 -0
  40. package/components/chip/snice-chip.types.ts +15 -0
  41. package/components/date-picker/README.md +233 -0
  42. package/components/date-picker/demo.html +191 -0
  43. package/components/date-picker/snice-date-picker.css +330 -0
  44. package/components/date-picker/snice-date-picker.ts +777 -0
  45. package/components/date-picker/snice-date-picker.types.ts +83 -0
  46. package/components/divider/demo.html +233 -0
  47. package/components/divider/snice-divider.css +155 -0
  48. package/components/divider/snice-divider.ts +69 -0
  49. package/components/divider/snice-divider.types.ts +15 -0
  50. package/components/drawer/demo.html +328 -0
  51. package/components/drawer/snice-drawer.css +476 -0
  52. package/components/drawer/snice-drawer.ts +287 -0
  53. package/components/drawer/snice-drawer.types.ts +17 -0
  54. package/components/global.d.ts +14 -0
  55. package/components/input/demo.html +303 -0
  56. package/components/input/snice-input.css +257 -0
  57. package/components/input/snice-input.ts +442 -0
  58. package/components/input/snice-input.types.ts +59 -0
  59. package/components/input/test.html +77 -0
  60. package/components/layout/README.md +260 -0
  61. package/components/layout/demo.html +538 -0
  62. package/components/layout/snice-layout-blog.css +129 -0
  63. package/components/layout/snice-layout-blog.ts +48 -0
  64. package/components/layout/snice-layout-card.css +104 -0
  65. package/components/layout/snice-layout-card.ts +35 -0
  66. package/components/layout/snice-layout-centered.css +51 -0
  67. package/components/layout/snice-layout-centered.ts +22 -0
  68. package/components/layout/snice-layout-dashboard.css +98 -0
  69. package/components/layout/snice-layout-dashboard.ts +45 -0
  70. package/components/layout/snice-layout-fullscreen.css +72 -0
  71. package/components/layout/snice-layout-fullscreen.ts +34 -0
  72. package/components/layout/snice-layout-landing.css +92 -0
  73. package/components/layout/snice-layout-landing.ts +47 -0
  74. package/components/layout/snice-layout-minimal.css +16 -0
  75. package/components/layout/snice-layout-minimal.ts +19 -0
  76. package/components/layout/snice-layout-sidebar.css +117 -0
  77. package/components/layout/snice-layout-sidebar.ts +48 -0
  78. package/components/layout/snice-layout-split.css +103 -0
  79. package/components/layout/snice-layout-split.ts +29 -0
  80. package/components/layout/snice-layout.css +72 -0
  81. package/components/layout/snice-layout.ts +35 -0
  82. package/components/layout/snice-layout.types.ts +5 -0
  83. package/components/login/demo-auth-controller.ts +185 -0
  84. package/components/login/demo.html +470 -0
  85. package/components/login/snice-login.css +204 -0
  86. package/components/login/snice-login.ts +337 -0
  87. package/components/login/snice-login.types.ts +34 -0
  88. package/components/modal/demo.html +291 -0
  89. package/components/modal/snice-modal.css +203 -0
  90. package/components/modal/snice-modal.ts +233 -0
  91. package/components/modal/snice-modal.types.ts +21 -0
  92. package/components/pagination/demo.html +395 -0
  93. package/components/pagination/snice-pagination.ts +333 -0
  94. package/components/pagination/snice-pagination.types.ts +21 -0
  95. package/components/progress/demo.html +510 -0
  96. package/components/progress/snice-progress.css +267 -0
  97. package/components/progress/snice-progress.ts +247 -0
  98. package/components/progress/snice-progress.types.ts +19 -0
  99. package/components/radio/demo.html +287 -0
  100. package/components/radio/snice-radio.css +171 -0
  101. package/components/radio/snice-radio.ts +218 -0
  102. package/components/radio/snice-radio.types.ts +21 -0
  103. package/components/select/demo.html +511 -0
  104. package/components/select/snice-option.ts +52 -0
  105. package/components/select/snice-option.types.ts +14 -0
  106. package/components/select/snice-select.css +392 -0
  107. package/components/select/snice-select.ts +796 -0
  108. package/components/select/snice-select.types.ts +55 -0
  109. package/components/skeleton/demo.html +514 -0
  110. package/components/skeleton/snice-skeleton.css +109 -0
  111. package/components/skeleton/snice-skeleton.ts +126 -0
  112. package/components/skeleton/snice-skeleton.types.ts +11 -0
  113. package/components/switch/demo.html +284 -0
  114. package/components/switch/snice-switch.css +221 -0
  115. package/components/switch/snice-switch.ts +229 -0
  116. package/components/switch/snice-switch.types.ts +23 -0
  117. package/components/symbols.ts +23 -0
  118. package/components/table/demo-table-controller.ts +100 -0
  119. package/components/table/demo.html +480 -0
  120. package/components/table/snice-cell-boolean.ts +112 -0
  121. package/components/table/snice-cell-date.ts +210 -0
  122. package/components/table/snice-cell-duration.ts +91 -0
  123. package/components/table/snice-cell-filesize.ts +90 -0
  124. package/components/table/snice-cell-number.ts +165 -0
  125. package/components/table/snice-cell-progress.ts +83 -0
  126. package/components/table/snice-cell-rating.ts +82 -0
  127. package/components/table/snice-cell-sparkline.ts +253 -0
  128. package/components/table/snice-cell-text.ts +125 -0
  129. package/components/table/snice-cell.css +296 -0
  130. package/components/table/snice-cell.ts +473 -0
  131. package/components/table/snice-column.ts +353 -0
  132. package/components/table/snice-header.css +243 -0
  133. package/components/table/snice-header.ts +261 -0
  134. package/components/table/snice-progress.ts +66 -0
  135. package/components/table/snice-rating.ts +45 -0
  136. package/components/table/snice-row.css +255 -0
  137. package/components/table/snice-row.ts +331 -0
  138. package/components/table/snice-table.css +241 -0
  139. package/components/table/snice-table.ts +737 -0
  140. package/components/table/snice-table.types.ts +158 -0
  141. package/components/tabs/demo.html +487 -0
  142. package/components/tabs/snice-tab-panel.css +264 -0
  143. package/components/tabs/snice-tab-panel.ts +47 -0
  144. package/components/tabs/snice-tab.css +96 -0
  145. package/components/tabs/snice-tab.ts +65 -0
  146. package/components/tabs/snice-tabs.css +189 -0
  147. package/components/tabs/snice-tabs.ts +332 -0
  148. package/components/tabs/snice-tabs.types.ts +28 -0
  149. package/components/theme/theme.css +234 -0
  150. package/components/toast/demo.html +329 -0
  151. package/components/toast/snice-toast-container.ts +256 -0
  152. package/components/toast/snice-toast.css +213 -0
  153. package/components/toast/snice-toast.ts +276 -0
  154. package/components/toast/snice-toast.types.ts +35 -0
  155. package/components/tooltip/demo.html +350 -0
  156. package/components/tooltip/snice-tooltip-portal.css +79 -0
  157. package/components/tooltip/snice-tooltip.css +117 -0
  158. package/components/tooltip/snice-tooltip.ts +612 -0
  159. package/components/tooltip/snice-tooltip.types.ts +32 -0
  160. package/components/transitions.ts +94 -0
  161. package/components/tsconfig.json +18 -0
  162. package/dist/index.cjs +441 -329
  163. package/dist/index.cjs.map +1 -1
  164. package/dist/index.cjs.min.map +1 -1
  165. package/dist/index.esm.js +441 -329
  166. package/dist/index.esm.js.map +1 -1
  167. package/dist/index.esm.min.js +3 -3
  168. package/dist/index.esm.min.js.map +1 -1
  169. package/dist/index.iife.js +441 -329
  170. package/dist/index.iife.js.map +1 -1
  171. package/dist/index.iife.min.js +3 -3
  172. package/dist/index.iife.min.js.map +1 -1
  173. package/dist/symbols.esm.js +1 -1
  174. package/dist/transitions.esm.js +1 -1
  175. package/dist/types/controller.d.ts +1 -1
  176. package/dist/types/element.d.ts +10 -10
  177. package/dist/types/events.d.ts +2 -2
  178. package/dist/types/index.d.ts +1 -1
  179. package/dist/types/observe.d.ts +1 -1
  180. package/dist/types/request-response.d.ts +2 -3
  181. package/dist/types/router.d.ts +1 -1
  182. package/package.json +9 -3
  183. package/dist/index.cjs.min +0 -15
  184. package/dist/symbols.cjs +0 -103
  185. package/dist/transitions.cjs +0 -219
@@ -0,0 +1,796 @@
1
+ import { element, property, query, queryAll, on, watch, dispatch, ready, dispose } from 'snice';
2
+ import css from './snice-select.css?inline';
3
+ import type { SelectSize, SelectOption, SniceSelectElement } from './snice-select.types';
4
+ import './snice-option';
5
+
6
+ @element('snice-select')
7
+ export class SniceSelect extends HTMLElement implements SniceSelectElement {
8
+ @property({ type: Boolean, reflect: true })
9
+ disabled = false;
10
+
11
+ @property({ type: Boolean, reflect: true })
12
+ required = false;
13
+
14
+ @property({ type: Boolean, reflect: true })
15
+ invalid = false;
16
+
17
+ @property({ type: Boolean, reflect: true })
18
+ readonly = false;
19
+
20
+ @property({ type: Boolean, reflect: true })
21
+ multiple = false;
22
+
23
+ @property({ type: Boolean, reflect: true })
24
+ searchable = false;
25
+
26
+ @property({ type: Boolean, reflect: true })
27
+ clearable = false;
28
+
29
+ @property({ type: Boolean, reflect: true })
30
+ open = false;
31
+
32
+ @property({ reflect: true })
33
+ size: SelectSize = 'medium';
34
+
35
+ @property({ reflect: true })
36
+ name = '';
37
+
38
+ @property({ reflect: true })
39
+ value = '';
40
+
41
+ @property({ reflect: true })
42
+ label = '';
43
+
44
+ @property({ reflect: true })
45
+ placeholder = 'Select an option';
46
+
47
+ @property({ reflect: true, attribute: 'max-height' })
48
+ maxHeight = '200px';
49
+
50
+ // Options will be read from child snice-option elements
51
+ private options: SelectOption[] = [];
52
+
53
+ @query('.select-trigger')
54
+ trigger?: HTMLButtonElement;
55
+
56
+ @query('.select-dropdown')
57
+ dropdown?: HTMLElement;
58
+
59
+ @query('.select-value')
60
+ valueDisplay?: HTMLElement;
61
+
62
+ @query('.select-label')
63
+ labelElement?: HTMLElement;
64
+
65
+ @query('.select-search-input')
66
+ searchInput?: HTMLInputElement;
67
+
68
+ @query('.select-options')
69
+ optionsList?: HTMLElement;
70
+
71
+ @query('.select-native')
72
+ nativeSelect?: HTMLSelectElement;
73
+
74
+ @query('.select-clear')
75
+ clearButton?: HTMLElement;
76
+
77
+ @query('.select-arrow')
78
+ arrow?: HTMLElement;
79
+
80
+ @query('.select-search')
81
+ searchContainer?: HTMLElement;
82
+
83
+ @queryAll('.select-option')
84
+ optionElements?: HTMLElement[];
85
+
86
+ private filteredOptions: SelectOption[] = [];
87
+ private selectedValues: Set<string> = new Set();
88
+ private focusedIndex = -1;
89
+
90
+ html() {
91
+ // Initial render - options will be populated in @ready()
92
+ return /*html*/`
93
+ <div class="select-wrapper">
94
+ <label class="select-label select-label--${this.size} ${this.required ? 'select-label--required' : ''}" part="label" ${!this.label ? 'hidden' : ''}>
95
+ ${this.label}
96
+ </label>
97
+
98
+ <button
99
+ type="button"
100
+ class="select-trigger select-trigger--${this.size}"
101
+ aria-haspopup="listbox"
102
+ aria-expanded="false"
103
+ aria-label="${this.label || 'Select'}"
104
+ part="trigger">
105
+
106
+ <div class="select-value" part="value">
107
+ <span class="select-placeholder">${this.placeholder}</span>
108
+ </div>
109
+
110
+ <span class="select-icons">
111
+ <span class="select-clear" aria-label="Clear selection" style="display: none;">
112
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
113
+ <path d="M14 1.41L12.59 0L7 5.59L1.41 0L0 1.41L5.59 7L0 12.59L1.41 14L7 8.41L12.59 14L14 12.59L8.41 7L14 1.41Z"/>
114
+ </svg>
115
+ </span>
116
+ <span class="select-arrow">
117
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
118
+ <path d="M6 9L1 4h10L6 9z"/>
119
+ </svg>
120
+ </span>
121
+ </span>
122
+ </button>
123
+
124
+ <div class="select-dropdown"
125
+ role="listbox"
126
+ aria-label="${this.label || 'Options'}"
127
+ part="dropdown">
128
+
129
+ <div class="select-search" part="search" ${!this.searchable ? 'hidden' : ''}>
130
+ <input
131
+ type="text"
132
+ class="select-search-input"
133
+ placeholder="Search..."
134
+ aria-label="Search options"
135
+ part="search-input" />
136
+ </div>
137
+
138
+ <div class="select-options" part="options">
139
+ <!-- Options will be rendered in @ready() -->
140
+ </div>
141
+ </div>
142
+
143
+ <!-- Hidden native select for form submission -->
144
+ <select
145
+ class="select-native"
146
+ name="${this.name || ''}"
147
+ tabindex="-1"
148
+ aria-hidden="true">
149
+ <!-- Options will be added in @ready() -->
150
+ </select>
151
+ </div>
152
+ `;
153
+ }
154
+
155
+ private renderOptions(): string {
156
+ const options = this.searchable ? this.filteredOptions : this.options;
157
+
158
+ if (options.length === 0) {
159
+ return /*html*/`
160
+ <div class="select-no-options">
161
+ <span class="select-no-options-text" data-search="true" ${!this.searchable || this.filteredOptions.length > 0 ? 'hidden' : ''}>No matches found</span>
162
+ <span class="select-no-options-text" data-search="false" ${this.searchable && this.filteredOptions.length === 0 ? 'hidden' : ''}>No options available</span>
163
+ </div>
164
+ `;
165
+ }
166
+
167
+ return options.map((opt, index) => {
168
+ const isSelected = this.multiple ?
169
+ this.selectedValues.has(opt.value) :
170
+ opt.value === this.value;
171
+
172
+ return /*html*/`
173
+ <div class="select-option
174
+ ${isSelected ? 'select-option--selected' : ''}
175
+ ${opt.disabled ? 'select-option--disabled' : ''}
176
+ ${index === this.focusedIndex ? 'select-option--focused' : ''}
177
+ ${opt.icon ? 'select-option--has-icon' : ''}"
178
+ data-value="${opt.value}"
179
+ role="option"
180
+ aria-selected="${isSelected}"
181
+ aria-disabled="${opt.disabled}"
182
+ part="option">
183
+ <span class="select-option-check" ${!this.multiple ? 'hidden' : ''}>
184
+ <span class="select-option-check-mark" ${!isSelected ? 'hidden' : ''}>✓</span>
185
+ </span>
186
+ <img class="select-option-icon" src="${opt.icon || ''}" alt="" ${!opt.icon ? 'hidden' : ''} />
187
+ <span class="select-option-label">${opt.label}</span>
188
+ </div>
189
+ `;
190
+ }).join('');
191
+ }
192
+
193
+ css() {
194
+ return css;
195
+ }
196
+
197
+ @ready()
198
+ init() {
199
+ // Read options from child snice-option elements
200
+ this.readOptionsFromChildren();
201
+
202
+ // Initialize selected values
203
+ if (this.multiple && this.value) {
204
+ this.selectedValues = new Set(this.value.split(',').map(v => v.trim()));
205
+ }
206
+
207
+ // Initialize filtered options
208
+ this.filteredOptions = [...this.options];
209
+
210
+ // Set initial imperative state
211
+ this.updateTriggerState();
212
+ this.updateDropdownState();
213
+ this.updateNativeSelectAttributes();
214
+
215
+ // Now that we have options, update everything
216
+ this.updateDropdownContent();
217
+ this.updateNativeSelect();
218
+ this.updateValueDisplay();
219
+ this.updateClearButton();
220
+
221
+ // Watch for changes to child options
222
+ this.observeChildren();
223
+
224
+ // Setup global event listeners
225
+ this.setupGlobalListeners();
226
+ }
227
+
228
+ @dispose()
229
+ cleanup() {
230
+ this.removeGlobalListeners();
231
+ this.childObserver?.disconnect();
232
+ }
233
+
234
+ private outsideClickHandler?: (e: MouseEvent) => void;
235
+ private globalKeyHandler?: (e: KeyboardEvent) => void;
236
+
237
+ private setupGlobalListeners() {
238
+ // Create bound handlers
239
+ this.outsideClickHandler = (e: MouseEvent) => {
240
+ if (!this.contains(e.target as Node) && this.open) {
241
+ this.closeDropdown();
242
+ }
243
+ };
244
+
245
+ this.globalKeyHandler = (e: KeyboardEvent) => {
246
+ if (!this.open) return;
247
+
248
+ switch (e.key) {
249
+ case 'Escape':
250
+ this.closeDropdown();
251
+ this.trigger?.focus();
252
+ break;
253
+ case 'ArrowDown':
254
+ e.preventDefault();
255
+ this.focusNextOption();
256
+ break;
257
+ case 'ArrowUp':
258
+ e.preventDefault();
259
+ this.focusPreviousOption();
260
+ break;
261
+ case 'Enter':
262
+ case ' ':
263
+ e.preventDefault();
264
+ if (this.focusedIndex >= 0) {
265
+ const options = this.searchable ? this.filteredOptions : this.options;
266
+ const option = options[this.focusedIndex];
267
+ if (option && !option.disabled) {
268
+ this.handleOptionSelect(option);
269
+ }
270
+ }
271
+ break;
272
+ }
273
+ };
274
+
275
+ // Add listeners
276
+ document.addEventListener('click', this.outsideClickHandler);
277
+ document.addEventListener('keydown', this.globalKeyHandler);
278
+ }
279
+
280
+ private removeGlobalListeners() {
281
+ if (this.outsideClickHandler) {
282
+ document.removeEventListener('click', this.outsideClickHandler);
283
+ }
284
+ if (this.globalKeyHandler) {
285
+ document.removeEventListener('keydown', this.globalKeyHandler);
286
+ }
287
+ }
288
+
289
+ // Manual observation of light DOM children (snice-option elements)
290
+ private observeChildren() {
291
+ const observer = new MutationObserver((mutations) => {
292
+ this.handleChildrenChange(mutations);
293
+ });
294
+
295
+ // Observe the host element (this) for changes to its light DOM children
296
+ observer.observe(this, {
297
+ childList: true,
298
+ subtree: true,
299
+ attributes: true,
300
+ attributeFilter: ['value', 'label', 'disabled', 'selected']
301
+ });
302
+
303
+ // Store for cleanup
304
+ this.childObserver = observer;
305
+ }
306
+
307
+ private childObserver?: MutationObserver;
308
+
309
+ private handleChildrenChange(mutations: MutationRecord[]) {
310
+ // Check if any of the mutations are relevant (snice-option elements or their attributes)
311
+ const relevant = mutations.some(m => {
312
+ if (m.type === 'childList') return true;
313
+ if (m.type === 'attributes' && ['value', 'label', 'disabled', 'selected'].includes(m.attributeName!)) {
314
+ return m.target.nodeName === 'SNICE-OPTION';
315
+ }
316
+ return false;
317
+ });
318
+
319
+ if (relevant) {
320
+ this.readOptionsFromChildren();
321
+ this.filteredOptions = [...this.options];
322
+ this.updateNativeSelect();
323
+ this.updateValueDisplay();
324
+ this.updateClearButton();
325
+ this.updateDropdownContent();
326
+ }
327
+ }
328
+
329
+
330
+ private readOptionsFromChildren() {
331
+ // Get all snice-option children from light DOM
332
+ const optionElements = Array.from(this.querySelectorAll('snice-option'));
333
+
334
+ this.options = optionElements.map(opt => {
335
+ const sniceOption = opt as any;
336
+ // Use the optionData getter if available, otherwise construct from properties
337
+ if (sniceOption.optionData) {
338
+ return sniceOption.optionData;
339
+ }
340
+
341
+ return {
342
+ value: opt.getAttribute('value') || '',
343
+ label: opt.getAttribute('label') || opt.textContent?.trim() || '',
344
+ disabled: opt.hasAttribute('disabled'),
345
+ selected: opt.hasAttribute('selected')
346
+ };
347
+ });
348
+ }
349
+
350
+ @on(['keydown:Enter', 'keydown:Space', 'keydown:ArrowDown', 'keydown:ArrowUp'], '.select-trigger')
351
+ handleTriggerOpen(e: KeyboardEvent) {
352
+ e.preventDefault();
353
+ if (!this.open) {
354
+ this.openDropdown();
355
+ }
356
+ }
357
+
358
+ @on('keydown:Escape', '.select-search-input')
359
+ handleSearchEscape() {
360
+ this.closeDropdown();
361
+ this.trigger?.focus();
362
+ }
363
+
364
+ @on('keydown:ArrowDown', '.select-search-input')
365
+ handleSearchArrowDown(e: KeyboardEvent) {
366
+ e.preventDefault();
367
+ this.focusNextOption();
368
+ }
369
+
370
+ @on('keydown:ArrowUp', '.select-search-input')
371
+ handleSearchArrowUp(e: KeyboardEvent) {
372
+ e.preventDefault();
373
+ this.focusPreviousOption();
374
+ }
375
+
376
+ @on('keydown:Enter', '.select-search-input')
377
+ handleSearchEnter(e: KeyboardEvent) {
378
+ e.preventDefault();
379
+ if (this.focusedIndex >= 0) {
380
+ const options = this.searchable ? this.filteredOptions : this.options;
381
+ const option = options[this.focusedIndex];
382
+ if (option && !option.disabled) {
383
+ this.handleOptionSelect(option);
384
+ }
385
+ }
386
+ }
387
+
388
+ private focusNextOption() {
389
+ const options = this.searchable ? this.filteredOptions : this.options;
390
+ const enabledOptions = options.filter(opt => !opt.disabled);
391
+ if (enabledOptions.length === 0) return;
392
+
393
+ this.focusedIndex++;
394
+ if (this.focusedIndex >= options.length) {
395
+ this.focusedIndex = 0;
396
+ }
397
+ while (options[this.focusedIndex]?.disabled) {
398
+ this.focusedIndex++;
399
+ if (this.focusedIndex >= options.length) {
400
+ this.focusedIndex = 0;
401
+ }
402
+ }
403
+ this.updateOptionFocus();
404
+ }
405
+
406
+ private focusPreviousOption() {
407
+ const options = this.searchable ? this.filteredOptions : this.options;
408
+ const enabledOptions = options.filter(opt => !opt.disabled);
409
+ if (enabledOptions.length === 0) return;
410
+
411
+ this.focusedIndex--;
412
+ if (this.focusedIndex < 0) {
413
+ this.focusedIndex = options.length - 1;
414
+ }
415
+ while (options[this.focusedIndex]?.disabled) {
416
+ this.focusedIndex--;
417
+ if (this.focusedIndex < 0) {
418
+ this.focusedIndex = options.length - 1;
419
+ }
420
+ }
421
+ this.updateOptionFocus();
422
+ }
423
+
424
+ private updateOptionFocus() {
425
+ if (this.optionElements) {
426
+ this.optionElements.forEach((el, index) => {
427
+ el.classList.toggle('select-option--focused', index === this.focusedIndex);
428
+ });
429
+ }
430
+ }
431
+
432
+ @on('click', '.select-trigger')
433
+ handleTriggerClick(e: Event) {
434
+ e.stopPropagation();
435
+
436
+ // Don't toggle if clicking on the clear button or tag remove buttons
437
+ const target = e.target as HTMLElement;
438
+ if (target.closest('.select-clear') || target.closest('.select-tag-remove')) {
439
+ return;
440
+ }
441
+
442
+ if (!this.disabled && !this.readonly) {
443
+ this.toggleDropdown();
444
+ }
445
+ }
446
+
447
+ @on('click', '.select-clear', { preventDefault: true, stopPropagation: true })
448
+ handleClearClick(_e: Event) {
449
+ this.clear();
450
+ }
451
+
452
+ @on('click', '.select-tag-remove')
453
+ handleTagRemove(e: Event) {
454
+ e.stopPropagation();
455
+ const target = e.target as HTMLElement;
456
+ const value = target.getAttribute('data-value');
457
+ if (value && this.multiple) {
458
+ this.selectedValues.delete(value);
459
+ this.value = Array.from(this.selectedValues).join(',');
460
+ this.updateNativeSelect();
461
+ this.updateValueDisplay();
462
+ this.updateClearButton();
463
+ this.dispatchChangeEvent();
464
+ }
465
+ }
466
+
467
+ @on('click', '.select-options')
468
+ handleOptionsClick(e: Event) {
469
+ e.stopPropagation();
470
+
471
+ const target = e.target as HTMLElement;
472
+ const optionEl = target.closest('.select-option') as HTMLElement;
473
+
474
+ if (!optionEl) return;
475
+
476
+ const value = optionEl.getAttribute('data-value');
477
+ if (!value) return;
478
+
479
+ const option = this.options.find(opt => opt.value === value);
480
+ if (option && !option.disabled) {
481
+ this.handleOptionSelect(option);
482
+ }
483
+ }
484
+
485
+ @on('input', '.select-search-input')
486
+ handleSearchInput(e: Event) {
487
+ const target = e.target as HTMLInputElement;
488
+ const searchTerm = target.value.toLowerCase();
489
+
490
+ if (searchTerm) {
491
+ this.filteredOptions = this.options.filter(opt =>
492
+ opt.label.toLowerCase().includes(searchTerm)
493
+ );
494
+ } else {
495
+ this.filteredOptions = [...this.options];
496
+ }
497
+
498
+ this.focusedIndex = -1;
499
+ this.updateDropdownContent();
500
+ }
501
+
502
+ private handleOptionSelect(option: SelectOption) {
503
+ if (this.multiple) {
504
+ if (this.selectedValues.has(option.value)) {
505
+ this.selectedValues.delete(option.value);
506
+ } else {
507
+ this.selectedValues.add(option.value);
508
+ }
509
+ this.value = Array.from(this.selectedValues).join(',');
510
+ this.updateDropdownContent();
511
+ } else {
512
+ this.value = option.value;
513
+ this.closeDropdown();
514
+ }
515
+
516
+ this.updateNativeSelect();
517
+ this.updateValueDisplay();
518
+ this.updateClearButton();
519
+ this.dispatchChangeEvent(option);
520
+ }
521
+
522
+ @watch('value')
523
+ handleValueChange() {
524
+ if (this.multiple) {
525
+ this.selectedValues = new Set(this.value ? this.value.split(',').map(v => v.trim()) : []);
526
+ }
527
+ this.updateNativeSelect();
528
+ this.updateValueDisplay();
529
+ this.updateClearButton();
530
+ }
531
+
532
+ @watch('disabled')
533
+ handleDisabledChange() {
534
+ this.updateTriggerState();
535
+ this.updateNativeSelectAttributes();
536
+ this.updateClearButton();
537
+ if (this.disabled && this.open) {
538
+ this.closeDropdown();
539
+ }
540
+ }
541
+
542
+ @watch('readonly')
543
+ handleReadonlyChange() {
544
+ this.updateTriggerState();
545
+ this.updateClearButton();
546
+ }
547
+
548
+ @watch('invalid')
549
+ handleInvalidChange() {
550
+ this.updateTriggerState();
551
+ }
552
+
553
+ // Remove the @watch('options') since options are now read from children
554
+
555
+ @watch('open')
556
+ handleOpenChange() {
557
+ this.updateDropdownState();
558
+ this.updateTriggerState();
559
+
560
+ if (this.open && this.searchable && this.searchInput) {
561
+ setTimeout(() => this.searchInput?.focus(), 100);
562
+ }
563
+
564
+ if (!this.open) {
565
+ this.focusedIndex = -1;
566
+ if (this.searchInput) {
567
+ this.searchInput.value = '';
568
+ this.filteredOptions = [...this.options];
569
+ this.updateDropdownContent();
570
+ }
571
+ }
572
+ }
573
+
574
+ @watch('label')
575
+ handleLabelChange() {
576
+ if (this.labelElement) {
577
+ this.labelElement.textContent = this.label;
578
+ if (this.label) {
579
+ this.labelElement.removeAttribute('hidden');
580
+ } else {
581
+ this.labelElement.setAttribute('hidden', '');
582
+ }
583
+ }
584
+ }
585
+
586
+ @watch('placeholder')
587
+ handlePlaceholderChange() {
588
+ this.updateValueDisplay();
589
+ }
590
+
591
+ @watch('required')
592
+ handleRequiredChange() {
593
+ if (this.labelElement) {
594
+ this.labelElement.classList.toggle('select-label--required', this.required);
595
+ }
596
+ this.updateNativeSelectAttributes();
597
+ }
598
+
599
+ @watch('multiple')
600
+ handleMultipleChange() {
601
+ this.updateNativeSelectAttributes();
602
+ }
603
+
604
+ @watch('name')
605
+ handleNameChange() {
606
+ this.updateNativeSelectAttributes();
607
+ }
608
+
609
+
610
+ @watch('clearable')
611
+ handleClearableChange() {
612
+ this.updateClearButton();
613
+ }
614
+
615
+ @watch('searchable')
616
+ handleSearchableChange() {
617
+ if (this.searchContainer) {
618
+ if (this.searchable) {
619
+ this.searchContainer.removeAttribute('hidden');
620
+ } else {
621
+ this.searchContainer.setAttribute('hidden', '');
622
+ }
623
+ }
624
+ }
625
+
626
+ private updateValueDisplay() {
627
+ if (!this.valueDisplay) return;
628
+
629
+ const selectedOptions = this.options.filter(opt =>
630
+ this.multiple ? this.selectedValues.has(opt.value) : opt.value === this.value
631
+ );
632
+
633
+ if (this.multiple && selectedOptions.length > 0) {
634
+ this.valueDisplay.innerHTML = /*html*/`
635
+ <div class="select-value--multiple">
636
+ ${selectedOptions.map(opt => /*html*/`
637
+ <span class="select-tag">
638
+ <img class="select-tag-icon" src="${opt.icon || ''}" alt="" ${!opt.icon ? 'hidden' : ''} />
639
+ ${opt.label}
640
+ <span class="select-tag-remove" data-value="${opt.value}" aria-label="Remove ${opt.label}" ${this.disabled || this.readonly ? 'hidden' : ''}>×</span>
641
+ </span>
642
+ `).join('')}
643
+ </div>
644
+ `;
645
+ } else if (selectedOptions.length > 0) {
646
+ const selected = selectedOptions[0];
647
+ this.valueDisplay.innerHTML = /*html*/`
648
+ <div class="select-value--single">
649
+ <img class="select-value-icon" src="${selected.icon || ''}" alt="" ${!selected.icon ? 'hidden' : ''} />
650
+ <span>${selected.label}</span>
651
+ </div>
652
+ `;
653
+ } else {
654
+ this.valueDisplay.innerHTML = /*html*/`<span class="select-placeholder">${this.placeholder}</span>`;
655
+ }
656
+ }
657
+
658
+ private updateClearButton() {
659
+ if (!this.clearButton) return;
660
+
661
+ const selectedOptions = this.options.filter(opt =>
662
+ this.multiple ? this.selectedValues.has(opt.value) : opt.value === this.value
663
+ );
664
+
665
+ const shouldShow = this.clearable && selectedOptions.length > 0 && !this.disabled && !this.readonly;
666
+ this.clearButton.style.display = shouldShow ? '' : 'none';
667
+ }
668
+
669
+ private updateDropdownContent() {
670
+ if (!this.optionsList) return;
671
+ this.optionsList.innerHTML = this.renderOptions();
672
+ }
673
+
674
+ private updateNativeSelect() {
675
+ if (!this.nativeSelect) return;
676
+
677
+ // Clear and rebuild options
678
+ this.nativeSelect.innerHTML = '';
679
+ this.options.forEach(opt => {
680
+ const option = document.createElement('option');
681
+ option.value = opt.value;
682
+ option.textContent = opt.label;
683
+ option.selected = this.multiple ?
684
+ this.selectedValues.has(opt.value) :
685
+ opt.value === this.value;
686
+ this.nativeSelect!.appendChild(option);
687
+ });
688
+ }
689
+
690
+ @dispatch('@snice/select-change', { bubbles: true, composed: true })
691
+ private dispatchChangeEvent(option?: SelectOption) {
692
+ return {
693
+ value: this.multiple ? Array.from(this.selectedValues) : this.value,
694
+ option,
695
+ select: this
696
+ };
697
+ }
698
+
699
+ @dispatch('@snice/select-open', { bubbles: true, composed: true })
700
+ private dispatchOpenEvent() {
701
+ return { select: this };
702
+ }
703
+
704
+ @dispatch('@snice/select-close', { bubbles: true, composed: true })
705
+ private dispatchCloseEvent() {
706
+ return { select: this };
707
+ }
708
+
709
+ // Public API
710
+ focus() {
711
+ this.trigger?.focus();
712
+ }
713
+
714
+ blur() {
715
+ this.trigger?.blur();
716
+ if (this.open) {
717
+ this.closeDropdown();
718
+ }
719
+ }
720
+
721
+ clear() {
722
+ if (this.multiple) {
723
+ this.selectedValues.clear();
724
+ this.value = '';
725
+ } else {
726
+ this.value = '';
727
+ }
728
+ this.updateNativeSelect();
729
+ this.updateValueDisplay();
730
+ this.updateClearButton();
731
+ this.dispatchChangeEvent();
732
+ }
733
+
734
+ openDropdown() {
735
+ if (!this.open && !this.disabled && !this.readonly) {
736
+ this.open = true;
737
+ this.dispatchOpenEvent();
738
+ }
739
+ }
740
+
741
+ closeDropdown() {
742
+ if (this.open) {
743
+ this.open = false;
744
+ this.dispatchCloseEvent();
745
+ }
746
+ }
747
+
748
+ toggleDropdown() {
749
+ if (this.open) {
750
+ this.closeDropdown();
751
+ } else {
752
+ this.openDropdown();
753
+ }
754
+ }
755
+
756
+ selectOption(value: string) {
757
+ const option = this.options.find(opt => opt.value === value);
758
+ if (option && !option.disabled) {
759
+ this.handleOptionSelect(option);
760
+ }
761
+ }
762
+
763
+ private updateTriggerState() {
764
+ if (!this.trigger) return;
765
+
766
+ this.trigger.classList.toggle('select-trigger--open', this.open);
767
+ this.trigger.classList.toggle('select-trigger--disabled', this.disabled);
768
+ this.trigger.classList.toggle('select-trigger--readonly', this.readonly);
769
+ this.trigger.classList.toggle('select-trigger--invalid', this.invalid);
770
+ this.trigger.setAttribute('aria-expanded', String(this.open));
771
+ this.trigger.disabled = this.disabled;
772
+ }
773
+
774
+ private updateDropdownState() {
775
+ if (!this.dropdown) return;
776
+
777
+ this.dropdown.classList.toggle('select-dropdown--open', this.open);
778
+
779
+ if (this.arrow) {
780
+ this.arrow.classList.toggle('select-arrow--open', this.open);
781
+ }
782
+ }
783
+
784
+ private updateNativeSelectAttributes() {
785
+ if (!this.nativeSelect) return;
786
+
787
+ this.nativeSelect.disabled = this.disabled;
788
+ this.nativeSelect.required = this.required;
789
+ this.nativeSelect.multiple = this.multiple;
790
+ if (this.name) {
791
+ this.nativeSelect.name = this.name;
792
+ } else {
793
+ this.nativeSelect.removeAttribute('name');
794
+ }
795
+ }
796
+ }