tyrell-components 1.0.0-TC44 → 1.0.0-TC46

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