stimeo-ui 0.16.0 → 0.18.0

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 (337) hide show
  1. package/CHANGELOG.md +136 -0
  2. package/README.md +27 -11
  3. package/dist/cable/index.d.ts +88 -31
  4. package/dist/cable/index.js +384 -449
  5. package/dist/cable/index.js.map +1 -1
  6. package/dist/coerce-JtZVNG5h.d.ts +20 -0
  7. package/dist/controllers/accordion_controller.d.ts +4 -2
  8. package/dist/controllers/accordion_controller.js +174 -55
  9. package/dist/controllers/accordion_controller.js.map +1 -1
  10. package/dist/controllers/alert_dialog_controller.d.ts +12 -2
  11. package/dist/controllers/alert_dialog_controller.js +903 -235
  12. package/dist/controllers/alert_dialog_controller.js.map +1 -1
  13. package/dist/controllers/announcer_controller.d.ts +37 -5
  14. package/dist/controllers/announcer_controller.js +238 -205
  15. package/dist/controllers/announcer_controller.js.map +1 -1
  16. package/dist/controllers/aspect_ratio_controller.js +0 -10
  17. package/dist/controllers/aspect_ratio_controller.js.map +1 -1
  18. package/dist/controllers/auto_submit_controller.d.ts +30 -6
  19. package/dist/controllers/auto_submit_controller.js +414 -145
  20. package/dist/controllers/auto_submit_controller.js.map +1 -1
  21. package/dist/controllers/avatar_controller.js +185 -107
  22. package/dist/controllers/avatar_controller.js.map +1 -1
  23. package/dist/controllers/breadcrumb_controller.js +0 -130
  24. package/dist/controllers/breadcrumb_controller.js.map +1 -1
  25. package/dist/controllers/bulk_select_controller.d.ts +21 -3
  26. package/dist/controllers/bulk_select_controller.js +282 -58
  27. package/dist/controllers/bulk_select_controller.js.map +1 -1
  28. package/dist/controllers/calendar_controller.d.ts +38 -16
  29. package/dist/controllers/calendar_controller.js +228 -260
  30. package/dist/controllers/calendar_controller.js.map +1 -1
  31. package/dist/controllers/carousel_controller.d.ts +43 -12
  32. package/dist/controllers/carousel_controller.js +536 -436
  33. package/dist/controllers/carousel_controller.js.map +1 -1
  34. package/dist/controllers/character_counter_controller.d.ts +44 -7
  35. package/dist/controllers/character_counter_controller.js +493 -207
  36. package/dist/controllers/character_counter_controller.js.map +1 -1
  37. package/dist/controllers/checkbox_controller.d.ts +7 -2
  38. package/dist/controllers/checkbox_controller.js +38 -74
  39. package/dist/controllers/checkbox_controller.js.map +1 -1
  40. package/dist/controllers/clipboard_controller.d.ts +16 -5
  41. package/dist/controllers/clipboard_controller.js +261 -149
  42. package/dist/controllers/clipboard_controller.js.map +1 -1
  43. package/dist/controllers/collapsible_controller.d.ts +17 -5
  44. package/dist/controllers/collapsible_controller.js +304 -120
  45. package/dist/controllers/collapsible_controller.js.map +1 -1
  46. package/dist/controllers/color_picker_controller.d.ts +18 -6
  47. package/dist/controllers/color_picker_controller.js +178 -146
  48. package/dist/controllers/color_picker_controller.js.map +1 -1
  49. package/dist/controllers/combobox_controller.d.ts +24 -6
  50. package/dist/controllers/combobox_controller.js +522 -156
  51. package/dist/controllers/combobox_controller.js.map +1 -1
  52. package/dist/controllers/command_palette_controller.d.ts +51 -20
  53. package/dist/controllers/command_palette_controller.js +1156 -395
  54. package/dist/controllers/command_palette_controller.js.map +1 -1
  55. package/dist/controllers/conditional_fields_controller.d.ts +15 -2
  56. package/dist/controllers/conditional_fields_controller.js +300 -171
  57. package/dist/controllers/conditional_fields_controller.js.map +1 -1
  58. package/dist/controllers/confirm_controller.d.ts +49 -12
  59. package/dist/controllers/confirm_controller.js +980 -262
  60. package/dist/controllers/confirm_controller.js.map +1 -1
  61. package/dist/controllers/context_menu_controller.d.ts +80 -18
  62. package/dist/controllers/context_menu_controller.js +346 -153
  63. package/dist/controllers/context_menu_controller.js.map +1 -1
  64. package/dist/controllers/count_up_controller.d.ts +13 -1
  65. package/dist/controllers/count_up_controller.js +73 -21
  66. package/dist/controllers/count_up_controller.js.map +1 -1
  67. package/dist/controllers/countdown_controller.d.ts +47 -3
  68. package/dist/controllers/countdown_controller.js +281 -158
  69. package/dist/controllers/countdown_controller.js.map +1 -1
  70. package/dist/controllers/currency_input_controller.d.ts +93 -10
  71. package/dist/controllers/currency_input_controller.js +575 -225
  72. package/dist/controllers/currency_input_controller.js.map +1 -1
  73. package/dist/controllers/data_grid_controller.d.ts +25 -12
  74. package/dist/controllers/data_grid_controller.js +248 -214
  75. package/dist/controllers/data_grid_controller.js.map +1 -1
  76. package/dist/controllers/date_range_picker_controller.d.ts +42 -14
  77. package/dist/controllers/date_range_picker_controller.js +338 -235
  78. package/dist/controllers/date_range_picker_controller.js.map +1 -1
  79. package/dist/controllers/dialog_controller.d.ts +17 -6
  80. package/dist/controllers/dialog_controller.js +899 -237
  81. package/dist/controllers/dialog_controller.js.map +1 -1
  82. package/dist/controllers/direct_upload_controller.d.ts +25 -8
  83. package/dist/controllers/direct_upload_controller.js +97 -167
  84. package/dist/controllers/direct_upload_controller.js.map +1 -1
  85. package/dist/controllers/dirty_form_controller.d.ts +6 -1
  86. package/dist/controllers/dirty_form_controller.js +72 -69
  87. package/dist/controllers/dirty_form_controller.js.map +1 -1
  88. package/dist/controllers/dismissible_controller.d.ts +19 -0
  89. package/dist/controllers/dismissible_controller.js +196 -24
  90. package/dist/controllers/dismissible_controller.js.map +1 -1
  91. package/dist/controllers/drawer_controller.d.ts +39 -11
  92. package/dist/controllers/drawer_controller.js +974 -355
  93. package/dist/controllers/drawer_controller.js.map +1 -1
  94. package/dist/controllers/dropdown_controller.d.ts +31 -1
  95. package/dist/controllers/dropdown_controller.js +339 -96
  96. package/dist/controllers/dropdown_controller.js.map +1 -1
  97. package/dist/controllers/editable_controller.d.ts +12 -1
  98. package/dist/controllers/editable_controller.js +315 -88
  99. package/dist/controllers/editable_controller.js.map +1 -1
  100. package/dist/controllers/empty_state_controller.js +79 -58
  101. package/dist/controllers/empty_state_controller.js.map +1 -1
  102. package/dist/controllers/file_dropzone_controller.d.ts +63 -18
  103. package/dist/controllers/file_dropzone_controller.js +322 -211
  104. package/dist/controllers/file_dropzone_controller.js.map +1 -1
  105. package/dist/controllers/filter_controller.js +77 -42
  106. package/dist/controllers/filter_controller.js.map +1 -1
  107. package/dist/controllers/flash_controller.d.ts +35 -12
  108. package/dist/controllers/flash_controller.js +238 -347
  109. package/dist/controllers/flash_controller.js.map +1 -1
  110. package/dist/controllers/focus_controller.d.ts +15 -6
  111. package/dist/controllers/focus_controller.js +679 -240
  112. package/dist/controllers/focus_controller.js.map +1 -1
  113. package/dist/controllers/form_field_controller.js +156 -132
  114. package/dist/controllers/form_field_controller.js.map +1 -1
  115. package/dist/controllers/form_validation_controller.d.ts +3 -3
  116. package/dist/controllers/form_validation_controller.js +31 -97
  117. package/dist/controllers/form_validation_controller.js.map +1 -1
  118. package/dist/controllers/frame_loading_controller.d.ts +47 -14
  119. package/dist/controllers/frame_loading_controller.js +275 -217
  120. package/dist/controllers/frame_loading_controller.js.map +1 -1
  121. package/dist/controllers/highlight_controller.d.ts +27 -6
  122. package/dist/controllers/highlight_controller.js +107 -75
  123. package/dist/controllers/highlight_controller.js.map +1 -1
  124. package/dist/controllers/hover_card_controller.d.ts +60 -4
  125. package/dist/controllers/hover_card_controller.js +327 -130
  126. package/dist/controllers/hover_card_controller.js.map +1 -1
  127. package/dist/controllers/idle_controller.d.ts +48 -6
  128. package/dist/controllers/idle_controller.js +271 -101
  129. package/dist/controllers/idle_controller.js.map +1 -1
  130. package/dist/controllers/input_mask_controller.d.ts +30 -13
  131. package/dist/controllers/input_mask_controller.js +483 -97
  132. package/dist/controllers/input_mask_controller.js.map +1 -1
  133. package/dist/controllers/intersection_controller.d.ts +32 -13
  134. package/dist/controllers/intersection_controller.js +150 -106
  135. package/dist/controllers/intersection_controller.js.map +1 -1
  136. package/dist/controllers/lazy_frame_controller.d.ts +19 -2
  137. package/dist/controllers/lazy_frame_controller.js +24 -68
  138. package/dist/controllers/lazy_frame_controller.js.map +1 -1
  139. package/dist/controllers/listbox_controller.d.ts +44 -9
  140. package/dist/controllers/listbox_controller.js +523 -240
  141. package/dist/controllers/listbox_controller.js.map +1 -1
  142. package/dist/controllers/local_time_controller.d.ts +9 -7
  143. package/dist/controllers/local_time_controller.js +102 -77
  144. package/dist/controllers/local_time_controller.js.map +1 -1
  145. package/dist/controllers/masonry_controller.d.ts +11 -3
  146. package/dist/controllers/masonry_controller.js +80 -96
  147. package/dist/controllers/masonry_controller.js.map +1 -1
  148. package/dist/controllers/menu_controller.d.ts +55 -11
  149. package/dist/controllers/menu_controller.js +302 -188
  150. package/dist/controllers/menu_controller.js.map +1 -1
  151. package/dist/controllers/menubar_controller.d.ts +51 -9
  152. package/dist/controllers/menubar_controller.js +232 -345
  153. package/dist/controllers/menubar_controller.js.map +1 -1
  154. package/dist/controllers/meter_controller.d.ts +33 -20
  155. package/dist/controllers/meter_controller.js +417 -87
  156. package/dist/controllers/meter_controller.js.map +1 -1
  157. package/dist/controllers/multi_select_controller.d.ts +48 -18
  158. package/dist/controllers/multi_select_controller.js +785 -393
  159. package/dist/controllers/multi_select_controller.js.map +1 -1
  160. package/dist/controllers/navigation_menu_controller.d.ts +27 -6
  161. package/dist/controllers/navigation_menu_controller.js +190 -250
  162. package/dist/controllers/navigation_menu_controller.js.map +1 -1
  163. package/dist/controllers/nested_form_controller.d.ts +10 -0
  164. package/dist/controllers/nested_form_controller.js +396 -192
  165. package/dist/controllers/nested_form_controller.js.map +1 -1
  166. package/dist/controllers/network_status_controller.d.ts +23 -0
  167. package/dist/controllers/network_status_controller.js +268 -62
  168. package/dist/controllers/network_status_controller.js.map +1 -1
  169. package/dist/controllers/number_input_controller.d.ts +50 -8
  170. package/dist/controllers/number_input_controller.js +610 -303
  171. package/dist/controllers/number_input_controller.js.map +1 -1
  172. package/dist/controllers/optimistic_controller.d.ts +8 -5
  173. package/dist/controllers/optimistic_controller.js +45 -87
  174. package/dist/controllers/optimistic_controller.js.map +1 -1
  175. package/dist/controllers/otp_controller.d.ts +55 -8
  176. package/dist/controllers/otp_controller.js +658 -255
  177. package/dist/controllers/otp_controller.js.map +1 -1
  178. package/dist/controllers/overflow_indicator_controller.d.ts +5 -0
  179. package/dist/controllers/overflow_indicator_controller.js +122 -113
  180. package/dist/controllers/overflow_indicator_controller.js.map +1 -1
  181. package/dist/controllers/overflow_menu_controller.d.ts +58 -20
  182. package/dist/controllers/overflow_menu_controller.js +470 -295
  183. package/dist/controllers/overflow_menu_controller.js.map +1 -1
  184. package/dist/controllers/pagination_controller.d.ts +37 -7
  185. package/dist/controllers/pagination_controller.js +332 -123
  186. package/dist/controllers/pagination_controller.js.map +1 -1
  187. package/dist/controllers/password_reveal_controller.d.ts +46 -8
  188. package/dist/controllers/password_reveal_controller.js +392 -121
  189. package/dist/controllers/password_reveal_controller.js.map +1 -1
  190. package/dist/controllers/password_strength_controller.d.ts +15 -6
  191. package/dist/controllers/password_strength_controller.js +111 -158
  192. package/dist/controllers/password_strength_controller.js.map +1 -1
  193. package/dist/controllers/persist_controller.d.ts +11 -4
  194. package/dist/controllers/persist_controller.js +165 -111
  195. package/dist/controllers/persist_controller.js.map +1 -1
  196. package/dist/controllers/pointer_drag_controller.d.ts +35 -12
  197. package/dist/controllers/pointer_drag_controller.js +434 -188
  198. package/dist/controllers/pointer_drag_controller.js.map +1 -1
  199. package/dist/controllers/popover_controller.d.ts +41 -5
  200. package/dist/controllers/popover_controller.js +269 -113
  201. package/dist/controllers/popover_controller.js.map +1 -1
  202. package/dist/controllers/portal_controller.d.ts +44 -4
  203. package/dist/controllers/portal_controller.js +215 -76
  204. package/dist/controllers/portal_controller.js.map +1 -1
  205. package/dist/controllers/preview_guard_controller.d.ts +10 -4
  206. package/dist/controllers/preview_guard_controller.js +243 -116
  207. package/dist/controllers/preview_guard_controller.js.map +1 -1
  208. package/dist/controllers/progress_controller.d.ts +20 -19
  209. package/dist/controllers/progress_controller.js +391 -78
  210. package/dist/controllers/progress_controller.js.map +1 -1
  211. package/dist/controllers/radio_group_controller.d.ts +21 -9
  212. package/dist/controllers/radio_group_controller.js +278 -211
  213. package/dist/controllers/radio_group_controller.js.map +1 -1
  214. package/dist/controllers/range_slider_controller.d.ts +47 -6
  215. package/dist/controllers/range_slider_controller.js +220 -113
  216. package/dist/controllers/range_slider_controller.js.map +1 -1
  217. package/dist/controllers/rating_controller.d.ts +20 -4
  218. package/dist/controllers/rating_controller.js +458 -211
  219. package/dist/controllers/rating_controller.js.map +1 -1
  220. package/dist/controllers/read_more_controller.d.ts +2 -0
  221. package/dist/controllers/read_more_controller.js +138 -63
  222. package/dist/controllers/read_more_controller.js.map +1 -1
  223. package/dist/controllers/reading_progress_controller.d.ts +1 -1
  224. package/dist/controllers/reading_progress_controller.js +166 -137
  225. package/dist/controllers/reading_progress_controller.js.map +1 -1
  226. package/dist/controllers/relative_time_controller.d.ts +20 -6
  227. package/dist/controllers/relative_time_controller.js +211 -112
  228. package/dist/controllers/relative_time_controller.js.map +1 -1
  229. package/dist/controllers/reset_on_restore_controller.d.ts +85 -0
  230. package/dist/controllers/{reset_before_cache_controller.js → reset_on_restore_controller.js} +103 -38
  231. package/dist/controllers/reset_on_restore_controller.js.map +1 -0
  232. package/dist/controllers/resizable_controller.d.ts +31 -2
  233. package/dist/controllers/resizable_controller.js +299 -150
  234. package/dist/controllers/resizable_controller.js.map +1 -1
  235. package/dist/controllers/roving_controller.d.ts +3 -3
  236. package/dist/controllers/roving_controller.js +49 -88
  237. package/dist/controllers/roving_controller.js.map +1 -1
  238. package/dist/controllers/scroll_area_controller.d.ts +7 -3
  239. package/dist/controllers/scroll_area_controller.js +490 -246
  240. package/dist/controllers/scroll_area_controller.js.map +1 -1
  241. package/dist/controllers/scroll_restore_controller.d.ts +3 -3
  242. package/dist/controllers/scroll_restore_controller.js +14 -55
  243. package/dist/controllers/scroll_restore_controller.js.map +1 -1
  244. package/dist/controllers/scroll_visibility_controller.d.ts +28 -12
  245. package/dist/controllers/scroll_visibility_controller.js +432 -149
  246. package/dist/controllers/scroll_visibility_controller.js.map +1 -1
  247. package/dist/controllers/scrollspy_controller.d.ts +6 -1
  248. package/dist/controllers/scrollspy_controller.js +193 -248
  249. package/dist/controllers/scrollspy_controller.js.map +1 -1
  250. package/dist/controllers/separator_controller.d.ts +20 -5
  251. package/dist/controllers/separator_controller.js +285 -142
  252. package/dist/controllers/separator_controller.js.map +1 -1
  253. package/dist/controllers/sidebar_controller.d.ts +65 -8
  254. package/dist/controllers/sidebar_controller.js +1069 -404
  255. package/dist/controllers/sidebar_controller.js.map +1 -1
  256. package/dist/controllers/skeleton_controller.d.ts +23 -0
  257. package/dist/controllers/skeleton_controller.js +259 -101
  258. package/dist/controllers/skeleton_controller.js.map +1 -1
  259. package/dist/controllers/slider_controller.d.ts +29 -2
  260. package/dist/controllers/slider_controller.js +187 -86
  261. package/dist/controllers/slider_controller.js.map +1 -1
  262. package/dist/controllers/smart_sticky_header_controller.d.ts +22 -4
  263. package/dist/controllers/smart_sticky_header_controller.js +173 -66
  264. package/dist/controllers/smart_sticky_header_controller.js.map +1 -1
  265. package/dist/controllers/sortable_controller.d.ts +4 -4
  266. package/dist/controllers/sortable_controller.js +26 -125
  267. package/dist/controllers/sortable_controller.js.map +1 -1
  268. package/dist/controllers/spinner_controller.d.ts +39 -4
  269. package/dist/controllers/spinner_controller.js +288 -173
  270. package/dist/controllers/spinner_controller.js.map +1 -1
  271. package/dist/controllers/step_indicator_controller.d.ts +9 -4
  272. package/dist/controllers/step_indicator_controller.js +105 -46
  273. package/dist/controllers/step_indicator_controller.js.map +1 -1
  274. package/dist/controllers/stepper_controller.d.ts +14 -6
  275. package/dist/controllers/stepper_controller.js +120 -73
  276. package/dist/controllers/stepper_controller.js.map +1 -1
  277. package/dist/controllers/stick_to_bottom_controller.d.ts +9 -1
  278. package/dist/controllers/stick_to_bottom_controller.js +275 -140
  279. package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
  280. package/dist/controllers/sticky_observer_controller.d.ts +11 -0
  281. package/dist/controllers/sticky_observer_controller.js +220 -49
  282. package/dist/controllers/sticky_observer_controller.js.map +1 -1
  283. package/dist/controllers/submit_once_controller.d.ts +24 -8
  284. package/dist/controllers/submit_once_controller.js +376 -227
  285. package/dist/controllers/submit_once_controller.js.map +1 -1
  286. package/dist/controllers/switch_controller.d.ts +18 -4
  287. package/dist/controllers/switch_controller.js +201 -84
  288. package/dist/controllers/switch_controller.js.map +1 -1
  289. package/dist/controllers/tabs_controller.d.ts +9 -6
  290. package/dist/controllers/tabs_controller.js +41 -30
  291. package/dist/controllers/tabs_controller.js.map +1 -1
  292. package/dist/controllers/tags_input_controller.d.ts +16 -3
  293. package/dist/controllers/tags_input_controller.js +293 -173
  294. package/dist/controllers/tags_input_controller.js.map +1 -1
  295. package/dist/controllers/textarea_autosize_controller.d.ts +11 -1
  296. package/dist/controllers/textarea_autosize_controller.js +108 -74
  297. package/dist/controllers/textarea_autosize_controller.js.map +1 -1
  298. package/dist/controllers/theme_controller.d.ts +24 -11
  299. package/dist/controllers/theme_controller.js +140 -116
  300. package/dist/controllers/theme_controller.js.map +1 -1
  301. package/dist/controllers/time_picker_controller.d.ts +55 -5
  302. package/dist/controllers/time_picker_controller.js +331 -127
  303. package/dist/controllers/time_picker_controller.js.map +1 -1
  304. package/dist/controllers/toast_controller.d.ts +40 -16
  305. package/dist/controllers/toast_controller.js +244 -324
  306. package/dist/controllers/toast_controller.js.map +1 -1
  307. package/dist/controllers/toggle_group_controller.d.ts +14 -6
  308. package/dist/controllers/toggle_group_controller.js +252 -191
  309. package/dist/controllers/toggle_group_controller.js.map +1 -1
  310. package/dist/controllers/toolbar_controller.d.ts +8 -6
  311. package/dist/controllers/toolbar_controller.js +219 -120
  312. package/dist/controllers/toolbar_controller.js.map +1 -1
  313. package/dist/controllers/tooltip_controller.d.ts +49 -4
  314. package/dist/controllers/tooltip_controller.js +158 -124
  315. package/dist/controllers/tooltip_controller.js.map +1 -1
  316. package/dist/controllers/transition_controller.d.ts +22 -4
  317. package/dist/controllers/transition_controller.js +123 -119
  318. package/dist/controllers/transition_controller.js.map +1 -1
  319. package/dist/controllers/tree_view_controller.d.ts +22 -12
  320. package/dist/controllers/tree_view_controller.js +283 -369
  321. package/dist/controllers/tree_view_controller.js.map +1 -1
  322. package/dist/index.d.ts +10 -5
  323. package/dist/index.js +8573 -10410
  324. package/dist/index.js.map +1 -1
  325. package/dist/inspector/cli.d.ts +38 -14
  326. package/dist/inspector/cli.js +108 -40
  327. package/dist/inspector/cli.js.map +1 -1
  328. package/dist/inspector/cli_bin.js +145 -64
  329. package/dist/inspector/cli_bin.js.map +1 -1
  330. package/dist/inspector/examples.json +20 -20
  331. package/dist/inspector/manifest.json +802 -101
  332. package/dist/positioning/index.d.ts +15 -7
  333. package/dist/positioning/index.js +154 -47
  334. package/dist/positioning/index.js.map +1 -1
  335. package/package.json +11 -10
  336. package/dist/controllers/reset_before_cache_controller.d.ts +0 -76
  337. package/dist/controllers/reset_before_cache_controller.js.map +0 -1
@@ -1,8 +1,34 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
- // src/controllers/multi_select_controller.ts
3
+ var FOCUS_EVENTS = /* @__PURE__ */ new Set(["blur", "focus", "focusin", "focusout"]);
4
+ var POINTER_EVENTS = /* @__PURE__ */ new Set(["mouseenter", "mouseleave", "pointerenter", "pointerleave"]);
5
+ function stateReasonFor(event) {
6
+ if (!event) return "api";
7
+ if (FOCUS_EVENTS.has(event.type)) return "focus";
8
+ if (POINTER_EVENTS.has(event.type)) return "pointer";
9
+ return "user";
10
+ }
11
+
12
+ function actionSource(source) {
13
+ if (source instanceof HTMLElement) {
14
+ return { event: null, host: source, origin: source, reason: "api" };
15
+ }
16
+ const event = source ?? null;
17
+ return {
18
+ event,
19
+ host: event?.currentTarget instanceof Element ? event.currentTarget : null,
20
+ origin: event?.target instanceof Element ? event.target : null,
21
+ reason: stateReasonFor(event)
22
+ };
23
+ }
24
+
25
+ function fillTemplate(template, values) {
26
+ return template.replace(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g, (match, name) => {
27
+ const replacement = Object.hasOwn(values, name) ? values[name] : void 0;
28
+ return replacement === void 0 ? match : String(replacement);
29
+ });
30
+ }
4
31
 
5
- // src/utils/announce.ts
6
32
  function announce(message, options = {}) {
7
33
  const text = message.trim();
8
34
  if (text.length === 0) return;
@@ -12,20 +38,23 @@ function announce(message, options = {}) {
12
38
  })
13
39
  );
14
40
  }
15
- function fillTemplate(template, values) {
16
- return template.replace(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g, (match, name) => {
17
- const replacement = values[name];
18
- return replacement === void 0 ? match : String(replacement);
19
- });
41
+
42
+ function sharedRegistry(key, create) {
43
+ const symbol = Symbol.for(key);
44
+ const scope = globalThis;
45
+ const existing = scope[symbol];
46
+ if (existing !== void 0) return existing;
47
+ const registry2 = create();
48
+ Object.defineProperty(globalThis, symbol, { value: registry2 });
49
+ return registry2;
20
50
  }
21
51
 
22
- // src/utils/aria_ids.ts
23
- var counter = 0;
52
+ var ids = sharedRegistry("stimeo-ui.aria-ids.registry.v1", () => ({ counter: 0 }));
24
53
  function uniqueId(prefix = "stimeo") {
25
54
  let candidate;
26
55
  do {
27
- counter += 1;
28
- candidate = `${prefix}-${counter}`;
56
+ ids.counter += 1;
57
+ candidate = `${prefix}-${ids.counter}`;
29
58
  } while (typeof document !== "undefined" && document.getElementById(candidate) !== null);
30
59
  return candidate;
31
60
  }
@@ -36,12 +65,10 @@ function ensureId(element, prefix = "stimeo") {
36
65
  return id;
37
66
  }
38
67
 
39
- // src/utils/logical_scroll.ts
40
68
  function isRtl(element) {
41
69
  return window.getComputedStyle(element).direction === "rtl";
42
70
  }
43
71
 
44
- // src/utils/arrow_step.ts
45
72
  function logicalArrowKey(key, element) {
46
73
  if (key !== "ArrowRight" && key !== "ArrowLeft") return key;
47
74
  if (!isRtl(element)) return key;
@@ -51,8 +78,153 @@ function isReservedArrowChord(event, allow = []) {
51
78
  if (!event.key.startsWith("Arrow")) return false;
52
79
  return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
53
80
  }
81
+ function hasModifierChord(event) {
82
+ return event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
83
+ }
84
+
85
+ function setAttributeIfChanged(element, name, value, beforeWrite) {
86
+ if (element.getAttribute(name) === value) return false;
87
+ if (value === null) element.removeAttribute(name);
88
+ else element.setAttribute(name, value);
89
+ return true;
90
+ }
91
+
92
+ var registry = sharedRegistry(
93
+ "stimeo-ui.attribute-lease.registry.v1",
94
+ () => ({ stacks: /* @__PURE__ */ new WeakMap(), names: /* @__PURE__ */ new Map() })
95
+ );
96
+ function isAttributeValue(value) {
97
+ return value === null || typeof value === "string";
98
+ }
99
+ function readLeaseRecord(element, record) {
100
+ try {
101
+ const parsed = JSON.parse(element.getAttribute(record) ?? "null");
102
+ if (!Array.isArray(parsed)) return void 0;
103
+ const [original, written, ...rest] = parsed;
104
+ const linked = rest[0] !== false;
105
+ const beneath = linked ? rest : rest.slice(1);
106
+ if (!beneath.every((under) => typeof under === "string")) return void 0;
107
+ return isAttributeValue(original) && isAttributeValue(written) ? { original, written, linked, beneath } : void 0;
108
+ } catch {
109
+ return void 0;
110
+ }
111
+ }
112
+ function attributeStack(element, attribute) {
113
+ const byAttribute = registry.stacks.get(element) ?? /* @__PURE__ */ new Map();
114
+ registry.stacks.set(element, byAttribute);
115
+ const known = byAttribute.get(attribute);
116
+ if (known) return known;
117
+ const found = [];
118
+ for (const record of registry.names.get(attribute) ?? []) {
119
+ const read = readLeaseRecord(element, record);
120
+ if (read) found.push({ ...read, record });
121
+ }
122
+ const depth = ({ beneath }) => beneath.filter((name) => found.some((other) => other.record === name)).length;
123
+ const stack = found.sort((one, other) => depth(one) - depth(other)).map(({ record, written, original, linked }, index) => ({
124
+ lease: null,
125
+ record,
126
+ written,
127
+ original,
128
+ linked: linked && index > 0
129
+ }));
130
+ byAttribute.set(attribute, stack);
131
+ return stack;
132
+ }
133
+ function findHold(stack, lease, record) {
134
+ return stack.find((held) => held.lease === lease) ?? stack.find((held) => held.lease === null && held.record === record);
135
+ }
136
+ var AttributeLease = class {
137
+ #attribute;
138
+ #record;
139
+ #base;
140
+ #preserveAuthor;
141
+ #held = /* @__PURE__ */ new Set();
142
+ constructor(attribute, owner, options = {}) {
143
+ this.#attribute = attribute;
144
+ this.#record = `data-${owner}-${attribute}-lease`;
145
+ this.#base = options.base === true;
146
+ this.#preserveAuthor = options.preserveAuthor === true;
147
+ const names = registry.names;
148
+ names.set(attribute, (names.get(attribute) ?? /* @__PURE__ */ new Set()).add(this.#record));
149
+ }
150
+ write(element, value) {
151
+ const stack = attributeStack(element, this.#attribute);
152
+ const hold = this.#take(stack) ?? this.#acquire(element, stack);
153
+ this.#held.add(element);
154
+ const shows = !this.#covered(element, stack, hold);
155
+ hold.written = value;
156
+ this.#mark(element, stack);
157
+ if (shows) this.#reflect(element, value);
158
+ }
159
+ return(element) {
160
+ const held = this.#held.delete(element);
161
+ const known = registry.stacks.get(element)?.has(this.#attribute);
162
+ const stack = held || known || element.hasAttribute(this.#record) ? attributeStack(element, this.#attribute) : [];
163
+ const hold = this.#take(stack);
164
+ if (!hold) return false;
165
+ const carries = element.getAttribute(this.#attribute) === hold.written;
166
+ const owns = carries && !this.#covered(element, stack, hold);
167
+ const index = stack.indexOf(hold);
168
+ stack.splice(index, 1);
169
+ const above = stack[index];
170
+ if (above) above.linked &&= hold.linked;
171
+ this.#mark(element, stack);
172
+ if (!owns) return true;
173
+ const below = hold.linked ? stack[index - 1] : void 0;
174
+ this.#reflect(element, below ? below.written : hold.original);
175
+ return true;
176
+ }
177
+ returnAll() {
178
+ for (const element of Array.from(this.#held)) this.return(element);
179
+ }
180
+ #take(stack) {
181
+ const hold = findHold(stack, this, this.#record);
182
+ if (hold) hold.lease = this;
183
+ return hold;
184
+ }
185
+ #acquire(element, stack) {
186
+ const current = element.getAttribute(this.#attribute);
187
+ const top = stack.at(-1);
188
+ const bottom = stack[0];
189
+ const over = top?.written === current ? top : void 0;
190
+ const from = this.#base ? this.#preserveAuthor && !stack.some((held) => held.written === current) ? void 0 : bottom : over;
191
+ const hold = {
192
+ lease: this,
193
+ record: this.#record,
194
+ written: current,
195
+ original: from ? from.original : current,
196
+ linked: !this.#base && over !== void 0
197
+ };
198
+ if (this.#base && bottom) {
199
+ bottom.linked = true;
200
+ stack.unshift(hold);
201
+ } else stack.push(hold);
202
+ return hold;
203
+ }
204
+ #covered(element, stack, hold) {
205
+ const current = element.getAttribute(this.#attribute);
206
+ return stack.slice(stack.indexOf(hold) + 1).some((above) => above.written === current);
207
+ }
208
+ #mark(element, stack) {
209
+ const records = /* @__PURE__ */ new Map([[this.#record, null]]);
210
+ stack.forEach((hold, index) => {
211
+ const names = stack.slice(0, index).map((under) => under.record);
212
+ const beneath = [...new Set(names.reverse())].filter((name) => name !== hold.record);
213
+ const marker = hold.linked || beneath.length === 0 ? [] : [false];
214
+ records.set(
215
+ hold.record,
216
+ hold.written === hold.original && beneath.length === 0 && index === stack.length - 1 ? null : JSON.stringify([hold.original, hold.written, ...marker, ...beneath])
217
+ );
218
+ });
219
+ for (const [name, recorded] of records) {
220
+ setAttributeIfChanged(element, name, recorded);
221
+ }
222
+ }
223
+ #reflect(element, value) {
224
+ setAttributeIfChanged(element, this.#attribute, value);
225
+ }
226
+ };
54
227
 
55
- // src/utils/element_part.ts
56
228
  function matchingPart(root, selector) {
57
229
  return root.matches(selector) ? root : root.querySelector(selector);
58
230
  }
@@ -79,31 +251,14 @@ function readLabel(slot) {
79
251
  return text.trim();
80
252
  }
81
253
 
82
- // src/utils/roving_tabindex.ts
83
254
  var RovingTabindex = class {
84
- /** Returns the current ordered item elements; called on every operation. */
85
255
  #getItems;
86
- /**
87
- * @param getItems - Returns the current ordered item elements. Called on every
88
- * operation so the live target list is always used.
89
- */
90
256
  constructor(getItems) {
91
257
  this.#getItems = getItems;
92
258
  }
93
- /** Index of the currently tabbable item (`tabindex="0"`), or `-1` if none. */
94
259
  get activeIndex() {
95
260
  return this.#getItems().findIndex((item) => item.tabIndex === 0);
96
261
  }
97
- /**
98
- * Makes exactly the item at `index` tabbable (`tabindex="0"`) and removes every
99
- * other item from the Tab sequence (`tabindex="-1"`). An out-of-range `index`
100
- * (e.g. `-1`) leaves all items at `-1`, which a controller can use to express
101
- * "nothing is currently tabbable".
102
- *
103
- * @param index - Position of the item to make tabbable.
104
- * @param options - Pass `{ focus: true }` to also move DOM focus to that item,
105
- * and `items` to reuse an event-scoped collection snapshot.
106
- */
107
262
  setActive(index, options = {}) {
108
263
  const { focus = false } = options;
109
264
  const items = options.items ?? this.#getItems();
@@ -114,9 +269,8 @@ var RovingTabindex = class {
114
269
  }
115
270
  };
116
271
 
117
- // src/utils/chip_row.ts
118
272
  var ChipRow = class {
119
- #directionElement;
273
+ #getDirectionElement;
120
274
  #getItems;
121
275
  #getButton;
122
276
  #onRemove;
@@ -124,16 +278,12 @@ var ChipRow = class {
124
278
  #roving = new RovingTabindex(() => this.buttons);
125
279
  #container = null;
126
280
  constructor(options) {
127
- this.#directionElement = options.directionElement;
281
+ this.#getDirectionElement = options.getDirectionElement;
128
282
  this.#getItems = options.getItems;
129
283
  this.#getButton = options.getButton ?? ((item) => matchingPart(item, "button"));
130
284
  this.#onRemove = options.onRemove;
131
285
  this.#focusAfterEnd = options.focusAfterEnd;
132
286
  }
133
- /**
134
- * Binds delegation to `container`; any row currently bound is released first.
135
- * Idempotent for the same node, so target callbacks may call it freely.
136
- */
137
287
  connect(container) {
138
288
  if (this.#container === container) return;
139
289
  this.disconnect();
@@ -141,10 +291,6 @@ var ChipRow = class {
141
291
  container.addEventListener("click", this.#onClick);
142
292
  container.addEventListener("keydown", this.#onKeydown);
143
293
  }
144
- /**
145
- * Releases the current row. When `container` is supplied, a stale disconnect
146
- * callback cannot tear listeners off a newer replacement target.
147
- */
148
294
  disconnect(container) {
149
295
  const current = this.#container;
150
296
  if (!current || container !== void 0 && current !== container) return;
@@ -152,19 +298,15 @@ var ChipRow = class {
152
298
  current.removeEventListener("keydown", this.#onKeydown);
153
299
  this.#container = null;
154
300
  }
155
- /** Current remove buttons, at most one consumer-resolved button per chip item. */
156
301
  get buttons() {
157
302
  return this.#entries.map(({ button }) => button);
158
303
  }
159
- /** Number of currently navigable remove buttons. */
160
304
  get length() {
161
305
  return this.buttons.length;
162
306
  }
163
- /** Declared-item index of the last navigable chip, or `-1` for an empty row. */
164
307
  get lastIndex() {
165
308
  return this.#entries.at(-1)?.itemIndex ?? -1;
166
309
  }
167
- /** Keeps exactly one button tabbable, preferring the first authored Tab stop. */
168
310
  ensureTabStop() {
169
311
  const buttons = this.#entries.map(({ button }) => button);
170
312
  const active = buttons.findIndex((button) => button.tabIndex === 0);
@@ -173,7 +315,6 @@ var ChipRow = class {
173
315
  this.#roving.setActive(active === -1 ? 0 : active, { items: buttons });
174
316
  }
175
317
  }
176
- /** Focuses the last chip, returning false for an empty row. */
177
318
  focusLast() {
178
319
  const buttons = this.#entries.map(({ button }) => button);
179
320
  const last = buttons.length - 1;
@@ -181,10 +322,6 @@ var ChipRow = class {
181
322
  this.#roving.setActive(last, { focus: true, items: buttons });
182
323
  return true;
183
324
  }
184
- /**
185
- * Focuses the chip that followed a removed index, or the new last chip.
186
- * Returns false when removal emptied the row so the consumer can rescue focus.
187
- */
188
325
  focusAfterRemoval(index) {
189
326
  const entries = this.#entries;
190
327
  if (entries.length === 0) return false;
@@ -195,14 +332,12 @@ var ChipRow = class {
195
332
  });
196
333
  return true;
197
334
  }
198
- /** Current remove-button entries, retaining each button's declared item index. */
199
335
  get #entries() {
200
336
  return this.#getItems().flatMap((item, itemIndex) => {
201
337
  const button = this.#getButton(item);
202
338
  return button ? [{ button, itemIndex }] : [];
203
339
  });
204
340
  }
205
- /** Resolves a delegated event to the remove button owned by a declared chip. */
206
341
  #entry(event) {
207
342
  const target = event.target;
208
343
  if (!(target instanceof Element)) return null;
@@ -213,20 +348,18 @@ var ChipRow = class {
213
348
  const entry = entries[buttonIndex];
214
349
  return { ...entry, buttonIndex, buttons: entries.map(({ button: button2 }) => button2) };
215
350
  }
216
- /** Delegates removal clicks without waiting for Stimulus to wire a new chip. */
217
351
  #onClick = (event) => {
218
352
  const entry = this.#entry(event);
219
353
  if (!entry) return;
220
354
  this.#onRemove(entry.itemIndex);
221
355
  };
222
- /** Applies the shared logical-arrow and removal policy within the chip row. */
223
356
  #onKeydown = (event) => {
224
357
  if (event.defaultPrevented || isReservedArrowChord(event)) return;
225
358
  const entry = this.#entry(event);
226
359
  if (!entry) return;
227
360
  const buttons = entry.buttons;
228
361
  const index = entry.buttonIndex;
229
- switch (logicalArrowKey(event.key, this.#directionElement)) {
362
+ switch (logicalArrowKey(event.key, this.#getDirectionElement())) {
230
363
  case "ArrowLeft":
231
364
  if (index > 0) {
232
365
  event.preventDefault();
@@ -250,86 +383,191 @@ var ChipRow = class {
250
383
  };
251
384
  };
252
385
 
253
- // src/utils/composition_tracker.ts
254
386
  var COMPOSITION_INPUT_TYPES = /* @__PURE__ */ new Set([
255
387
  "insertCompositionText",
256
388
  "insertFromComposition",
257
389
  "deleteCompositionText",
258
390
  "deleteByComposition"
259
391
  ]);
392
+ function isCompositionInputType(inputType) {
393
+ return inputType !== void 0 && COMPOSITION_INPUT_TYPES.has(inputType);
394
+ }
395
+ var BEFORE_MORPH_ATTRIBUTE = "turbo:before-morph-attribute";
396
+ var textOf = (element) => element.value;
397
+ var holdsFocus = (element) => element instanceof Node && element.getRootNode().activeElement === element;
260
398
  var CompositionTracker = class {
261
399
  #observedTargets = /* @__PURE__ */ new Set();
262
- #activeTargets = /* @__PURE__ */ new Set();
400
+ #composition = null;
263
401
  #onStart;
264
402
  #onEnd;
265
- /** The field whose confirming `input` is still owed, while the window is open. */
403
+ #onAbandon;
266
404
  #confirmedTarget = null;
405
+ #keyComposing = false;
406
+ #connected = false;
407
+ #abandoned = null;
267
408
  constructor(options = {}) {
268
409
  this.#onStart = options.onStart;
269
410
  this.#onEnd = options.onEnd;
411
+ this.#onAbandon = options.onAbandon;
270
412
  }
271
- /** Starts lifecycle tracking for `target`; repeated calls are idempotent. */
272
413
  observe(target) {
414
+ this.#connected = true;
273
415
  if (this.#observedTargets.has(target)) return;
274
416
  target.addEventListener("compositionstart", this.#handleStart);
275
417
  target.addEventListener("compositionend", this.#handleEnd);
276
- target.addEventListener("keydown", this.#handleKeydown);
418
+ target.addEventListener("keydown", this.#handleKeydown, true);
419
+ target.addEventListener("beforeinput", this.#handleBeforeInput, true);
420
+ target.addEventListener("input", this.#handleInput, true);
421
+ target.addEventListener("blur", this.#handleBlur, true);
422
+ target.addEventListener(BEFORE_MORPH_ATTRIBUTE, this.#handleBeforeMorphAttribute);
277
423
  this.#observedTargets.add(target);
278
424
  }
279
- /** Stops tracking one target and clears any active composition it owned. */
280
425
  unobserve(target) {
281
426
  if (!this.#observedTargets.delete(target)) return;
282
- target.removeEventListener("compositionstart", this.#handleStart);
283
- target.removeEventListener("compositionend", this.#handleEnd);
284
- target.removeEventListener("keydown", this.#handleKeydown);
285
- this.#activeTargets.delete(target);
427
+ this.#release(target);
428
+ if (this.#composition?.target === target) this.#letGo();
286
429
  if (this.#confirmedTarget === target) this.#confirmedTarget = null;
287
430
  }
288
- /** Releases every listener and clears state so reconnect starts cleanly. */
289
431
  disconnect() {
290
- for (const target of this.#observedTargets) {
291
- target.removeEventListener("compositionstart", this.#handleStart);
292
- target.removeEventListener("compositionend", this.#handleEnd);
293
- target.removeEventListener("keydown", this.#handleKeydown);
294
- }
432
+ this.#connected = false;
433
+ for (const target of this.#observedTargets) this.#release(target);
295
434
  this.#observedTargets.clear();
296
- this.#activeTargets.clear();
435
+ this.#letGo();
297
436
  this.#confirmedTarget = null;
437
+ this.#keyComposing = false;
438
+ this.#abandoned = null;
298
439
  }
299
- /** True when lifecycle tracking or the current event reports composition. */
300
440
  isComposing(event) {
301
- return this.#activeTargets.size > 0 || event?.isComposing === true;
302
- }
303
- /**
304
- * Whether `event` is the `input` echoing the composition just confirmed.
305
- *
306
- * Asking closes the window either way, so one confirmation is folded at most
307
- * once and a consumer asks once per `input`.
308
- */
441
+ this.#settle();
442
+ return event?.isComposing === true || this.#composition !== null;
443
+ }
444
+ composingElement() {
445
+ this.#settle();
446
+ return this.#composition?.element ?? null;
447
+ }
448
+ owns(event) {
449
+ const element = this.composingElement();
450
+ return event.isComposing === true || element !== null && element === event.target;
451
+ }
309
452
  consumesConfirmedInput(event) {
310
453
  const confirmed = this.#confirmedTarget;
311
454
  this.#confirmedTarget = null;
312
455
  if (confirmed === null || confirmed !== event.target) return false;
313
456
  const inputType = event.inputType;
314
- return !inputType || COMPOSITION_INPUT_TYPES.has(inputType);
457
+ return !inputType || isCompositionInputType(inputType);
315
458
  }
459
+ #release(target) {
460
+ target.removeEventListener("compositionstart", this.#handleStart);
461
+ target.removeEventListener("compositionend", this.#handleEnd);
462
+ target.removeEventListener("keydown", this.#handleKeydown, true);
463
+ target.removeEventListener("beforeinput", this.#handleBeforeInput, true);
464
+ target.removeEventListener("input", this.#handleInput, true);
465
+ target.removeEventListener("blur", this.#handleBlur, true);
466
+ target.removeEventListener(BEFORE_MORPH_ATTRIBUTE, this.#handleBeforeMorphAttribute);
467
+ }
468
+ #letGo() {
469
+ const composition = this.#composition;
470
+ if (composition === null) return;
471
+ if (!holdsFocus(composition.element)) {
472
+ this.#composition = null;
473
+ return;
474
+ }
475
+ queueMicrotask(() => {
476
+ if (this.#composition !== composition || this.#observedTargets.has(composition.target)) {
477
+ return;
478
+ }
479
+ this.#composition = null;
480
+ if (!this.#connected) return;
481
+ this.#abandoned = { element: composition.element, inPlace: true };
482
+ this.#runAbandon();
483
+ });
484
+ }
485
+ #settle() {
486
+ const composition = this.#composition;
487
+ if (composition === null) return;
488
+ const { target, element, text } = composition;
489
+ if (target instanceof Node && element instanceof Node && !target.contains(element)) {
490
+ this.#composition = null;
491
+ } else if (textOf(element) !== text) {
492
+ this.#abandon(false);
493
+ }
494
+ }
495
+ #abandon(inPlace) {
496
+ this.#owe(inPlace);
497
+ this.#composition = null;
498
+ queueMicrotask(this.#runAbandon);
499
+ }
500
+ #owe(inPlace) {
501
+ const composition = this.#composition;
502
+ if (composition !== null) this.#abandoned = { element: composition.element, inPlace };
503
+ }
504
+ #runAbandon = () => {
505
+ const abandoned = this.#abandoned;
506
+ if (abandoned === null) return;
507
+ this.#abandoned = null;
508
+ this.#onAbandon?.(abandoned.element, abandoned.inPlace);
509
+ };
316
510
  #handleStart = (event) => {
317
511
  this.#confirmedTarget = null;
318
- if (event.currentTarget) this.#activeTargets.add(event.currentTarget);
512
+ this.#settle();
513
+ this.#owe(true);
514
+ const target = event.currentTarget;
515
+ const element = event.target;
516
+ this.#composition = { target, element, text: textOf(element) };
517
+ this.#runAbandon();
319
518
  this.#onStart?.(event);
320
519
  };
321
520
  #handleEnd = (event) => {
322
- if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);
521
+ this.#runAbandon();
522
+ if (this.#composition?.target === event.currentTarget) this.#composition = null;
323
523
  this.#confirmedTarget = event.target;
324
524
  this.#onEnd?.(event);
325
525
  };
326
- /** A key on an observed field opens an edit of its own, so no echo is owed. */
327
- #handleKeydown = () => {
526
+ #handleKeydown = (event) => {
527
+ this.#settle();
528
+ this.#runAbandon();
328
529
  this.#confirmedTarget = null;
530
+ this.#keyComposing = event.isComposing === true;
531
+ };
532
+ #handleBeforeInput = (event) => {
533
+ const keyComposing = this.#keyComposing;
534
+ this.#keyComposing = false;
535
+ this.#settle();
536
+ const { isComposing, inputType } = event;
537
+ if (this.#composition !== null && !keyComposing && !isComposing && inputType && !isCompositionInputType(inputType)) {
538
+ this.#abandon(true);
539
+ }
540
+ this.#runAbandon();
541
+ };
542
+ #handleInput = (event) => {
543
+ const composition = this.#composition;
544
+ const { isTrusted, isComposing, inputType } = event;
545
+ if (composition !== null && event.target === composition.element && (isTrusted || isComposing || isCompositionInputType(inputType))) {
546
+ composition.text = textOf(composition.element);
547
+ } else {
548
+ this.#settle();
549
+ }
550
+ this.#runAbandon();
551
+ };
552
+ #handleBlur = (event) => {
553
+ this.#settle();
554
+ this.#runAbandon();
555
+ const composition = this.#composition;
556
+ if (composition === null || composition.target === composition.element) return;
557
+ const { element } = composition;
558
+ if (!(element instanceof Node) || event.target !== element) return;
559
+ const root = element.getRootNode();
560
+ if (root.activeElement !== element) this.#composition = null;
561
+ };
562
+ #handleBeforeMorphAttribute = (event) => {
563
+ this.#settle();
564
+ const composition = this.#composition;
565
+ if (composition?.target !== event.currentTarget || event.target !== composition.element) return;
566
+ const detail = event.detail;
567
+ if (detail?.attributeName === "value") event.preventDefault();
329
568
  };
330
569
  };
331
570
 
332
- // src/utils/field_mirror.ts
333
571
  function writeFields(container, values, { name, form = "" }) {
334
572
  const current = [...container.children];
335
573
  const submits = current.length === values.length && current.every(
@@ -352,27 +590,36 @@ function commitField(target) {
352
590
  target.dispatchEvent(new Event("change", { bubbles: true }));
353
591
  }
354
592
 
355
- // src/utils/microtask_coalescer.ts
593
+ var ListenerSet = class {
594
+ #abort = new AbortController();
595
+ add(target, type, handler, options) {
596
+ target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
597
+ }
598
+ dispose() {
599
+ this.#abort.abort();
600
+ this.#abort = new AbortController();
601
+ }
602
+ };
603
+
356
604
  var MicrotaskCoalescer = class {
357
605
  #run;
358
606
  #queued = false;
359
607
  #active = false;
360
608
  #generation = 0;
361
- /** @param run - the single reconciliation pass, invoked at most once per batch. */
362
609
  constructor(run) {
363
610
  this.#run = run;
364
611
  }
365
- /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
366
612
  activate() {
367
613
  this.#active = true;
368
614
  }
369
- /** Closes the window and drops any pending pass; call from `disconnect()`. */
370
615
  cancel() {
371
616
  this.#active = false;
372
617
  this.#queued = false;
373
618
  this.#generation += 1;
374
619
  }
375
- /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
620
+ get pending() {
621
+ return this.#queued;
622
+ }
376
623
  schedule() {
377
624
  if (!this.#active || this.#queued) return;
378
625
  this.#queued = true;
@@ -385,7 +632,89 @@ var MicrotaskCoalescer = class {
385
632
  }
386
633
  };
387
634
 
388
- // src/utils/option_scroll.ts
635
+ var MorphRenderWatcher = class {
636
+ #listeners = new ListenerSet();
637
+ #pass;
638
+ #element = null;
639
+ #onMorph = () => this.#pass.schedule();
640
+ constructor(run) {
641
+ this.#pass = new MicrotaskCoalescer(run);
642
+ }
643
+ observe(element) {
644
+ if (this.#element === element) return;
645
+ this.disconnect();
646
+ this.#element = element;
647
+ this.#pass.activate();
648
+ this.#listeners.add(element, "turbo:morph-element", this.#onMorph);
649
+ }
650
+ schedule() {
651
+ this.#pass.schedule();
652
+ }
653
+ get pending() {
654
+ return this.#pass.pending;
655
+ }
656
+ disconnect() {
657
+ this.#listeners.dispose();
658
+ this.#pass.cancel();
659
+ this.#element = null;
660
+ }
661
+ };
662
+
663
+ var MoveCounter = class {
664
+ #sequence = 0;
665
+ record() {
666
+ this.#sequence += 1;
667
+ return this.#sequence;
668
+ }
669
+ isLatest(token) {
670
+ return token === this.#sequence;
671
+ }
672
+ };
673
+
674
+ var NUMBER_BOUNDS = {
675
+ finite: { finite: true }};
676
+ function matchesNumberBounds(value, bounds) {
677
+ if (!Number.isFinite(value)) {
678
+ const direction = value === Infinity ? "positive" : value === -Infinity ? "negative" : null;
679
+ if (direction === null) return false;
680
+ if (bounds.allowInfinity !== "both" && bounds.allowInfinity !== direction) return false;
681
+ }
682
+ if (bounds.min !== void 0 && value < bounds.min) return false;
683
+ if (bounds.max !== void 0 && value > bounds.max) return false;
684
+ if (bounds.exclusiveMin !== void 0 && value <= bounds.exclusiveMin) return false;
685
+ if (bounds.integer && !Number.isInteger(value)) return false;
686
+ if (bounds.allowedValues !== void 0 && !bounds.allowedValues.includes(value)) return false;
687
+ return true;
688
+ }
689
+
690
+ function readNumber(raw, fallback, bounds) {
691
+ return matchesNumberBounds(raw, bounds) ? raw : fallback;
692
+ }
693
+
694
+ var NumberValueReader = class {
695
+ #lastRejected = /* @__PURE__ */ new Map();
696
+ read(owner, name, raw, fallback, bounds) {
697
+ const resolved = readNumber(raw, fallback, bounds);
698
+ if (matchesNumberBounds(raw, bounds)) {
699
+ this.#lastRejected.delete(name);
700
+ return resolved;
701
+ }
702
+ const attribute = `data-${owner.identifier}-${name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}-value`;
703
+ const literal = owner.element.getAttribute(attribute);
704
+ if (literal === null) {
705
+ this.#lastRejected.delete(name);
706
+ return resolved;
707
+ }
708
+ if (this.#lastRejected.get(name) !== literal) {
709
+ this.#lastRejected.set(name, literal);
710
+ console.warn(
711
+ `Stimeo UI: "${owner.identifier}" has an invalid number Value "${name}" declaration ${JSON.stringify(literal)}; using ${fallback}.`
712
+ );
713
+ }
714
+ return resolved;
715
+ }
716
+ };
717
+
389
718
  function scrollOptionIntoView(list, option) {
390
719
  if (list.scrollHeight <= list.clientHeight) return;
391
720
  const listRect = list.getBoundingClientRect();
@@ -397,16 +726,20 @@ function scrollOptionIntoView(list, option) {
397
726
  }
398
727
  }
399
728
 
400
- // src/utils/state_regions.ts
401
- var StateRegions = class {
729
+ var StateRegions = class _StateRegions {
730
+ static #registry = sharedRegistry(
731
+ "stimeo-ui.state-regions.registry.v1",
732
+ () => ({ stacks: /* @__PURE__ */ new WeakMap(), names: /* @__PURE__ */ new Set() })
733
+ );
402
734
  #whenTrue;
403
735
  #whenFalse;
404
- #taken = /* @__PURE__ */ new WeakSet();
405
- constructor(options) {
736
+ #record;
737
+ constructor(options, owner) {
406
738
  this.#whenTrue = options.whenTrue;
407
739
  this.#whenFalse = options.whenFalse ?? null;
740
+ this.#record = `data-${owner}-hidden-region`;
741
+ _StateRegions.#registry.names.add(this.#record);
408
742
  }
409
- /** Shows the regions inside `host` that belong to `isTrue` and hides the others. */
410
743
  reflect(host, isTrue) {
411
744
  const shown = this.#inside(host, this.#whenTrue());
412
745
  if (!this.#whenFalse) {
@@ -422,88 +755,248 @@ var StateRegions = class {
422
755
  for (const region of shown) this.#write(region, !isTrue);
423
756
  for (const region of hiddenSide) this.#write(region, isTrue);
424
757
  }
425
- /** Writes `hidden` where it moves, noting which regions it takes out of view. */
758
+ release(host) {
759
+ for (const region of [host, ...host.querySelectorAll("*")]) {
760
+ if (!(region instanceof HTMLElement)) continue;
761
+ const stack = this.#held(region);
762
+ const hold = stack && this.#take(stack);
763
+ if (!stack || !hold) continue;
764
+ const owns = region.hidden === hold.written && !this.#covered(region, stack, hold);
765
+ const index = stack.indexOf(hold);
766
+ stack.splice(index, 1);
767
+ const above = stack[index];
768
+ if (above) above.linked &&= hold.linked;
769
+ if (owns) {
770
+ const below = hold.linked ? stack[index - 1] : void 0;
771
+ if (below) {
772
+ if (region.hidden !== below.written) region.hidden = below.written;
773
+ } else {
774
+ setAttributeIfChanged(region, "hidden", hold.authored);
775
+ }
776
+ }
777
+ this.#mark(region, stack);
778
+ }
779
+ }
426
780
  #write(region, hidden) {
427
- if (hidden) this.#taken.add(region);
428
- else this.#taken.delete(region);
429
- if (region.hidden !== hidden) region.hidden = hidden;
781
+ const stack = this.#stack(region);
782
+ const hold = this.#take(stack) ?? this.#acquire(region, stack);
783
+ const shows = !this.#covered(region, stack, hold);
784
+ hold.written = hidden;
785
+ if (shows && region.hidden !== hidden) region.hidden = hidden;
786
+ this.#mark(region, stack);
430
787
  }
431
- /** Returns a region this instance hid; one it never hid keeps what it carries. */
432
788
  #give(region) {
433
- if (this.#taken.delete(region)) region.hidden = false;
789
+ const stack = this.#held(region);
790
+ if (stack && this.#take(stack)?.written) this.#write(region, false);
791
+ }
792
+ #held(region) {
793
+ return _StateRegions.#registry.stacks.has(region) || region.hasAttribute(this.#record) ? this.#stack(region) : void 0;
794
+ }
795
+ #stack(region) {
796
+ const stacks = _StateRegions.#registry.stacks;
797
+ const stack = stacks.get(region) ?? this.#seed(region);
798
+ stacks.set(region, stack);
799
+ return stack;
800
+ }
801
+ #seed(region) {
802
+ const found = [];
803
+ for (const record of _StateRegions.#registry.names) {
804
+ const read = this.#recorded(region, record);
805
+ if (read) found.push({ ...read, record });
806
+ }
807
+ const depth = ({ beneath }) => beneath.filter((name) => found.some((other) => other.record === name)).length;
808
+ return found.sort((one, other) => depth(one) - depth(other)).map(({ record, authored, written, linked }, index) => ({
809
+ instance: null,
810
+ record,
811
+ authored,
812
+ written,
813
+ linked: linked && index > 0
814
+ }));
815
+ }
816
+ #take(stack) {
817
+ const hold = stack.find((held) => held.instance === this) ?? stack.find((held) => held.instance === null && held.record === this.#record);
818
+ if (hold) hold.instance = this;
819
+ return hold;
820
+ }
821
+ #covered(region, stack, hold) {
822
+ return stack.slice(stack.indexOf(hold) + 1).some((above) => above.written === region.hidden);
823
+ }
824
+ #acquire(region, stack) {
825
+ const top = stack.at(-1);
826
+ const over = top && region.hidden === top.written ? top : void 0;
827
+ const hold = {
828
+ instance: this,
829
+ record: this.#record,
830
+ authored: over ? over.authored : region.getAttribute("hidden"),
831
+ written: false,
832
+ linked: over !== void 0
833
+ };
834
+ stack.push(hold);
835
+ return hold;
836
+ }
837
+ #mark(region, stack) {
838
+ const records = /* @__PURE__ */ new Map([[this.#record, null]]);
839
+ stack.forEach((hold, index) => {
840
+ const names = stack.slice(0, index).map((under) => under.record);
841
+ const beneath = [...new Set(names.reverse())].filter((name) => name !== hold.record);
842
+ const marker = hold.linked || beneath.length === 0 ? [] : [false];
843
+ records.set(
844
+ hold.record,
845
+ region.getAttribute("hidden") === hold.authored && beneath.length === 0 && index === stack.length - 1 ? null : JSON.stringify([hold.authored, hold.written, ...marker, ...beneath])
846
+ );
847
+ });
848
+ for (const [name, recorded] of records) {
849
+ setAttributeIfChanged(region, name, recorded);
850
+ }
851
+ }
852
+ #recorded(region, name) {
853
+ try {
854
+ const parsed = JSON.parse(region.getAttribute(name) ?? "null");
855
+ if (!Array.isArray(parsed)) return null;
856
+ const [authored, written, ...rest] = parsed;
857
+ if (authored !== null && typeof authored !== "string") return null;
858
+ const linked = rest[0] !== false;
859
+ const beneath = linked ? rest : rest.slice(1);
860
+ if (!beneath.every((under) => typeof under === "string")) return null;
861
+ return typeof written === "boolean" ? { authored, written, linked, beneath } : null;
862
+ } catch {
863
+ return null;
864
+ }
434
865
  }
435
- /** The declared regions that sit within `host`, which may be the host itself. */
436
866
  #inside(host, regions) {
437
867
  return regions.filter((region) => host.contains(region));
438
868
  }
439
869
  };
870
+ function deepActiveElement(ownerDocument) {
871
+ let active = ownerDocument.activeElement;
872
+ while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
873
+ return active;
874
+ }
440
875
 
441
- // src/utils/before_cache_reset.ts
442
- var BeforeCacheReset = class _BeforeCacheReset {
443
- /** Every subscribed instance, iterated by the one shared document listener. */
444
- static #subscribers = /* @__PURE__ */ new Set();
445
- /** The shared listener; installed while at least one instance is subscribed. */
446
- static #onBeforeCache = () => {
447
- for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
448
- };
449
- #rewind;
450
- /** @param rewind - the pass that returns this controller's state to its initial form. */
451
- constructor(rewind) {
452
- this.#rewind = rewind;
453
- }
454
- /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
455
- activate() {
456
- const first = _BeforeCacheReset.#subscribers.size === 0;
457
- _BeforeCacheReset.#subscribers.add(this);
458
- if (first) {
459
- document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
876
+ function queryOpenDescendants(root, selector) {
877
+ const matches = [];
878
+ const roots = [root];
879
+ for (const scope of roots) {
880
+ matches.push(...scope.querySelectorAll(selector));
881
+ if (scope.nodeType === Node.ELEMENT_NODE) {
882
+ const shadow = scope.shadowRoot;
883
+ if (shadow) roots.push(shadow);
884
+ }
885
+ for (const element of scope.querySelectorAll("*")) {
886
+ if (element.shadowRoot) roots.push(element.shadowRoot);
460
887
  }
461
888
  }
462
- /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
463
- deactivate() {
464
- _BeforeCacheReset.#subscribers.delete(this);
465
- if (_BeforeCacheReset.#subscribers.size > 0) return;
466
- document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
467
- }
468
- };
889
+ return matches;
890
+ }
469
891
 
470
- // src/utils/tabindex_loan.ts
471
- var TabindexLoan = class {
892
+ function holdsFocus2(element) {
893
+ return element.matches(":focus") || deepActiveElement(element.ownerDocument) === element;
894
+ }
895
+ var TabindexLoan = class _TabindexLoan {
896
+ static #registry = sharedRegistry(
897
+ "stimeo-ui.tabindex-loan.registry.v1",
898
+ () => ({ stacks: /* @__PURE__ */ new WeakMap(), names: /* @__PURE__ */ new Set() })
899
+ );
472
900
  #value;
901
+ #record;
902
+ #preserveFocus;
473
903
  #lent = /* @__PURE__ */ new Set();
474
- /** Returns live loans before Turbo can copy them into its page snapshot. */
475
- #beforeCache = new BeforeCacheReset(() => this.returnAll());
476
- /**
477
- * @param value - the `tabindex` to lend. `"-1"` (the default) is
478
- * programmatically focusable but not a Tab stop; `"0"` is a real Tab stop,
479
- * which a scroll region with no focusable content of its own needs.
480
- */
481
- constructor(value = "-1") {
904
+ constructor(value, owner, options = {}) {
482
905
  this.#value = value;
483
- }
484
- /** Lends `element` the value; no-ops when it already carries a `tabindex`. */
485
- lend(element) {
486
- if (element.hasAttribute("tabindex")) return;
487
- element.setAttribute("tabindex", this.#value);
906
+ this.#record = `data-${owner}-tabindex-loan`;
907
+ this.#preserveFocus = options.preserveFocus === true;
908
+ _TabindexLoan.#registry.names.add(this.#record);
909
+ }
910
+ lend(element, options = {}) {
911
+ const stack = this.#stack(element);
912
+ const current = element.getAttribute("tabindex");
913
+ const held = stack.find((hold) => hold.loan === this) ?? stack.find(
914
+ (hold) => hold.loan === null && hold.record === this.#record && hold.value === this.#value
915
+ );
916
+ const linked = stack.at(-1)?.value === current;
917
+ if (!held && options.onlyIfUnfocusable && (current !== null || element.tabIndex >= 0)) return;
918
+ if (!held && current !== null && !linked) return;
488
919
  this.#lent.add(element);
489
- this.#beforeCache.activate();
920
+ if (held) held.loan = this;
921
+ else stack.push({ loan: this, record: this.#record, value: this.#value, linked });
922
+ this.#mark(element, stack);
923
+ if (current === null || !held && current !== this.#value) {
924
+ element.setAttribute("tabindex", this.#value);
925
+ }
926
+ }
927
+ return(element) {
928
+ const stack = this.#stack(element);
929
+ const hold = stack.find((held) => held.loan === this) ?? stack.find(
930
+ (held) => held.loan === null && held.record === this.#record && held.value === this.#value
931
+ );
932
+ if (!hold) return false;
933
+ if (!this.#release(element, stack, hold, this.#preserveFocus)) {
934
+ hold.loan = this;
935
+ this.#lent.add(element);
936
+ return false;
937
+ }
938
+ this.#lent.delete(element);
939
+ return true;
490
940
  }
491
- /** Takes back every loan whose value is still the one that was lent. */
492
941
  returnAll() {
493
- for (const element of this.#lent) {
494
- if (element.getAttribute("tabindex") === this.#value) element.removeAttribute("tabindex");
942
+ for (const element of Array.from(this.#lent)) this.return(element);
943
+ }
944
+ reclaim(element) {
945
+ if (element.getAttribute(this.#record) !== this.#value) return;
946
+ const stack = this.#stack(element);
947
+ const hold = stack.find((held) => held.loan === null && held.record === this.#record);
948
+ if (hold && !holdsFocus2(element)) this.#release(element, stack, hold);
949
+ }
950
+ reclaimWithin(root) {
951
+ for (const element of queryOpenDescendants(root, `[${this.#record}]`)) {
952
+ this.reclaim(element);
953
+ }
954
+ }
955
+ #stack(element) {
956
+ const stacks = _TabindexLoan.#registry.stacks;
957
+ const stack = stacks.get(element) ?? this.#seed(element);
958
+ stacks.set(element, stack);
959
+ return stack;
960
+ }
961
+ #seed(element) {
962
+ const current = element.getAttribute("tabindex");
963
+ const holds = [];
964
+ for (const record of _TabindexLoan.#registry.names) {
965
+ const value = element.getAttribute(record);
966
+ if (value !== null) holds.push({ loan: null, record, value, linked: true });
967
+ }
968
+ holds.sort((one, other) => Number(one.value === current) - Number(other.value === current));
969
+ for (const [index, hold] of holds.entries()) hold.linked = index > 0;
970
+ return holds;
971
+ }
972
+ #release(element, stack, hold, preserveFocus = false) {
973
+ const current = element.getAttribute("tabindex");
974
+ const index = stack.indexOf(hold);
975
+ const owns = current === hold.value && !stack.slice(index + 1).some((above2) => above2.value === current);
976
+ if (owns && preserveFocus && holdsFocus2(element)) return false;
977
+ stack.splice(index, 1);
978
+ const above = stack[index];
979
+ if (above) above.linked &&= hold.linked;
980
+ this.#mark(element, stack);
981
+ if (!owns) return true;
982
+ const below = hold.linked ? stack[index - 1] : void 0;
983
+ if (!below) element.removeAttribute("tabindex");
984
+ else if (below.value !== current) element.setAttribute("tabindex", below.value);
985
+ return true;
986
+ }
987
+ #mark(element, stack) {
988
+ const records = /* @__PURE__ */ new Map([[this.#record, null]]);
989
+ for (const hold of stack) records.set(hold.record, hold.value);
990
+ for (const [name, recorded] of records) {
991
+ setAttributeIfChanged(element, name, recorded);
495
992
  }
496
- this.#lent.clear();
497
- this.#beforeCache.deactivate();
498
993
  }
499
994
  };
500
995
 
501
- // src/utils/target_selector.ts
502
996
  function targetSelector(identifier, name) {
503
997
  return `[data-${identifier}-target~="${name}"]`;
504
998
  }
505
999
 
506
- // src/utils/template_row.ts
507
1000
  function cloneTemplateRoot(template) {
508
1001
  const root = template.content.firstElementChild;
509
1002
  return root instanceof HTMLElement ? root.cloneNode(true) : null;
@@ -514,22 +1007,15 @@ var TemplateRow = class {
514
1007
  constructor(options) {
515
1008
  this.#options = options;
516
1009
  }
517
- /** The attribute selector for one declared part, in this controller's namespace. */
518
1010
  selector(name) {
519
1011
  return targetSelector(this.#options.identifier, name);
520
1012
  }
521
- /** Re-arms the once-per-connection diagnostic. */
522
1013
  connect() {
523
1014
  this.#warned = false;
524
1015
  }
525
- /**
526
- * Names one missing part on the console, at most once per connection, and
527
- * returns `null` so a caller can hand it straight back.
528
- */
529
1016
  report(missing) {
530
1017
  return this.#say(`lacks ${missing}`);
531
1018
  }
532
- /** Writes one diagnostic per connection, saying what the template got wrong. */
533
1019
  #say(problem) {
534
1020
  if (this.#warned) return null;
535
1021
  this.#warned = true;
@@ -537,10 +1023,6 @@ var TemplateRow = class {
537
1023
  console.warn(`Stimeo UI: "${identifier}" ${outcome} because its ${noun} ${problem}.`);
538
1024
  return null;
539
1025
  }
540
- /**
541
- * Clones the row and resolves its parts, or names what the template got wrong
542
- * and returns `null`. `values` fills the button's authored `aria-label`.
543
- */
544
1026
  instantiate(template, values) {
545
1027
  const { root: rootName, required, optional, button: buttonName } = this.#options;
546
1028
  const root = cloneTemplateRoot(template);
@@ -568,7 +1050,6 @@ var TemplateRow = class {
568
1050
  button.setAttribute("aria-label", fillTemplate(name, values));
569
1051
  return { root, slots, button };
570
1052
  }
571
- /** The row may be its own part; otherwise the first descendant carrying the target. */
572
1053
  #resolve(root, name) {
573
1054
  return matchingPart(root, this.selector(name));
574
1055
  }
@@ -577,8 +1058,9 @@ var TemplateRow = class {
577
1058
  }
578
1059
  };
579
1060
 
580
- // src/controllers/multi_select_controller.ts
581
- var MultiSelectController = class extends Controller {
1061
+ var MultiSelectController = class _MultiSelectController extends Controller {
1062
+ #numbers = new NumberValueReader();
1063
+ #moves = new MoveCounter();
582
1064
  static targets = [
583
1065
  "input",
584
1066
  "list",
@@ -598,28 +1080,28 @@ var MultiSelectController = class extends Controller {
598
1080
  announceText: { type: String, default: "" },
599
1081
  announceRemovedText: { type: String, default: "" }
600
1082
  };
1083
+ static valueConstraints = {
1084
+ max: NUMBER_BOUNDS.finite
1085
+ };
601
1086
  static actions = ["close", "filter", "onKeydown", "open", "toggleOption"];
602
1087
  static events = ["change", "filter", "reconcile"];
603
- /** Stable id of the active option; the current target is resolved from the DOM. */
604
1088
  #activeOptionId = null;
605
- /** Whether the root borrowed a tab stop to catch focus, so teardown can undo it. */
606
- #tabindex = new TabindexLoan();
607
- /** Last reconciled selection, used to distinguish state changes from derived-DOM repair. */
1089
+ #tabindex = new TabindexLoan("-1", this.identifier);
608
1090
  #selectionValues = [];
609
- /** Whether initial normalization finished, so a fields callback cannot mirror stale state. */
610
1091
  #connected = false;
611
- /** Owns `hidden` on the regions declared for the empty-result state. */
612
- #emptyRegion = new StateRegions({ whenTrue: () => this.emptyTargets });
613
- /** Collapses one batch of target callbacks into a single final-DOM reconciliation. */
614
- #reconcile = new MicrotaskCoalescer(() => this.#reconcileOptions());
615
- /** Owns IME lifecycle state; confirmed text emits one filter result. */
1092
+ #emptyRegion = new StateRegions({ whenTrue: () => this.emptyTargets }, this.identifier);
1093
+ #listHidden = new AttributeLease("hidden", this.identifier);
1094
+ #expanded = new AttributeLease("aria-expanded", this.identifier);
1095
+ #activeDescendant = new AttributeLease(
1096
+ "aria-activedescendant",
1097
+ this.identifier
1098
+ );
1099
+ #open = false;
1100
+ #openList = null;
1101
+ #reconcile = new MorphRenderWatcher(() => this.#reconcileOptions());
616
1102
  #composition = new CompositionTracker({
617
- // Apply the confirmed query even in browsers that omit a final input event;
618
- // where one follows, the tracker folds it so consumers receive one filter
619
- // event for the confirmed text, not two.
620
1103
  onEnd: () => this.filter()
621
1104
  });
622
- /** Builds one chip from the authored template and owns its diagnostic. */
623
1105
  #rows = new TemplateRow({
624
1106
  identifier: this.identifier,
625
1107
  root: "tag",
@@ -628,16 +1110,16 @@ var MultiSelectController = class extends Controller {
628
1110
  outcome: "changed no selection",
629
1111
  noun: "chip template"
630
1112
  });
631
- /** Shared delegated interaction for the replaceable row of removable chips. */
632
1113
  #chipRow = new ChipRow({
633
- directionElement: this.element,
1114
+ getDirectionElement: () => this.#chipDirectionElement,
634
1115
  getItems: () => this.tagTargets,
635
1116
  getButton: (tag) => matchingPart(tag, `button${this.#rows.selector("remove")}`),
636
1117
  onRemove: (index) => this.#removeTagAt(index),
637
1118
  focusAfterEnd: () => this.#focusInput()
638
1119
  });
639
- /** Starts closed, syncs chips for any pre-selected options, and listens out. */
1120
+ #listeners = new ListenerSet();
640
1121
  connect() {
1122
+ this.#tabindex.reclaim(this.element);
641
1123
  this.#rows.connect();
642
1124
  this.#normalizeSelection([]);
643
1125
  this.close();
@@ -647,15 +1129,10 @@ var MultiSelectController = class extends Controller {
647
1129
  }
648
1130
  this.#selectionValues = this.#values;
649
1131
  this.#syncFields();
650
- document.addEventListener("click", this.#onOutsideClick, true);
1132
+ this.#listeners.add(document, "click", this.#onOutsideClick, { capture: true });
651
1133
  this.#connected = true;
652
- this.#reconcile.activate();
1134
+ this.#reconcile.observe(this.element);
653
1135
  }
654
- /**
655
- * Derives the chips from the selected options, idempotently: a Turbo Drive cache
656
- * restore or morph can re-connect with chips already in the DOM, so they are
657
- * cleared before deriving afresh to avoid duplicates.
658
- */
659
1136
  #rebuildTags() {
660
1137
  if (!this.hasTagsTarget || !this.hasTagTemplateTarget) return;
661
1138
  const chips = [];
@@ -668,72 +1145,67 @@ var MultiSelectController = class extends Controller {
668
1145
  this.tagsTarget.append(...chips);
669
1146
  this.#chipRow.ensureTabStop();
670
1147
  }
671
- /** Tears down document and chip listeners on disconnect (Turbo included). */
672
1148
  disconnect() {
673
1149
  this.#connected = false;
674
- this.#reconcile.cancel();
1150
+ this.#reconcile.disconnect();
675
1151
  this.#composition.disconnect();
676
1152
  this.#chipRow.disconnect();
677
- document.removeEventListener("click", this.#onOutsideClick, true);
1153
+ this.#listeners.dispose();
678
1154
  this.#releaseTabindex();
679
1155
  }
680
- /** Reconciles active state after an option target is added at runtime. */
681
1156
  optionTargetConnected() {
682
1157
  this.#scheduleOptionReconcile();
683
1158
  }
684
- /** Cleans a removed target and reconciles active state against the surviving DOM. */
685
1159
  optionTargetDisconnected(option) {
686
1160
  option.removeAttribute("data-active");
687
1161
  this.#scheduleOptionReconcile();
688
1162
  }
689
- /** Settles a region inserted after connect on the side of the current state. */
690
1163
  emptyTargetConnected() {
691
1164
  if (this.#connected) this.#reflectEmpty();
692
1165
  }
693
- /** Rebinds delegated chip interaction when Turbo replaces the tags container. */
694
1166
  tagsTargetConnected(tags) {
695
1167
  this.#chipRow.connect(tags);
696
1168
  this.#scheduleOptionReconcile();
697
1169
  }
698
- /** Releases only the row that actually disconnected, never a newer replacement. */
699
1170
  tagsTargetDisconnected(tags) {
700
1171
  this.#chipRow.disconnect(tags);
701
1172
  this.#scheduleOptionReconcile();
702
1173
  }
703
- /** Seeds a fields target inserted after connect from the current selection. */
704
1174
  fieldsTargetConnected() {
705
1175
  if (this.#connected) this.#syncFields();
706
1176
  }
707
- /** Reconciles a runtime max change, including dropping any newly invalid overflow. */
1177
+ fieldsTargetDisconnected() {
1178
+ this.#scheduleOptionReconcile();
1179
+ }
1180
+ listTargetConnected(_list) {
1181
+ if (!this.#connected) return;
1182
+ this.#reconcileOpenState();
1183
+ this.#scheduleOptionReconcile();
1184
+ }
1185
+ listTargetDisconnected(list) {
1186
+ if (!this.listTargets.includes(list)) {
1187
+ if (list === this.#openList) {
1188
+ this.#adoptPageClose();
1189
+ this.#openList = null;
1190
+ }
1191
+ this.#listHidden.return(list);
1192
+ }
1193
+ if (!this.#connected) return;
1194
+ this.#reconcileOpenState();
1195
+ this.#scheduleOptionReconcile();
1196
+ }
708
1197
  maxValueChanged() {
709
1198
  this.#scheduleOptionReconcile();
710
1199
  }
711
- /** Rebuilds submitted fields when their public name changes at runtime. */
712
1200
  nameValueChanged() {
713
1201
  this.#scheduleOptionReconcile();
714
1202
  }
715
- /** Rebuilds submitted fields when their associated form changes at runtime. */
716
1203
  formValueChanged() {
717
1204
  this.#scheduleOptionReconcile();
718
1205
  }
719
- /** Schedules one reconciliation after all callbacks in the mutation batch. */
720
1206
  #scheduleOptionReconcile() {
721
1207
  this.#reconcile.schedule();
722
1208
  }
723
- /**
724
- * Keeps a surviving/same-id active target, otherwise falls back to the first
725
- * visible one — and brings the derived state back in line with the new option set.
726
- *
727
- * The baseline pass fills in any missing `aria-selected`, and the chips and
728
- * hidden fields are re-derived from it, because the options are the truth source
729
- * for the selection. The chips are rebuilt **only when the selected value set
730
- * actually moved**: the rebuild removes and recreates every chip, so running it
731
- * for an unrelated option would drop focus from a chip's remove button to
732
- * `<body>`, losing the keyboard user's place for something that did not concern
733
- * them.
734
- *
735
- * @stimeoRenderRoot
736
- */
737
1209
  #reconcileOptions() {
738
1210
  const visible = this.#visibleOptions;
739
1211
  const active = this.#activeOption;
@@ -762,24 +1234,23 @@ var MultiSelectController = class extends Controller {
762
1234
  const changed = !this.#sameSelection(previous, values);
763
1235
  this.#selectionValues = values;
764
1236
  if (!changed) return;
1237
+ const token = this.#moves.record();
765
1238
  const options = new Map(selected.map((option) => [this.#optionValue(option), option]));
766
1239
  for (const value of previous) {
1240
+ if (!this.#moves.isLatest(token)) return;
767
1241
  if (!values.includes(value)) {
768
1242
  this.#announceTransition(false, previousLabels.get(value) || value, value, values.length);
769
1243
  }
770
1244
  }
771
1245
  for (const value of values) {
1246
+ if (!this.#moves.isLatest(token)) return;
772
1247
  if (previous.includes(value)) continue;
773
1248
  const option = options.get(value);
774
1249
  this.#announceTransition(true, this.#optionLabel(option), value, values.length);
775
1250
  }
1251
+ if (!this.#moves.isLatest(token)) return;
776
1252
  this.dispatch("reconcile", { detail: { values } });
777
1253
  }
778
- /**
779
- * Gives every option an explicit `aria-selected` and enforces the current cap.
780
- * Priority preserves existing chip order at runtime; a fresh connection passes
781
- * no priority, so deterministic option DOM order chooses the initial survivors.
782
- */
783
1254
  #normalizeSelection(priority) {
784
1255
  const selected = this.optionTargets.filter(
785
1256
  (option) => option.getAttribute("aria-selected") === "true"
@@ -805,25 +1276,36 @@ var MultiSelectController = class extends Controller {
805
1276
  option.setAttribute("aria-selected", String(kept.has(option)));
806
1277
  }
807
1278
  }
808
- /**
809
- * Tracks an input added initially or after connect, and makes it describe the
810
- * widget that is actually on screen: a swapped-in input arrives with the
811
- * authored ARIA of a fresh node while this controller still holds the popup
812
- * state, and the open path cannot repair that (it seeds an active option only
813
- * when there is none), so a live list would go unannounced.
814
- */
815
1279
  inputTargetConnected(input) {
816
1280
  this.#composition.observe(input);
817
- input.setAttribute("aria-expanded", String(!this.#isClosed));
818
- const active = this.#activeOption;
819
- if (active) input.setAttribute("aria-activedescendant", ensureId(active, "stimeo-ms-opt"));
820
- else input.removeAttribute("aria-activedescendant");
1281
+ this.#describeInput(input);
821
1282
  }
822
- /** Removes composition listeners when the active input is replaced or removed. */
823
1283
  inputTargetDisconnected(input) {
824
1284
  this.#composition.unobserve(input);
1285
+ if (!this.inputTargets.includes(input)) {
1286
+ this.#expanded.return(input);
1287
+ this.#activeDescendant.return(input);
1288
+ }
1289
+ if (this.#connected && this.hasInputTarget) this.#describeInput(this.inputTarget);
1290
+ }
1291
+ #describeInput(input) {
1292
+ this.#expanded.write(input, String(!this.#isClosed));
1293
+ const active = this.#activeOption;
1294
+ this.#activeDescendant.write(input, active ? ensureId(active, "stimeo-ms-opt") : null);
1295
+ }
1296
+ #reconcileOpenState() {
1297
+ if (!this.hasListTarget) return;
1298
+ this.#adoptPageClose();
1299
+ const list = this.listTarget;
1300
+ if (list !== this.#openList) {
1301
+ this.#showList(list, this.#open);
1302
+ this.#openList = list;
1303
+ }
1304
+ if (this.hasInputTarget) this.#expanded.write(this.inputTarget, String(!list.hidden));
1305
+ }
1306
+ #adoptPageClose() {
1307
+ if (this.#openList?.hidden) this.#open = false;
825
1308
  }
826
- /** Filters confirmed input text, opens, and re-seeds the active option. */
827
1309
  filter(event) {
828
1310
  if (!this.hasInputTarget) return;
829
1311
  if (event && this.#composition.consumesConfirmedInput(event)) return;
@@ -835,53 +1317,39 @@ var MultiSelectController = class extends Controller {
835
1317
  }
836
1318
  this.open();
837
1319
  const visible = this.#visibleOptions;
838
- this.#reflectEmpty();
839
1320
  this.#setActive(visible[0] ?? null);
840
1321
  this.dispatch("filter", { detail: { query } });
841
1322
  }
842
- /**
843
- * Opens the list and activates the first visible option when none is active.
844
- *
845
- * Needs the input, which owns `aria-expanded` and `aria-activedescendant`: a
846
- * list shown without one is a popup no assistive technology is told about. So
847
- * opening is skipped entirely, where {@link MultiSelectController.close | close}
848
- * still closes.
849
- */
850
1323
  open() {
851
1324
  if (!this.hasListTarget || !this.hasInputTarget) {
852
1325
  this.#reflectEmpty();
853
1326
  return;
854
1327
  }
855
- this.listTarget.hidden = false;
856
- this.inputTarget.setAttribute("aria-expanded", "true");
1328
+ this.#showList(this.listTarget, true);
1329
+ this.#open = true;
1330
+ this.#openList = this.listTarget;
1331
+ this.#expanded.write(this.inputTarget, "true");
857
1332
  if (!this.#activeOption) this.#setActive(this.#visibleOptions[0] ?? null);
858
1333
  this.#reflectEmpty();
859
1334
  }
860
- /**
861
- * Closes the list and clears the active option.
862
- *
863
- * Survives a missing input in both directions. `connect()` calls this second,
864
- * so dereferencing the input here would throw before the chips, the roving
865
- * seed, the chip listeners, the hidden fields and the outside-click listener —
866
- * and Stimulus keeps the controller alive after that throw, so none of them
867
- * would ever run and the selection would silently stop submitting. An input
868
- * removed while the list is open must still let it come down *and* forget its
869
- * active option, so only the `aria-expanded` write is guarded.
870
- */
871
1335
  close() {
872
- if (this.hasListTarget) this.listTarget.hidden = true;
1336
+ this.#open = false;
1337
+ if (this.hasListTarget) {
1338
+ this.#showList(this.listTarget, false);
1339
+ this.#openList = this.listTarget;
1340
+ }
873
1341
  this.#setActive(null);
874
- if (this.hasInputTarget) this.inputTarget.setAttribute("aria-expanded", "false");
1342
+ if (this.hasInputTarget) this.#expanded.write(this.inputTarget, "false");
875
1343
  this.#reflectEmpty();
876
1344
  }
877
- /** Routes input keyboard interaction per the multi-select combobox model. */
878
1345
  onKeydown(event) {
879
1346
  if (event.defaultPrevented) return;
880
1347
  if (isReservedArrowChord(event)) return;
1348
+ if ((event.key === "Home" || event.key === "End") && hasModifierChord(event)) return;
881
1349
  if (!this.hasInputTarget) return;
882
1350
  if (this.#composition.isComposing(event)) return;
883
1351
  this.#reconcileActiveForInteraction();
884
- switch (logicalArrowKey(event.key, this.element)) {
1352
+ switch (logicalArrowKey(event.key, this.#chipDirectionElement)) {
885
1353
  case "ArrowDown":
886
1354
  event.preventDefault();
887
1355
  if (this.#isClosed) this.open();
@@ -939,39 +1407,23 @@ var MultiSelectController = class extends Controller {
939
1407
  break;
940
1408
  }
941
1409
  }
942
- /**
943
- * Toggles the clicked option's selection. Bound via `data-action`. Focus is
944
- * re-homed to the input afterwards: options are non-focusable, so the click blurs
945
- * the input to `body` — and with the list deliberately staying open, every
946
- * keyboard affordance (Escape, arrows, typing) is bound to the input and would
947
- * otherwise go dead until the user clicks back in ("focus stays on the input").
948
- */
949
- toggleOption(event) {
950
- const option = event.currentTarget.closest('[role="option"]');
1410
+ toggleOption(source) {
1411
+ const { event, host, origin, reason } = actionSource(source);
1412
+ if (origin && origin.closest(`[data-controller~="${this.identifier}"]`) !== this.element)
1413
+ return;
1414
+ const option = host?.closest(targetSelector(this.identifier, "option"));
951
1415
  if (!option || !this.optionTargets.includes(option)) return;
952
- this.#toggleSelection(option);
953
- this.#focusInput();
954
- }
955
- /**
956
- * Re-homes focus to the input, or leaves it where it is when there is none.
957
- *
958
- * All three callers run *after* an option or a chip already took focus, and all
959
- * three are reachable without an input — options and chips carry their own
960
- * `data-action`. Throwing here would leave the chip removed but focus stranded
961
- * on a detached button.
962
- */
1416
+ const focus = event !== null || this.element.contains(document.activeElement);
1417
+ if (!this.#toggleSelection(option, reason)) return;
1418
+ if (focus && (event !== null || this.element.contains(document.activeElement)))
1419
+ this.#focusInput();
1420
+ }
1421
+ get #chipDirectionElement() {
1422
+ return this.hasTagsTarget ? this.tagsTarget : this.element;
1423
+ }
963
1424
  #focusInput() {
964
1425
  if (this.hasInputTarget) this.inputTarget.focus();
965
1426
  }
966
- /**
967
- * Re-homes focus after the last chip was removed: to the input, else the root.
968
- *
969
- * Unlike the other {@link #focusInput} callers, the element that held focus has
970
- * just left the DOM, so "leave it alone" is not an option — the browser already
971
- * dropped it to `<body>`. The root borrows a `tabindex="-1"` just-in-time (not a
972
- * Tab stop, handed back before Turbo caches or on teardown). Focus that landed
973
- * on a real element is left alone, so a chip removed out of band never steals it.
974
- */
975
1427
  #focusAfterLastTag() {
976
1428
  if (this.hasInputTarget) {
977
1429
  this.inputTarget.focus();
@@ -985,15 +1437,9 @@ var MultiSelectController = class extends Controller {
985
1437
  this.#tabindex.lend(this.element);
986
1438
  this.element.focus();
987
1439
  }
988
- /**
989
- * Returns the borrowed tab stop. Owning the borrow is not enough — the value
990
- * has to still be the one this instance wrote, since a consumer that changed it
991
- * afterwards owns it now.
992
- */
993
1440
  #releaseTabindex() {
994
1441
  this.#tabindex.returnAll();
995
1442
  }
996
- /** Moves the active option by `delta` among visible options, wrapping. */
997
1443
  #moveActive(delta) {
998
1444
  const visible = this.#visibleOptions;
999
1445
  const active = this.#activeOption;
@@ -1002,41 +1448,27 @@ var MultiSelectController = class extends Controller {
1002
1448
  const next = (candidate + visible.length) % visible.length;
1003
1449
  this.#setActive(visible[next] ?? null);
1004
1450
  }
1005
- /** Selects/deselects `option`, honoring `max`, and syncs chip + announcement. */
1006
- #toggleSelection(option) {
1451
+ #toggleSelection(option, reason = "user") {
1007
1452
  const selected = option.getAttribute("aria-selected") === "true";
1008
1453
  const limit = this.#selectionLimit;
1009
1454
  if (!selected && limit > 0 && this.#selectedOptions.length >= limit) {
1010
- return;
1455
+ return true;
1011
1456
  }
1012
1457
  if (selected) {
1013
1458
  option.setAttribute("aria-selected", "false");
1014
1459
  this.#removeTagFor(option);
1015
1460
  } else {
1016
- if (this.hasTagTemplateTarget && !this.#appendTag(option)) return;
1461
+ if (this.hasTagTemplateTarget && !this.#appendTag(option)) return true;
1017
1462
  option.setAttribute("aria-selected", "true");
1018
1463
  }
1019
1464
  this.#refreshRoving();
1020
- this.#syncFields(true);
1021
- const values = this.#values;
1022
- this.#selectionValues = values;
1023
- this.#announceTransition(
1465
+ return this.#publishSelection(
1024
1466
  !selected,
1025
1467
  this.#optionLabel(option),
1026
1468
  this.#optionValue(option),
1027
- values.length
1469
+ reason
1028
1470
  );
1029
- this.dispatch("change", { detail: { values } });
1030
- }
1031
- /**
1032
- * Re-reads each chip's label from its option, in place.
1033
- *
1034
- * The value order alone does not say the chips are still correct: a server can
1035
- * re-render the same candidate with a new label ("Apple" → "Green Apple"), and
1036
- * the chip text and its `Remove {label}` name are both derived from the option.
1037
- * Updating them here keeps the rebuild — which would drop focus — for the case
1038
- * that actually needs it, a changed selection.
1039
- */
1471
+ }
1040
1472
  #refreshTagLabels(selected) {
1041
1473
  const options = new Map(selected.map((option) => [this.#optionValue(option), option]));
1042
1474
  for (const tag of this.tagTargets) {
@@ -1047,10 +1479,9 @@ var MultiSelectController = class extends Controller {
1047
1479
  const name = this.#removeName(text, this.#optionValue(option));
1048
1480
  if (!label || !button || !name) continue;
1049
1481
  if (readLabel(label) !== text) writeLabel(label, text);
1050
- if (button.getAttribute("aria-label") !== name) button.setAttribute("aria-label", name);
1482
+ setAttributeIfChanged(button, "aria-label", name);
1051
1483
  }
1052
1484
  }
1053
- /** Builds one chip from the template for `option`. */
1054
1485
  #appendTag(option) {
1055
1486
  if (!this.hasTagsTarget) {
1056
1487
  this.#rows.report('a "tags" target to append the chip to');
@@ -1061,14 +1492,6 @@ var MultiSelectController = class extends Controller {
1061
1492
  this.tagsTarget.appendChild(chip);
1062
1493
  return true;
1063
1494
  }
1064
- /**
1065
- * Builds one fully named chip without mutating the live tag row, or `null`
1066
- * when the authored template cannot produce one — in which case the selection
1067
- * stays untouched and the row names the missing part once per connection.
1068
- * Both callers establish the template first: a field authored without
1069
- * `tagTemplate` renders no chips at all — a supported configuration — and
1070
- * never reaches here.
1071
- */
1072
1495
  #buildTag(option) {
1073
1496
  const text = this.#optionLabel(option);
1074
1497
  const value = this.#optionValue(option);
@@ -1079,19 +1502,16 @@ var MultiSelectController = class extends Controller {
1079
1502
  row.button.tabIndex = -1;
1080
1503
  return row.root;
1081
1504
  }
1082
- /** Expands the current template's localized remove-button name. */
1083
1505
  #removeName(label, value) {
1084
1506
  if (!this.hasTagTemplateTarget) return null;
1085
1507
  const template = this.tagTemplateTarget.content.querySelector(`button${this.#rows.selector("remove")}`)?.getAttribute("aria-label")?.trim();
1086
1508
  return template ? fillTemplate(template, { label, value }) : null;
1087
1509
  }
1088
- /** Removes the chip mirroring `option`, if present. */
1089
1510
  #removeTagFor(option) {
1090
1511
  const value = this.#optionValue(option);
1091
1512
  const tag = this.tagTargets.find((candidate) => candidate.dataset.value === value);
1092
1513
  tag?.remove();
1093
1514
  }
1094
- /** Removes chip `index` and deselects its option, re-homing focus. */
1095
1515
  #removeTagAt(index, focus = "neighbor") {
1096
1516
  const tag = this.tagTargets[index];
1097
1517
  if (!tag) return;
@@ -1100,31 +1520,13 @@ var MultiSelectController = class extends Controller {
1100
1520
  if (option) option.setAttribute("aria-selected", "false");
1101
1521
  tag.remove();
1102
1522
  this.#refreshRoving();
1103
- this.#syncFields(true);
1104
- const values = this.#values;
1105
- this.#selectionValues = values;
1106
- this.#announceTransition(
1107
- false,
1108
- option ? this.#optionLabel(option) : value,
1109
- value,
1110
- values.length
1111
- );
1112
- this.dispatch("change", { detail: { values } });
1523
+ if (!this.#publishSelection(false, option ? this.#optionLabel(option) : value, value)) return;
1113
1524
  if (focus === "input") {
1114
1525
  this.#focusInput();
1115
1526
  return;
1116
1527
  }
1117
1528
  if (!this.#chipRow.focusAfterRemoval(index)) this.#focusAfterLastTag();
1118
1529
  }
1119
- /**
1120
- * Marks `option` active via `data-active` and the input's
1121
- * `aria-activedescendant` (the attribute is removed, not emptied, when null).
1122
- *
1123
- * The state half runs even with no input, so a `close()` that cannot touch ARIA
1124
- * still clears it: {@link MultiSelectController.open | open} seeds an active
1125
- * option only when there is none, so a stale one makes the next open skip the
1126
- * seeding and a replacement input gets no `aria-activedescendant` at all.
1127
- */
1128
1530
  #setActive(option) {
1129
1531
  const activeId = option ? ensureId(option, "stimeo-ms-opt") : null;
1130
1532
  this.#activeOptionId = activeId;
@@ -1132,14 +1534,8 @@ var MultiSelectController = class extends Controller {
1132
1534
  candidate.toggleAttribute("data-active", candidate === option);
1133
1535
  }
1134
1536
  if (option && this.hasListTarget) scrollOptionIntoView(this.listTarget, option);
1135
- if (!this.hasInputTarget) return;
1136
- if (activeId !== null) {
1137
- this.inputTarget.setAttribute("aria-activedescendant", activeId);
1138
- } else {
1139
- this.inputTarget.removeAttribute("aria-activedescendant");
1140
- }
1537
+ if (this.hasInputTarget) this.#activeDescendant.write(this.inputTarget, activeId);
1141
1538
  }
1142
- /** Repairs only active identity before a key; the fallback waits for the target callback. */
1143
1539
  #reconcileActiveForInteraction() {
1144
1540
  const activeId = this.#activeOptionId;
1145
1541
  if (activeId === null) return;
@@ -1151,88 +1547,84 @@ var MultiSelectController = class extends Controller {
1151
1547
  this.#setActive(active);
1152
1548
  }
1153
1549
  }
1154
- /**
1155
- * Reflects whether the open list currently has no visible option targets, on
1156
- * the root's state attribute and on the regions declared for that state.
1157
- */
1158
1550
  #reflectEmpty() {
1159
1551
  const empty = !this.#isClosed && this.#visibleOptions.length === 0;
1160
1552
  this.element.toggleAttribute(`data-${this.identifier}-empty`, empty);
1161
1553
  this.#emptyRegion.reflect(this.element, empty);
1162
1554
  }
1163
- /**
1164
- * Mirrors the selected values into named hidden inputs under the `fields`
1165
- * target so the selection submits with a normal form (parity with tags-input).
1166
- * No-ops without a `fields` target. When the
1167
- * `form` value is set, each input gets a matching `form` attribute so the picker
1168
- * can submit with a `<form>` it lives outside of.
1169
- */
1170
- #syncFields(notify = false) {
1171
- if (!this.hasFieldsTarget) return;
1555
+ #syncFields() {
1556
+ if (!this.hasFieldsTarget) return false;
1172
1557
  const options = { name: this.nameValue, form: this.formValue };
1173
- if (writeFields(this.fieldsTarget, this.#values, options) && notify) {
1174
- commitField(this.fieldsTarget);
1175
- }
1558
+ return writeFields(this.fieldsTarget, this.#values, options);
1559
+ }
1560
+ #publishSelection(selected, label, value, reason = "user") {
1561
+ const values = this.#values;
1562
+ const changed = !this.#sameSelection(this.#selectionValues, values);
1563
+ const fieldChanged = this.#syncFields();
1564
+ this.#selectionValues = values;
1565
+ if (!changed) return true;
1566
+ const token = this.#moves.record();
1567
+ if (fieldChanged) commitField(this.fieldsTarget);
1568
+ if (!this.#moves.isLatest(token)) return false;
1569
+ this.#announceTransition(selected, label, value, values.length);
1570
+ if (!this.#moves.isLatest(token)) return false;
1571
+ this.dispatch("change", { detail: { values, reason } });
1572
+ return this.#moves.isLatest(token);
1176
1573
  }
1177
- /** Keeps exactly one chip remove button tabbable after the set changes. */
1178
1574
  #refreshRoving() {
1179
1575
  this.#chipRow.ensureTabStop();
1180
1576
  }
1181
- /**
1182
- * Sends one localized selection transition through the page's shared announcer.
1183
- *
1184
- * @stimeoRuntimeOnly The texts word the one announcement of this change.
1185
- */
1186
1577
  #announceTransition(selected, label, value, count) {
1187
1578
  const template = selected ? this.announceTextValue : this.announceRemovedTextValue;
1188
1579
  announce(fillTemplate(template, { label, value, count }));
1189
1580
  }
1190
- /** Whether two arrays represent the same selection set, independent of DOM order. */
1191
1581
  #sameSelection(left, right) {
1192
1582
  if (left.length !== right.length) return false;
1193
1583
  const a = [...left].sort();
1194
1584
  const b = [...right].sort();
1195
1585
  return a.every((value, index) => value === b[index]);
1196
1586
  }
1197
- /** Closes the list on a click outside the controller element. */
1198
1587
  #onOutsideClick = (event) => {
1199
1588
  if (!this.#isClosed && !this.element.contains(event.target)) this.close();
1200
1589
  };
1201
- /** Trimmed visible label of an option. */
1202
1590
  #optionLabel(option) {
1203
1591
  return (option.textContent ?? "").trim();
1204
1592
  }
1205
- /** An option's stable value: its `data-value`, else its display label. */
1206
1593
  #optionValue(option) {
1207
1594
  return option.dataset.value ?? this.#optionLabel(option);
1208
1595
  }
1209
- /** Options not hidden by the current filter. */
1210
1596
  get #visibleOptions() {
1211
1597
  return this.optionTargets.filter((option) => !option.hidden);
1212
1598
  }
1213
- /** Current active target resolved by stable id, never a detached node reference. */
1214
1599
  get #activeOption() {
1215
1600
  return this.optionTargets.find((option) => option.id === this.#activeOptionId) ?? null;
1216
1601
  }
1217
- /** Options currently selected. */
1218
1602
  get #selectedOptions() {
1219
1603
  return this.optionTargets.filter((option) => option.getAttribute("aria-selected") === "true");
1220
1604
  }
1221
- /** Selected values in option order. */
1222
1605
  get #values() {
1223
1606
  return this.#selectedOptions.map((option) => this.#optionValue(option));
1224
1607
  }
1225
- /** Normalized cardinality cap: zero and below are unlimited; a positive value floors, never below 1. */
1226
1608
  get #selectionLimit() {
1227
- if (!Number.isFinite(this.maxValue) || this.maxValue <= 0) return 0;
1228
- return Math.max(1, Math.floor(this.maxValue));
1609
+ if (this.#safeMax <= 0) return 0;
1610
+ return Math.max(1, Math.floor(this.#safeMax));
1611
+ }
1612
+ #showList(list, open) {
1613
+ this.#listHidden.write(list, open ? null : "");
1229
1614
  }
1230
- /** Whether the list is currently hidden. */
1231
1615
  get #isClosed() {
1232
1616
  return !this.hasListTarget || this.listTarget.hidden !== false;
1233
1617
  }
1618
+ get #safeMax() {
1619
+ return this.#numbers.read(
1620
+ this,
1621
+ "max",
1622
+ this.maxValue,
1623
+ _MultiSelectController.values.max.default,
1624
+ _MultiSelectController.valueConstraints.max
1625
+ );
1626
+ }
1234
1627
  };
1235
1628
 
1236
1629
  export { MultiSelectController };
1237
- //# sourceMappingURL=multi_select_controller.js.map
1238
1630
  //# sourceMappingURL=multi_select_controller.js.map