tyrell-components 1.0.0-TC25 → 1.0.0-TC26

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 (120) hide show
  1. package/css/tyrell-brand.css +15 -11
  2. package/css/tyrell.css +4 -4
  3. package/dist/tyrell-brand.css +15 -11
  4. package/dist/tyrell.css +4 -4
  5. package/dist/tyrell.js +1 -1
  6. package/lib/components/calendar-month.d.ts +6 -0
  7. package/lib/components/calendar-month.d.ts.map +1 -1
  8. package/lib/components/calendar-month.js +52 -9
  9. package/lib/components/calendar-month.js.map +1 -1
  10. package/lib/components/calendar-navigation.d.ts +31 -5
  11. package/lib/components/calendar-navigation.d.ts.map +1 -1
  12. package/lib/components/calendar-navigation.js +71 -31
  13. package/lib/components/calendar-navigation.js.map +1 -1
  14. package/lib/components/calendar.d.ts +14 -1
  15. package/lib/components/calendar.d.ts.map +1 -1
  16. package/lib/components/calendar.js +65 -4
  17. package/lib/components/calendar.js.map +1 -1
  18. package/lib/components/checkbox.d.ts +8 -0
  19. package/lib/components/checkbox.d.ts.map +1 -1
  20. package/lib/components/checkbox.js +48 -27
  21. package/lib/components/checkbox.js.map +1 -1
  22. package/lib/components/copy.d.ts +10 -0
  23. package/lib/components/copy.d.ts.map +1 -1
  24. package/lib/components/copy.js +25 -4
  25. package/lib/components/copy.js.map +1 -1
  26. package/lib/components/date-picker.d.ts +14 -0
  27. package/lib/components/date-picker.d.ts.map +1 -1
  28. package/lib/components/date-picker.js +29 -0
  29. package/lib/components/date-picker.js.map +1 -1
  30. package/lib/components/input.d.ts +6 -0
  31. package/lib/components/input.d.ts.map +1 -1
  32. package/lib/components/input.js +30 -0
  33. package/lib/components/input.js.map +1 -1
  34. package/lib/components/modal.js +6 -0
  35. package/lib/components/modal.js.map +1 -1
  36. package/lib/components/radio.d.ts +1 -0
  37. package/lib/components/radio.d.ts.map +1 -1
  38. package/lib/components/radio.js +37 -8
  39. package/lib/components/radio.js.map +1 -1
  40. package/lib/components/select.d.ts +440 -0
  41. package/lib/components/select.d.ts.map +1 -0
  42. package/lib/components/select.js +1747 -0
  43. package/lib/components/select.js.map +1 -0
  44. package/lib/components/selected-tags.d.ts +80 -0
  45. package/lib/components/selected-tags.d.ts.map +1 -0
  46. package/lib/components/selected-tags.js +250 -0
  47. package/lib/components/selected-tags.js.map +1 -0
  48. package/lib/components/switch.d.ts.map +1 -1
  49. package/lib/components/switch.js +19 -7
  50. package/lib/components/switch.js.map +1 -1
  51. package/lib/index.d.ts +2 -0
  52. package/lib/index.d.ts.map +1 -1
  53. package/lib/index.js +2 -0
  54. package/lib/index.js.map +1 -1
  55. package/lib/styles/calendar-month.d.ts +1 -1
  56. package/lib/styles/calendar-month.d.ts.map +1 -1
  57. package/lib/styles/calendar-month.js +9 -1
  58. package/lib/styles/calendar-month.js.map +1 -1
  59. package/lib/styles/calendar-navigation.d.ts +1 -1
  60. package/lib/styles/calendar-navigation.d.ts.map +1 -1
  61. package/lib/styles/calendar-navigation.js +7 -1
  62. package/lib/styles/calendar-navigation.js.map +1 -1
  63. package/lib/styles/checkbox.d.ts +5 -5
  64. package/lib/styles/checkbox.d.ts.map +1 -1
  65. package/lib/styles/checkbox.js +147 -8
  66. package/lib/styles/checkbox.js.map +1 -1
  67. package/lib/styles/copy.d.ts +1 -1
  68. package/lib/styles/copy.d.ts.map +1 -1
  69. package/lib/styles/copy.js +32 -0
  70. package/lib/styles/copy.js.map +1 -1
  71. package/lib/styles/input.d.ts +1 -1
  72. package/lib/styles/input.d.ts.map +1 -1
  73. package/lib/styles/input.js +28 -298
  74. package/lib/styles/input.js.map +1 -1
  75. package/lib/styles/option.d.ts +1 -1
  76. package/lib/styles/option.d.ts.map +1 -1
  77. package/lib/styles/option.js +7 -3
  78. package/lib/styles/option.js.map +1 -1
  79. package/lib/styles/radio.d.ts +1 -1
  80. package/lib/styles/radio.d.ts.map +1 -1
  81. package/lib/styles/select.d.ts +12 -0
  82. package/lib/styles/select.d.ts.map +1 -0
  83. package/lib/styles/select.js +257 -0
  84. package/lib/styles/select.js.map +1 -0
  85. package/lib/styles/switch.d.ts +1 -1
  86. package/lib/styles/switch.d.ts.map +1 -1
  87. package/lib/styles/textarea.d.ts +1 -1
  88. package/lib/styles/textarea.d.ts.map +1 -1
  89. package/lib/styles/textarea.js +1 -1
  90. package/lib/utils/calendar-utils.d.ts +0 -48
  91. package/lib/utils/calendar-utils.d.ts.map +1 -1
  92. package/lib/utils/calendar-utils.js +0 -69
  93. package/lib/utils/calendar-utils.js.map +1 -1
  94. package/lib/utils/icon-registry.d.ts +0 -13
  95. package/lib/utils/icon-registry.d.ts.map +1 -1
  96. package/lib/utils/icon-registry.js +0 -35
  97. package/lib/utils/icon-registry.js.map +1 -1
  98. package/lib/utils/locale.d.ts +0 -57
  99. package/lib/utils/locale.d.ts.map +1 -1
  100. package/lib/utils/locale.js +0 -89
  101. package/lib/utils/locale.js.map +1 -1
  102. package/lib/utils/number-format.d.ts +0 -21
  103. package/lib/utils/number-format.d.ts.map +1 -1
  104. package/lib/utils/number-format.js +0 -27
  105. package/lib/utils/number-format.js.map +1 -1
  106. package/lib/utils/positioning.d.ts +0 -24
  107. package/lib/utils/positioning.d.ts.map +1 -1
  108. package/lib/utils/positioning.js +3 -86
  109. package/lib/utils/positioning.js.map +1 -1
  110. package/lib/utils/scroll-lock.d.ts.map +1 -1
  111. package/lib/utils/scroll-lock.js +0 -1
  112. package/lib/utils/scroll-lock.js.map +1 -1
  113. package/lib/utils/styles.d.ts +0 -5
  114. package/lib/utils/styles.d.ts.map +1 -1
  115. package/lib/utils/styles.js +0 -7
  116. package/lib/utils/styles.js.map +1 -1
  117. package/lib/version.d.ts +1 -1
  118. package/lib/version.js +1 -1
  119. package/package.json +9 -1
  120. package/css/tyrell-simple.css +0 -257
@@ -0,0 +1,1747 @@
1
+ /**
2
+ * TySelect Web Component
3
+ *
4
+ * The select control — replaces ty-dropdown and ty-multiselect.
5
+ *
6
+ * Cardinality (native <select> semantics):
7
+ * - default: SINGLE select — scalar value, picking an option closes the popup
8
+ * - `multiple`: multi select — comma value / repeated FormData entries,
9
+ * options toggle and the popup stays open
10
+ *
11
+ * Skins:
12
+ * - default: form FIELD — full width, --ty-input-* tokens, matches ty-input;
13
+ * shows the selected label(s) inline
14
+ * - `compact`: content-hugging trigger for toolbars/filter bars; single shows
15
+ * the selected label, multiple shows placeholder + count badge (pair with
16
+ * <ty-selected-tags>)
17
+ * - slot="trigger": consumer skin — replaces field/compact chrome entirely,
18
+ * behavior/ARIA/form participation unchanged
19
+ *
20
+ * - ty-option children (ty-tag also accepted)
21
+ * - Desktop popup with smart positioning, mobile full-screen modal
22
+ * - Search / external-search / debounce, keyboard navigation
23
+ * - Form association: single submits one entry, multiple submits repeated
24
+ * `name=` entries (HTMX-ready)
25
+ * - change event detail: { value, values, items: [{value, label, flavor}], action, item }
26
+ * (`value` is a scalar for single, array for multiple)
27
+ *
28
+ * @example
29
+ * ```html
30
+ * <!-- Single select, field skin -->
31
+ * <ty-select label="Robot" name="robot">
32
+ * <ty-option value="bobo">Bobo Robot</ty-option>
33
+ * <ty-option value="eywa">EYWA Dataset Example</ty-option>
34
+ * </ty-select>
35
+ *
36
+ * <!-- Multi select, compact skin + out-of-band chips -->
37
+ * <ty-select multiple compact label="Robots" name="robots" id="robots">...</ty-select>
38
+ * <ty-selected-tags for="robots"></ty-selected-tags>
39
+ * ```
40
+ */
41
+ import { ensureStyles } from "../utils/styles.js";
42
+ import { multiselectStyles } from "../styles/multiselect.js";
43
+ import { selectStyles } from "../styles/select.js";
44
+ import { getLoaderSvg } from "../utils/loader-registry.js";
45
+ import { lockScroll, unlockScroll } from "../utils/scroll-lock.js";
46
+ import { isMobileTouch } from "../utils/mobile.js";
47
+ import { TyComponent } from "../base/ty-component.js";
48
+ import { CustomScrollbar, isCustomScrollbarEnabled, } from "../utils/custom-scrollbar.js";
49
+ // ============================================================================
50
+ // Element Hash Utility (for consistent scroll lock IDs)
51
+ // ============================================================================
52
+ /**
53
+ * Counter for generating unique element IDs
54
+ */
55
+ let elementIdCounter = 0;
56
+ /**
57
+ * WeakMap to store consistent element hashes
58
+ * Automatically garbage collects when element is destroyed
59
+ */
60
+ const elementIds = new WeakMap();
61
+ /**
62
+ * Get a consistent unique ID for an element
63
+ * Returns the same ID for the same element across multiple calls
64
+ *
65
+ * @param element - The element to hash
66
+ * @returns A consistent numeric hash for the element
67
+ */
68
+ function getElementHash(element) {
69
+ let id = elementIds.get(element);
70
+ if (id === undefined) {
71
+ id = ++elementIdCounter;
72
+ elementIds.set(element, id);
73
+ }
74
+ return id;
75
+ }
76
+ // ============================================================================
77
+ // SVG Icons
78
+ // ============================================================================
79
+ /**
80
+ * Required indicator SVG icon (from Lucide)
81
+ */
82
+ const REQUIRED_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-asterisk"><path d="M12 6v12"/><path d="M17.196 9 6.804 15"/><path d="m6.804 9 10.392 6"/></svg>`;
83
+ /**
84
+ * Chevron down icon SVG
85
+ */
86
+ const CHEVRON_DOWN_SVG = `<svg viewBox="0 0 20 20" fill="currentColor">
87
+ <path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
88
+ </svg>`;
89
+ /**
90
+ * 'auto' searchable mode: option count above which the search row appears.
91
+ * Short lists scan faster than they search — native <select> has no search.
92
+ */
93
+ const SEARCH_AUTO_THRESHOLD = 7;
94
+ /**
95
+ * Search (magnifier) icon SVG — popup search input adornment (from Lucide)
96
+ */
97
+ const SEARCH_ICON_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
98
+ <circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>
99
+ </svg>`;
100
+ /**
101
+ * Ty Multiselect Component
102
+ */
103
+ export class TySelect extends TyComponent {
104
+ constructor() {
105
+ super(); // TyComponent handles attachInternals() and attachShadow()
106
+ // ============================================================================
107
+ // INTERNAL STATE
108
+ // ============================================================================
109
+ this._name = "";
110
+ this._multiple = false;
111
+ this._compact = false;
112
+ this._placeholder = "Select...";
113
+ this._label = "";
114
+ this._disabled = false;
115
+ this._readonly = false;
116
+ this._required = false;
117
+ this._externalSearch = false;
118
+ this._searchable = "auto";
119
+ this._loading = false;
120
+ this._scrollLockId = null;
121
+ this._size = "md";
122
+ this._availableLabel = "Available";
123
+ this._noOptionsMessage = "No options available";
124
+ // Component state
125
+ this._state = {
126
+ open: false,
127
+ search: "",
128
+ highlightedIndex: -1,
129
+ filteredTags: [],
130
+ selectedValues: [],
131
+ mode: "desktop", // Updated dynamically on render via syncMode()
132
+ };
133
+ // Event handler references for cleanup
134
+ this._stubClickHandler = null;
135
+ this._tagClickHandler = null;
136
+ this._searchInputHandler = null;
137
+ this._blockSearchClick = null;
138
+ this._keyboardHandler = null;
139
+ // Debounce properties for search event
140
+ this._debounce = 0;
141
+ this._searchDebounceTimer = null;
142
+ // Custom scrollbar for options list
143
+ this._optionsScrollbar = null;
144
+ // MutationObserver for light-DOM children — re-syncs selected tags' visual
145
+ // state when consumers swap tag children (external-search refresh pattern).
146
+ this._childObserver = null;
147
+ // Last single-select display clone — survives external-search option swaps
148
+ // that remove the matching option (and often the clone itself) from the DOM.
149
+ this._selectedClone = null;
150
+ const shadow = this.shadowRoot;
151
+ ensureStyles(shadow, { css: multiselectStyles, id: "ty-select-base" });
152
+ ensureStyles(shadow, { css: selectStyles, id: "ty-select" });
153
+ // DON'T render here - wait for onConnect() to initialize values first
154
+ // This matches dropdown.ts pattern and prevents showing empty state
155
+ }
156
+ /**
157
+ * Called when component is connected to DOM
158
+ * TyComponent handles property capture automatically
159
+ */
160
+ onConnect() {
161
+ // SAFETY: Close any open dialogs to prevent scroll locking
162
+ const shadow = this.shadowRoot;
163
+ const dialogs = shadow.querySelectorAll("dialog");
164
+ dialogs.forEach((dialog) => {
165
+ if (dialog.open) {
166
+ console.warn("⚠️ Found open dialog on connect, closing it");
167
+ dialog.close();
168
+ }
169
+ });
170
+ // Render FIRST to create DOM structure
171
+ this.render();
172
+ // THEN initialize and sync tags (after DOM exists)
173
+ requestAnimationFrame(() => {
174
+ this.initializeState();
175
+ // Visual updates happen automatically via onPropertiesChanged
176
+ });
177
+ // Observe light-DOM children — re-sync selected state when consumers swap
178
+ // tag children (external-search refresh). syncSelectedTags is idempotent
179
+ // (only acts on tags whose desired-vs-actual selected state differs), so
180
+ // spurious firings caused by our own re-slot work are no-ops.
181
+ this._childObserver = new MutationObserver(() => {
182
+ if (this._state.selectedValues.length > 0) {
183
+ this.syncSelectedTags(this._state.selectedValues);
184
+ }
185
+ // Option count may have crossed the 'auto' searchable threshold
186
+ this.updateSearchVisibility();
187
+ // External-search consumers replace ALL ty-option children — often
188
+ // including our display clone. Rebuild the selection display (idempotent:
189
+ // a clone that already matches is left alone, so no observer loop).
190
+ this.updateSelectionDisplay();
191
+ });
192
+ this._childObserver.observe(this, { childList: true });
193
+ }
194
+ /**
195
+ * Called when component is disconnected from DOM
196
+ * Clean up event listeners and timers
197
+ */
198
+ onDisconnect() {
199
+ // CRITICAL: Close all dialogs to prevent scroll locking
200
+ const shadow = this.shadowRoot;
201
+ const dialogs = shadow.querySelectorAll("dialog");
202
+ dialogs.forEach((dialog) => {
203
+ if (dialog.open) {
204
+ dialog.close();
205
+ }
206
+ });
207
+ // Clean up document-level listeners
208
+ if (this._keyboardHandler) {
209
+ document.removeEventListener("keydown", this._keyboardHandler);
210
+ this._keyboardHandler = null;
211
+ }
212
+ // Clear any pending debounce timer
213
+ if (this._searchDebounceTimer !== null) {
214
+ clearTimeout(this._searchDebounceTimer);
215
+ this._searchDebounceTimer = null;
216
+ }
217
+ // Cleanup custom scrollbar
218
+ this._destroyOptionsScrollbar();
219
+ // Disconnect children observer
220
+ if (this._childObserver) {
221
+ this._childObserver.disconnect();
222
+ this._childObserver = null;
223
+ }
224
+ }
225
+ /**
226
+ * Called when properties change
227
+ * Handle state synchronization BEFORE render
228
+ */
229
+ onPropertiesChanged(changes) {
230
+ for (const { name, newValue } of changes) {
231
+ switch (name) {
232
+ case "value":
233
+ const selectedValues = this.parseValue(newValue);
234
+ this._state.selectedValues = selectedValues;
235
+ // CRITICAL: Only sync tags if we're connected and tags exist
236
+ // During initial property setup (before onConnect), tags don't exist yet
237
+ if (this.isConnected && this.shadowRoot) {
238
+ this.syncSelectedTags(selectedValues);
239
+ this.updateSelectionDisplay();
240
+ this.updateMobileSelectedState();
241
+ }
242
+ // (not-yet-connected: sync deferred to initializeState)
243
+ break;
244
+ case "name":
245
+ this._name = newValue || "";
246
+ break;
247
+ case "multiple":
248
+ this._multiple = newValue;
249
+ // Dropping to single with a multi selection: keep only the first
250
+ if (!newValue && this._state.selectedValues.length > 1) {
251
+ this.updateComponentValue([this._state.selectedValues[0]], false, "set", null);
252
+ }
253
+ break;
254
+ case "compact":
255
+ this._compact = newValue;
256
+ break;
257
+ case "placeholder":
258
+ this._placeholder = newValue || "Select...";
259
+ if (this.isConnected && this.shadowRoot)
260
+ this.updateSelectionDisplay();
261
+ break;
262
+ case "label":
263
+ this._label = newValue || "";
264
+ break;
265
+ case "disabled":
266
+ this._disabled = newValue;
267
+ break;
268
+ case "readonly":
269
+ this._readonly = newValue;
270
+ break;
271
+ case "required":
272
+ this._required = newValue;
273
+ break;
274
+ case "externalSearch":
275
+ this._externalSearch = newValue;
276
+ break;
277
+ case "searchable":
278
+ this._searchable = newValue;
279
+ if (this.isConnected && this.shadowRoot)
280
+ this.updateSearchVisibility();
281
+ break;
282
+ case "size":
283
+ this._size = newValue;
284
+ break;
285
+ case "debounce":
286
+ this._debounce = newValue;
287
+ break;
288
+ case "available-label":
289
+ this._availableLabel = newValue || "Available";
290
+ break;
291
+ case "no-options-message":
292
+ this._noOptionsMessage = newValue || "No options available";
293
+ break;
294
+ case "loading":
295
+ this._loading = newValue;
296
+ this.applyLoadingState();
297
+ break;
298
+ }
299
+ }
300
+ }
301
+ /**
302
+ * Toggle the loading visual state on the open popup.
303
+ * Replaces the available-options area with a centered spinner; search input stays usable.
304
+ * Pulls the latest registered loader SVG on each call so registry changes
305
+ * take effect on the next loading toggle.
306
+ */
307
+ applyLoadingState() {
308
+ const shadow = this.shadowRoot;
309
+ if (!shadow)
310
+ return;
311
+ const svg = this._loading ? getLoaderSvg() : null;
312
+ shadow.querySelectorAll(".dropdown-options-wrapper").forEach((wrapper) => {
313
+ wrapper.classList.toggle("loading", this._loading);
314
+ if (this._loading) {
315
+ wrapper.setAttribute("aria-busy", "true");
316
+ const spinner = wrapper.querySelector(".dropdown-loading-spinner");
317
+ if (spinner && svg)
318
+ spinner.innerHTML = svg;
319
+ }
320
+ else {
321
+ wrapper.removeAttribute("aria-busy");
322
+ }
323
+ });
324
+ }
325
+ /**
326
+ * Get the form value for this component
327
+ * Returns FormData with multiple entries (HTMX standard)
328
+ */
329
+ getFormValue() {
330
+ const selectedValues = this._state.selectedValues;
331
+ if (this._name && selectedValues.length > 0) {
332
+ // Single select submits one plain entry
333
+ if (!this._multiple)
334
+ return selectedValues[0];
335
+ // Multiple submits repeated `name=` entries (HTMX standard)
336
+ const formData = new FormData();
337
+ selectedValues.forEach((value) => {
338
+ formData.append(this._name, value);
339
+ });
340
+ return formData;
341
+ }
342
+ return null;
343
+ }
344
+ /**
345
+ * Parse multiselect value (comma-separated string to array)
346
+ */
347
+ parseValue(value) {
348
+ // Defensive check: ensure value is actually a string before calling .trim()
349
+ if (!value || typeof value !== "string" || value.trim() === "")
350
+ return [];
351
+ return value
352
+ .split(",")
353
+ .map((v) => v.trim())
354
+ .filter((v) => v !== "");
355
+ }
356
+ /**
357
+ * Initialize component state from attributes
358
+ * Reads from both property and attribute (like ClojureScript version)
359
+ */
360
+ initializeState() {
361
+ const initialValue = this.getProperty("value") || "";
362
+ if (initialValue) {
363
+ // Explicit value provided - sync tags directly
364
+ // DON'T use updateComponentValue() because the property is already set!
365
+ const selectedValues = this.parseValue(initialValue);
366
+ // Update internal state
367
+ this._state.selectedValues = selectedValues;
368
+ // Sync the tags to match the property value
369
+ this.syncSelectedTags(selectedValues);
370
+ // Update the visual display
371
+ this.updateSelectionDisplay();
372
+ this.updateMobileSelectedState();
373
+ }
374
+ else {
375
+ // No explicit value - check for pre-selected tags
376
+ const allTags = this.getTagElements();
377
+ const preSelectedTags = allTags
378
+ .filter((tag) => tag.hasAttribute("selected"))
379
+ .map((tag) => this.getTagData(tag).value);
380
+ if (preSelectedTags.length > 0) {
381
+ // Set the property value and sync (updateComponentValue handles everything)
382
+ this.updateComponentValue(preSelectedTags, false);
383
+ }
384
+ }
385
+ }
386
+ // ============================================================================
387
+ // TAG MANAGEMENT METHODS (Phase 2)
388
+ // ============================================================================
389
+ /**
390
+ * Get all option elements from the component (ty-option preferred, ty-tag accepted)
391
+ */
392
+ getTagElements() {
393
+ // :not([cloned]) — the single-select display clone (slot="selected")
394
+ // lives in our light DOM too and must never count as an option
395
+ return Array.from(this.querySelectorAll("ty-option:not([cloned]), ty-tag:not([cloned])"));
396
+ }
397
+ /**
398
+ * Extract value and text from a ty-tag element
399
+ */
400
+ getTagData(element) {
401
+ // Get value from either property or attribute
402
+ const value = element.value ||
403
+ element.getAttribute("value") ||
404
+ element.textContent ||
405
+ "";
406
+ // Display label: explicit label attribute wins over textContent —
407
+ // native <option label> semantics; lets rich options declare clean text
408
+ const text = element.getAttribute("label") || element.textContent || "";
409
+ return { value, text, element };
410
+ }
411
+ /**
412
+ * Select an option - attribute only. The option stays in the list with
413
+ * selected styling; chips are rendered out-of-band (ty-selected-tags).
414
+ */
415
+ selectTag(tag) {
416
+ tag.setAttribute("selected", "");
417
+ }
418
+ /**
419
+ * Deselect an option - attribute only.
420
+ */
421
+ deselectTag(tag) {
422
+ tag.removeAttribute("selected");
423
+ }
424
+ /**
425
+ * Get array of currently selected values from tags (ALWAYS reads from DOM)
426
+ */
427
+ getSelectedValues() {
428
+ return this.getTagElements()
429
+ .filter((tag) => tag.hasAttribute("selected"))
430
+ .map((tag) => this.getTagData(tag).value)
431
+ .filter((value) => value !== "");
432
+ }
433
+ /**
434
+ * Sync tag selection states with desired values
435
+ */
436
+ syncSelectedTags(selectedValues) {
437
+ const selectedSet = new Set(selectedValues);
438
+ const tags = this.getTagElements();
439
+ tags.forEach((tag) => {
440
+ const tagValue = this.getTagData(tag).value;
441
+ const shouldBeSelected = selectedSet.has(tagValue);
442
+ const isSelected = tag.hasAttribute("selected");
443
+ if (shouldBeSelected && !isSelected) {
444
+ this.selectTag(tag);
445
+ }
446
+ else if (!shouldBeSelected && isSelected) {
447
+ this.deselectTag(tag);
448
+ }
449
+ });
450
+ }
451
+ /**
452
+ * Central update function - synchronizes everything
453
+ * Uses TyComponent's property system for proper lifecycle
454
+ */
455
+ updateComponentValue(newValues, dispatchChange = false, action = "set", item = null) {
456
+ const oldValues = this.getSelectedValues();
457
+ const valueStr = newValues.join(",");
458
+ // Only update if changed
459
+ if (JSON.stringify(newValues.sort()) !== JSON.stringify(oldValues.sort())) {
460
+ // Use TyComponent's property system - this will trigger:
461
+ // 1. onPropertiesChanged() → syncs tags via syncSelectedTags()
462
+ // 2. onPropertiesChanged() → updates placeholder via updateSelectionDisplay()
463
+ // 3. updateFormValue() → automatic (formValue: true in config)
464
+ // 4. render() → automatic if visual properties changed
465
+ this.setProperty("value", valueStr);
466
+ // Dispatch change event (with rich items for out-of-band chip rendering)
467
+ if (dispatchChange) {
468
+ this.dispatchChangeEvent({
469
+ // Scalar for single select, array for multiple
470
+ value: this._multiple ? [...newValues] : (newValues[0] ?? null),
471
+ values: newValues,
472
+ items: this.getSelectedItems(newValues),
473
+ action,
474
+ item,
475
+ });
476
+ }
477
+ }
478
+ }
479
+ /**
480
+ * Build rich item info for values, read off the matching option elements
481
+ */
482
+ getSelectedItems(values) {
483
+ const byValue = new Map(this.getTagElements().map((el) => [this.getTagData(el).value, el]));
484
+ return values.map((value) => {
485
+ const el = byValue.get(value);
486
+ return {
487
+ value,
488
+ // label attribute wins (native <option label> semantics)
489
+ label: el?.getAttribute("label") || el?.textContent?.trim() || value,
490
+ flavor: el?.getAttribute("flavor") ?? null,
491
+ };
492
+ });
493
+ }
494
+ // ============================================================================
495
+ // DROPDOWN METHODS (Phase 3 & 4)
496
+ // ============================================================================
497
+ /**
498
+ * Calculate and set dropdown position with smart direction detection
499
+ */
500
+ calculatePosition() {
501
+ const shadow = this.shadowRoot;
502
+ const stub = shadow.querySelector(".multiselect-stub");
503
+ const dialog = shadow.querySelector(".dropdown-dialog");
504
+ if (!stub || !dialog)
505
+ return;
506
+ const stubRect = stub.getBoundingClientRect();
507
+ const viewportHeight = window.innerHeight;
508
+ const viewportWidth = window.innerWidth;
509
+ // Get dialog dimensions (it's already shown with showModal).
510
+ // Cap the estimate: with hundreds of options the raw measurement can be
511
+ // the uncapped content height, which made "fits below?" always false.
512
+ // 440px ≈ options max-height (24rem) + search header.
513
+ const MAX_POPUP_ESTIMATE = 440;
514
+ const dialogRect = dialog.getBoundingClientRect();
515
+ const estimatedHeight = Math.min(dialogRect.height || 200, MAX_POPUP_ESTIMATE);
516
+ const padding = 8;
517
+ const wrapPadding = 20;
518
+ // Popup width is independent of the trigger — a small button trigger still
519
+ // gets a usable list. Overridable via --ty-select-popup-width.
520
+ const cssWidth = parseFloat(getComputedStyle(this).getPropertyValue("--ty-select-popup-width"));
521
+ const popupWidth = !isNaN(cssWidth)
522
+ ? cssWidth
523
+ : Math.max(stubRect.width, 320);
524
+ // Available space calculations
525
+ const spaceBelow = viewportHeight - stubRect.bottom;
526
+ const spaceAbove = stubRect.top;
527
+ // Smart direction logic: below when it fits — and when neither side
528
+ // fits, take the side with MORE room instead of blindly flipping up
529
+ // (a field near the viewport top used to clip the popup off-screen).
530
+ const positionBelow = spaceBelow >= estimatedHeight + padding || spaceBelow >= spaceAbove;
531
+ // Anchor to the trigger's left edge, clamped into the viewport
532
+ const x = Math.max(padding - wrapPadding, Math.min(stubRect.left - wrapPadding, viewportWidth - popupWidth - wrapPadding - padding));
533
+ // Open below (or above) the trigger — never covering it
534
+ const gap = 4;
535
+ const y = positionBelow
536
+ ? stubRect.bottom + gap - wrapPadding
537
+ : viewportHeight - stubRect.top + gap - wrapPadding;
538
+ const width = popupWidth + wrapPadding + wrapPadding;
539
+ // Set CSS variables for positioning
540
+ this.style.setProperty("--dropdown-x", `${x}px`);
541
+ this.style.setProperty("--dropdown-y", `${y}px`);
542
+ this.style.setProperty("--dropdown-width", `${width}px`);
543
+ this.style.setProperty("--dropdown-offset-x", "0px");
544
+ this.style.setProperty("--dropdown-offset-y", "0px");
545
+ this.style.setProperty("--dropdown-padding", `${wrapPadding}px`);
546
+ // Set direction classes for CSS styling
547
+ if (positionBelow) {
548
+ dialog.classList.add("position-below");
549
+ dialog.classList.remove("position-above");
550
+ }
551
+ else {
552
+ dialog.classList.add("position-above");
553
+ dialog.classList.remove("position-below");
554
+ }
555
+ // Optional: Store direction for debugging
556
+ this.style.setProperty("--dropdown-direction", positionBelow ? "below" : "above");
557
+ }
558
+ // ============================================================================
559
+ // CUSTOM SCROLLBAR FOR OPTIONS
560
+ // ============================================================================
561
+ _setupOptionsScrollbar() {
562
+ if (!isCustomScrollbarEnabled())
563
+ return;
564
+ const shadow = this.shadowRoot;
565
+ const optionsDiv = shadow.querySelector(".dropdown-options");
566
+ const optionsWrapper = shadow.querySelector(".dropdown-options-wrapper");
567
+ if (!optionsDiv || !optionsWrapper)
568
+ return;
569
+ this._destroyOptionsScrollbar();
570
+ optionsDiv.classList.add("ty-custom-scroll");
571
+ this._optionsScrollbar = new CustomScrollbar(optionsDiv, {
572
+ vertical: true,
573
+ });
574
+ if (this._optionsScrollbar.trackY) {
575
+ optionsWrapper.appendChild(this._optionsScrollbar.trackY);
576
+ }
577
+ }
578
+ _destroyOptionsScrollbar() {
579
+ if (this._optionsScrollbar) {
580
+ this._optionsScrollbar.trackY?.remove();
581
+ this._optionsScrollbar.destroy();
582
+ this._optionsScrollbar = null;
583
+ }
584
+ }
585
+ /**
586
+ * Open dropdown dialog (desktop mode)
587
+ *
588
+ * `<dialog>.showModal()` puts the dialog in the top layer with a backdrop, but
589
+ * does NOT prevent the page behind it from scrolling. We use the shared scroll
590
+ * lock utility (overflow:hidden on <html>) to keep wheel/touch scrolling from
591
+ * leaking through to the body — same behavior <ty-dropdown> and <ty-modal>
592
+ * implement.
593
+ */
594
+ openDropdown() {
595
+ const shadow = this.shadowRoot;
596
+ const dialog = shadow.querySelector(".dropdown-dialog");
597
+ if (!dialog)
598
+ return;
599
+ // Lock body scroll while dropdown is open
600
+ const lockId = `multiselect-${this.id || "anon"}-${getElementHash(this)}`;
601
+ this._scrollLockId = lockId;
602
+ lockScroll(lockId);
603
+ // Show modal
604
+ dialog.showModal();
605
+ dialog.classList.add("open");
606
+ // Position dropdown AFTER showing modal
607
+ this.calculatePosition();
608
+ // Update component state
609
+ this._state.open = true;
610
+ // Update visual states
611
+ const chevron = shadow.querySelector(".dropdown-chevron");
612
+ if (chevron)
613
+ chevron.classList.add("open");
614
+ const searchChevron = shadow.querySelector(".dropdown-search-chevron");
615
+ if (searchChevron)
616
+ searchChevron.classList.add("open");
617
+ // Initialize options state
618
+ const tags = this.getTagElements().map((el) => this.getTagData(el));
619
+ this._state.filteredTags = tags;
620
+ this._state.highlightedIndex = -1;
621
+ // Ensure options area is visible (may have been hidden from previous search)
622
+ this.updateOptionsVisibility(true);
623
+ // Setup custom scrollbar on options
624
+ this._setupOptionsScrollbar();
625
+ // Focus search input (when the search row is shown)
626
+ const searchInput = shadow.querySelector(".dropdown-search-input");
627
+ if (searchInput && this.shouldShowSearch()) {
628
+ setTimeout(() => searchInput.focus(), 100);
629
+ }
630
+ // Lifecycle event (also fires empty search if external-search)
631
+ this.fireOpenEvent();
632
+ }
633
+ /**
634
+ * Close dropdown dialog (desktop mode)
635
+ */
636
+ closeDropdown() {
637
+ const shadow = this.shadowRoot;
638
+ const dialog = shadow.querySelector(".dropdown-dialog");
639
+ if (!dialog)
640
+ return;
641
+ // Destroy custom scrollbar
642
+ this._destroyOptionsScrollbar();
643
+ // Close dialog
644
+ dialog.classList.remove("open");
645
+ dialog.classList.remove("position-above");
646
+ dialog.classList.remove("position-below");
647
+ dialog.close();
648
+ // Unlock body scroll (paired with the lock in openDropdown)
649
+ if (this._scrollLockId) {
650
+ unlockScroll(this._scrollLockId);
651
+ this._scrollLockId = null;
652
+ }
653
+ // Update state
654
+ this._state.open = false;
655
+ this._state.highlightedIndex = -1;
656
+ // Update visual states
657
+ const chevron = shadow.querySelector(".dropdown-chevron");
658
+ if (chevron)
659
+ chevron.classList.remove("open");
660
+ const searchChevron = shadow.querySelector(".dropdown-search-chevron");
661
+ if (searchChevron)
662
+ searchChevron.classList.remove("open");
663
+ // Reset search and restore all tags
664
+ const hadQuery = this._state.search !== "";
665
+ this._state.search = "";
666
+ const searchInput = shadow.querySelector(".dropdown-search-input");
667
+ if (searchInput) {
668
+ searchInput.value = "";
669
+ }
670
+ if (this._externalSearch) {
671
+ // External mode — notify consumer so it can reset its own filtered state.
672
+ // Bypass the debounce timer (which would delay the clear by `debounce` ms);
673
+ // close-time should be immediate so the consumer's filtered state syncs
674
+ // before the dropdown reopens. Only fire if there was actually a query.
675
+ if (hadQuery) {
676
+ if (this._searchDebounceTimer !== null) {
677
+ clearTimeout(this._searchDebounceTimer);
678
+ this._searchDebounceTimer = null;
679
+ }
680
+ this.fireSearchEvent("");
681
+ }
682
+ }
683
+ else {
684
+ // Internal mode — restore visibility of all tags ourselves
685
+ const allTags = this.getTagElements().map((el) => this.getTagData(el));
686
+ this._state.filteredTags = allTags;
687
+ this.updateTagVisibility(allTags, allTags);
688
+ this.clearHighlights(allTags);
689
+ }
690
+ // Lifecycle event
691
+ this.fireCloseEvent();
692
+ }
693
+ /**
694
+ * Open mobile modal (mobile mode)
695
+ * Now using <dialog> element for native z-index management
696
+ */
697
+ openMobileModal() {
698
+ const shadow = this.shadowRoot;
699
+ const dialog = shadow.querySelector(".mobile-dialog");
700
+ if (!dialog)
701
+ return;
702
+ // Lock body scroll while mobile modal is open
703
+ const lockId = `multiselect-${this.id || "anon"}-${getElementHash(this)}`;
704
+ this._scrollLockId = lockId;
705
+ lockScroll(lockId);
706
+ // Show dialog using native API (handles z-index automatically)
707
+ dialog.showModal();
708
+ dialog.classList.add("open");
709
+ // Update component state
710
+ this._state.open = true;
711
+ // Initialize options state
712
+ const tags = this.getTagElements().map((el) => this.getTagData(el));
713
+ this._state.filteredTags = tags;
714
+ // Focus search input (when the search row is shown)
715
+ const searchInput = shadow.querySelector(".mobile-search-input");
716
+ if (searchInput && this.shouldShowSearch()) {
717
+ // Small delay to ensure dialog is ready
718
+ setTimeout(() => searchInput.focus(), 100);
719
+ }
720
+ // Update state after slots are ready
721
+ requestAnimationFrame(() => {
722
+ this.updateMobileSelectedState();
723
+ });
724
+ // Lifecycle event (also fires empty search if external-search)
725
+ this.fireOpenEvent();
726
+ }
727
+ /**
728
+ * Close mobile modal (mobile mode)
729
+ * Now using <dialog> element for native management
730
+ */
731
+ closeMobileModal() {
732
+ const shadow = this.shadowRoot;
733
+ const dialog = shadow.querySelector(".mobile-dialog");
734
+ if (!dialog)
735
+ return;
736
+ // Close immediately — ::backdrop doesn't support transitions
737
+ dialog.classList.remove("open");
738
+ dialog.close();
739
+ // Unlock body scroll (paired with the lock in openMobileModal)
740
+ if (this._scrollLockId) {
741
+ unlockScroll(this._scrollLockId);
742
+ this._scrollLockId = null;
743
+ }
744
+ // Update state
745
+ this._state.open = false;
746
+ this._state.highlightedIndex = -1;
747
+ // Reset search
748
+ const hadQuery = this._state.search !== "";
749
+ this._state.search = "";
750
+ const searchInput = shadow.querySelector(".mobile-search-input");
751
+ if (searchInput) {
752
+ searchInput.value = "";
753
+ }
754
+ if (this._externalSearch) {
755
+ // External mode — notify consumer that the search was cleared on close
756
+ // (bypass debounce, see desktop close path for rationale).
757
+ if (hadQuery) {
758
+ if (this._searchDebounceTimer !== null) {
759
+ clearTimeout(this._searchDebounceTimer);
760
+ this._searchDebounceTimer = null;
761
+ }
762
+ this.fireSearchEvent("");
763
+ }
764
+ }
765
+ else {
766
+ // Internal mode — unhide all tags ourselves
767
+ const allTags = this.getTagElements();
768
+ allTags.forEach((el) => el.removeAttribute("hidden"));
769
+ }
770
+ // Lifecycle event
771
+ this.fireCloseEvent();
772
+ }
773
+ // ============================================================================
774
+ // EVENT HANDLERS (Phase 5 & 6)
775
+ // ============================================================================
776
+ handleStubClick(e) {
777
+ e.preventDefault();
778
+ e.stopPropagation();
779
+ if (this._disabled || this._readonly) {
780
+ return;
781
+ }
782
+ this.openDropdown();
783
+ }
784
+ handleTagClick(e) {
785
+ const target = e.target;
786
+ // Find the option element (ty-option or ty-tag)
787
+ const tag = target.closest("ty-option, ty-tag");
788
+ if (!tag || tag.hasAttribute("disabled"))
789
+ return;
790
+ e.preventDefault();
791
+ e.stopPropagation();
792
+ const tagValue = this.getTagData(tag).value;
793
+ const currentValues = this.getSelectedValues();
794
+ // Single select: picking replaces the selection and closes the popup
795
+ // (native <select> semantics — re-picking the same option just closes)
796
+ if (!this._multiple) {
797
+ this.updateComponentValue([tagValue], true, "set", tagValue);
798
+ if (this._state.mode === "mobile") {
799
+ this.closeMobileModal();
800
+ }
801
+ else {
802
+ this.closeDropdown();
803
+ }
804
+ return;
805
+ }
806
+ // Multiple: toggle — clicking a selected option deselects it
807
+ if (tag.hasAttribute("selected")) {
808
+ const newValues = currentValues.filter((v) => v !== tagValue);
809
+ this.updateComponentValue(newValues, true, "remove", tagValue);
810
+ }
811
+ else {
812
+ this.updateComponentValue([...currentValues, tagValue], true, "add", tagValue);
813
+ }
814
+ }
815
+ blockSearchClick(e) {
816
+ e.stopPropagation();
817
+ e.preventDefault();
818
+ }
819
+ handleSearchInput(e) {
820
+ const target = e.target;
821
+ const query = target.value;
822
+ // Update search state
823
+ this._state.search = query;
824
+ if (this._externalSearch) {
825
+ // External (remote) search: parent owns filtering — delegate via event.
826
+ // Tag visibility is left untouched; consumer is expected to update children.
827
+ this.dispatchSearchEvent(query);
828
+ return;
829
+ }
830
+ // Internal search: filter ALL options locally — selected ones stay visible
831
+ // (with selected styling) so they can be toggled off while searching
832
+ const allTags = this.getTagElements().map((el) => this.getTagData(el));
833
+ const filtered = this.filterTags(allTags, query);
834
+ // Update state
835
+ this._state.filteredTags = filtered;
836
+ this._state.highlightedIndex = -1;
837
+ // Update visibility
838
+ this.updateTagVisibility(filtered, allTags);
839
+ // Hide options area if no results (desktop)
840
+ this.updateOptionsVisibility(filtered.length > 0);
841
+ // Refresh mobile count + empty-state to reflect filtered visibility
842
+ this.updateMobileSelectedState();
843
+ // Clear highlights
844
+ this.clearHighlights(allTags);
845
+ }
846
+ handleKeyboard(e) {
847
+ if (!this._state.open)
848
+ return;
849
+ const shadow = this.shadowRoot;
850
+ const searchInput = shadow.querySelector(".dropdown-search-input");
851
+ const target = e.target;
852
+ // Only handle navigation keys when dropdown is open and either:
853
+ // 1. Event comes from search input, OR
854
+ // 2. Event comes from document but search input is not focused
855
+ const shouldHandle = target === searchInput || document.activeElement !== searchInput;
856
+ if (!shouldHandle)
857
+ return;
858
+ // Get current state values
859
+ const filteredTags = this._state.filteredTags;
860
+ const tagsCount = filteredTags.length;
861
+ const currentHighlightedIndex = this._state.highlightedIndex;
862
+ switch (e.key) {
863
+ case "Escape":
864
+ e.preventDefault();
865
+ e.stopPropagation();
866
+ this.closeDropdown();
867
+ break;
868
+ case "Enter":
869
+ e.preventDefault();
870
+ e.stopPropagation();
871
+ // Select highlighted tag if any
872
+ if (currentHighlightedIndex >= 0 &&
873
+ currentHighlightedIndex < tagsCount) {
874
+ const tag = filteredTags[currentHighlightedIndex];
875
+ this.handleTagClick({ target: tag.element });
876
+ }
877
+ break;
878
+ case "ArrowUp":
879
+ e.preventDefault();
880
+ e.stopPropagation();
881
+ let newIndexUp;
882
+ if (tagsCount === 0) {
883
+ newIndexUp = -1;
884
+ }
885
+ else if (currentHighlightedIndex === -1) {
886
+ // Nothing highlighted, go to last tag
887
+ newIndexUp = tagsCount - 1;
888
+ }
889
+ else if (currentHighlightedIndex === 0) {
890
+ // At first tag, wrap to last
891
+ newIndexUp = tagsCount - 1;
892
+ }
893
+ else {
894
+ // Move up one
895
+ newIndexUp = currentHighlightedIndex - 1;
896
+ }
897
+ this._state.highlightedIndex = newIndexUp;
898
+ this.highlightTag(filteredTags, newIndexUp);
899
+ break;
900
+ case "ArrowDown":
901
+ e.preventDefault();
902
+ e.stopPropagation();
903
+ let newIndexDown;
904
+ if (tagsCount === 0) {
905
+ newIndexDown = -1;
906
+ }
907
+ else if (currentHighlightedIndex === -1) {
908
+ // Nothing highlighted, go to first tag
909
+ newIndexDown = 0;
910
+ }
911
+ else if (currentHighlightedIndex === tagsCount - 1) {
912
+ // At last tag, wrap to first
913
+ newIndexDown = 0;
914
+ }
915
+ else {
916
+ // Move down one
917
+ newIndexDown = currentHighlightedIndex + 1;
918
+ }
919
+ this._state.highlightedIndex = newIndexDown;
920
+ this.highlightTag(filteredTags, newIndexDown);
921
+ break;
922
+ }
923
+ }
924
+ // ============================================================================
925
+ // SEARCH & FILTERING HELPERS (Phase 6)
926
+ // ============================================================================
927
+ /**
928
+ * Filter tags based on search query
929
+ */
930
+ filterTags(tags, query) {
931
+ if (!query || query.trim() === "") {
932
+ return tags;
933
+ }
934
+ const searchLower = query.toLowerCase();
935
+ return tags.filter(({ text }) => text.toLowerCase().includes(searchLower));
936
+ }
937
+ /**
938
+ * Update visibility of tags based on filtered list
939
+ */
940
+ updateTagVisibility(filteredTags, allTags) {
941
+ const visibleValues = new Set(filteredTags.map((tag) => tag.value));
942
+ allTags.forEach(({ value, element }) => {
943
+ if (visibleValues.has(value)) {
944
+ element.removeAttribute("hidden");
945
+ }
946
+ else {
947
+ element.setAttribute("hidden", "");
948
+ }
949
+ });
950
+ }
951
+ /**
952
+ * Show/hide the dropdown options area
953
+ */
954
+ updateOptionsVisibility(hasOptions) {
955
+ const shadow = this.shadowRoot;
956
+ const options = shadow.querySelector(".dropdown-options");
957
+ if (options) {
958
+ options.style.display = hasOptions ? "" : "none";
959
+ }
960
+ }
961
+ /**
962
+ * Clear all tag highlights
963
+ */
964
+ clearHighlights(tags) {
965
+ tags.forEach(({ element }) => {
966
+ element.removeAttribute("highlighted");
967
+ });
968
+ }
969
+ /**
970
+ * Highlight tag at specific index
971
+ */
972
+ highlightTag(tags, index) {
973
+ this.clearHighlights(tags);
974
+ if (index >= 0 && index < tags.length) {
975
+ const { element } = tags[index];
976
+ element.setAttribute("highlighted", "");
977
+ // Scroll into view
978
+ element.scrollIntoView({
979
+ behavior: "smooth",
980
+ block: "nearest",
981
+ inline: "nearest",
982
+ });
983
+ }
984
+ }
985
+ /**
986
+ * Dispatch search event for external search handling
987
+ * With optional debounce support
988
+ */
989
+ dispatchSearchEvent(query) {
990
+ // Clear existing timer
991
+ if (this._searchDebounceTimer !== null) {
992
+ clearTimeout(this._searchDebounceTimer);
993
+ this._searchDebounceTimer = null;
994
+ }
995
+ // If debounce is set, debounce the event
996
+ if (this._debounce > 0) {
997
+ this._searchDebounceTimer = window.setTimeout(() => {
998
+ this.fireSearchEvent(query);
999
+ this._searchDebounceTimer = null;
1000
+ }, this._debounce);
1001
+ }
1002
+ else {
1003
+ // Fire immediately if no debounce
1004
+ this.fireSearchEvent(query);
1005
+ }
1006
+ }
1007
+ /**
1008
+ * Fire the actual search event
1009
+ */
1010
+ fireSearchEvent(query) {
1011
+ this.dispatchEvent(new CustomEvent("search", {
1012
+ detail: {
1013
+ query,
1014
+ element: this,
1015
+ },
1016
+ bubbles: true,
1017
+ composed: true,
1018
+ }));
1019
+ }
1020
+ /**
1021
+ * Dispatch lifecycle events for popup open/close.
1022
+ * On open with external-search, also fire a `search` event with an empty
1023
+ * query so consumers have a clean hook to reset/refetch the option list.
1024
+ */
1025
+ fireOpenEvent() {
1026
+ this.dispatchEvent(new CustomEvent("open", {
1027
+ detail: { mode: this._state.mode, element: this },
1028
+ bubbles: true,
1029
+ composed: true,
1030
+ }));
1031
+ if (this._externalSearch) {
1032
+ this.fireSearchEvent("");
1033
+ }
1034
+ }
1035
+ fireCloseEvent() {
1036
+ this.dispatchEvent(new CustomEvent("close", {
1037
+ detail: { mode: this._state.mode, element: this },
1038
+ bubbles: true,
1039
+ composed: true,
1040
+ }));
1041
+ }
1042
+ // ============================================================================
1043
+ // CHANGE EVENT DISPATCHING (Phase 5)
1044
+ // ============================================================================
1045
+ /**
1046
+ * Dispatch custom change event
1047
+ */
1048
+ dispatchChangeEvent(detail) {
1049
+ this.dispatchEvent(new CustomEvent("change", {
1050
+ detail,
1051
+ bubbles: true,
1052
+ cancelable: true,
1053
+ }));
1054
+ }
1055
+ // ============================================================================
1056
+ // RENDERING
1057
+ // ============================================================================
1058
+ /**
1059
+ * Main render method (required by TyComponent)
1060
+ * Delegates to mode-specific renderer
1061
+ */
1062
+ render() {
1063
+ // Sync mode on every render so rotation/resize is picked up
1064
+ this._state.mode = isMobileTouch() ? "mobile" : "desktop";
1065
+ if (this._state.mode === "mobile") {
1066
+ this.renderMobile();
1067
+ }
1068
+ else {
1069
+ this.renderDesktop();
1070
+ }
1071
+ this.updateSearchVisibility();
1072
+ // Loading wrapper is rendered dynamically — re-apply each render
1073
+ this.applyLoadingState();
1074
+ }
1075
+ /**
1076
+ * When a consumer slots their own trigger (slot="trigger"), strip the
1077
+ * default field chrome so the trigger is styled entirely by the consumer.
1078
+ */
1079
+ setupTriggerSlot() {
1080
+ const shadow = this.shadowRoot;
1081
+ const stub = shadow.querySelector(".multiselect-stub");
1082
+ const slot = shadow.querySelector('slot[name="trigger"]');
1083
+ if (!stub || !slot)
1084
+ return;
1085
+ const sync = () => stub.classList.toggle("custom-trigger", slot.assignedElements().length > 0);
1086
+ slot.addEventListener("slotchange", sync);
1087
+ sync();
1088
+ }
1089
+ /**
1090
+ * Setup event listeners
1091
+ */
1092
+ setupEventListeners() {
1093
+ const shadow = this.shadowRoot;
1094
+ const stub = shadow.querySelector(".multiselect-stub");
1095
+ const optionsSlot = shadow.querySelector("#options-slot");
1096
+ const searchInput = shadow.querySelector(".dropdown-search-input");
1097
+ if (stub) {
1098
+ this._stubClickHandler = this.handleStubClick.bind(this);
1099
+ stub.addEventListener("click", this._stubClickHandler);
1100
+ }
1101
+ this.setupTriggerSlot();
1102
+ // Add tag click handler to slot
1103
+ if (optionsSlot) {
1104
+ this._tagClickHandler = this.handleTagClick.bind(this);
1105
+ optionsSlot.addEventListener("click", this._tagClickHandler);
1106
+ }
1107
+ // Add search input handlers
1108
+ if (searchInput) {
1109
+ this._searchInputHandler = this.handleSearchInput.bind(this);
1110
+ this._blockSearchClick = this.blockSearchClick.bind(this);
1111
+ searchInput.addEventListener("input", this._searchInputHandler);
1112
+ searchInput.addEventListener("click", this._blockSearchClick);
1113
+ // searchInput.addEventListener('blur', this._searchBlurHandler)
1114
+ }
1115
+ // Setup dialog backdrop click handler
1116
+ const dialog = shadow.querySelector(".dropdown-dialog");
1117
+ if (dialog) {
1118
+ dialog.addEventListener("click", (e) => {
1119
+ // Only close if clicking directly on the dialog (backdrop), not its children
1120
+ if (e.target === dialog) {
1121
+ this.closeDropdown();
1122
+ }
1123
+ });
1124
+ }
1125
+ // Setup keyboard handler
1126
+ this._keyboardHandler = this.handleKeyboard.bind(this);
1127
+ document.addEventListener("keydown", this._keyboardHandler);
1128
+ }
1129
+ /**
1130
+ * Build CSS class list for stub
1131
+ */
1132
+ buildStubClasses() {
1133
+ const classes = [this._size];
1134
+ if (this._disabled)
1135
+ classes.push("disabled");
1136
+ if (this._compact)
1137
+ classes.push("compact");
1138
+ return classes.join(" ");
1139
+ }
1140
+ /**
1141
+ * Render desktop mode with dialog
1142
+ */
1143
+ renderDesktop() {
1144
+ const shadow = this.shadowRoot;
1145
+ // Only set innerHTML and setup listeners if container doesn't exist
1146
+ if (!shadow.querySelector(".multiselect-container")) {
1147
+ const stubClasses = this.buildStubClasses();
1148
+ const labelHtml = this._label
1149
+ ? `
1150
+ <label class="ty-field-label">
1151
+ ${this._label}
1152
+ ${this._required ? `<span class="required-icon">${REQUIRED_ICON_SVG}</span>` : ""}
1153
+ </label>
1154
+ `
1155
+ : "";
1156
+ const searchPlaceholder = "Search...";
1157
+ shadow.innerHTML = `
1158
+ <div class="multiselect-container dropdown-mode-desktop">
1159
+ ${labelHtml}
1160
+ <div class="dropdown-wrapper">
1161
+ <div class="dropdown-stub multiselect-stub ${stubClasses}"
1162
+ ${this._disabled ? "disabled" : ""}>
1163
+ <slot name="trigger">
1164
+ <slot name="start"></slot>
1165
+ <slot name="selected"></slot>
1166
+ <span class="dropdown-placeholder">${this._placeholder}</span>
1167
+ <span class="select-count" hidden></span>
1168
+ <slot name="end"></slot>
1169
+ <div class="dropdown-chevron">
1170
+ ${CHEVRON_DOWN_SVG}
1171
+ </div>
1172
+ </slot>
1173
+ </div>
1174
+ <dialog class="dropdown-dialog">
1175
+ <div class="dropdown-header">
1176
+ <div class="dropdown-search-icon" aria-hidden="true">
1177
+ ${SEARCH_ICON_SVG}
1178
+ </div>
1179
+ <input
1180
+ class="dropdown-search-input ${this._size}"
1181
+ type="text"
1182
+ placeholder="${searchPlaceholder}"
1183
+ ${this._disabled ? "disabled" : ""}
1184
+ />
1185
+ <div class="dropdown-search-chevron">
1186
+ ${CHEVRON_DOWN_SVG}
1187
+ </div>
1188
+ </div>
1189
+ <div class="dropdown-options-wrapper">
1190
+ <div class="dropdown-options">
1191
+ <slot id="options-slot"></slot>
1192
+ </div>
1193
+ <div class="dropdown-loading" aria-hidden="true">
1194
+ <slot name="loading">
1195
+ <span class="dropdown-loading-spinner"></span>
1196
+ <span class="dropdown-loading-text">Searching…</span>
1197
+ </slot>
1198
+ </div>
1199
+ </div>
1200
+ </dialog>
1201
+ </div>
1202
+ </div>
1203
+ `;
1204
+ // Setup event listeners ONCE
1205
+ this.setupEventListeners();
1206
+ // Don't initialize here - will be done in connectedCallback
1207
+ // after properties are set and children are available
1208
+ }
1209
+ // Always update placeholder visibility on re-render
1210
+ this.updateSelectionDisplay();
1211
+ }
1212
+ /**
1213
+ * Render mobile mode with full-screen modal
1214
+ * Following dropdown.ts mobile structure
1215
+ */
1216
+ renderMobile() {
1217
+ const shadow = this.shadowRoot;
1218
+ // Only set innerHTML and setup listeners if container doesn't exist
1219
+ if (!shadow.querySelector(".multiselect-container")) {
1220
+ const stubClasses = this.buildStubClasses();
1221
+ const labelHtml = this._label
1222
+ ? `
1223
+ <label class="ty-field-label">
1224
+ ${this._label}
1225
+ ${this._required ? `<span class="required-icon">${REQUIRED_ICON_SVG}</span>` : ""}
1226
+ </label>
1227
+ `
1228
+ : "";
1229
+ // Close button SVG (X icon)
1230
+ const closeButtonSvg = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1231
+ <line x1="18" y1="6" x2="6" y2="18"></line>
1232
+ <line x1="6" y1="6" x2="18" y2="18"></line>
1233
+ </svg>`;
1234
+ // Search placeholder: "Search <label>..." or just "Search..."
1235
+ const searchPlaceholder = this._label
1236
+ ? `Search ${this._label}...`
1237
+ : "Search...";
1238
+ // Search is always available — only the mode (internal vs external) varies.
1239
+ const searchHeaderHtml = `
1240
+ <div class="mobile-search-header">
1241
+ ${this._label ? `<span class="mobile-header-label">${this._label}</span>` : ""}
1242
+ <div class="mobile-header-content">
1243
+ <div class="dropdown-search-icon" aria-hidden="true">
1244
+ ${SEARCH_ICON_SVG}
1245
+ </div>
1246
+ <input
1247
+ class="mobile-search-input ${this._size}"
1248
+ type="text"
1249
+ placeholder="${searchPlaceholder}"
1250
+ ${this._disabled ? "disabled" : ""}
1251
+ />
1252
+ <button class="mobile-close-button" type="button" aria-label="Close">
1253
+ ${closeButtonSvg}
1254
+ </button>
1255
+ </div>
1256
+ </div>
1257
+ `;
1258
+ shadow.innerHTML = `
1259
+ <div class="multiselect-container dropdown-mode-mobile">
1260
+ ${labelHtml}
1261
+ <div class="dropdown-wrapper">
1262
+ <div class="dropdown-stub multiselect-stub ${stubClasses}"
1263
+ ${this._disabled ? "disabled" : ""}>
1264
+ <slot name="trigger">
1265
+ <slot name="start"></slot>
1266
+ <slot name="selected"></slot>
1267
+ <span class="dropdown-placeholder">${this._placeholder}</span>
1268
+ <span class="select-count" hidden></span>
1269
+ <slot name="end"></slot>
1270
+ <div class="dropdown-chevron">
1271
+ ${CHEVRON_DOWN_SVG}
1272
+ </div>
1273
+ </slot>
1274
+ </div>
1275
+
1276
+ <dialog class="mobile-dialog">
1277
+ <div class="mobile-dialog-content">
1278
+
1279
+ <!-- HEADER (matches dropdown.ts) -->
1280
+ ${searchHeaderHtml}
1281
+
1282
+ <!-- BODY: options list with toggleable selected state -->
1283
+ <div class="mobile-body">
1284
+
1285
+ <!-- AVAILABLE LIST (always visible, takes remaining space) -->
1286
+ <div class="mobile-available-section" data-empty="false">
1287
+ <div class="section-header">
1288
+ <span class="section-title">${this._availableLabel}</span>
1289
+ </div>
1290
+ <div class="section-content dropdown-options-wrapper">
1291
+ <slot id="options-slot"></slot>
1292
+ <div class="empty-state">${this._noOptionsMessage}</div>
1293
+ <div class="dropdown-loading" aria-hidden="true">
1294
+ <slot name="loading">
1295
+ <span class="dropdown-loading-spinner"></span>
1296
+ <span class="dropdown-loading-text">Searching…</span>
1297
+ </slot>
1298
+ </div>
1299
+ </div>
1300
+ </div>
1301
+
1302
+ </div>
1303
+ </div>
1304
+ </dialog>
1305
+ </div>
1306
+ </div>
1307
+ `;
1308
+ // Setup event listeners ONCE
1309
+ this.setupMobileEventListeners();
1310
+ }
1311
+ // Always update placeholder visibility
1312
+ this.updateSelectionDisplay();
1313
+ }
1314
+ /**
1315
+ * Setup event listeners for mobile mode
1316
+ * Using <dialog> element - backdrop clicks handled natively
1317
+ */
1318
+ setupMobileEventListeners() {
1319
+ const shadow = this.shadowRoot;
1320
+ const stub = shadow.querySelector(".multiselect-stub");
1321
+ const optionsSlot = shadow.querySelector("#options-slot");
1322
+ const searchInput = shadow.querySelector(".mobile-search-input");
1323
+ const closeButton = shadow.querySelector(".mobile-close-button");
1324
+ const dialog = shadow.querySelector(".mobile-dialog");
1325
+ if (stub) {
1326
+ stub.addEventListener("click", (e) => this.handleMobileStubClick(e));
1327
+ }
1328
+ this.setupTriggerSlot();
1329
+ // Add tag click handler to slot
1330
+ if (optionsSlot) {
1331
+ optionsSlot.addEventListener("click", (e) => this.handleMobileTagClick(e));
1332
+ }
1333
+ // Add search input handlers (if searchable)
1334
+ if (searchInput) {
1335
+ searchInput.addEventListener("input", (e) => this.handleSearchInput(e));
1336
+ }
1337
+ // Close button click
1338
+ if (closeButton) {
1339
+ closeButton.addEventListener("click", () => this.closeMobileModal());
1340
+ }
1341
+ // Backdrop click to close (native dialog behavior)
1342
+ if (dialog) {
1343
+ dialog.addEventListener("click", (e) => {
1344
+ // Only close if clicking directly on the dialog element (backdrop)
1345
+ // Not if clicking on its children (dialog-content)
1346
+ if (e.target === dialog) {
1347
+ this.closeMobileModal();
1348
+ }
1349
+ });
1350
+ // Also handle Escape key via cancel event
1351
+ dialog.addEventListener("cancel", (e) => {
1352
+ e.preventDefault(); // Prevent default to handle it our way
1353
+ this.closeMobileModal();
1354
+ });
1355
+ }
1356
+ }
1357
+ /**
1358
+ * Handle mobile stub click - open modal
1359
+ */
1360
+ handleMobileStubClick(e) {
1361
+ e.preventDefault();
1362
+ e.stopPropagation();
1363
+ if (this._disabled || this._readonly) {
1364
+ return;
1365
+ }
1366
+ this.openMobileModal();
1367
+ }
1368
+ /**
1369
+ * Handle mobile tag click - select and potentially close
1370
+ */
1371
+ handleMobileTagClick(e) {
1372
+ // Use the same tag click handler as desktop
1373
+ // It already handles mobile mode for auto-close
1374
+ this.handleTagClick(e);
1375
+ }
1376
+ /**
1377
+ * Update mobile options section state (count, empty state)
1378
+ */
1379
+ updateMobileSelectedState() {
1380
+ if (this._state.mode !== "mobile")
1381
+ return;
1382
+ const shadow = this.shadowRoot;
1383
+ const availableSection = shadow.querySelector(".mobile-available-section");
1384
+ if (availableSection) {
1385
+ // Count *visible* options — options hidden by search filtering count as 0
1386
+ const visibleAvailable = this.getTagElements().filter((tag) => !tag.hasAttribute("hidden")).length;
1387
+ availableSection.setAttribute("data-empty", String(visibleAvailable === 0));
1388
+ // Update available header count
1389
+ const availableTitleSpan = shadow.querySelector(".mobile-available-section .section-title");
1390
+ if (availableTitleSpan) {
1391
+ availableTitleSpan.textContent = `${this._availableLabel} (${visibleAvailable})`;
1392
+ }
1393
+ }
1394
+ }
1395
+ /**
1396
+ * Update selection display: count badge next to the placeholder
1397
+ */
1398
+ /**
1399
+ * Should the popup show a search row? external-search always (the input is
1400
+ * the mechanism); searchable always/never wins otherwise; 'auto' shows it
1401
+ * only when the option list is long enough to need filtering.
1402
+ */
1403
+ shouldShowSearch() {
1404
+ if (this._externalSearch)
1405
+ return true;
1406
+ if (this._searchable === "always")
1407
+ return true;
1408
+ if (this._searchable === "never")
1409
+ return false;
1410
+ return this.getTagElements().length > SEARCH_AUTO_THRESHOLD;
1411
+ }
1412
+ /**
1413
+ * Toggle the search row (desktop header / mobile input) per shouldShowSearch.
1414
+ */
1415
+ updateSearchVisibility() {
1416
+ const shadow = this.shadowRoot;
1417
+ if (!shadow)
1418
+ return;
1419
+ const show = this.shouldShowSearch();
1420
+ const header = shadow.querySelector(".dropdown-header");
1421
+ if (header)
1422
+ header.hidden = !show;
1423
+ // Mobile keeps its header (label + close button); only the input+icon hide
1424
+ const mobileInput = shadow.querySelector(".mobile-search-input");
1425
+ if (mobileInput)
1426
+ mobileInput.hidden = !show;
1427
+ const mobileIcon = shadow.querySelector(".mobile-header-content .dropdown-search-icon");
1428
+ if (mobileIcon)
1429
+ mobileIcon.hidden = !show;
1430
+ }
1431
+ /**
1432
+ * Selection summary appropriate to cardinality × skin:
1433
+ * - single (field or compact): the selected OPTION itself — cloned into
1434
+ * slot="selected" so rich HTML options display intact (dropdown parity);
1435
+ * placeholder when empty
1436
+ * - multiple + field: joined labels (ellipsis via CSS), or placeholder
1437
+ * - multiple + compact: placeholder + count badge
1438
+ */
1439
+ updateSelectionDisplay() {
1440
+ const shadow = this.shadowRoot;
1441
+ const stub = shadow.querySelector(".multiselect-stub");
1442
+ if (!stub)
1443
+ return;
1444
+ // Selection comes from STATE, not from scanning option elements —
1445
+ // external search replaces the option children, and the selection must
1446
+ // survive even when the current results don't include the selected value.
1447
+ const values = this._state.selectedValues;
1448
+ const count = values.length;
1449
+ stub.classList.toggle("has-selection", count > 0);
1450
+ stub.classList.toggle("compact", this._compact);
1451
+ const badge = stub.querySelector(".select-count");
1452
+ if (badge) {
1453
+ badge.textContent = String(count);
1454
+ badge.hidden = count === 0 || !this._multiple || !this._compact;
1455
+ }
1456
+ // Single select: project the selected option into the stub as a clone
1457
+ // (rich HTML content survives — same mechanism as ty-dropdown).
1458
+ const wantClone = !this._multiple && count > 0 ? values[0] : null;
1459
+ this.syncSelectedClone(wantClone);
1460
+ stub.classList.toggle("has-clone", wantClone !== null);
1461
+ const textEl = stub.querySelector(".dropdown-placeholder");
1462
+ if (textEl) {
1463
+ // The clone replaces the text display entirely in single mode
1464
+ textEl.hidden = wantClone !== null;
1465
+ const showLabels = count > 0 && this._multiple && !this._compact;
1466
+ if (showLabels) {
1467
+ textEl.textContent = this.getSelectedItems(values)
1468
+ .map((i) => i.label)
1469
+ .join(", ");
1470
+ textEl.classList.remove("placeholder-shown");
1471
+ }
1472
+ else {
1473
+ textEl.textContent = this._placeholder;
1474
+ textEl.classList.add("placeholder-shown");
1475
+ }
1476
+ }
1477
+ }
1478
+ /**
1479
+ * Keep exactly one display clone (slot="selected", marked `cloned`) in our
1480
+ * light DOM matching the given value — or none. The clone never counts as
1481
+ * an option (getTagElements filters [cloned]) and ty-option's own
1482
+ * :host([cloned]) styling strips its list-row chrome.
1483
+ */
1484
+ syncSelectedClone(value) {
1485
+ const existing = this.querySelector(':scope > [cloned][slot="selected"]');
1486
+ if (existing?.getAttribute("value") === value) {
1487
+ this._selectedClone = existing;
1488
+ return; // already right
1489
+ }
1490
+ existing?.remove();
1491
+ if (value === null) {
1492
+ this._selectedClone = null;
1493
+ return;
1494
+ }
1495
+ const option = this.getTagElements().find((el) => this.getTagData(el).value === value);
1496
+ if (option) {
1497
+ const clone = option.cloneNode(true);
1498
+ clone.removeAttribute("selected"); // no tick/selected styling in the stub
1499
+ clone.setAttribute("slot", "selected");
1500
+ clone.setAttribute("cloned", "true");
1501
+ this.appendChild(clone);
1502
+ this._selectedClone = clone;
1503
+ }
1504
+ else if (this._selectedClone?.getAttribute("value") === value) {
1505
+ // The option list no longer contains the selection (external search
1506
+ // swapped the children — possibly wiping our clone with them). Restore
1507
+ // the saved display clone so the selection stays visible.
1508
+ const restored = this._selectedClone.cloneNode(true);
1509
+ this.appendChild(restored);
1510
+ this._selectedClone = restored;
1511
+ }
1512
+ }
1513
+ // ============================================================================
1514
+ // PUBLIC API - Getters/Setters
1515
+ // ============================================================================
1516
+ /**
1517
+ * Deselect a value programmatically WITH a change event.
1518
+ * Used by out-of-band chip displays (ty-selected-tags) so dismissing a chip
1519
+ * fires change like any in-popup interaction (HTMX hx-trigger="change" etc.).
1520
+ */
1521
+ deselectValue(value) {
1522
+ const current = this.getSelectedValues();
1523
+ if (!current.includes(value))
1524
+ return;
1525
+ this.updateComponentValue(current.filter((v) => v !== value), true, "remove", value);
1526
+ }
1527
+ get value() {
1528
+ // Always read from DOM - tags with 'selected' attribute are source of truth
1529
+ return this.getSelectedValues().join(",");
1530
+ }
1531
+ set value(val) {
1532
+ this.setProperty("value", val);
1533
+ }
1534
+ get name() {
1535
+ return this.getProperty("name");
1536
+ }
1537
+ set name(val) {
1538
+ this.setProperty("name", val);
1539
+ }
1540
+ get multiple() {
1541
+ return this.getProperty("multiple");
1542
+ }
1543
+ set multiple(value) {
1544
+ this.setProperty("multiple", value);
1545
+ }
1546
+ get compact() {
1547
+ return this.getProperty("compact");
1548
+ }
1549
+ set compact(value) {
1550
+ this.setProperty("compact", value);
1551
+ }
1552
+ get placeholder() {
1553
+ return this.getProperty("placeholder");
1554
+ }
1555
+ set placeholder(val) {
1556
+ this.setProperty("placeholder", val);
1557
+ }
1558
+ get label() {
1559
+ return this.getProperty("label");
1560
+ }
1561
+ set label(val) {
1562
+ this.setProperty("label", val);
1563
+ }
1564
+ get disabled() {
1565
+ return this.getProperty("disabled");
1566
+ }
1567
+ set disabled(value) {
1568
+ this.setProperty("disabled", value);
1569
+ }
1570
+ get loading() {
1571
+ return this.getProperty("loading");
1572
+ }
1573
+ set loading(value) {
1574
+ this.setProperty("loading", value);
1575
+ }
1576
+ get readonly() {
1577
+ return this.getProperty("readonly");
1578
+ }
1579
+ set readonly(value) {
1580
+ this.setProperty("readonly", value);
1581
+ }
1582
+ get required() {
1583
+ return this.getProperty("required");
1584
+ }
1585
+ set required(value) {
1586
+ this.setProperty("required", value);
1587
+ }
1588
+ get searchable() {
1589
+ return this.getProperty("searchable");
1590
+ }
1591
+ set searchable(value) {
1592
+ this.setProperty("searchable", value);
1593
+ }
1594
+ get externalSearch() {
1595
+ return this.getProperty("externalSearch");
1596
+ }
1597
+ set externalSearch(value) {
1598
+ this.setProperty("externalSearch", value);
1599
+ }
1600
+ get debounce() {
1601
+ return this.getProperty("debounce");
1602
+ }
1603
+ set debounce(value) {
1604
+ const numValue = typeof value === "string" ? parseInt(value, 10) : value;
1605
+ this.setProperty("debounce", numValue);
1606
+ }
1607
+ get size() {
1608
+ return this.getProperty("size");
1609
+ }
1610
+ set size(value) {
1611
+ this.setProperty("size", value);
1612
+ }
1613
+ get form() {
1614
+ return this._internals.form;
1615
+ }
1616
+ }
1617
+ // ============================================================================
1618
+ // PROPERTY CONFIGURATION - Declarative property lifecycle
1619
+ // ============================================================================
1620
+ TySelect.properties = {
1621
+ value: {
1622
+ type: "string",
1623
+ visual: true,
1624
+ formValue: true,
1625
+ emitChange: false,
1626
+ default: "",
1627
+ coerce: (v) => {
1628
+ // Handle array input (from React, Reagent, etc.)
1629
+ if (Array.isArray(v)) {
1630
+ return v.join(",");
1631
+ }
1632
+ // Handle null/undefined
1633
+ if (v === null || v === undefined) {
1634
+ return "";
1635
+ }
1636
+ // String already
1637
+ return String(v);
1638
+ },
1639
+ },
1640
+ name: {
1641
+ type: "string",
1642
+ default: "",
1643
+ },
1644
+ // Cardinality — native <select> semantics: absent = single select
1645
+ multiple: {
1646
+ type: "boolean",
1647
+ visual: true,
1648
+ default: false,
1649
+ },
1650
+ // Skin — absent = form field (matches ty-input); present = compact
1651
+ // content-hugging trigger (toolbars, filter bars)
1652
+ compact: {
1653
+ type: "boolean",
1654
+ visual: true,
1655
+ default: false,
1656
+ },
1657
+ placeholder: {
1658
+ type: "string",
1659
+ visual: true,
1660
+ default: "Select...",
1661
+ },
1662
+ label: {
1663
+ type: "string",
1664
+ visual: true,
1665
+ default: "",
1666
+ },
1667
+ disabled: {
1668
+ type: "boolean",
1669
+ visual: true,
1670
+ default: false,
1671
+ },
1672
+ readonly: {
1673
+ type: "boolean",
1674
+ visual: true,
1675
+ default: false,
1676
+ },
1677
+ required: {
1678
+ type: "boolean",
1679
+ visual: true,
1680
+ default: false,
1681
+ },
1682
+ externalSearch: {
1683
+ type: "boolean",
1684
+ visual: true,
1685
+ default: false,
1686
+ aliases: { "external-search": true },
1687
+ },
1688
+ // Search row visibility: 'auto' (default) shows it only for long option
1689
+ // lists; searchable / searchable="true" forces it on, searchable="false"
1690
+ // off. external-search always shows it (the input IS the mechanism).
1691
+ searchable: {
1692
+ type: "string",
1693
+ visual: true,
1694
+ default: "auto",
1695
+ coerce: (v) => {
1696
+ if (v === true || v === "" || v === "true")
1697
+ return "always";
1698
+ if (v === false || v === "false")
1699
+ return "never";
1700
+ return "auto";
1701
+ },
1702
+ },
1703
+ size: {
1704
+ type: "string",
1705
+ visual: true,
1706
+ default: "md",
1707
+ validate: (v) => ["sm", "md", "lg"].includes(v),
1708
+ coerce: (v) => {
1709
+ if (!["sm", "md", "lg"].includes(v)) {
1710
+ console.warn(`[ty-select] Invalid size. Using md.`);
1711
+ return "md";
1712
+ }
1713
+ return v;
1714
+ },
1715
+ },
1716
+ debounce: {
1717
+ type: "number",
1718
+ default: 0,
1719
+ validate: (v) => v >= 0 && v <= 5000,
1720
+ coerce: (v) => {
1721
+ const num = Number(v);
1722
+ if (isNaN(num))
1723
+ return 0;
1724
+ return Math.max(0, Math.min(5000, num));
1725
+ },
1726
+ },
1727
+ "available-label": {
1728
+ type: "string",
1729
+ visual: true,
1730
+ default: "Available",
1731
+ },
1732
+ "no-options-message": {
1733
+ type: "string",
1734
+ visual: true,
1735
+ default: "No options available",
1736
+ },
1737
+ loading: {
1738
+ type: "boolean",
1739
+ visual: true,
1740
+ default: false,
1741
+ },
1742
+ };
1743
+ // Register the custom element
1744
+ if (!customElements.get("ty-select")) {
1745
+ customElements.define("ty-select", TySelect);
1746
+ }
1747
+ //# sourceMappingURL=select.js.map