tyrell-components 1.0.0-RC6

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