tyrell-components 1.0.0-RC10

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 (344) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +221 -0
  3. package/css/tyrell-brand.css +767 -0
  4. package/css/tyrell.css +1679 -0
  5. package/dist/tyrell-brand.css +767 -0
  6. package/dist/tyrell.css +1679 -0
  7. package/dist/tyrell.js +2 -0
  8. package/lib/base/ty-component.d.ts +133 -0
  9. package/lib/base/ty-component.d.ts.map +1 -0
  10. package/lib/base/ty-component.js +297 -0
  11. package/lib/base/ty-component.js.map +1 -0
  12. package/lib/components/button.d.ts +135 -0
  13. package/lib/components/button.d.ts.map +1 -0
  14. package/lib/components/button.js +277 -0
  15. package/lib/components/button.js.map +1 -0
  16. package/lib/components/calendar-month.d.ts +132 -0
  17. package/lib/components/calendar-month.d.ts.map +1 -0
  18. package/lib/components/calendar-month.js +440 -0
  19. package/lib/components/calendar-month.js.map +1 -0
  20. package/lib/components/calendar-navigation.d.ts +137 -0
  21. package/lib/components/calendar-navigation.d.ts.map +1 -0
  22. package/lib/components/calendar-navigation.js +366 -0
  23. package/lib/components/calendar-navigation.js.map +1 -0
  24. package/lib/components/calendar.d.ts +166 -0
  25. package/lib/components/calendar.d.ts.map +1 -0
  26. package/lib/components/calendar.js +774 -0
  27. package/lib/components/calendar.js.map +1 -0
  28. package/lib/components/checkbox.d.ts +189 -0
  29. package/lib/components/checkbox.d.ts.map +1 -0
  30. package/lib/components/checkbox.js +400 -0
  31. package/lib/components/checkbox.js.map +1 -0
  32. package/lib/components/copy.d.ts +180 -0
  33. package/lib/components/copy.d.ts.map +1 -0
  34. package/lib/components/copy.js +393 -0
  35. package/lib/components/copy.js.map +1 -0
  36. package/lib/components/date-picker.d.ts +379 -0
  37. package/lib/components/date-picker.d.ts.map +1 -0
  38. package/lib/components/date-picker.js +1586 -0
  39. package/lib/components/date-picker.js.map +1 -0
  40. package/lib/components/dropdown.d.ts +424 -0
  41. package/lib/components/dropdown.d.ts.map +1 -0
  42. package/lib/components/dropdown.js +1640 -0
  43. package/lib/components/dropdown.js.map +1 -0
  44. package/lib/components/file-upload.d.ts +121 -0
  45. package/lib/components/file-upload.d.ts.map +1 -0
  46. package/lib/components/file-upload.js +441 -0
  47. package/lib/components/file-upload.js.map +1 -0
  48. package/lib/components/icon.d.ts +118 -0
  49. package/lib/components/icon.d.ts.map +1 -0
  50. package/lib/components/icon.js +245 -0
  51. package/lib/components/icon.js.map +1 -0
  52. package/lib/components/input.d.ts +270 -0
  53. package/lib/components/input.d.ts.map +1 -0
  54. package/lib/components/input.js +721 -0
  55. package/lib/components/input.js.map +1 -0
  56. package/lib/components/modal.d.ts +78 -0
  57. package/lib/components/modal.d.ts.map +1 -0
  58. package/lib/components/modal.js +497 -0
  59. package/lib/components/modal.js.map +1 -0
  60. package/lib/components/multiselect.d.ts +397 -0
  61. package/lib/components/multiselect.d.ts.map +1 -0
  62. package/lib/components/multiselect.js +1595 -0
  63. package/lib/components/multiselect.js.map +1 -0
  64. package/lib/components/option.d.ts +66 -0
  65. package/lib/components/option.d.ts.map +1 -0
  66. package/lib/components/option.js +314 -0
  67. package/lib/components/option.js.map +1 -0
  68. package/lib/components/popup.d.ts +43 -0
  69. package/lib/components/popup.d.ts.map +1 -0
  70. package/lib/components/popup.js +380 -0
  71. package/lib/components/popup.js.map +1 -0
  72. package/lib/components/radio.d.ts +198 -0
  73. package/lib/components/radio.d.ts.map +1 -0
  74. package/lib/components/radio.js +437 -0
  75. package/lib/components/radio.js.map +1 -0
  76. package/lib/components/resize-observer.d.ts +48 -0
  77. package/lib/components/resize-observer.d.ts.map +1 -0
  78. package/lib/components/resize-observer.js +108 -0
  79. package/lib/components/resize-observer.js.map +1 -0
  80. package/lib/components/scroll-container.d.ts +51 -0
  81. package/lib/components/scroll-container.d.ts.map +1 -0
  82. package/lib/components/scroll-container.js +239 -0
  83. package/lib/components/scroll-container.js.map +1 -0
  84. package/lib/components/step.d.ts +26 -0
  85. package/lib/components/step.d.ts.map +1 -0
  86. package/lib/components/step.js +75 -0
  87. package/lib/components/step.js.map +1 -0
  88. package/lib/components/switch.d.ts +111 -0
  89. package/lib/components/switch.d.ts.map +1 -0
  90. package/lib/components/switch.js +240 -0
  91. package/lib/components/switch.js.map +1 -0
  92. package/lib/components/tab.d.ts +23 -0
  93. package/lib/components/tab.d.ts.map +1 -0
  94. package/lib/components/tab.js +76 -0
  95. package/lib/components/tab.js.map +1 -0
  96. package/lib/components/tabs.d.ts +93 -0
  97. package/lib/components/tabs.d.ts.map +1 -0
  98. package/lib/components/tabs.js +653 -0
  99. package/lib/components/tabs.js.map +1 -0
  100. package/lib/components/tag.d.ts +144 -0
  101. package/lib/components/tag.d.ts.map +1 -0
  102. package/lib/components/tag.js +316 -0
  103. package/lib/components/tag.js.map +1 -0
  104. package/lib/components/textarea.d.ts +241 -0
  105. package/lib/components/textarea.d.ts.map +1 -0
  106. package/lib/components/textarea.js +585 -0
  107. package/lib/components/textarea.js.map +1 -0
  108. package/lib/components/tooltip.d.ts +40 -0
  109. package/lib/components/tooltip.d.ts.map +1 -0
  110. package/lib/components/tooltip.js +439 -0
  111. package/lib/components/tooltip.js.map +1 -0
  112. package/lib/components/wizard.d.ts +86 -0
  113. package/lib/components/wizard.d.ts.map +1 -0
  114. package/lib/components/wizard.js +636 -0
  115. package/lib/components/wizard.js.map +1 -0
  116. package/lib/icons/fontawesome/brands.d.ts +557 -0
  117. package/lib/icons/fontawesome/brands.d.ts.map +1 -0
  118. package/lib/icons/fontawesome/brands.js +557 -0
  119. package/lib/icons/fontawesome/brands.js.map +1 -0
  120. package/lib/icons/fontawesome/regular.d.ts +281 -0
  121. package/lib/icons/fontawesome/regular.d.ts.map +1 -0
  122. package/lib/icons/fontawesome/regular.js +281 -0
  123. package/lib/icons/fontawesome/regular.js.map +1 -0
  124. package/lib/icons/fontawesome/solid.d.ts +1992 -0
  125. package/lib/icons/fontawesome/solid.d.ts.map +1 -0
  126. package/lib/icons/fontawesome/solid.js +1992 -0
  127. package/lib/icons/fontawesome/solid.js.map +1 -0
  128. package/lib/icons/heroicons/micro.d.ts +324 -0
  129. package/lib/icons/heroicons/micro.d.ts.map +1 -0
  130. package/lib/icons/heroicons/micro.js +1032 -0
  131. package/lib/icons/heroicons/micro.js.map +1 -0
  132. package/lib/icons/heroicons/mini.d.ts +332 -0
  133. package/lib/icons/heroicons/mini.d.ts.map +1 -0
  134. package/lib/icons/heroicons/mini.js +1038 -0
  135. package/lib/icons/heroicons/mini.js.map +1 -0
  136. package/lib/icons/heroicons/outline.d.ts +332 -0
  137. package/lib/icons/heroicons/outline.d.ts.map +1 -0
  138. package/lib/icons/heroicons/outline.js +993 -0
  139. package/lib/icons/heroicons/outline.js.map +1 -0
  140. package/lib/icons/heroicons/solid.d.ts +332 -0
  141. package/lib/icons/heroicons/solid.d.ts.map +1 -0
  142. package/lib/icons/heroicons/solid.js +1063 -0
  143. package/lib/icons/heroicons/solid.js.map +1 -0
  144. package/lib/icons/lucide.d.ts +1872 -0
  145. package/lib/icons/lucide.d.ts.map +1 -0
  146. package/lib/icons/lucide.js +28212 -0
  147. package/lib/icons/lucide.js.map +1 -0
  148. package/lib/icons/material/filled.d.ts +2180 -0
  149. package/lib/icons/material/filled.d.ts.map +1 -0
  150. package/lib/icons/material/filled.js +14003 -0
  151. package/lib/icons/material/filled.js.map +1 -0
  152. package/lib/icons/material/outlined.d.ts +2142 -0
  153. package/lib/icons/material/outlined.d.ts.map +1 -0
  154. package/lib/icons/material/outlined.js +14545 -0
  155. package/lib/icons/material/outlined.js.map +1 -0
  156. package/lib/icons/material/round.d.ts +2147 -0
  157. package/lib/icons/material/round.d.ts.map +1 -0
  158. package/lib/icons/material/round.js +14779 -0
  159. package/lib/icons/material/round.js.map +1 -0
  160. package/lib/icons/material/sharp.d.ts +2147 -0
  161. package/lib/icons/material/sharp.d.ts.map +1 -0
  162. package/lib/icons/material/sharp.js +14189 -0
  163. package/lib/icons/material/sharp.js.map +1 -0
  164. package/lib/icons/material/two-tone.d.ts +2185 -0
  165. package/lib/icons/material/two-tone.d.ts.map +1 -0
  166. package/lib/icons/material/two-tone.js +17152 -0
  167. package/lib/icons/material/two-tone.js.map +1 -0
  168. package/lib/index.d.ts +86 -0
  169. package/lib/index.d.ts.map +1 -0
  170. package/lib/index.js +78 -0
  171. package/lib/index.js.map +1 -0
  172. package/lib/styles/button.d.ts +14 -0
  173. package/lib/styles/button.d.ts.map +1 -0
  174. package/lib/styles/button.js +498 -0
  175. package/lib/styles/button.js.map +1 -0
  176. package/lib/styles/calendar-month.d.ts +6 -0
  177. package/lib/styles/calendar-month.d.ts.map +1 -0
  178. package/lib/styles/calendar-month.js +275 -0
  179. package/lib/styles/calendar-month.js.map +1 -0
  180. package/lib/styles/calendar-navigation.d.ts +6 -0
  181. package/lib/styles/calendar-navigation.d.ts.map +1 -0
  182. package/lib/styles/calendar-navigation.js +143 -0
  183. package/lib/styles/calendar-navigation.js.map +1 -0
  184. package/lib/styles/calendar.d.ts +6 -0
  185. package/lib/styles/calendar.d.ts.map +1 -0
  186. package/lib/styles/calendar.js +28 -0
  187. package/lib/styles/calendar.js.map +1 -0
  188. package/lib/styles/checkbox.d.ts +9 -0
  189. package/lib/styles/checkbox.d.ts.map +1 -0
  190. package/lib/styles/checkbox.js +19 -0
  191. package/lib/styles/checkbox.js.map +1 -0
  192. package/lib/styles/copy.d.ts +7 -0
  193. package/lib/styles/copy.d.ts.map +1 -0
  194. package/lib/styles/copy.js +94 -0
  195. package/lib/styles/copy.js.map +1 -0
  196. package/lib/styles/custom-scrollbar.d.ts +6 -0
  197. package/lib/styles/custom-scrollbar.d.ts.map +1 -0
  198. package/lib/styles/custom-scrollbar.js +157 -0
  199. package/lib/styles/custom-scrollbar.js.map +1 -0
  200. package/lib/styles/date-picker.d.ts +6 -0
  201. package/lib/styles/date-picker.d.ts.map +1 -0
  202. package/lib/styles/date-picker.js +438 -0
  203. package/lib/styles/date-picker.js.map +1 -0
  204. package/lib/styles/dropdown.d.ts +12 -0
  205. package/lib/styles/dropdown.d.ts.map +1 -0
  206. package/lib/styles/dropdown.js +1081 -0
  207. package/lib/styles/dropdown.js.map +1 -0
  208. package/lib/styles/file-upload.d.ts +2 -0
  209. package/lib/styles/file-upload.d.ts.map +1 -0
  210. package/lib/styles/file-upload.js +241 -0
  211. package/lib/styles/file-upload.js.map +1 -0
  212. package/lib/styles/icon.d.ts +6 -0
  213. package/lib/styles/icon.d.ts.map +1 -0
  214. package/lib/styles/icon.js +241 -0
  215. package/lib/styles/icon.js.map +1 -0
  216. package/lib/styles/input.d.ts +7 -0
  217. package/lib/styles/input.d.ts.map +1 -0
  218. package/lib/styles/input.js +685 -0
  219. package/lib/styles/input.js.map +1 -0
  220. package/lib/styles/modal.d.ts +8 -0
  221. package/lib/styles/modal.d.ts.map +1 -0
  222. package/lib/styles/modal.js +134 -0
  223. package/lib/styles/modal.js.map +1 -0
  224. package/lib/styles/multiselect.d.ts +6 -0
  225. package/lib/styles/multiselect.d.ts.map +1 -0
  226. package/lib/styles/multiselect.js +825 -0
  227. package/lib/styles/multiselect.js.map +1 -0
  228. package/lib/styles/option.d.ts +6 -0
  229. package/lib/styles/option.d.ts.map +1 -0
  230. package/lib/styles/option.js +116 -0
  231. package/lib/styles/option.js.map +1 -0
  232. package/lib/styles/popup.d.ts +8 -0
  233. package/lib/styles/popup.d.ts.map +1 -0
  234. package/lib/styles/popup.js +95 -0
  235. package/lib/styles/popup.js.map +1 -0
  236. package/lib/styles/radio.d.ts +8 -0
  237. package/lib/styles/radio.d.ts.map +1 -0
  238. package/lib/styles/radio.js +160 -0
  239. package/lib/styles/radio.js.map +1 -0
  240. package/lib/styles/resize-observer.d.ts +6 -0
  241. package/lib/styles/resize-observer.d.ts.map +1 -0
  242. package/lib/styles/resize-observer.js +18 -0
  243. package/lib/styles/resize-observer.js.map +1 -0
  244. package/lib/styles/scroll-container.d.ts +6 -0
  245. package/lib/styles/scroll-container.d.ts.map +1 -0
  246. package/lib/styles/scroll-container.js +198 -0
  247. package/lib/styles/scroll-container.js.map +1 -0
  248. package/lib/styles/step.d.ts +5 -0
  249. package/lib/styles/step.d.ts.map +1 -0
  250. package/lib/styles/step.js +50 -0
  251. package/lib/styles/step.js.map +1 -0
  252. package/lib/styles/switch.d.ts +9 -0
  253. package/lib/styles/switch.d.ts.map +1 -0
  254. package/lib/styles/switch.js +100 -0
  255. package/lib/styles/switch.js.map +1 -0
  256. package/lib/styles/tab.d.ts +5 -0
  257. package/lib/styles/tab.d.ts.map +1 -0
  258. package/lib/styles/tab.js +51 -0
  259. package/lib/styles/tab.js.map +1 -0
  260. package/lib/styles/tabs.d.ts +13 -0
  261. package/lib/styles/tabs.d.ts.map +1 -0
  262. package/lib/styles/tabs.js +184 -0
  263. package/lib/styles/tabs.js.map +1 -0
  264. package/lib/styles/tag.d.ts +6 -0
  265. package/lib/styles/tag.d.ts.map +1 -0
  266. package/lib/styles/tag.js +409 -0
  267. package/lib/styles/tag.js.map +1 -0
  268. package/lib/styles/textarea.d.ts +6 -0
  269. package/lib/styles/textarea.d.ts.map +1 -0
  270. package/lib/styles/textarea.js +350 -0
  271. package/lib/styles/textarea.js.map +1 -0
  272. package/lib/styles/tooltip.d.ts +9 -0
  273. package/lib/styles/tooltip.d.ts.map +1 -0
  274. package/lib/styles/tooltip.js +133 -0
  275. package/lib/styles/tooltip.js.map +1 -0
  276. package/lib/styles/wizard.d.ts +25 -0
  277. package/lib/styles/wizard.d.ts.map +1 -0
  278. package/lib/styles/wizard.js +348 -0
  279. package/lib/styles/wizard.js.map +1 -0
  280. package/lib/types/common.d.ts +143 -0
  281. package/lib/types/common.d.ts.map +1 -0
  282. package/lib/types/common.js +5 -0
  283. package/lib/types/common.js.map +1 -0
  284. package/lib/utils/calendar-utils.d.ts +176 -0
  285. package/lib/utils/calendar-utils.d.ts.map +1 -0
  286. package/lib/utils/calendar-utils.js +370 -0
  287. package/lib/utils/calendar-utils.js.map +1 -0
  288. package/lib/utils/custom-scrollbar.d.ts +82 -0
  289. package/lib/utils/custom-scrollbar.d.ts.map +1 -0
  290. package/lib/utils/custom-scrollbar.js +320 -0
  291. package/lib/utils/custom-scrollbar.js.map +1 -0
  292. package/lib/utils/icon-registry.d.ts +78 -0
  293. package/lib/utils/icon-registry.d.ts.map +1 -0
  294. package/lib/utils/icon-registry.js +304 -0
  295. package/lib/utils/icon-registry.js.map +1 -0
  296. package/lib/utils/loader-registry.d.ts +35 -0
  297. package/lib/utils/loader-registry.d.ts.map +1 -0
  298. package/lib/utils/loader-registry.js +43 -0
  299. package/lib/utils/loader-registry.js.map +1 -0
  300. package/lib/utils/locale.d.ts +136 -0
  301. package/lib/utils/locale.d.ts.map +1 -0
  302. package/lib/utils/locale.js +213 -0
  303. package/lib/utils/locale.js.map +1 -0
  304. package/lib/utils/mobile.d.ts +14 -0
  305. package/lib/utils/mobile.d.ts.map +1 -0
  306. package/lib/utils/mobile.js +21 -0
  307. package/lib/utils/mobile.js.map +1 -0
  308. package/lib/utils/number-format.d.ts +83 -0
  309. package/lib/utils/number-format.d.ts.map +1 -0
  310. package/lib/utils/number-format.js +143 -0
  311. package/lib/utils/number-format.js.map +1 -0
  312. package/lib/utils/parse-boolean.d.ts +39 -0
  313. package/lib/utils/parse-boolean.d.ts.map +1 -0
  314. package/lib/utils/parse-boolean.js +58 -0
  315. package/lib/utils/parse-boolean.js.map +1 -0
  316. package/lib/utils/positioning.d.ts +143 -0
  317. package/lib/utils/positioning.d.ts.map +1 -0
  318. package/lib/utils/positioning.js +308 -0
  319. package/lib/utils/positioning.js.map +1 -0
  320. package/lib/utils/property-capture.d.ts +132 -0
  321. package/lib/utils/property-capture.d.ts.map +1 -0
  322. package/lib/utils/property-capture.js +152 -0
  323. package/lib/utils/property-capture.js.map +1 -0
  324. package/lib/utils/property-manager.d.ts +90 -0
  325. package/lib/utils/property-manager.d.ts.map +1 -0
  326. package/lib/utils/property-manager.js +197 -0
  327. package/lib/utils/property-manager.js.map +1 -0
  328. package/lib/utils/resize-observer.d.ts +42 -0
  329. package/lib/utils/resize-observer.d.ts.map +1 -0
  330. package/lib/utils/resize-observer.js +71 -0
  331. package/lib/utils/resize-observer.js.map +1 -0
  332. package/lib/utils/scroll-lock.d.ts +79 -0
  333. package/lib/utils/scroll-lock.d.ts.map +1 -0
  334. package/lib/utils/scroll-lock.js +197 -0
  335. package/lib/utils/scroll-lock.js.map +1 -0
  336. package/lib/utils/styles.d.ts +27 -0
  337. package/lib/utils/styles.d.ts.map +1 -0
  338. package/lib/utils/styles.js +53 -0
  339. package/lib/utils/styles.js.map +1 -0
  340. package/lib/version.d.ts +8 -0
  341. package/lib/version.d.ts.map +1 -0
  342. package/lib/version.js +11 -0
  343. package/lib/version.js.map +1 -0
  344. package/package.json +163 -0
@@ -0,0 +1,1640 @@
1
+ /**
2
+ * TyDropdown Web Component
3
+ * PORTED FROM: clj/ty/components/dropdown.cljs
4
+ *
5
+ * A semantic dropdown component with:
6
+ * - Desktop mode with smart positioning
7
+ * - Mobile mode with full-screen modal
8
+ * - Search and filtering capabilities
9
+ * - Keyboard navigation
10
+ * - Form association for native form submission
11
+ * - Rich option support (option, ty-option, ty-tag)
12
+ *
13
+ * @example
14
+ * ```html
15
+ * <!-- Basic dropdown -->
16
+ * <ty-dropdown label="Country" placeholder="Select country" required>
17
+ * <option value="us">United States</option>
18
+ * <option value="uk">United Kingdom</option>
19
+ * <option value="ca">Canada</option>
20
+ * </ty-dropdown>
21
+ *
22
+ * <!-- With rich options -->
23
+ * <ty-dropdown label="User">
24
+ * <ty-option value="1">
25
+ * <div class="flex items-center gap-2">
26
+ * <img src="avatar1.jpg" class="w-8 h-8 rounded-full" />
27
+ * <span>John Doe</span>
28
+ * </div>
29
+ * </ty-option>
30
+ * </ty-dropdown>
31
+ *
32
+ * <!-- External search (parent owns filtering, dropdown emits `search` events) -->
33
+ * <ty-dropdown label="Search API" external-search debounce="300">
34
+ * <option value="1">Result 1</option>
35
+ * </ty-dropdown>
36
+ * ```
37
+ */
38
+ import { ensureStyles } from '../utils/styles.js';
39
+ import { dropdownStyles } from '../styles/dropdown.js';
40
+ import { lockScroll, unlockScroll } from '../utils/scroll-lock.js';
41
+ import { isMobileTouch } from '../utils/mobile.js';
42
+ import { TyComponent } from '../base/ty-component.js';
43
+ import { CustomScrollbar, isCustomScrollbarEnabled } from '../utils/custom-scrollbar.js';
44
+ import { getLoaderSvg } from '../utils/loader-registry.js';
45
+ // ============================================================================
46
+ // Element Hash Utility (equivalent to ClojureScript's hash function)
47
+ // ============================================================================
48
+ /**
49
+ * Counter for generating unique element IDs
50
+ */
51
+ let elementIdCounter = 0;
52
+ /**
53
+ * WeakMap to store consistent element hashes
54
+ * Automatically garbage collects when element is destroyed
55
+ */
56
+ const elementIds = new WeakMap();
57
+ /**
58
+ * Get a consistent unique ID for an element (similar to ClojureScript's hash function)
59
+ * Returns the same ID for the same element across multiple calls
60
+ *
61
+ * @param element - The element to hash
62
+ * @returns A consistent numeric hash for the element
63
+ */
64
+ function getElementHash(element) {
65
+ let id = elementIds.get(element);
66
+ if (id === undefined) {
67
+ id = ++elementIdCounter;
68
+ elementIds.set(element, id);
69
+ }
70
+ return id;
71
+ }
72
+ // ============================================================================
73
+ // SVG Icons
74
+ // ============================================================================
75
+ /**
76
+ * Required indicator SVG icon (from Lucide)
77
+ */
78
+ const REQUIRED_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-asterisk"><path d="M12 6v12"/><path d="M17.196 9 6.804 15"/><path d="m6.804 9 10.392 6"/></svg>`;
79
+ /**
80
+ * Chevron down icon SVG
81
+ */
82
+ const CHEVRON_DOWN_SVG = `<svg viewBox="0 0 20 20" fill="currentColor">
83
+ <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" />
84
+ </svg>`;
85
+ /**
86
+ * Clear button X icon SVG (simple X)
87
+ */
88
+ const CLEAR_ICON_SVG = `<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2">
89
+ <path d="M6 6L14 14M14 6L6 14" stroke-linecap="round" />
90
+ </svg>`;
91
+ /**
92
+ * Ty Dropdown Component
93
+ */
94
+ export class TyDropdown extends TyComponent {
95
+ constructor() {
96
+ super(); // TyComponent handles attachInternals() and attachShadow()
97
+ // ============================================================================
98
+ // PRIVATE FIELDS (will be removed in Phase 3)
99
+ // ============================================================================
100
+ this._value = '';
101
+ this._placeholder = 'Select an option...';
102
+ this._label = '';
103
+ this._disabled = false;
104
+ this._readonly = false;
105
+ this._required = false;
106
+ this._externalSearch = false;
107
+ this._clearable = true;
108
+ this._loading = false;
109
+ this._size = 'md';
110
+ // Component state
111
+ this._state = {
112
+ open: false,
113
+ search: '',
114
+ highlightedIndex: -1,
115
+ filteredOptions: [],
116
+ currentValue: null,
117
+ mode: 'desktop' // Updated dynamically on render via syncMode()
118
+ };
119
+ // Scroll lock ID (consistent across open/close cycles)
120
+ this._scrollLockId = null;
121
+ // Event handler references for cleanup
122
+ this._stubClickHandler = null;
123
+ this._outsideClickHandler = null;
124
+ this._optionClickHandler = null;
125
+ this._searchInputHandler = null;
126
+ this._blockSearchClick = null;
127
+ this._keyboardHandler = null;
128
+ this._clearClickHandler = null;
129
+ // Debounce properties for search event
130
+ this._debounce = 0;
131
+ this._searchDebounceTimer = null;
132
+ // Custom scrollbar for options list
133
+ this._optionsScrollbar = null;
134
+ // MutationObserver for light-DOM children — re-establishes the visual selection
135
+ // when consumers swap option children (external-search refresh pattern).
136
+ this._childObserver = null;
137
+ const shadow = this.shadowRoot;
138
+ ensureStyles(shadow, { css: dropdownStyles, id: 'ty-dropdown' });
139
+ // Render based on device type
140
+ }
141
+ render() {
142
+ // Sync mode on every render so rotation/resize is picked up
143
+ this._state.mode = isMobileTouch() ? 'mobile' : 'desktop';
144
+ if (this._state.mode === 'mobile') {
145
+ this.renderMobile();
146
+ }
147
+ else {
148
+ this.renderDesktop();
149
+ }
150
+ // Loading visual lives on a dynamically rendered wrapper; reapply each render
151
+ this.applyLoadingState();
152
+ }
153
+ // ============================================================================
154
+ // LIFECYCLE HOOKS - TyComponent integration
155
+ // ============================================================================
156
+ /**
157
+ * Called when component is connected to DOM
158
+ * TyComponent handles property capture automatically
159
+ */
160
+ onConnect() {
161
+ this.initializeState();
162
+ // Observe light-DOM children so we can re-establish the visual selection
163
+ // when consumers replace options (typical external-search pattern).
164
+ // redisplaySelected() is idempotent — bails fast when already in sync,
165
+ // so spurious firings caused by our own clone management are no-ops.
166
+ this._childObserver = new MutationObserver(() => this.redisplaySelected());
167
+ this._childObserver.observe(this, { childList: true });
168
+ }
169
+ /**
170
+ * Called when component is disconnected from DOM
171
+ * Clean up event listeners and timers
172
+ */
173
+ onDisconnect() {
174
+ // Clean up document-level listeners
175
+ const outsideClickHandler = this.tyOutsideClickHandler;
176
+ if (outsideClickHandler) {
177
+ document.removeEventListener('click', outsideClickHandler);
178
+ this.tyOutsideClickHandler = null;
179
+ }
180
+ // Clear any pending debounce timer
181
+ if (this._searchDebounceTimer !== null) {
182
+ clearTimeout(this._searchDebounceTimer);
183
+ this._searchDebounceTimer = null;
184
+ }
185
+ // Cleanup custom scrollbar
186
+ this._destroyOptionsScrollbar();
187
+ // Disconnect children observer
188
+ if (this._childObserver) {
189
+ this._childObserver.disconnect();
190
+ this._childObserver = null;
191
+ }
192
+ }
193
+ /**
194
+ * Re-establish the visual selection (selected attribute + slot clone) for the
195
+ * current value, when the matching option exists in children but isn't yet
196
+ * marked. Called from the children MutationObserver — covers the case where
197
+ * a consumer swaps options dynamically (external-search refresh) and wipes
198
+ * out the previous selection's marker + clone in the process.
199
+ *
200
+ * Idempotent — bails when nothing to do, so it's safe to call from the
201
+ * MutationObserver callback even when the trigger was our own clone work.
202
+ */
203
+ redisplaySelected() {
204
+ const currentValue = this._state.currentValue;
205
+ if (!currentValue)
206
+ return;
207
+ const options = this.getOptions();
208
+ const matching = options.find(opt => this.getOptionData(opt).value === currentValue);
209
+ // Already correctly displayed — nothing to do
210
+ if (!matching || matching.hasAttribute('selected'))
211
+ return;
212
+ // Re-mark and re-clone WITHOUT firing change (we're recovering display state,
213
+ // not making a new selection)
214
+ this.clearSelection();
215
+ const clone = matching.cloneNode(true);
216
+ clone.setAttribute('slot', 'selected');
217
+ clone.setAttribute('cloned', 'true');
218
+ matching.parentNode.appendChild(clone);
219
+ matching.setAttribute('selected', '');
220
+ this.updateSelectionDisplay();
221
+ }
222
+ /**
223
+ * Called when properties change
224
+ * Handle state synchronization BEFORE render
225
+ */
226
+ onPropertiesChanged(changes) {
227
+ // Sync private fields from PropertyManager
228
+ // (Render methods still use private fields)
229
+ for (const { name, newValue } of changes) {
230
+ switch (name) {
231
+ case 'value':
232
+ this._value = newValue || '';
233
+ this._state.currentValue = newValue || null;
234
+ this.syncSelectedOption();
235
+ break;
236
+ case 'placeholder':
237
+ this._placeholder = newValue || 'Select an option...';
238
+ this.updatePlaceholderInDOM();
239
+ break;
240
+ case 'label':
241
+ this._label = newValue || '';
242
+ break;
243
+ case 'disabled':
244
+ this._disabled = newValue;
245
+ break;
246
+ case 'readonly':
247
+ this._readonly = newValue;
248
+ break;
249
+ case 'required':
250
+ this._required = newValue;
251
+ break;
252
+ case 'externalSearch':
253
+ this._externalSearch = newValue;
254
+ break;
255
+ case 'clearable':
256
+ this._clearable = newValue;
257
+ this.updateClearButton();
258
+ break;
259
+ case 'size':
260
+ this._size = newValue;
261
+ break;
262
+ case 'debounce':
263
+ this._debounce = newValue;
264
+ break;
265
+ case 'loading':
266
+ this._loading = newValue;
267
+ this.applyLoadingState();
268
+ break;
269
+ }
270
+ }
271
+ }
272
+ /**
273
+ * Toggle the loading visual state on the open popup.
274
+ * Replaces the options list with a centered spinner; search input stays usable.
275
+ * Pulls the latest registered loader SVG on each call so registry changes
276
+ * take effect on the next loading toggle.
277
+ */
278
+ applyLoadingState() {
279
+ const shadow = this.shadowRoot;
280
+ if (!shadow)
281
+ return;
282
+ const svg = this._loading ? getLoaderSvg() : null;
283
+ shadow.querySelectorAll('.dropdown-options-wrapper').forEach((wrapper) => {
284
+ wrapper.classList.toggle('loading', this._loading);
285
+ if (this._loading) {
286
+ wrapper.setAttribute('aria-busy', 'true');
287
+ const spinner = wrapper.querySelector('.dropdown-loading-spinner');
288
+ if (spinner && svg)
289
+ spinner.innerHTML = svg;
290
+ }
291
+ else {
292
+ wrapper.removeAttribute('aria-busy');
293
+ }
294
+ });
295
+ }
296
+ /**
297
+ * Get the form value for this component
298
+ * TyComponent calls this automatically when formValue property changes
299
+ */
300
+ getFormValue() {
301
+ return this._state.currentValue || null;
302
+ }
303
+ /**
304
+ * Parse dropdown value (single string)
305
+ */
306
+ parseValue(value) {
307
+ // Defensive check: ensure value is actually a string before calling .trim()
308
+ if (!value || typeof value !== 'string' || value.trim() === '')
309
+ return null;
310
+ return value.trim();
311
+ }
312
+ /**
313
+ * Initialize component state from attributes
314
+ */
315
+ initializeState() {
316
+ if (this._value) {
317
+ this._state.currentValue = this.parseValue(this._value);
318
+ // CRITICAL: Options may not be slotted yet when connectedCallback runs
319
+ // Defer sync to next frame to ensure options are available
320
+ requestAnimationFrame(() => {
321
+ this.syncSelectedOption();
322
+ // updateFormValue() called automatically by TyComponent
323
+ });
324
+ }
325
+ // Listen for clear-selection events from ty-options (mobile clear button)
326
+ this.addEventListener('clear-selection', (e) => {
327
+ e.stopPropagation(); // Prevent bubbling
328
+ // Clear the selection
329
+ this.clearSelection();
330
+ this._state.currentValue = null;
331
+ this.updateComponentValue();
332
+ this.updateSelectionDisplay();
333
+ // updateFormValue() called automatically by TyComponent
334
+ // Dispatch change event
335
+ this.dispatchEvent(new CustomEvent('change', {
336
+ detail: {
337
+ value: null,
338
+ text: '',
339
+ option: null,
340
+ originalEvent: e
341
+ },
342
+ bubbles: true,
343
+ composed: true
344
+ }));
345
+ // If in mobile modal, close it
346
+ if (this._state.open && this._state.mode === 'mobile') {
347
+ this.closeMobileModal();
348
+ }
349
+ });
350
+ // Render the component with current state
351
+ this.render();
352
+ }
353
+ /**
354
+ * Update placeholder text in existing rendered HTML
355
+ * Called when placeholder changes after initial render
356
+ */
357
+ updatePlaceholderInDOM() {
358
+ const shadow = this.shadowRoot;
359
+ // Update stub placeholder
360
+ const stubPlaceholder = shadow.querySelector('.dropdown-placeholder');
361
+ if (stubPlaceholder) {
362
+ stubPlaceholder.textContent = this._placeholder;
363
+ }
364
+ // Update search input placeholder (desktop)
365
+ const searchInput = shadow.querySelector('.dropdown-search-input');
366
+ if (searchInput) {
367
+ searchInput.placeholder = this._placeholder;
368
+ }
369
+ // Update mobile search input placeholder
370
+ const mobileSearchInput = shadow.querySelector('.mobile-search-input');
371
+ if (mobileSearchInput) {
372
+ mobileSearchInput.placeholder = this._placeholder;
373
+ }
374
+ }
375
+ // ============================================================================
376
+ // SHARED CORE METHODS
377
+ // ============================================================================
378
+ // Methods used by BOTH desktop and mobile implementations:
379
+ //
380
+ // OPTION MANAGEMENT:
381
+ // - getOptions(): Get all option elements from slot
382
+ // - getOptionData(): Extract value and text from option element
383
+ // - selectOption(): Select an option (used by both desktop/mobile clicks)
384
+ // - clearSelection(): Clear all selections
385
+ // - syncSelectedOption(): Sync selected option based on current value
386
+ //
387
+ // STATE SYNCHRONIZATION:
388
+ // - updateComponentValue(): Update component value attribute
389
+ // - updateFormValue(): Update form value via ElementInternals
390
+ //
391
+ // DISPLAY UPDATES:
392
+ // - updateSelectionDisplay(): Show/hide placeholder
393
+ // - updateClearButton(): Show/hide clear button (desktop only)
394
+ //
395
+ // EVENT DISPATCHING:
396
+ // - dispatchChangeEvent(): Dispatch change event
397
+ // - dispatchSearchEvent(): Dispatch search event with debounce
398
+ // - fireSearchEvent(): Fire the actual search event
399
+ //
400
+ // FILTERING & SEARCH:
401
+ // - filterOptions(): Filter options by query
402
+ // - updateOptionVisibility(): Show/hide options based on filter
403
+ //
404
+ // HIGHLIGHTING (keyboard navigation):
405
+ // - clearHighlights(): Clear all option highlights
406
+ // - highlightOption(): Highlight option at index
407
+ //
408
+ // UTILITY:
409
+ // - buildStubClasses(): Build CSS class list for stub
410
+ // ============================================================================
411
+ /**
412
+ * Update form value via ElementInternals
413
+ */
414
+ /**
415
+ * Get all option elements from slot
416
+ * Supports: <option>, <ty-option>, <ty-tag>
417
+ */
418
+ getOptions() {
419
+ const shadow = this.shadowRoot;
420
+ const slot = shadow.querySelector('slot:not([name])');
421
+ if (!slot)
422
+ return [];
423
+ const assigned = (slot.assignedElements ? slot.assignedElements() : []);
424
+ return assigned.filter(el => {
425
+ const tag = el.tagName;
426
+ return tag === 'OPTION' || tag === 'TY-OPTION' || tag === 'TY-TAG';
427
+ });
428
+ }
429
+ /**
430
+ * Extract value and text from option element
431
+ */
432
+ getOptionData(element) {
433
+ const tag = element.tagName;
434
+ if (tag === 'OPTION') {
435
+ const optionEl = element;
436
+ return {
437
+ value: optionEl.value || optionEl.textContent || '',
438
+ text: optionEl.textContent || '',
439
+ element
440
+ };
441
+ }
442
+ if (tag === 'TY-OPTION') {
443
+ // CRITICAL: Use .value property, not getAttribute!
444
+ // Framework may set property before attribute
445
+ const tyOption = element;
446
+ return {
447
+ value: tyOption.value || element.textContent || '',
448
+ text: element.textContent || '',
449
+ element
450
+ };
451
+ }
452
+ if (tag === 'TY-TAG') {
453
+ return {
454
+ value: element.getAttribute('value') || element.textContent || '',
455
+ text: element.textContent || '',
456
+ element
457
+ };
458
+ }
459
+ return {
460
+ value: element.textContent || '',
461
+ text: element.textContent || '',
462
+ element
463
+ };
464
+ }
465
+ /**
466
+ * Clear all selections
467
+ */
468
+ clearSelection() {
469
+ const shadow = this.shadowRoot;
470
+ const options = this.getOptions();
471
+ // Clear selected attribute from all options
472
+ options.forEach(opt => opt.removeAttribute('selected'));
473
+ // Remove clones from selected slot
474
+ const selectedSlot = shadow.querySelector('slot[name="selected"]');
475
+ if (selectedSlot) {
476
+ const assigned = selectedSlot.assignedElements ? selectedSlot.assignedElements() : [];
477
+ const clones = Array.from(assigned).filter((el) => el.hasAttribute('cloned'));
478
+ clones.forEach((clone) => clone.remove());
479
+ }
480
+ }
481
+ /**
482
+ * Select an option
483
+ */
484
+ selectOption(option, originalEvent) {
485
+ const optionData = this.getOptionData(option);
486
+ const isEmpty = !optionData.value || optionData.value.trim() === '';
487
+ this.clearSelection(); // If value is empty, just clear and don't clone anything
488
+ if (isEmpty) {
489
+ this._state.currentValue = null;
490
+ }
491
+ else {
492
+ // Clone option for display in stub
493
+ const clone = option.cloneNode(true);
494
+ clone.setAttribute('slot', 'selected');
495
+ clone.setAttribute('cloned', 'true');
496
+ option.parentNode.appendChild(clone);
497
+ // Mark original as selected
498
+ option.setAttribute('selected', '');
499
+ // Update state
500
+ this._state.currentValue = optionData.value;
501
+ }
502
+ this.updateComponentValue();
503
+ this.updateSelectionDisplay();
504
+ // updateFormValue() called automatically by TyComponent
505
+ // Dispatch change event
506
+ this.dispatchChangeEvent(option, originalEvent);
507
+ }
508
+ /**
509
+ * Sync selected option based on current value
510
+ */
511
+ syncSelectedOption() {
512
+ const options = this.getOptions();
513
+ const currentValue = this._state.currentValue;
514
+ if (!currentValue) {
515
+ this.clearSelection();
516
+ return;
517
+ }
518
+ // Find matching option
519
+ const matchingOption = options.find(opt => {
520
+ const data = this.getOptionData(opt);
521
+ return data.value === currentValue;
522
+ });
523
+ if (matchingOption) {
524
+ this.selectOption(matchingOption);
525
+ }
526
+ }
527
+ /**
528
+ * Update component value attribute for consistency
529
+ */
530
+ updateComponentValue() {
531
+ const currentValue = this._state.currentValue;
532
+ if (currentValue !== null) {
533
+ this.setAttribute('value', currentValue);
534
+ }
535
+ else {
536
+ this.removeAttribute('value');
537
+ }
538
+ // Also set property
539
+ this._value = currentValue || '';
540
+ }
541
+ /**
542
+ * Update selection display (show/hide placeholder)
543
+ */
544
+ updateSelectionDisplay() {
545
+ const shadow = this.shadowRoot;
546
+ const stub = shadow.querySelector('.dropdown-stub');
547
+ if (!stub)
548
+ return;
549
+ const options = this.getOptions();
550
+ const hasSelected = options.some(opt => opt.hasAttribute('selected'));
551
+ if (hasSelected) {
552
+ stub.classList.add('has-selection');
553
+ }
554
+ else {
555
+ stub.classList.remove('has-selection');
556
+ }
557
+ // Update clear button visibility
558
+ this.updateClearButton();
559
+ }
560
+ updateClearButton() {
561
+ const shadow = this.shadowRoot;
562
+ const clearBtn = shadow.querySelector('.dropdown-clear-btn');
563
+ if (!clearBtn)
564
+ return;
565
+ const hasSelection = this._state.currentValue !== null && this._state.currentValue !== '';
566
+ const shouldShow = this._clearable &&
567
+ hasSelection &&
568
+ !this._disabled &&
569
+ !this._readonly &&
570
+ !this._state.open &&
571
+ this._state.mode !== 'mobile';
572
+ // CRITICAL: Only show clear button when dropdown is closed
573
+ if (shouldShow) {
574
+ clearBtn.style.display = 'block';
575
+ }
576
+ else {
577
+ clearBtn.style.display = 'none';
578
+ }
579
+ }
580
+ /**
581
+ * Dispatch change event
582
+ */
583
+ dispatchChangeEvent(option, originalEvent) {
584
+ const optionData = this.getOptionData(option);
585
+ this.dispatchEvent(new CustomEvent('change', {
586
+ detail: {
587
+ value: this._state.currentValue,
588
+ text: optionData.text,
589
+ option,
590
+ originalEvent: originalEvent || null
591
+ },
592
+ bubbles: true,
593
+ composed: true
594
+ }));
595
+ }
596
+ filterOptions(options, query) {
597
+ if (!query || query.trim() === '') {
598
+ return options;
599
+ }
600
+ const searchLower = query.toLowerCase();
601
+ return options.filter(({ text }) => text.toLowerCase().includes(searchLower));
602
+ }
603
+ updateOptionVisibility(filteredOptions, allOptions) {
604
+ const visibleValues = new Set(filteredOptions.map(opt => opt.value));
605
+ allOptions.forEach(({ value, element }) => {
606
+ if (visibleValues.has(value)) {
607
+ element.removeAttribute('hidden');
608
+ }
609
+ else {
610
+ element.setAttribute('hidden', '');
611
+ }
612
+ });
613
+ }
614
+ /**
615
+ * Clear all option highlights
616
+ */
617
+ clearHighlights(options) {
618
+ options.forEach(({ element }) => {
619
+ element.removeAttribute('highlighted');
620
+ });
621
+ }
622
+ highlightOption(options, index) {
623
+ this.clearHighlights(options);
624
+ if (index >= 0 && index < options.length) {
625
+ const { element } = options[index];
626
+ element.setAttribute('highlighted', '');
627
+ // Scroll into view
628
+ element.scrollIntoView({
629
+ behavior: 'smooth',
630
+ block: 'nearest',
631
+ inline: 'nearest'
632
+ });
633
+ }
634
+ }
635
+ // ============================================================================
636
+ // SHARED EVENT HANDLERS
637
+ // ============================================================================
638
+ // Event handlers used by BOTH desktop and mobile:
639
+ //
640
+ // - handleSearchInput(): Handles search input for both dialog and modal
641
+ // * Desktop: Filters options locally OR dispatches search event
642
+ // * Mobile: Same behavior
643
+ // ============================================================================
644
+ /**
645
+ * Setup event listeners
646
+ */
647
+ setupDesktopEventListeners() {
648
+ const shadow = this.shadowRoot;
649
+ const stub = shadow.querySelector('.dropdown-stub');
650
+ const optionsSlot = shadow.querySelector('#options-slot');
651
+ const searchInput = shadow.querySelector('.dropdown-search-input');
652
+ if (stub) {
653
+ this._stubClickHandler = this.handleDesktopStubClick.bind(this);
654
+ stub.addEventListener('click', this._stubClickHandler);
655
+ }
656
+ // Add option click handler to slot
657
+ if (optionsSlot) {
658
+ this._optionClickHandler = this.handleDesktopOptionClick.bind(this);
659
+ optionsSlot.addEventListener('click', this._optionClickHandler);
660
+ }
661
+ // Add search input handlers
662
+ if (searchInput) {
663
+ this._searchInputHandler = this.handleSearchInput.bind(this);
664
+ this._blockSearchClick = this.blockSearchClick.bind(this);
665
+ searchInput.addEventListener('input', this._searchInputHandler);
666
+ searchInput.addEventListener('click', this._blockSearchClick);
667
+ }
668
+ // Add clear button handler
669
+ const clearBtn = shadow.querySelector('.dropdown-clear-btn');
670
+ if (clearBtn) {
671
+ this._clearClickHandler = this.handleDesktopClearClick.bind(this);
672
+ clearBtn.addEventListener('click', this._clearClickHandler);
673
+ }
674
+ // Setup document-level event listeners immediately (like ClojureScript version)
675
+ this._outsideClickHandler = this.handleDesktopOutsideClick.bind(this);
676
+ this._keyboardHandler = this.handleDesktopKeyboard.bind(this);
677
+ document.addEventListener('click', this._outsideClickHandler);
678
+ document.addEventListener('keydown', this._keyboardHandler);
679
+ }
680
+ // removeEventListeners removed - now using ClojureScript pattern
681
+ // ============================================================================
682
+ // SHARED HELPER METHODS (Internal)
683
+ // ============================================================================
684
+ // Internal helpers that reduce duplication between desktop and mobile.
685
+ // These are called by both implementations but not exposed publicly.
686
+ // ============================================================================
687
+ /**
688
+ * Lock scroll with consistent ID generation
689
+ */
690
+ lockDropdownScroll() {
691
+ const dropdownId = `dropdown-${this.id || 'anon'}-${getElementHash(this)}`;
692
+ this._scrollLockId = dropdownId;
693
+ lockScroll(dropdownId);
694
+ }
695
+ /**
696
+ * Unlock scroll using stored ID
697
+ */
698
+ unlockDropdownScroll() {
699
+ if (this._scrollLockId) {
700
+ unlockScroll(this._scrollLockId);
701
+ this._scrollLockId = null;
702
+ }
703
+ }
704
+ /**
705
+ * Initialize options state when opening dropdown/modal
706
+ * @param highlightSelected - Whether to highlight the currently selected option
707
+ */
708
+ initializeOptionsState(highlightSelected = false) {
709
+ const options = this.getOptions().map(el => this.getOptionData(el));
710
+ const currentValue = this._state.currentValue;
711
+ this._state.filteredOptions = options;
712
+ if (highlightSelected && currentValue) {
713
+ // Find the index of the currently selected option
714
+ const selectedIndex = options.findIndex(opt => opt.value === currentValue);
715
+ this._state.highlightedIndex = selectedIndex;
716
+ // Highlight and scroll to selected option if it exists
717
+ if (selectedIndex >= 0) {
718
+ this.highlightOption(options, selectedIndex);
719
+ }
720
+ }
721
+ else {
722
+ this._state.highlightedIndex = -1;
723
+ }
724
+ }
725
+ /**
726
+ * Base stub click handler - validates state then calls open callback
727
+ * @param e - Click event
728
+ * @param openCallback - Function to call to open (desktop or mobile)
729
+ */
730
+ handleStubClickBase(e, openCallback) {
731
+ e.preventDefault();
732
+ e.stopPropagation();
733
+ if (this._disabled || this._readonly) {
734
+ return;
735
+ }
736
+ openCallback();
737
+ }
738
+ /**
739
+ * Base option click handler - finds option, selects it, then calls close callback
740
+ * @param e - Click event
741
+ * @param closeCallback - Function to call to close (desktop or mobile)
742
+ */
743
+ handleOptionClickBase(e, closeCallback) {
744
+ e.preventDefault();
745
+ e.stopPropagation();
746
+ const target = e.target;
747
+ // Find the option element (might be clicking on child element)
748
+ const option = target.closest('option, ty-option, ty-tag');
749
+ if (!option)
750
+ return;
751
+ // Select the option
752
+ this.selectOption(option, e);
753
+ // Update display
754
+ this.updateSelectionDisplay();
755
+ // Close dropdown/modal
756
+ closeCallback();
757
+ }
758
+ /**
759
+ * Base clear click handler - clears selection and optionally closes
760
+ * @param e - Click event
761
+ * @param closeCallback - Optional function to call to close (mobile only)
762
+ */
763
+ handleClearClickBase(e, closeCallback) {
764
+ e.preventDefault();
765
+ e.stopPropagation();
766
+ // Clear the selection
767
+ this.clearSelection();
768
+ this._state.currentValue = null;
769
+ this.updateComponentValue();
770
+ this.updateSelectionDisplay();
771
+ // updateFormValue() called automatically by TyComponent
772
+ // Dispatch change event with null value
773
+ this.dispatchEvent(new CustomEvent('change', {
774
+ detail: {
775
+ value: null,
776
+ text: '',
777
+ option: null,
778
+ originalEvent: e
779
+ },
780
+ bubbles: true,
781
+ composed: true
782
+ }));
783
+ // Close if callback provided (mobile modal case)
784
+ if (closeCallback) {
785
+ closeCallback();
786
+ }
787
+ }
788
+ // ============================================================================
789
+ // DESKTOP IMPLEMENTATION (Dialog-based)
790
+ // ============================================================================
791
+ // Desktop uses <dialog> element with smart positioning.
792
+ //
793
+ // EVENT FLOW:
794
+ // 1. User clicks stub → handleDesktopStubClick() → openDesktopDropdown()
795
+ // 2. Dialog shown with showModal(), positioned via calculateDesktopPosition()
796
+ // 3. User types in search → handleSearchInput() [SHARED]
797
+ // 4. User clicks option → handleDesktopOptionClick() → selectOption() [SHARED] → closeDesktopDropdown()
798
+ // 5. User clicks outside → handleDesktopOutsideClick() → closeDesktopDropdown()
799
+ // 6. User presses Escape → handleDesktopKeyboard() → closeDesktopDropdown()
800
+ // 7. User clicks clear button → handleDesktopClearClick() → clearSelection() [SHARED]
801
+ //
802
+ // METHODS:
803
+ // - calculateDesktopPosition(): Smart dropdown positioning (below/above stub)
804
+ // - openDesktopDropdown(): Open desktop dialog
805
+ // - closeDesktopDropdown(): Close desktop dialog
806
+ // - handleDesktopStubClick(): Open on stub click
807
+ // - handleDesktopOptionClick(): Select option and close
808
+ // - handleDesktopClearClick(): Clear selection
809
+ // - handleDesktopOutsideClick(): Close on outside click
810
+ // - handleDesktopKeyboard(): Arrow navigation + Enter/Escape
811
+ // - renderDesktop(): Render desktop HTML
812
+ // - setupDesktopEventListeners(): Setup desktop event handlers
813
+ // ============================================================================
814
+ /**
815
+ * Calculate and set dropdown position with smart direction detection
816
+ */
817
+ calculateDesktopPosition() {
818
+ const shadow = this.shadowRoot;
819
+ const stub = shadow.querySelector('.dropdown-stub');
820
+ const dialog = shadow.querySelector('.dropdown-dialog');
821
+ if (!stub || !dialog)
822
+ return;
823
+ const stubRect = stub.getBoundingClientRect();
824
+ const viewportHeight = window.innerHeight;
825
+ const viewportWidth = window.innerWidth;
826
+ // Get dialog dimensions (it's already shown with showModal)
827
+ const dialogRect = dialog.getBoundingClientRect();
828
+ const estimatedHeight = dialogRect.height || 200;
829
+ const padding = 8;
830
+ const wrapPadding = 20;
831
+ // Available space calculations
832
+ const spaceBelow = viewportHeight - stubRect.bottom;
833
+ const spaceRight = viewportWidth - stubRect.left;
834
+ // Smart direction logic
835
+ const positionBelow = spaceBelow >= estimatedHeight + padding;
836
+ const fitsHorizontally = spaceRight >= stubRect.width;
837
+ // Calculate position coordinates
838
+ const x = fitsHorizontally
839
+ ? stubRect.left - wrapPadding
840
+ : Math.max(padding, viewportWidth - stubRect.width - padding);
841
+ const y = positionBelow
842
+ ? stubRect.top - wrapPadding
843
+ : viewportHeight - stubRect.bottom - wrapPadding;
844
+ const width = stubRect.width + wrapPadding + wrapPadding;
845
+ // Set CSS variables for positioning
846
+ this.style.setProperty('--dropdown-x', `${x}px`);
847
+ this.style.setProperty('--dropdown-y', `${y}px`);
848
+ this.style.setProperty('--dropdown-width', `${width}px`);
849
+ this.style.setProperty('--dropdown-offset-x', '0px');
850
+ this.style.setProperty('--dropdown-offset-y', '0px');
851
+ this.style.setProperty('--dropdown-padding', `${wrapPadding}px`);
852
+ // Set direction classes for CSS styling
853
+ if (positionBelow) {
854
+ dialog.classList.add('position-below');
855
+ dialog.classList.remove('position-above');
856
+ }
857
+ else {
858
+ dialog.classList.add('position-above');
859
+ dialog.classList.remove('position-below');
860
+ }
861
+ // Optional: Store direction for debugging
862
+ this.style.setProperty('--dropdown-direction', positionBelow ? 'below' : 'above');
863
+ }
864
+ /**
865
+ * Open dropdown dialog
866
+ */
867
+ openDesktopDropdown() {
868
+ const shadow = this.shadowRoot;
869
+ const dialog = shadow.querySelector('.dropdown-dialog');
870
+ if (!dialog)
871
+ return;
872
+ // Lock scroll
873
+ this.lockDropdownScroll();
874
+ // Show modal first so browser can calculate dimensions
875
+ dialog.showModal();
876
+ dialog.classList.add('open');
877
+ // Position dropdown AFTER showing modal
878
+ this.calculateDesktopPosition();
879
+ // Update component state
880
+ this._state.open = true;
881
+ // Update visual states
882
+ const chevron = shadow.querySelector('.dropdown-chevron');
883
+ if (chevron)
884
+ chevron.classList.add('open');
885
+ const searchChevron = shadow.querySelector('.dropdown-search-chevron');
886
+ if (searchChevron)
887
+ searchChevron.classList.add('open');
888
+ // Initialize options state and highlight selected option
889
+ this.initializeOptionsState(true);
890
+ // Focus search input
891
+ const searchInput = shadow.querySelector('.dropdown-search-input');
892
+ if (searchInput) {
893
+ setTimeout(() => searchInput.focus(), 100);
894
+ }
895
+ // Setup custom scrollbar on options
896
+ this._setupOptionsScrollbar();
897
+ // Hide clear button when dropdown is open
898
+ this.updateClearButton();
899
+ // Lifecycle event (also fires empty search if external-search)
900
+ this.fireOpenEvent();
901
+ }
902
+ /**
903
+ * Close dropdown dialog
904
+ */
905
+ closeDesktopDropdown() {
906
+ const shadow = this.shadowRoot;
907
+ const dialog = shadow.querySelector('.dropdown-dialog');
908
+ if (!dialog)
909
+ return;
910
+ // Unlock scroll
911
+ this.unlockDropdownScroll();
912
+ // Destroy custom scrollbar
913
+ this._destroyOptionsScrollbar();
914
+ // Close dialog
915
+ dialog.classList.remove('open');
916
+ dialog.classList.remove('position-above');
917
+ dialog.classList.remove('position-below');
918
+ dialog.close();
919
+ // Update state
920
+ this._state.open = false;
921
+ this._state.highlightedIndex = -1;
922
+ // Update visual states
923
+ const chevron = shadow.querySelector('.dropdown-chevron');
924
+ if (chevron)
925
+ chevron.classList.remove('open');
926
+ const searchChevron = shadow.querySelector('.dropdown-search-chevron');
927
+ if (searchChevron)
928
+ searchChevron.classList.remove('open');
929
+ // Reset search query when in external mode (parent owns the options)
930
+ if (this._externalSearch) {
931
+ this._state.search = '';
932
+ }
933
+ // Show clear button when dropdown is closed (if applicable)
934
+ this.updateClearButton();
935
+ // Lifecycle event
936
+ this.fireCloseEvent();
937
+ }
938
+ /**
939
+ * Handle outside click to close dropdown
940
+ */
941
+ handleDesktopOutsideClick(e) {
942
+ if (!this._state.open)
943
+ return;
944
+ const target = e.target;
945
+ const shadow = this.shadowRoot;
946
+ // CRITICAL: Check if click is inside .dropdown-wrapper (like ClojureScript version)
947
+ // Not the whole component - this prevents the component itself from being counted
948
+ const wrapper = shadow.querySelector('.dropdown-wrapper');
949
+ const clickedInside = wrapper && wrapper.contains(target);
950
+ if (!clickedInside) {
951
+ this.closeDesktopDropdown();
952
+ }
953
+ }
954
+ /**
955
+ * Handle search input changes
956
+ */
957
+ handleSearchInput(e) {
958
+ const target = e.target;
959
+ const query = target.value;
960
+ // Update search state
961
+ this._state.search = query;
962
+ if (this._externalSearch) {
963
+ // External search: dispatch event for external handling
964
+ this.dispatchSearchEvent(query, e);
965
+ }
966
+ else {
967
+ // Internal search: filter options locally
968
+ const allOptions = this.getOptions().map(el => this.getOptionData(el));
969
+ const filtered = this.filterOptions(allOptions, query);
970
+ // Update state
971
+ this._state.filteredOptions = filtered;
972
+ this._state.highlightedIndex = -1;
973
+ // Update visibility
974
+ this.updateOptionVisibility(filtered, allOptions);
975
+ // Clear highlights
976
+ this.clearHighlights(allOptions);
977
+ }
978
+ }
979
+ /**
980
+ * Block search input click from bubbling
981
+ * Prevents search input clicks from triggering outside click handler
982
+ */
983
+ blockSearchClick(e) {
984
+ e.stopPropagation();
985
+ e.preventDefault();
986
+ }
987
+ /**
988
+ * Dispatch search event for external search handling
989
+ * With optional debounce support
990
+ */
991
+ dispatchSearchEvent(query, originalEvent) {
992
+ // Clear existing timer
993
+ if (this._searchDebounceTimer !== null) {
994
+ clearTimeout(this._searchDebounceTimer);
995
+ this._searchDebounceTimer = null;
996
+ }
997
+ // If debounce is set, debounce the event
998
+ if (this._debounce > 0) {
999
+ this._searchDebounceTimer = window.setTimeout(() => {
1000
+ this.fireSearchEvent(query, originalEvent);
1001
+ this._searchDebounceTimer = null;
1002
+ }, this._debounce);
1003
+ }
1004
+ else {
1005
+ // Fire immediately if no debounce
1006
+ this.fireSearchEvent(query, originalEvent);
1007
+ }
1008
+ }
1009
+ /**
1010
+ * Fire the actual search event
1011
+ */
1012
+ fireSearchEvent(query, originalEvent) {
1013
+ this.dispatchEvent(new CustomEvent('search', {
1014
+ detail: {
1015
+ query,
1016
+ originalEvent: originalEvent || null
1017
+ },
1018
+ bubbles: true,
1019
+ composed: true
1020
+ }));
1021
+ }
1022
+ /**
1023
+ * Dispatch lifecycle events for popup open/close.
1024
+ * On open with external-search, also fire a `search` event with an empty
1025
+ * query so consumers have a clean hook to reset/refetch the option list —
1026
+ * fixes the "stale filtered options on reopen" UX issue.
1027
+ */
1028
+ fireOpenEvent() {
1029
+ this.dispatchEvent(new CustomEvent('open', {
1030
+ detail: { mode: this._state.mode },
1031
+ bubbles: true,
1032
+ composed: true
1033
+ }));
1034
+ if (this._externalSearch) {
1035
+ this.fireSearchEvent('');
1036
+ }
1037
+ }
1038
+ fireCloseEvent() {
1039
+ this.dispatchEvent(new CustomEvent('close', {
1040
+ detail: { mode: this._state.mode },
1041
+ bubbles: true,
1042
+ composed: true
1043
+ }));
1044
+ }
1045
+ // ============================================================================
1046
+ // DESKTOP KEYBOARD NAVIGATION
1047
+ // ============================================================================
1048
+ // Handles: Escape, Enter, ArrowUp, ArrowDown
1049
+ // Works with both search input focus and document-level events
1050
+ // Wraps around: ArrowUp at index 0 → last option, ArrowDown at last → first
1051
+ // ============================================================================
1052
+ /**
1053
+ * Handle keyboard navigation
1054
+ */
1055
+ handleDesktopKeyboard(e) {
1056
+ if (!this._state.open)
1057
+ return;
1058
+ const shadow = this.shadowRoot;
1059
+ const searchInput = shadow.querySelector('.dropdown-search-input');
1060
+ const target = e.target;
1061
+ // Only handle navigation keys when dropdown is open and either:
1062
+ // 1. Event comes from search input, OR
1063
+ // 2. Event comes from document but search input is not focused
1064
+ const shouldHandle = target === searchInput ||
1065
+ document.activeElement !== searchInput;
1066
+ if (!shouldHandle)
1067
+ return;
1068
+ // Get current state values
1069
+ const filteredOptions = this._state.filteredOptions;
1070
+ const optionsCount = filteredOptions.length;
1071
+ const currentHighlightedIndex = this._state.highlightedIndex;
1072
+ switch (e.key) {
1073
+ case 'Escape':
1074
+ e.preventDefault();
1075
+ e.stopPropagation();
1076
+ this.closeDesktopDropdown();
1077
+ break;
1078
+ case 'Enter':
1079
+ e.preventDefault();
1080
+ e.stopPropagation();
1081
+ // Select highlighted option if any
1082
+ if (currentHighlightedIndex >= 0 && currentHighlightedIndex < optionsCount) {
1083
+ const option = filteredOptions[currentHighlightedIndex];
1084
+ this.selectOption(option.element, e);
1085
+ this.updateSelectionDisplay();
1086
+ this.closeDesktopDropdown();
1087
+ }
1088
+ break;
1089
+ case 'ArrowUp':
1090
+ e.preventDefault();
1091
+ e.stopPropagation();
1092
+ let newIndexUp;
1093
+ if (optionsCount === 0) {
1094
+ newIndexUp = -1;
1095
+ }
1096
+ else if (currentHighlightedIndex === -1) {
1097
+ // Nothing highlighted, go to last option
1098
+ newIndexUp = optionsCount - 1;
1099
+ }
1100
+ else if (currentHighlightedIndex === 0) {
1101
+ // At first option, wrap to last
1102
+ newIndexUp = optionsCount - 1;
1103
+ }
1104
+ else {
1105
+ // Move up one
1106
+ newIndexUp = currentHighlightedIndex - 1;
1107
+ }
1108
+ this._state.highlightedIndex = newIndexUp;
1109
+ this.highlightOption(filteredOptions, newIndexUp);
1110
+ break;
1111
+ case 'ArrowDown':
1112
+ e.preventDefault();
1113
+ e.stopPropagation();
1114
+ let newIndexDown;
1115
+ if (optionsCount === 0) {
1116
+ newIndexDown = -1;
1117
+ }
1118
+ else if (currentHighlightedIndex === -1) {
1119
+ // Nothing highlighted, go to first option
1120
+ newIndexDown = 0;
1121
+ }
1122
+ else if (currentHighlightedIndex === optionsCount - 1) {
1123
+ // At last option, wrap to first
1124
+ newIndexDown = 0;
1125
+ }
1126
+ else {
1127
+ // Move down one
1128
+ newIndexDown = currentHighlightedIndex + 1;
1129
+ }
1130
+ this._state.highlightedIndex = newIndexDown;
1131
+ this.highlightOption(filteredOptions, newIndexDown);
1132
+ break;
1133
+ }
1134
+ }
1135
+ /**
1136
+ * Handle stub click - open dropdown
1137
+ */
1138
+ handleDesktopStubClick(e) {
1139
+ this.handleStubClickBase(e, () => this.openDesktopDropdown());
1140
+ }
1141
+ /**
1142
+ * Handle option click - select option and close
1143
+ */
1144
+ handleDesktopOptionClick(e) {
1145
+ this.handleOptionClickBase(e, () => this.closeDesktopDropdown());
1146
+ }
1147
+ /**
1148
+ * Handle clear button click - clear selection
1149
+ * CRITICAL: Must prevent dropdown from opening!
1150
+ */
1151
+ handleDesktopClearClick(e) {
1152
+ this.handleClearClickBase(e);
1153
+ // Desktop doesn't close on clear, just clears the selection
1154
+ }
1155
+ // ============================================================================
1156
+ // CUSTOM SCROLLBAR FOR OPTIONS
1157
+ // ============================================================================
1158
+ _setupOptionsScrollbar() {
1159
+ if (!isCustomScrollbarEnabled())
1160
+ return;
1161
+ const shadow = this.shadowRoot;
1162
+ const optionsDiv = shadow.querySelector('.dropdown-options');
1163
+ const optionsWrapper = shadow.querySelector('.dropdown-options-wrapper');
1164
+ if (!optionsDiv || !optionsWrapper)
1165
+ return;
1166
+ this._destroyOptionsScrollbar();
1167
+ optionsDiv.classList.add('ty-custom-scroll');
1168
+ this._optionsScrollbar = new CustomScrollbar(optionsDiv, { vertical: true });
1169
+ // Append track to wrapper (outside scroll area)
1170
+ if (this._optionsScrollbar.trackY) {
1171
+ optionsWrapper.appendChild(this._optionsScrollbar.trackY);
1172
+ }
1173
+ }
1174
+ _destroyOptionsScrollbar() {
1175
+ if (this._optionsScrollbar) {
1176
+ this._optionsScrollbar.trackY?.remove();
1177
+ this._optionsScrollbar.destroy();
1178
+ this._optionsScrollbar = null;
1179
+ }
1180
+ }
1181
+ /**
1182
+ * Build CSS class list for stub
1183
+ */
1184
+ buildStubClasses() {
1185
+ const classes = [this._size];
1186
+ if (this._disabled)
1187
+ classes.push('disabled');
1188
+ if (this._clearable)
1189
+ classes.push('clearable');
1190
+ return classes.join(' ');
1191
+ }
1192
+ /**
1193
+ * Render desktop mode with dialog
1194
+ */
1195
+ renderDesktop() {
1196
+ const shadow = this.shadowRoot;
1197
+ // Only set innerHTML and setup listeners if container doesn't exist (like ClojureScript)
1198
+ if (!shadow.querySelector('.dropdown-container')) {
1199
+ const stubClasses = this.buildStubClasses();
1200
+ const labelHtml = this._label ? `
1201
+ <label class="ty-field-label">
1202
+ ${this._label}
1203
+ ${this._required ? `<span class="required-icon">${REQUIRED_ICON_SVG}</span>` : ''}
1204
+ </label>
1205
+ ` : '';
1206
+ shadow.innerHTML = `
1207
+ <div class="dropdown-container dropdown-mode-desktop">
1208
+ ${labelHtml}
1209
+ <div class="dropdown-wrapper">
1210
+ <div class="dropdown-stub ${stubClasses}"
1211
+ ${this._disabled ? 'disabled' : ''}>
1212
+ <slot name="start"></slot>
1213
+ <slot name="selected"></slot>
1214
+ <span class="dropdown-placeholder">${this._placeholder}</span>
1215
+ <button class="dropdown-clear-btn" type="button" aria-label="Clear selection">
1216
+ ${CLEAR_ICON_SVG}
1217
+ </button>
1218
+ <div class="dropdown-chevron">
1219
+ ${CHEVRON_DOWN_SVG}
1220
+ </div>
1221
+ </div>
1222
+ <dialog class="dropdown-dialog">
1223
+ <div class="dropdown-header">
1224
+ <input
1225
+ class="dropdown-search-input ${this._size}"
1226
+ type="text"
1227
+ placeholder="${this._placeholder}"
1228
+ ${this._disabled ? 'disabled' : ''}
1229
+ />
1230
+ <div class="dropdown-search-chevron">
1231
+ ${CHEVRON_DOWN_SVG}
1232
+ </div>
1233
+ </div>
1234
+ <div class="dropdown-options-wrapper">
1235
+ <div class="dropdown-options">
1236
+ <slot id="options-slot"></slot>
1237
+ </div>
1238
+ <div class="dropdown-loading" aria-hidden="true">
1239
+ <slot name="loading">
1240
+ <span class="dropdown-loading-spinner"></span>
1241
+ <span class="dropdown-loading-text">Searching…</span>
1242
+ </slot>
1243
+ </div>
1244
+ </div>
1245
+ </dialog>
1246
+ </div>
1247
+ </div>
1248
+ `;
1249
+ // Setup event listeners ONCE (only when rendering for the first time)
1250
+ this.setupDesktopEventListeners();
1251
+ }
1252
+ // Dynamic label creation (like input.ts fix)
1253
+ const existingLabel = shadow.querySelector('.ty-field-label');
1254
+ const container = shadow.querySelector('.dropdown-container');
1255
+ const wrapper = shadow.querySelector('.dropdown-wrapper');
1256
+ if (this._label) {
1257
+ if (existingLabel) {
1258
+ // Label exists, update it
1259
+ existingLabel.innerHTML = this._label + (this._required ? '<span class="required-icon">' + REQUIRED_ICON_SVG + '</span>' : '');
1260
+ existingLabel.style.display = 'flex';
1261
+ }
1262
+ else if (container && wrapper) {
1263
+ // Label doesn't exist but we need one - CREATE IT!
1264
+ const labelEl = document.createElement('label');
1265
+ labelEl.className = 'ty-field-label';
1266
+ labelEl.innerHTML = this._label + (this._required ? '<span class="required-icon">' + REQUIRED_ICON_SVG + '</span>' : '');
1267
+ container.insertBefore(labelEl, wrapper);
1268
+ }
1269
+ }
1270
+ else if (existingLabel) {
1271
+ // No label text, hide existing label
1272
+ ;
1273
+ existingLabel.style.display = 'none';
1274
+ }
1275
+ // Always update selection display
1276
+ this.updateSelectionDisplay();
1277
+ }
1278
+ // ============================================================================
1279
+ // MOBILE IMPLEMENTATION (Full-screen Modal)
1280
+ // ============================================================================
1281
+ // Mobile uses full-screen modal with backdrop.
1282
+ //
1283
+ // EVENT FLOW:
1284
+ // 1. User clicks stub → handleMobileStubClick() → openMobileModal()
1285
+ // 2. Modal shown with CSS animation (display: flex + open class)
1286
+ // 3. User types in search → handleSearchInput() [SHARED]
1287
+ // 4. User clicks option → handleMobileOptionClick() → selectOption() [SHARED] → closeMobileModal()
1288
+ // 5. User clicks backdrop → closeMobileModal()
1289
+ // 6. User clicks close button → closeMobileModal()
1290
+ // 7. User presses Escape → handleMobileKeyboard() → closeMobileModal()
1291
+ // 8. ty-option dispatches clear-selection → handleMobileClearClick() → closeMobileModal()
1292
+ //
1293
+ // METHODS:
1294
+ // - openMobileModal(): Open mobile modal
1295
+ // - closeMobileModal(): Close mobile modal
1296
+ // - handleMobileStubClick(): Open on stub click
1297
+ // - handleMobileOptionClick(): Select option and close
1298
+ // - handleMobileClearClick(): Clear selection and close
1299
+ // - handleMobileKeyboard(): Escape key handler only (no arrow navigation)
1300
+ // - renderMobile(): Render mobile HTML
1301
+ // - setupMobileEventListeners(): Setup mobile event handlers
1302
+ // ============================================================================
1303
+ /**
1304
+ * Render mobile mode with full-screen modal
1305
+ */
1306
+ renderMobile() {
1307
+ const shadow = this.shadowRoot;
1308
+ // Only set innerHTML and setup listeners if container doesn't exist
1309
+ if (!shadow.querySelector('.dropdown-container')) {
1310
+ const stubClasses = this.buildStubClasses();
1311
+ const labelHtml = this._label ? `
1312
+ <label class="ty-field-label">
1313
+ ${this._label}
1314
+ ${this._required ? `<span class="required-icon">${REQUIRED_ICON_SVG}</span>` : ''}
1315
+ </label>
1316
+ ` : '';
1317
+ // Close button SVG (X icon)
1318
+ const closeButtonSvg = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1319
+ <line x1="18" y1="6" x2="6" y2="18"></line>
1320
+ <line x1="6" y1="6" x2="18" y2="18"></line>
1321
+ </svg>`;
1322
+ // Search placeholder: "Search <label>..." or just "Search..."
1323
+ const searchPlaceholder = this._label ? `Search ${this._label}...` : 'Search...';
1324
+ // Search header — always present; externalSearch only changes who handles input
1325
+ const searchHeaderHtml = `
1326
+ <div class="mobile-search-header">
1327
+ ${this._label ? `<span class="mobile-header-label">${this._label}</span>` : ''}
1328
+ <div class="mobile-header-content">
1329
+ <input
1330
+ class="mobile-search-input ${this._size}"
1331
+ type="text"
1332
+ placeholder="${searchPlaceholder}"
1333
+ ${this._disabled ? 'disabled' : ''}
1334
+ />
1335
+ <button class="mobile-close-button" type="button" aria-label="Close">
1336
+ ${closeButtonSvg}
1337
+ </button>
1338
+ </div>
1339
+ </div>
1340
+ `;
1341
+ shadow.innerHTML = `
1342
+ <div class="dropdown-container dropdown-mode-mobile">
1343
+ ${labelHtml}
1344
+ <div class="dropdown-wrapper">
1345
+ <div class="dropdown-stub ${stubClasses}"
1346
+ ${this._disabled ? 'disabled' : ''}>
1347
+ <slot name="start"></slot>
1348
+ <slot name="selected"></slot>
1349
+ <span class="dropdown-placeholder">${this._placeholder}</span>
1350
+ <div class="dropdown-chevron">
1351
+ ${CHEVRON_DOWN_SVG}
1352
+ </div>
1353
+ </div>
1354
+ <dialog class="mobile-dialog">
1355
+ <div class="mobile-dialog-content">
1356
+ ${searchHeaderHtml}
1357
+ <div class="mobile-options-container dropdown-options-wrapper">
1358
+ <slot id="options-slot"></slot>
1359
+ <div class="dropdown-loading" aria-hidden="true">
1360
+ <slot name="loading">
1361
+ <span class="dropdown-loading-spinner"></span>
1362
+ <span class="dropdown-loading-text">Searching…</span>
1363
+ </slot>
1364
+ </div>
1365
+ </div>
1366
+ </div>
1367
+ </dialog>
1368
+ </div>
1369
+ </div>
1370
+ `;
1371
+ // Setup event listeners ONCE (only when rendering for the first time)
1372
+ this.setupMobileEventListeners();
1373
+ }
1374
+ // Always update selection display
1375
+ this.updateSelectionDisplay();
1376
+ }
1377
+ /**
1378
+ * Setup event listeners for mobile mode
1379
+ */
1380
+ setupMobileEventListeners() {
1381
+ const shadow = this.shadowRoot;
1382
+ const stub = shadow.querySelector('.dropdown-stub');
1383
+ const optionsSlot = shadow.querySelector('#options-slot');
1384
+ const searchInput = shadow.querySelector('.mobile-search-input');
1385
+ const closeButton = shadow.querySelector('.mobile-close-button');
1386
+ const dialog = shadow.querySelector('.mobile-dialog');
1387
+ if (stub) {
1388
+ stub.addEventListener('click', (e) => this.handleMobileStubClick(e));
1389
+ }
1390
+ // Add option click handler to slot
1391
+ if (optionsSlot) {
1392
+ optionsSlot.addEventListener('click', (e) => this.handleMobileOptionClick(e));
1393
+ }
1394
+ // Add search input handlers
1395
+ if (searchInput) {
1396
+ searchInput.addEventListener('input', (e) => this.handleSearchInput(e));
1397
+ }
1398
+ // Close button click
1399
+ if (closeButton) {
1400
+ closeButton.addEventListener('click', () => this.closeMobileModal());
1401
+ }
1402
+ // Listen for clear-selection events from ty-option
1403
+ if (optionsSlot) {
1404
+ optionsSlot.addEventListener('clear-selection', (e) => {
1405
+ e.preventDefault();
1406
+ e.stopPropagation();
1407
+ this.handleMobileClearClick(e);
1408
+ });
1409
+ }
1410
+ // Native dialog: backdrop click and Escape key
1411
+ if (dialog) {
1412
+ dialog.addEventListener('click', (e) => {
1413
+ if (e.target === dialog) {
1414
+ this.closeMobileModal();
1415
+ }
1416
+ });
1417
+ dialog.addEventListener('cancel', (e) => {
1418
+ e.preventDefault();
1419
+ this.closeMobileModal();
1420
+ });
1421
+ }
1422
+ }
1423
+ /**
1424
+ * Handle mobile stub click - open modal
1425
+ */
1426
+ handleMobileStubClick(e) {
1427
+ this.handleStubClickBase(e, () => this.openMobileModal());
1428
+ }
1429
+ /**
1430
+ * Handle mobile option click - select and close
1431
+ */
1432
+ handleMobileOptionClick(e) {
1433
+ this.handleOptionClickBase(e, () => this.closeMobileModal());
1434
+ }
1435
+ /**
1436
+ * Handle clear click in mobile modal
1437
+ */
1438
+ handleMobileClearClick(e) {
1439
+ this.handleClearClickBase(e, () => this.closeMobileModal());
1440
+ // Mobile closes modal after clearing
1441
+ }
1442
+ /**
1443
+ * Open mobile modal
1444
+ */
1445
+ openMobileModal() {
1446
+ const shadow = this.shadowRoot;
1447
+ const dialog = shadow.querySelector('.mobile-dialog');
1448
+ if (!dialog)
1449
+ return;
1450
+ // Lock scroll
1451
+ this.lockDropdownScroll();
1452
+ // Show dialog with animation
1453
+ dialog.showModal();
1454
+ requestAnimationFrame(() => {
1455
+ dialog.classList.add('open');
1456
+ });
1457
+ // Update component state
1458
+ this._state.open = true;
1459
+ // Initialize options state (no highlight on mobile)
1460
+ this.initializeOptionsState(false);
1461
+ // Focus search input
1462
+ const searchInput = shadow.querySelector('.mobile-search-input');
1463
+ if (searchInput) {
1464
+ // Small delay to ensure dialog is visible and keyboard doesn't glitch
1465
+ setTimeout(() => searchInput.focus(), 300);
1466
+ }
1467
+ // Hide clear button when modal is open (stub clear button - desktop only)
1468
+ this.updateClearButton();
1469
+ // Lifecycle event (also fires empty search if external-search)
1470
+ this.fireOpenEvent();
1471
+ }
1472
+ /**
1473
+ * Close mobile modal
1474
+ */
1475
+ closeMobileModal() {
1476
+ const shadow = this.shadowRoot;
1477
+ const dialog = shadow.querySelector('.mobile-dialog');
1478
+ if (!dialog)
1479
+ return;
1480
+ // Unlock scroll
1481
+ this.unlockDropdownScroll();
1482
+ // Close immediately — ::backdrop doesn't support transitions
1483
+ dialog.classList.remove('open');
1484
+ dialog.close();
1485
+ // Update state
1486
+ this._state.open = false;
1487
+ this._state.highlightedIndex = -1;
1488
+ // Reset search and restore all options (internal mode only — parent owns options in external mode)
1489
+ this._state.search = '';
1490
+ this._state.filteredOptions = [];
1491
+ const searchInput = shadow.querySelector('.mobile-search-input');
1492
+ if (searchInput) {
1493
+ searchInput.value = '';
1494
+ }
1495
+ if (!this._externalSearch) {
1496
+ const allOptions = this.getOptions().map(el => this.getOptionData(el));
1497
+ allOptions.forEach(({ element }) => element.removeAttribute('hidden'));
1498
+ }
1499
+ // Show clear button when modal is closed (if applicable)
1500
+ this.updateClearButton();
1501
+ // Lifecycle event
1502
+ this.fireCloseEvent();
1503
+ }
1504
+ // ============================================================================
1505
+ // PUBLIC API - Getters/Setters
1506
+ // ============================================================================
1507
+ // ============================================================================
1508
+ // PROPERTY ACCESSORS - Simplified with TyComponent
1509
+ // ============================================================================
1510
+ get value() { return this.getProperty('value'); }
1511
+ set value(v) { this.setProperty('value', v); }
1512
+ get name() { return this.getProperty('name'); }
1513
+ set name(v) { this.setProperty('name', v); }
1514
+ get placeholder() { return this.getProperty('placeholder'); }
1515
+ set placeholder(v) { this.setProperty('placeholder', v); }
1516
+ get label() { return this.getProperty('label'); }
1517
+ set label(v) { this.setProperty('label', v); }
1518
+ get disabled() { return this.getProperty('disabled'); }
1519
+ set disabled(v) { this.setProperty('disabled', v); }
1520
+ get readonly() { return this.getProperty('readonly'); }
1521
+ set readonly(v) { this.setProperty('readonly', v); }
1522
+ get required() { return this.getProperty('required'); }
1523
+ set required(v) { this.setProperty('required', v); }
1524
+ get externalSearch() { return this.getProperty('externalSearch'); }
1525
+ set externalSearch(v) { this.setProperty('externalSearch', v); }
1526
+ get clearable() { return this.getProperty('clearable'); }
1527
+ set clearable(v) { this.setProperty('clearable', v); }
1528
+ get loading() { return this.getProperty('loading'); }
1529
+ set loading(v) { this.setProperty('loading', v); }
1530
+ get size() { return this.getProperty('size'); }
1531
+ set size(v) { this.setProperty('size', v); }
1532
+ get flavor() { return this.getProperty('flavor'); }
1533
+ set flavor(v) { this.setProperty('flavor', v); }
1534
+ get debounce() { return this.getProperty('debounce'); }
1535
+ set debounce(v) { this.setProperty('debounce', v); }
1536
+ get form() {
1537
+ return this._internals.form;
1538
+ }
1539
+ }
1540
+ // ============================================================================
1541
+ // PROPERTY CONFIGURATION - Declarative property lifecycle
1542
+ // ============================================================================
1543
+ TyDropdown.properties = {
1544
+ value: {
1545
+ type: 'string',
1546
+ visual: true,
1547
+ formValue: true,
1548
+ emitChange: true,
1549
+ default: ''
1550
+ },
1551
+ name: {
1552
+ type: 'string',
1553
+ default: ''
1554
+ },
1555
+ placeholder: {
1556
+ type: 'string',
1557
+ visual: true,
1558
+ default: 'Select an option...'
1559
+ },
1560
+ label: {
1561
+ type: 'string',
1562
+ visual: true,
1563
+ default: ''
1564
+ },
1565
+ disabled: {
1566
+ type: 'boolean',
1567
+ visual: true,
1568
+ default: false
1569
+ },
1570
+ readonly: {
1571
+ type: 'boolean',
1572
+ visual: true,
1573
+ default: false
1574
+ },
1575
+ required: {
1576
+ type: 'boolean',
1577
+ visual: true,
1578
+ default: false
1579
+ },
1580
+ externalSearch: {
1581
+ type: 'boolean',
1582
+ visual: true,
1583
+ default: false,
1584
+ aliases: { 'external-search': true }
1585
+ },
1586
+ clearable: {
1587
+ type: 'boolean',
1588
+ visual: true,
1589
+ default: true,
1590
+ aliases: { 'not-clearable': false }
1591
+ },
1592
+ size: {
1593
+ type: 'string',
1594
+ visual: true,
1595
+ default: 'md',
1596
+ validate: (v) => ['xs', 'sm', 'md', 'lg', 'xl'].includes(v),
1597
+ coerce: (v) => {
1598
+ if (!['xs', 'sm', 'md', 'lg', 'xl'].includes(v)) {
1599
+ console.warn(`[ty-dropdown] Invalid size. Using md.`);
1600
+ return 'md';
1601
+ }
1602
+ return v;
1603
+ }
1604
+ },
1605
+ flavor: {
1606
+ type: 'string',
1607
+ visual: true,
1608
+ default: 'neutral',
1609
+ validate: (v) => ['primary', 'secondary', 'success', 'danger', 'warning', 'neutral'].includes(v),
1610
+ coerce: (v) => {
1611
+ const valid = ['primary', 'secondary', 'success', 'danger', 'warning', 'neutral'];
1612
+ if (!valid.includes(v)) {
1613
+ console.warn(`[ty-dropdown] Invalid flavor. Using neutral.`);
1614
+ return 'neutral';
1615
+ }
1616
+ return v;
1617
+ }
1618
+ },
1619
+ debounce: {
1620
+ type: 'number',
1621
+ default: 0,
1622
+ validate: (v) => v >= 0 && v <= 5000,
1623
+ coerce: (v) => {
1624
+ const num = Number(v);
1625
+ if (isNaN(num))
1626
+ return 0;
1627
+ return Math.max(0, Math.min(5000, num));
1628
+ }
1629
+ },
1630
+ loading: {
1631
+ type: 'boolean',
1632
+ visual: true,
1633
+ default: false
1634
+ }
1635
+ };
1636
+ // Register the custom element
1637
+ if (!customElements.get('ty-dropdown')) {
1638
+ customElements.define('ty-dropdown', TyDropdown);
1639
+ }
1640
+ //# sourceMappingURL=dropdown.js.map