tyrell-components 1.0.0-RC11 → 1.0.0-RC13

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