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