tyrell-components 1.0.0-TC7

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