tyrell-components 1.0.0-RC13 → 1.0.0-RC14

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