stimeo-ui 0.2.1 → 0.4.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 (219) hide show
  1. package/CHANGELOG.md +160 -0
  2. package/dist/cable/index.d.ts +16 -16
  3. package/dist/cable/index.js.map +1 -1
  4. package/dist/controllers/accordion_controller.js +10 -0
  5. package/dist/controllers/accordion_controller.js.map +1 -1
  6. package/dist/controllers/alert_dialog_controller.js +32 -5
  7. package/dist/controllers/alert_dialog_controller.js.map +1 -1
  8. package/dist/controllers/announcer_controller.d.ts +32 -6
  9. package/dist/controllers/announcer_controller.js +255 -20
  10. package/dist/controllers/announcer_controller.js.map +1 -1
  11. package/dist/controllers/breadcrumb_controller.d.ts +97 -10
  12. package/dist/controllers/breadcrumb_controller.js +225 -13
  13. package/dist/controllers/breadcrumb_controller.js.map +1 -1
  14. package/dist/controllers/calendar_controller.d.ts +11 -1
  15. package/dist/controllers/calendar_controller.js +89 -22
  16. package/dist/controllers/calendar_controller.js.map +1 -1
  17. package/dist/controllers/carousel_controller.d.ts +16 -1
  18. package/dist/controllers/carousel_controller.js +47 -6
  19. package/dist/controllers/carousel_controller.js.map +1 -1
  20. package/dist/controllers/collapsible_controller.js +2 -2
  21. package/dist/controllers/collapsible_controller.js.map +1 -1
  22. package/dist/controllers/color_picker_controller.d.ts +14 -2
  23. package/dist/controllers/color_picker_controller.js +92 -7
  24. package/dist/controllers/color_picker_controller.js.map +1 -1
  25. package/dist/controllers/combobox_controller.d.ts +14 -1
  26. package/dist/controllers/combobox_controller.js +162 -23
  27. package/dist/controllers/combobox_controller.js.map +1 -1
  28. package/dist/controllers/command_palette_controller.d.ts +2 -0
  29. package/dist/controllers/command_palette_controller.js +226 -22
  30. package/dist/controllers/command_palette_controller.js.map +1 -1
  31. package/dist/controllers/confirm_controller.js +32 -5
  32. package/dist/controllers/confirm_controller.js.map +1 -1
  33. package/dist/controllers/context_menu_controller.d.ts +7 -6
  34. package/dist/controllers/context_menu_controller.js +32 -10
  35. package/dist/controllers/context_menu_controller.js.map +1 -1
  36. package/dist/controllers/count_up_controller.js.map +1 -1
  37. package/dist/controllers/countdown_controller.d.ts +19 -5
  38. package/dist/controllers/countdown_controller.js +112 -12
  39. package/dist/controllers/countdown_controller.js.map +1 -1
  40. package/dist/controllers/data_grid_controller.d.ts +19 -1
  41. package/dist/controllers/data_grid_controller.js +82 -4
  42. package/dist/controllers/data_grid_controller.js.map +1 -1
  43. package/dist/controllers/date_range_picker_controller.d.ts +5 -1
  44. package/dist/controllers/date_range_picker_controller.js +77 -3
  45. package/dist/controllers/date_range_picker_controller.js.map +1 -1
  46. package/dist/controllers/dialog_controller.js +32 -5
  47. package/dist/controllers/dialog_controller.js.map +1 -1
  48. package/dist/controllers/dismissible_controller.js.map +1 -1
  49. package/dist/controllers/drawer_controller.js +32 -5
  50. package/dist/controllers/drawer_controller.js.map +1 -1
  51. package/dist/controllers/dropdown_controller.d.ts +1 -2
  52. package/dist/controllers/dropdown_controller.js.map +1 -1
  53. package/dist/controllers/editable_controller.js +1 -0
  54. package/dist/controllers/editable_controller.js.map +1 -1
  55. package/dist/controllers/empty_state_controller.d.ts +9 -4
  56. package/dist/controllers/empty_state_controller.js +24 -10
  57. package/dist/controllers/empty_state_controller.js.map +1 -1
  58. package/dist/controllers/focus_controller.d.ts +1 -1
  59. package/dist/controllers/focus_controller.js +32 -5
  60. package/dist/controllers/focus_controller.js.map +1 -1
  61. package/dist/controllers/form_field_controller.js.map +1 -1
  62. package/dist/controllers/form_validation_controller.js +1 -1
  63. package/dist/controllers/form_validation_controller.js.map +1 -1
  64. package/dist/controllers/frame_loading_controller.d.ts +14 -3
  65. package/dist/controllers/frame_loading_controller.js +177 -15
  66. package/dist/controllers/frame_loading_controller.js.map +1 -1
  67. package/dist/controllers/highlight_controller.js.map +1 -1
  68. package/dist/controllers/hover_card_controller.d.ts +1 -1
  69. package/dist/controllers/hover_card_controller.js.map +1 -1
  70. package/dist/controllers/intersection_controller.js +36 -11
  71. package/dist/controllers/intersection_controller.js.map +1 -1
  72. package/dist/controllers/lazy_frame_controller.js +31 -10
  73. package/dist/controllers/lazy_frame_controller.js.map +1 -1
  74. package/dist/controllers/listbox_controller.d.ts +9 -2
  75. package/dist/controllers/listbox_controller.js +257 -53
  76. package/dist/controllers/listbox_controller.js.map +1 -1
  77. package/dist/controllers/local_time_controller.d.ts +19 -3
  78. package/dist/controllers/local_time_controller.js +102 -8
  79. package/dist/controllers/local_time_controller.js.map +1 -1
  80. package/dist/controllers/menu_controller.d.ts +29 -11
  81. package/dist/controllers/menu_controller.js +104 -17
  82. package/dist/controllers/menu_controller.js.map +1 -1
  83. package/dist/controllers/menubar_controller.d.ts +91 -11
  84. package/dist/controllers/menubar_controller.js +415 -63
  85. package/dist/controllers/menubar_controller.js.map +1 -1
  86. package/dist/controllers/meter_controller.d.ts +22 -2
  87. package/dist/controllers/meter_controller.js +145 -26
  88. package/dist/controllers/meter_controller.js.map +1 -1
  89. package/dist/controllers/multi_select_controller.d.ts +31 -5
  90. package/dist/controllers/multi_select_controller.js +312 -29
  91. package/dist/controllers/multi_select_controller.js.map +1 -1
  92. package/dist/controllers/navigation_menu_controller.d.ts +65 -18
  93. package/dist/controllers/navigation_menu_controller.js +154 -27
  94. package/dist/controllers/navigation_menu_controller.js.map +1 -1
  95. package/dist/controllers/nested_form_controller.d.ts +4 -5
  96. package/dist/controllers/nested_form_controller.js.map +1 -1
  97. package/dist/controllers/network_status_controller.d.ts +13 -1
  98. package/dist/controllers/network_status_controller.js +28 -8
  99. package/dist/controllers/network_status_controller.js.map +1 -1
  100. package/dist/controllers/number_input_controller.d.ts +1 -1
  101. package/dist/controllers/number_input_controller.js +7 -0
  102. package/dist/controllers/number_input_controller.js.map +1 -1
  103. package/dist/controllers/otp_controller.js +18 -1
  104. package/dist/controllers/otp_controller.js.map +1 -1
  105. package/dist/controllers/overflow_indicator_controller.d.ts +3 -2
  106. package/dist/controllers/overflow_indicator_controller.js +81 -13
  107. package/dist/controllers/overflow_indicator_controller.js.map +1 -1
  108. package/dist/controllers/overflow_menu_controller.d.ts +78 -19
  109. package/dist/controllers/overflow_menu_controller.js +408 -57
  110. package/dist/controllers/overflow_menu_controller.js.map +1 -1
  111. package/dist/controllers/pagination_controller.d.ts +36 -5
  112. package/dist/controllers/pagination_controller.js +163 -32
  113. package/dist/controllers/pagination_controller.js.map +1 -1
  114. package/dist/controllers/persist_controller.js +6 -6
  115. package/dist/controllers/persist_controller.js.map +1 -1
  116. package/dist/controllers/pointer_drag_controller.d.ts +2 -3
  117. package/dist/controllers/pointer_drag_controller.js +9 -1
  118. package/dist/controllers/pointer_drag_controller.js.map +1 -1
  119. package/dist/controllers/popover_controller.d.ts +1 -2
  120. package/dist/controllers/popover_controller.js +2 -2
  121. package/dist/controllers/popover_controller.js.map +1 -1
  122. package/dist/controllers/portal_controller.d.ts +4 -4
  123. package/dist/controllers/portal_controller.js.map +1 -1
  124. package/dist/controllers/progress_controller.d.ts +16 -1
  125. package/dist/controllers/progress_controller.js +116 -9
  126. package/dist/controllers/progress_controller.js.map +1 -1
  127. package/dist/controllers/radio_group_controller.js +22 -3
  128. package/dist/controllers/radio_group_controller.js.map +1 -1
  129. package/dist/controllers/range_slider_controller.d.ts +18 -5
  130. package/dist/controllers/range_slider_controller.js +100 -9
  131. package/dist/controllers/range_slider_controller.js.map +1 -1
  132. package/dist/controllers/rating_controller.d.ts +4 -1
  133. package/dist/controllers/rating_controller.js +69 -2
  134. package/dist/controllers/rating_controller.js.map +1 -1
  135. package/dist/controllers/read_more_controller.js +63 -19
  136. package/dist/controllers/read_more_controller.js.map +1 -1
  137. package/dist/controllers/reading_progress_controller.js.map +1 -1
  138. package/dist/controllers/relative_time_controller.d.ts +13 -0
  139. package/dist/controllers/relative_time_controller.js +133 -12
  140. package/dist/controllers/relative_time_controller.js.map +1 -1
  141. package/dist/controllers/resizable_controller.d.ts +3 -1
  142. package/dist/controllers/resizable_controller.js +65 -1
  143. package/dist/controllers/resizable_controller.js.map +1 -1
  144. package/dist/controllers/roving_controller.js +17 -2
  145. package/dist/controllers/roving_controller.js.map +1 -1
  146. package/dist/controllers/scroll_area_controller.js +86 -12
  147. package/dist/controllers/scroll_area_controller.js.map +1 -1
  148. package/dist/controllers/scroll_restore_controller.js +1 -1
  149. package/dist/controllers/scroll_restore_controller.js.map +1 -1
  150. package/dist/controllers/scroll_visibility_controller.d.ts +6 -3
  151. package/dist/controllers/scroll_visibility_controller.js +33 -3
  152. package/dist/controllers/scroll_visibility_controller.js.map +1 -1
  153. package/dist/controllers/scrollspy_controller.d.ts +64 -15
  154. package/dist/controllers/scrollspy_controller.js +346 -73
  155. package/dist/controllers/scrollspy_controller.js.map +1 -1
  156. package/dist/controllers/separator_controller.d.ts +20 -5
  157. package/dist/controllers/separator_controller.js +9 -0
  158. package/dist/controllers/separator_controller.js.map +1 -1
  159. package/dist/controllers/sidebar_controller.d.ts +2 -12
  160. package/dist/controllers/sidebar_controller.js +37 -8
  161. package/dist/controllers/sidebar_controller.js.map +1 -1
  162. package/dist/controllers/skeleton_controller.d.ts +7 -2
  163. package/dist/controllers/skeleton_controller.js +73 -20
  164. package/dist/controllers/skeleton_controller.js.map +1 -1
  165. package/dist/controllers/slider_controller.d.ts +11 -1
  166. package/dist/controllers/slider_controller.js +49 -8
  167. package/dist/controllers/slider_controller.js.map +1 -1
  168. package/dist/controllers/sortable_controller.d.ts +3 -1
  169. package/dist/controllers/sortable_controller.js +34 -3
  170. package/dist/controllers/sortable_controller.js.map +1 -1
  171. package/dist/controllers/spinner_controller.d.ts +37 -4
  172. package/dist/controllers/spinner_controller.js +228 -27
  173. package/dist/controllers/spinner_controller.js.map +1 -1
  174. package/dist/controllers/step_indicator_controller.d.ts +12 -1
  175. package/dist/controllers/step_indicator_controller.js +82 -5
  176. package/dist/controllers/step_indicator_controller.js.map +1 -1
  177. package/dist/controllers/stick_to_bottom_controller.d.ts +31 -8
  178. package/dist/controllers/stick_to_bottom_controller.js +61 -10
  179. package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
  180. package/dist/controllers/sticky_observer_controller.js +32 -11
  181. package/dist/controllers/sticky_observer_controller.js.map +1 -1
  182. package/dist/controllers/submit_once_controller.d.ts +2 -2
  183. package/dist/controllers/submit_once_controller.js.map +1 -1
  184. package/dist/controllers/switch_controller.js +1 -0
  185. package/dist/controllers/switch_controller.js.map +1 -1
  186. package/dist/controllers/tabs_controller.d.ts +11 -3
  187. package/dist/controllers/tabs_controller.js +26 -3
  188. package/dist/controllers/tabs_controller.js.map +1 -1
  189. package/dist/controllers/tags_input_controller.js +22 -2
  190. package/dist/controllers/tags_input_controller.js.map +1 -1
  191. package/dist/controllers/theme_controller.d.ts +1 -1
  192. package/dist/controllers/theme_controller.js +22 -3
  193. package/dist/controllers/theme_controller.js.map +1 -1
  194. package/dist/controllers/time_picker_controller.js +20 -1
  195. package/dist/controllers/time_picker_controller.js.map +1 -1
  196. package/dist/controllers/toast_controller.d.ts +4 -5
  197. package/dist/controllers/toast_controller.js +4 -5
  198. package/dist/controllers/toast_controller.js.map +1 -1
  199. package/dist/controllers/toggle_group_controller.js +23 -2
  200. package/dist/controllers/toggle_group_controller.js.map +1 -1
  201. package/dist/controllers/toolbar_controller.d.ts +53 -13
  202. package/dist/controllers/toolbar_controller.js +230 -31
  203. package/dist/controllers/toolbar_controller.js.map +1 -1
  204. package/dist/controllers/tooltip_controller.d.ts +1 -1
  205. package/dist/controllers/tooltip_controller.js.map +1 -1
  206. package/dist/controllers/transition_controller.js.map +1 -1
  207. package/dist/controllers/tree_view_controller.d.ts +87 -6
  208. package/dist/controllers/tree_view_controller.js +467 -51
  209. package/dist/controllers/tree_view_controller.js.map +1 -1
  210. package/dist/index.js +4507 -907
  211. package/dist/index.js.map +1 -1
  212. package/dist/inspector/cli.d.ts +375 -39
  213. package/dist/inspector/cli.js +1009 -86
  214. package/dist/inspector/cli.js.map +1 -1
  215. package/dist/inspector/cli_bin.js +1200 -120
  216. package/dist/inspector/cli_bin.js.map +1 -1
  217. package/dist/inspector/examples.json +28 -28
  218. package/dist/inspector/manifest.json +1060 -132
  219. package/package.json +5 -6
@@ -20,9 +20,14 @@ import { Controller } from '@hotwired/stimulus';
20
20
  * Behavior only. The machine-readable `datetime` (UTC) is left untouched so
21
21
  * assistive tech and crawlers keep the canonical value while only the display
22
22
  * text — and an optional `title` — change. Formatting is a pure function of
23
- * `datetime` with no module-scope state, listeners, or timers, so a Turbo Drive
24
- * cache restore or morph re-runs `connect()` and stays consistent. A parse or
25
- * `Intl` error leaves the authored absolute text in place rather than throwing.
23
+ * `datetime` with no module-scope state or timers, so a Turbo Drive cache restore
24
+ * re-runs `connect()` and stays consistent. A parse or `Intl` error leaves the
25
+ * authored absolute text in place rather than throwing.
26
+ *
27
+ * Render inputs are followed at runtime: a morph that swaps a Value or the
28
+ * `datetime` attribute on the live element — which keeps the element, so
29
+ * `connect()` never runs again — repaints through one coalesced pass
30
+ * ({@link MicrotaskCoalescer}) rather than leaving a stale reading on screen.
26
31
  */
27
32
  declare class LocalTimeController extends Controller<HTMLElement> {
28
33
  #private;
@@ -55,6 +60,17 @@ declare class LocalTimeController extends Controller<HTMLElement> {
55
60
  timeStyleValue: string;
56
61
  titleFormatValue: string;
57
62
  connect(): void;
63
+ disconnect(): void;
64
+ /** Repaints when application code (or a Turbo morph) changes `locale` at runtime. */
65
+ localeValueChanged(): void;
66
+ /** Repaints when application code (or a Turbo morph) changes `timeZone` at runtime. */
67
+ timeZoneValueChanged(): void;
68
+ /** Repaints when application code (or a Turbo morph) changes `dateStyle` at runtime. */
69
+ dateStyleValueChanged(): void;
70
+ /** Repaints when application code (or a Turbo morph) changes `timeStyle` at runtime. */
71
+ timeStyleValueChanged(): void;
72
+ /** Repaints when application code (or a Turbo morph) changes `titleFormat` at runtime. */
73
+ titleFormatValueChanged(): void;
58
74
  }
59
75
 
60
76
  export { LocalTimeController };
@@ -1,5 +1,40 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
+ // src/controllers/local_time_controller.ts
4
+
5
+ // src/utils/microtask_coalescer.ts
6
+ var MicrotaskCoalescer = class {
7
+ #run;
8
+ #queued = false;
9
+ #active = false;
10
+ #generation = 0;
11
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
12
+ constructor(run) {
13
+ this.#run = run;
14
+ }
15
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
16
+ activate() {
17
+ this.#active = true;
18
+ }
19
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
20
+ cancel() {
21
+ this.#active = false;
22
+ this.#queued = false;
23
+ this.#generation += 1;
24
+ }
25
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
26
+ schedule() {
27
+ if (!this.#active || this.#queued) return;
28
+ this.#queued = true;
29
+ const generation = this.#generation;
30
+ queueMicrotask(() => {
31
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
32
+ this.#queued = false;
33
+ this.#run();
34
+ });
35
+ }
36
+ };
37
+
3
38
  // src/controllers/local_time_controller.ts
4
39
  var STYLES = /* @__PURE__ */ new Set(["full", "long", "medium", "short"]);
5
40
  function toStyle(value) {
@@ -14,7 +49,53 @@ var LocalTimeController = class extends Controller {
14
49
  titleFormat: { type: String, default: "" }
15
50
  };
16
51
  static events = ["format"];
52
+ /** Collapses a morph that swaps several render inputs at once into one repaint. */
53
+ #resync = new MicrotaskCoalescer(() => this.#render());
54
+ /**
55
+ * Watches the one render input that is not a Value. Only `datetime` is filtered
56
+ * in, so the text and `title` this controller writes cannot re-enter the pass.
57
+ */
58
+ #datetimeWatch = new MutationObserver(() => {
59
+ this.#resync.schedule();
60
+ });
17
61
  connect() {
62
+ this.#resync.activate();
63
+ this.#datetimeWatch.observe(this.element, { attributeFilter: ["datetime"] });
64
+ this.#render();
65
+ }
66
+ disconnect() {
67
+ this.#resync.cancel();
68
+ this.#datetimeWatch.disconnect();
69
+ }
70
+ /** Repaints when application code (or a Turbo morph) changes `locale` at runtime. */
71
+ localeValueChanged() {
72
+ this.#resync.schedule();
73
+ }
74
+ /** Repaints when application code (or a Turbo morph) changes `timeZone` at runtime. */
75
+ timeZoneValueChanged() {
76
+ this.#resync.schedule();
77
+ }
78
+ /** Repaints when application code (or a Turbo morph) changes `dateStyle` at runtime. */
79
+ dateStyleValueChanged() {
80
+ this.#resync.schedule();
81
+ }
82
+ /** Repaints when application code (or a Turbo morph) changes `timeStyle` at runtime. */
83
+ timeStyleValueChanged() {
84
+ this.#resync.schedule();
85
+ }
86
+ /** Repaints when application code (or a Turbo morph) changes `titleFormat` at runtime. */
87
+ titleFormatValueChanged() {
88
+ this.#resync.schedule();
89
+ }
90
+ /**
91
+ * Formats the instant in `datetime` against the current Values and writes it out.
92
+ *
93
+ * The `format` event rides with every pass, including a repaint a morph triggers:
94
+ * its condition is that formatting was applied, and a repaint applies it with a
95
+ * new result. A pass that cannot format writes nothing and emits nothing, so the
96
+ * authored absolute text stays as the fallback.
97
+ */
98
+ #render() {
18
99
  const date = this.#parse();
19
100
  if (date === null) return;
20
101
  const formatted = this.#applyFormat(date, this.dateStyleValue, this.timeStyleValue);
@@ -24,7 +105,10 @@ var LocalTimeController = class extends Controller {
24
105
  if (title !== null) this.element.setAttribute("title", title);
25
106
  this.dispatch("format", { detail: { formatted } });
26
107
  }
27
- /** Parses the UTC `datetime` attribute into a {@link Date}, or `null`. */
108
+ /**
109
+ * Parses the UTC `datetime` attribute into a {@link Date}, or `null`. Whitespace
110
+ * around the attribute value is tolerated.
111
+ */
28
112
  #parse() {
29
113
  const raw = this.element.getAttribute("datetime");
30
114
  if (!raw) return null;
@@ -32,16 +116,26 @@ var LocalTimeController = class extends Controller {
32
116
  return Number.isNaN(ms) ? null : new Date(ms);
33
117
  }
34
118
  /**
35
- * Reads a timezone-less date-time as UTC — the documented input contract —
36
- * since `Date.parse` would otherwise interpret e.g. `"2026-06-08T12:30:00"` in
119
+ * Reads a timezone-less date-time as UTC — the input contract of this
120
+ * controller — since `Date.parse` would otherwise read `"2026-06-08T12:30:00"` in
37
121
  * the *runtime's* local zone, contradicting "the server emits UTC". Values that
38
122
  * already carry `Z` or a `±hh:mm` offset (and bare `YYYY-MM-DD` dates, already
39
123
  * parsed as UTC) are returned unchanged.
124
+ *
125
+ * HTML accepts a space where ISO 8601 wants `T`, and `Date.parse` of that form is
126
+ * left to each engine, so a whole value shaped that way is normalized to the `T`
127
+ * separator first. The pattern is anchored: a value trailing anything else — a
128
+ * zone word such as `"2026-06-08 12:30:00 UTC"` — is handed to `Date.parse` as
129
+ * authored instead of being turned into a string nothing can parse.
40
130
  */
41
131
  #asUtc(value) {
42
- const hasTime = /T\d{2}:\d{2}/.test(value);
43
- const hasZone = /(Z|[+-]\d{2}:?\d{2})$/.test(value);
44
- return hasTime && !hasZone ? `${value}Z` : value;
132
+ const isoLike = value.replace(
133
+ /^(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)$/,
134
+ "$1T$2"
135
+ );
136
+ const hasTime = /T\d{2}:\d{2}/.test(isoLike);
137
+ const hasZone = /(Z|[+-]\d{2}:?\d{2})$/.test(isoLike);
138
+ return hasTime && !hasZone ? `${isoLike}Z` : isoLike;
45
139
  }
46
140
  /**
47
141
  * Builds the optional detailed `title`. `titleFormat` is an `Intl` style
@@ -70,9 +164,9 @@ var LocalTimeController = class extends Controller {
70
164
  return null;
71
165
  }
72
166
  }
73
- /** Locale precedence: the value, then the element's `lang`, then the document's. */
167
+ /** Locale precedence: the value, then the nearest `lang` up the ancestor chain. */
74
168
  get #locale() {
75
- return this.localeValue || this.element.lang || document.documentElement.lang || void 0;
169
+ return this.localeValue || this.element.closest("[lang]")?.getAttribute("lang") || void 0;
76
170
  }
77
171
  };
78
172
 
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/controllers/local_time_controller.ts"],"names":[],"mappings":";;;AAMA,IAAM,MAAA,uBAAa,GAAA,CAAmB,CAAC,QAAQ,MAAA,EAAQ,QAAA,EAAU,OAAO,CAAC,CAAA;AAGzE,SAAS,QAAQ,KAAA,EAA0C;AACzD,EAAA,OAAQ,MAAA,CAAuB,GAAA,CAAI,KAAK,CAAA,GAAK,KAAA,GAA0B,MAAA;AACzE;AA0BO,IAAM,mBAAA,GAAN,cAAkC,UAAA,CAAwB;AAAA,EAC/D,OAAgB,MAAA,GAAS;AAAA,IACvB,MAAA,EAAQ,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,EAAA,EAAG;AAAA,IACpC,QAAA,EAAU,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,EAAA,EAAG;AAAA,IACtC,SAAA,EAAW,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,QAAA,EAAS;AAAA,IAC7C,SAAA,EAAW,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,OAAA,EAAQ;AAAA,IAC5C,WAAA,EAAa,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,EAAA;AAAG,GAC3C;AAAA,EACA,OAAO,MAAA,GAAS,CAAC,QAAQ,CAAA;AAAA,EAQhB,OAAA,GAAgB;AACvB,IAAA,MAAM,IAAA,GAAO,KAAK,MAAA,EAAO;AACzB,IAAA,IAAI,SAAS,IAAA,EAAM;AAEnB,IAAA,MAAM,YAAY,IAAA,CAAK,YAAA,CAAa,MAAM,IAAA,CAAK,cAAA,EAAgB,KAAK,cAAc,CAAA;AAClF,IAAA,IAAI,cAAc,IAAA,EAAM;AAIxB,IAAA,IAAA,CAAK,QAAQ,WAAA,GAAc,SAAA;AAE3B,IAAA,MAAM,KAAA,GAAQ,IAAA,CAAK,MAAA,CAAO,IAAI,CAAA;AAC9B,IAAA,IAAI,UAAU,IAAA,EAAM,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,SAAS,KAAK,CAAA;AAE5D,IAAA,IAAA,CAAK,SAAS,QAAA,EAAU,EAAE,QAAQ,EAAE,SAAA,IAAa,CAAA;AAAA,EACnD;AAAA;AAAA,EAGA,MAAA,GAAsB;AACpB,IAAA,MAAM,GAAA,GAAM,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,UAAU,CAAA;AAChD,IAAA,IAAI,CAAC,KAAK,OAAO,IAAA;AACjB,IAAA,MAAM,EAAA,GAAK,KAAK,KAAA,CAAM,IAAA,CAAK,OAAO,GAAA,CAAI,IAAA,EAAM,CAAC,CAAA;AAC7C,IAAA,OAAO,OAAO,KAAA,CAAM,EAAE,IAAI,IAAA,GAAO,IAAI,KAAK,EAAE,CAAA;AAAA,EAC9C;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASA,OAAO,KAAA,EAAuB;AAC5B,IAAA,MAAM,OAAA,GAAU,cAAA,CAAe,IAAA,CAAK,KAAK,CAAA;AACzC,IAAA,MAAM,OAAA,GAAU,uBAAA,CAAwB,IAAA,CAAK,KAAK,CAAA;AAClD,IAAA,OAAO,OAAA,IAAW,CAAC,OAAA,GAAU,CAAA,EAAG,KAAK,CAAA,CAAA,CAAA,GAAM,KAAA;AAAA,EAC7C;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,OAAO,IAAA,EAA2B;AAChC,IAAA,IAAI,IAAA,CAAK,gBAAA,CAAiB,MAAA,KAAW,CAAA,EAAG,OAAO,IAAA;AAC/C,IAAA,OAAO,KAAK,YAAA,CAAa,IAAA,EAAM,IAAA,CAAK,gBAAA,EAAkB,KAAK,gBAAgB,CAAA;AAAA,EAC7E;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,YAAA,CAAa,IAAA,EAAY,SAAA,EAAmB,SAAA,EAAkC;AAG5E,IAAA,MAAM,OAAA,GAAsC;AAAA,MAC1C,SAAA,EAAW,QAAQ,SAAS,CAAA;AAAA,MAC5B,SAAA,EAAW,QAAQ,SAAS;AAAA,KAC9B;AACA,IAAA,IAAI,QAAQ,SAAA,KAAc,MAAA,IAAa,OAAA,CAAQ,SAAA,KAAc,QAAW,OAAO,IAAA;AAC/E,IAAA,IAAI,KAAK,aAAA,CAAc,MAAA,GAAS,CAAA,EAAG,OAAA,CAAQ,WAAW,IAAA,CAAK,aAAA;AAE3D,IAAA,IAAI;AACF,MAAA,OAAO,IAAI,KAAK,cAAA,CAAe,IAAA,CAAK,SAAS,OAAO,CAAA,CAAE,OAAO,IAAI,CAAA;AAAA,IACnE,CAAA,CAAA,MAAQ;AAGN,MAAA,OAAO,IAAA;AAAA,IACT;AAAA,EACF;AAAA;AAAA,EAGA,IAAI,OAAA,GAA8B;AAChC,IAAA,OAAO,KAAK,WAAA,IAAe,IAAA,CAAK,QAAQ,IAAA,IAAQ,QAAA,CAAS,gBAAgB,IAAA,IAAQ,MAAA;AAAA,EACnF;AACF","file":"local_time_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/** A date/time style keyword accepted by `Intl` `dateStyle` / `timeStyle`. */\ntype DateTimeStyle = \"full\" | \"long\" | \"medium\" | \"short\";\n\n/** The valid `Intl` style keywords, used to validate the string-typed values. */\nconst STYLES = new Set<DateTimeStyle>([\"full\", \"long\", \"medium\", \"short\"]);\n\n/** Narrows an arbitrary string to a valid {@link DateTimeStyle}, or `undefined`. */\nfunction toStyle(value: string): DateTimeStyle | undefined {\n return (STYLES as Set<string>).has(value) ? (value as DateTimeStyle) : undefined;\n}\n\n/**\n * Headless local-time behavior: renders the UTC instant in a `<time datetime>`\n * as an absolute, viewer-localized string via `Intl.DateTimeFormat`. No\n * dedicated APG pattern; it follows the HTML `<time>` semantics.\n *\n * Markup contract (identifier: `stimeo--local-time`):\n * <time datetime=\"2026-06-08T12:30:00Z\"\n * data-controller=\"stimeo--local-time\"\n * data-stimeo--local-time-date-style-value=\"medium\"\n * data-stimeo--local-time-time-style-value=\"short\">2026-06-08 12:30 UTC</time>\n *\n * The server emits UTC (cache-friendly — it never needs to know the viewer's\n * timezone), and the controller reformats the visible text into the viewer's\n * locale/zone on connect. This is the *absolute* localization axis, distinct from\n * {@link RelativeTimeController}'s \"3 minutes ago\".\n *\n * @remarks\n * Behavior only. The machine-readable `datetime` (UTC) is left untouched so\n * assistive tech and crawlers keep the canonical value while only the display\n * text — and an optional `title` — change. Formatting is a pure function of\n * `datetime` with no module-scope state, listeners, or timers, so a Turbo Drive\n * cache restore or morph re-runs `connect()` and stays consistent. A parse or\n * `Intl` error leaves the authored absolute text in place rather than throwing.\n */\nexport class LocalTimeController extends Controller<HTMLElement> {\n static override values = {\n locale: { type: String, default: \"\" },\n timeZone: { type: String, default: \"\" },\n dateStyle: { type: String, default: \"medium\" },\n timeStyle: { type: String, default: \"short\" },\n titleFormat: { type: String, default: \"\" },\n };\n static events = [\"format\"] as const;\n\n declare localeValue: string;\n declare timeZoneValue: string;\n declare dateStyleValue: string;\n declare timeStyleValue: string;\n declare titleFormatValue: string;\n\n override connect(): void {\n const date = this.#parse();\n if (date === null) return;\n\n const formatted = this.#applyFormat(date, this.dateStyleValue, this.timeStyleValue);\n if (formatted === null) return;\n\n // Only the visible text (and optional title) change; `datetime` is the\n // immutable machine-readable source and is never rewritten.\n this.element.textContent = formatted;\n\n const title = this.#title(date);\n if (title !== null) this.element.setAttribute(\"title\", title);\n\n this.dispatch(\"format\", { detail: { formatted } });\n }\n\n /** Parses the UTC `datetime` attribute into a {@link Date}, or `null`. */\n #parse(): Date | null {\n const raw = this.element.getAttribute(\"datetime\");\n if (!raw) return null;\n const ms = Date.parse(this.#asUtc(raw.trim()));\n return Number.isNaN(ms) ? null : new Date(ms);\n }\n\n /**\n * Reads a timezone-less date-time as UTC — the documented input contract —\n * since `Date.parse` would otherwise interpret e.g. `\"2026-06-08T12:30:00\"` in\n * the *runtime's* local zone, contradicting \"the server emits UTC\". Values that\n * already carry `Z` or a `±hh:mm` offset (and bare `YYYY-MM-DD` dates, already\n * parsed as UTC) are returned unchanged.\n */\n #asUtc(value: string): string {\n const hasTime = /T\\d{2}:\\d{2}/.test(value);\n const hasZone = /(Z|[+-]\\d{2}:?\\d{2})$/.test(value);\n return hasTime && !hasZone ? `${value}Z` : value;\n }\n\n /**\n * Builds the optional detailed `title`. `titleFormat` is an `Intl` style\n * keyword applied to *both* date and time; empty (the default) adds no title.\n */\n #title(date: Date): string | null {\n if (this.titleFormatValue.length === 0) return null;\n return this.#applyFormat(date, this.titleFormatValue, this.titleFormatValue);\n }\n\n /**\n * Formats `date` with `Intl.DateTimeFormat`, including each style only when it\n * is a valid keyword (so a consumer can show date-only or time-only by clearing\n * the other). Returns `null` when neither style is usable or `Intl` throws, so\n * the caller can leave the authored text untouched.\n */\n #applyFormat(date: Date, dateStyle: string, timeStyle: string): string | null {\n // `toStyle` yields `undefined` for an empty/invalid keyword; assigning it is\n // equivalent to omitting the option, so a consumer can show date- or time-only.\n const options: Intl.DateTimeFormatOptions = {\n dateStyle: toStyle(dateStyle),\n timeStyle: toStyle(timeStyle),\n };\n if (options.dateStyle === undefined && options.timeStyle === undefined) return null;\n if (this.timeZoneValue.length > 0) options.timeZone = this.timeZoneValue;\n\n try {\n return new Intl.DateTimeFormat(this.#locale, options).format(date);\n } catch {\n // An invalid locale / timeZone (or unsupported style) must not break the\n // page; the authored absolute text remains as the graceful fallback.\n return null;\n }\n }\n\n /** Locale precedence: the value, then the element's `lang`, then the document's. */\n get #locale(): string | undefined {\n return this.localeValue || this.element.lang || document.documentElement.lang || undefined;\n }\n}\n"]}
1
+ {"version":3,"sources":["../../src/utils/microtask_coalescer.ts","../../src/controllers/local_time_controller.ts"],"names":[],"mappings":";;;;;AAmDO,IAAM,qBAAN,MAAyB;AAAA,EACrB,IAAA;AAAA,EACT,OAAA,GAAU,KAAA;AAAA,EACV,OAAA,GAAU,KAAA;AAAA,EACV,WAAA,GAAc,CAAA;AAAA;AAAA,EAGd,YAAY,GAAA,EAAiB;AAC3B,IAAA,IAAA,CAAK,IAAA,GAAO,GAAA;AAAA,EACd;AAAA;AAAA,EAGA,QAAA,GAAiB;AACf,IAAA,IAAA,CAAK,OAAA,GAAU,IAAA;AAAA,EACjB;AAAA;AAAA,EAGA,MAAA,GAAe;AACb,IAAA,IAAA,CAAK,OAAA,GAAU,KAAA;AACf,IAAA,IAAA,CAAK,OAAA,GAAU,KAAA;AACf,IAAA,IAAA,CAAK,WAAA,IAAe,CAAA;AAAA,EACtB;AAAA;AAAA,EAGA,QAAA,GAAiB;AACf,IAAA,IAAI,CAAC,IAAA,CAAK,OAAA,IAAW,IAAA,CAAK,OAAA,EAAS;AACnC,IAAA,IAAA,CAAK,OAAA,GAAU,IAAA;AACf,IAAA,MAAM,aAAa,IAAA,CAAK,WAAA;AACxB,IAAA,cAAA,CAAe,MAAM;AAEnB,MAAA,IAAI,UAAA,KAAe,KAAK,WAAA,IAAe,CAAC,KAAK,OAAA,IAAW,CAAC,KAAK,OAAA,EAAS;AACvE,MAAA,IAAA,CAAK,OAAA,GAAU,KAAA;AACf,MAAA,IAAA,CAAK,IAAA,EAAK;AAAA,IACZ,CAAC,CAAA;AAAA,EACH;AACF,CAAA;;;AC/EA,IAAM,MAAA,uBAAa,GAAA,CAAmB,CAAC,QAAQ,MAAA,EAAQ,QAAA,EAAU,OAAO,CAAC,CAAA;AAGzE,SAAS,QAAQ,KAAA,EAA0C;AACzD,EAAA,OAAQ,MAAA,CAAuB,GAAA,CAAI,KAAK,CAAA,GAAK,KAAA,GAA0B,MAAA;AACzE;AA+BO,IAAM,mBAAA,GAAN,cAAkC,UAAA,CAAwB;AAAA,EAC/D,OAAgB,MAAA,GAAS;AAAA,IACvB,MAAA,EAAQ,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,EAAA,EAAG;AAAA,IACpC,QAAA,EAAU,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,EAAA,EAAG;AAAA,IACtC,SAAA,EAAW,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,QAAA,EAAS;AAAA,IAC7C,SAAA,EAAW,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,OAAA,EAAQ;AAAA,IAC5C,WAAA,EAAa,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,EAAA;AAAG,GAC3C;AAAA,EACA,OAAO,MAAA,GAAS,CAAC,QAAQ,CAAA;AAAA;AAAA,EAShB,UAAU,IAAI,kBAAA,CAAmB,MAAM,IAAA,CAAK,SAAS,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAKrD,cAAA,GAAiB,IAAI,gBAAA,CAAiB,MAAM;AACnD,IAAA,IAAA,CAAK,QAAQ,QAAA,EAAS;AAAA,EACxB,CAAC,CAAA;AAAA,EAEQ,OAAA,GAAgB;AACvB,IAAA,IAAA,CAAK,QAAQ,QAAA,EAAS;AACtB,IAAA,IAAA,CAAK,cAAA,CAAe,QAAQ,IAAA,CAAK,OAAA,EAAS,EAAE,eAAA,EAAiB,CAAC,UAAU,CAAA,EAAG,CAAA;AAC3E,IAAA,IAAA,CAAK,OAAA,EAAQ;AAAA,EACf;AAAA,EAES,UAAA,GAAmB;AAC1B,IAAA,IAAA,CAAK,QAAQ,MAAA,EAAO;AACpB,IAAA,IAAA,CAAK,eAAe,UAAA,EAAW;AAAA,EACjC;AAAA;AAAA,EAGA,kBAAA,GAA2B;AACzB,IAAA,IAAA,CAAK,QAAQ,QAAA,EAAS;AAAA,EACxB;AAAA;AAAA,EAGA,oBAAA,GAA6B;AAC3B,IAAA,IAAA,CAAK,QAAQ,QAAA,EAAS;AAAA,EACxB;AAAA;AAAA,EAGA,qBAAA,GAA8B;AAC5B,IAAA,IAAA,CAAK,QAAQ,QAAA,EAAS;AAAA,EACxB;AAAA;AAAA,EAGA,qBAAA,GAA8B;AAC5B,IAAA,IAAA,CAAK,QAAQ,QAAA,EAAS;AAAA,EACxB;AAAA;AAAA,EAGA,uBAAA,GAAgC;AAC9B,IAAA,IAAA,CAAK,QAAQ,QAAA,EAAS;AAAA,EACxB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAUA,OAAA,GAAgB;AACd,IAAA,MAAM,IAAA,GAAO,KAAK,MAAA,EAAO;AACzB,IAAA,IAAI,SAAS,IAAA,EAAM;AAEnB,IAAA,MAAM,YAAY,IAAA,CAAK,YAAA,CAAa,MAAM,IAAA,CAAK,cAAA,EAAgB,KAAK,cAAc,CAAA;AAClF,IAAA,IAAI,cAAc,IAAA,EAAM;AAIxB,IAAA,IAAA,CAAK,QAAQ,WAAA,GAAc,SAAA;AAE3B,IAAA,MAAM,KAAA,GAAQ,IAAA,CAAK,MAAA,CAAO,IAAI,CAAA;AAC9B,IAAA,IAAI,UAAU,IAAA,EAAM,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,SAAS,KAAK,CAAA;AAE5D,IAAA,IAAA,CAAK,SAAS,QAAA,EAAU,EAAE,QAAQ,EAAE,SAAA,IAAa,CAAA;AAAA,EACnD;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,MAAA,GAAsB;AACpB,IAAA,MAAM,GAAA,GAAM,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,UAAU,CAAA;AAChD,IAAA,IAAI,CAAC,KAAK,OAAO,IAAA;AACjB,IAAA,MAAM,EAAA,GAAK,KAAK,KAAA,CAAM,IAAA,CAAK,OAAO,GAAA,CAAI,IAAA,EAAM,CAAC,CAAA;AAC7C,IAAA,OAAO,OAAO,KAAA,CAAM,EAAE,IAAI,IAAA,GAAO,IAAI,KAAK,EAAE,CAAA;AAAA,EAC9C;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAeA,OAAO,KAAA,EAAuB;AAC5B,IAAA,MAAM,UAAU,KAAA,CAAM,OAAA;AAAA,MACpB,iFAAA;AAAA,MACA;AAAA,KACF;AACA,IAAA,MAAM,OAAA,GAAU,cAAA,CAAe,IAAA,CAAK,OAAO,CAAA;AAC3C,IAAA,MAAM,OAAA,GAAU,uBAAA,CAAwB,IAAA,CAAK,OAAO,CAAA;AACpD,IAAA,OAAO,OAAA,IAAW,CAAC,OAAA,GAAU,CAAA,EAAG,OAAO,CAAA,CAAA,CAAA,GAAM,OAAA;AAAA,EAC/C;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,OAAO,IAAA,EAA2B;AAChC,IAAA,IAAI,IAAA,CAAK,gBAAA,CAAiB,MAAA,KAAW,CAAA,EAAG,OAAO,IAAA;AAC/C,IAAA,OAAO,KAAK,YAAA,CAAa,IAAA,EAAM,IAAA,CAAK,gBAAA,EAAkB,KAAK,gBAAgB,CAAA;AAAA,EAC7E;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,YAAA,CAAa,IAAA,EAAY,SAAA,EAAmB,SAAA,EAAkC;AAG5E,IAAA,MAAM,OAAA,GAAsC;AAAA,MAC1C,SAAA,EAAW,QAAQ,SAAS,CAAA;AAAA,MAC5B,SAAA,EAAW,QAAQ,SAAS;AAAA,KAC9B;AACA,IAAA,IAAI,QAAQ,SAAA,KAAc,MAAA,IAAa,OAAA,CAAQ,SAAA,KAAc,QAAW,OAAO,IAAA;AAC/E,IAAA,IAAI,KAAK,aAAA,CAAc,MAAA,GAAS,CAAA,EAAG,OAAA,CAAQ,WAAW,IAAA,CAAK,aAAA;AAE3D,IAAA,IAAI;AACF,MAAA,OAAO,IAAI,KAAK,cAAA,CAAe,IAAA,CAAK,SAAS,OAAO,CAAA,CAAE,OAAO,IAAI,CAAA;AAAA,IACnE,CAAA,CAAA,MAAQ;AAGN,MAAA,OAAO,IAAA;AAAA,IACT;AAAA,EACF;AAAA;AAAA,EAGA,IAAI,OAAA,GAA8B;AAChC,IAAA,OAAO,IAAA,CAAK,eAAe,IAAA,CAAK,OAAA,CAAQ,QAAQ,QAAQ,CAAA,EAAG,YAAA,CAAa,MAAM,CAAA,IAAK,MAAA;AAAA,EACrF;AACF","file":"local_time_controller.js","sourcesContent":["/**\n * Collapses many target callbacks from one DOM mutation into a single pass.\n *\n * Stimulus fires `<name>TargetConnected` / `Disconnected` once per element, so\n * replacing a list of N options delivers N callbacks — but the useful unit of\n * work is \"reconcile against the DOM that resulted\", once, after the batch has\n * settled. Every controller that owns a reconcilable target set needs the same\n * shape: a `queued` flag plus `queueMicrotask`.\n *\n * **A microtask is the right horizon, and the reason is specific.** Stimulus\n * drives these callbacks from a `MutationObserver`, whose own callback already\n * runs as a microtask with the whole batch in hand; scheduling one more lands\n * after the last sibling callback of that batch and still before paint or any\n * event handler. A timer would be later than it needs to be, and reconciling\n * synchronously would run once per element against a half-applied DOM.\n *\n * **The two guards are not the same guard.** Scheduling is refused before the\n * controller connects, and running is refused after it disconnects:\n *\n * - **Before `connect()`** — Stimulus delivers the *initial* target callbacks\n * ahead of `connect()`. Reconciling there would compute a fallback against a\n * controller whose own state has not been initialised, and `connect()` is\n * about to do a full pass anyway.\n * - **After `disconnect()`** — Stimulus fires a callback for **every** target\n * during teardown, and a microtask queued just before it would otherwise run\n * against a detached tree. {@link MicrotaskCoalescer.cancel} exists for the\n * teardown path to drop the pending pass outright.\n *\n * Both guards are part of one contract here rather than something each consumer\n * has to remember separately.\n *\n * Scope is the scheduling only. *What* to reconcile — keep the surviving active\n * option, fall back to the next / previous / first visible one, rebuild derived\n * chips or hidden fields — stays in the controller, because no two consumers\n * answer it the same way.\n *\n * This file's own doc block is dropped from `dist`, but every member comment is\n * inlined into each consumer entry (`tsup` builds with `splitting: false`), so\n * rationale belongs here and only the contract belongs on the members.\n *\n * @example\n * ```ts\n * readonly #reconcile = new MicrotaskCoalescer(() => this.#reconcileOptions());\n *\n * connect() { this.#reconcile.activate(); }\n * disconnect() { this.#reconcile.cancel(); }\n *\n * optionTargetConnected() { this.#reconcile.schedule(); }\n * optionTargetDisconnected() { this.#reconcile.schedule(); }\n * ```\n */\nexport class MicrotaskCoalescer {\n readonly #run: () => void;\n #queued = false;\n #active = false;\n #generation = 0;\n\n /** @param run - the single reconciliation pass, invoked at most once per batch. */\n constructor(run: () => void) {\n this.#run = run;\n }\n\n /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */\n activate(): void {\n this.#active = true;\n }\n\n /** Closes the window and drops any pending pass; call from `disconnect()`. */\n cancel(): void {\n this.#active = false;\n this.#queued = false;\n this.#generation += 1;\n }\n\n /** Requests one pass after the batch settles. Idempotent; inert outside the window. */\n schedule(): void {\n if (!this.#active || this.#queued) return;\n this.#queued = true;\n const generation = this.#generation;\n queueMicrotask(() => {\n // A cancelled callback must not consume a pass queued after reconnect.\n if (generation !== this.#generation || !this.#queued || !this.#active) return;\n this.#queued = false;\n this.#run();\n });\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { MicrotaskCoalescer } from \"../utils/microtask_coalescer\";\n\n/** A date/time style keyword accepted by `Intl` `dateStyle` / `timeStyle`. */\ntype DateTimeStyle = \"full\" | \"long\" | \"medium\" | \"short\";\n\n/** The valid `Intl` style keywords, used to validate the string-typed values. */\nconst STYLES = new Set<DateTimeStyle>([\"full\", \"long\", \"medium\", \"short\"]);\n\n/** Narrows an arbitrary string to a valid {@link DateTimeStyle}, or `undefined`. */\nfunction toStyle(value: string): DateTimeStyle | undefined {\n return (STYLES as Set<string>).has(value) ? (value as DateTimeStyle) : undefined;\n}\n\n/**\n * Headless local-time behavior: renders the UTC instant in a `<time datetime>`\n * as an absolute, viewer-localized string via `Intl.DateTimeFormat`. No\n * dedicated APG pattern; it follows the HTML `<time>` semantics.\n *\n * Markup contract (identifier: `stimeo--local-time`):\n * <time datetime=\"2026-06-08T12:30:00Z\"\n * data-controller=\"stimeo--local-time\"\n * data-stimeo--local-time-date-style-value=\"medium\"\n * data-stimeo--local-time-time-style-value=\"short\">2026-06-08 12:30 UTC</time>\n *\n * The server emits UTC (cache-friendly — it never needs to know the viewer's\n * timezone), and the controller reformats the visible text into the viewer's\n * locale/zone on connect. This is the *absolute* localization axis, distinct from\n * {@link RelativeTimeController}'s \"3 minutes ago\".\n *\n * @remarks\n * Behavior only. The machine-readable `datetime` (UTC) is left untouched so\n * assistive tech and crawlers keep the canonical value while only the display\n * text — and an optional `title` — change. Formatting is a pure function of\n * `datetime` with no module-scope state or timers, so a Turbo Drive cache restore\n * re-runs `connect()` and stays consistent. A parse or `Intl` error leaves the\n * authored absolute text in place rather than throwing.\n *\n * Render inputs are followed at runtime: a morph that swaps a Value or the\n * `datetime` attribute on the live element — which keeps the element, so\n * `connect()` never runs again — repaints through one coalesced pass\n * ({@link MicrotaskCoalescer}) rather than leaving a stale reading on screen.\n */\nexport class LocalTimeController extends Controller<HTMLElement> {\n static override values = {\n locale: { type: String, default: \"\" },\n timeZone: { type: String, default: \"\" },\n dateStyle: { type: String, default: \"medium\" },\n timeStyle: { type: String, default: \"short\" },\n titleFormat: { type: String, default: \"\" },\n };\n static events = [\"format\"] as const;\n\n declare localeValue: string;\n declare timeZoneValue: string;\n declare dateStyleValue: string;\n declare timeStyleValue: string;\n declare titleFormatValue: string;\n\n /** Collapses a morph that swaps several render inputs at once into one repaint. */\n readonly #resync = new MicrotaskCoalescer(() => this.#render());\n /**\n * Watches the one render input that is not a Value. Only `datetime` is filtered\n * in, so the text and `title` this controller writes cannot re-enter the pass.\n */\n readonly #datetimeWatch = new MutationObserver(() => {\n this.#resync.schedule();\n });\n\n override connect(): void {\n this.#resync.activate();\n this.#datetimeWatch.observe(this.element, { attributeFilter: [\"datetime\"] });\n this.#render();\n }\n\n override disconnect(): void {\n this.#resync.cancel();\n this.#datetimeWatch.disconnect();\n }\n\n /** Repaints when application code (or a Turbo morph) changes `locale` at runtime. */\n localeValueChanged(): void {\n this.#resync.schedule();\n }\n\n /** Repaints when application code (or a Turbo morph) changes `timeZone` at runtime. */\n timeZoneValueChanged(): void {\n this.#resync.schedule();\n }\n\n /** Repaints when application code (or a Turbo morph) changes `dateStyle` at runtime. */\n dateStyleValueChanged(): void {\n this.#resync.schedule();\n }\n\n /** Repaints when application code (or a Turbo morph) changes `timeStyle` at runtime. */\n timeStyleValueChanged(): void {\n this.#resync.schedule();\n }\n\n /** Repaints when application code (or a Turbo morph) changes `titleFormat` at runtime. */\n titleFormatValueChanged(): void {\n this.#resync.schedule();\n }\n\n /**\n * Formats the instant in `datetime` against the current Values and writes it out.\n *\n * The `format` event rides with every pass, including a repaint a morph triggers:\n * its condition is that formatting was applied, and a repaint applies it with a\n * new result. A pass that cannot format writes nothing and emits nothing, so the\n * authored absolute text stays as the fallback.\n */\n #render(): void {\n const date = this.#parse();\n if (date === null) return;\n\n const formatted = this.#applyFormat(date, this.dateStyleValue, this.timeStyleValue);\n if (formatted === null) return;\n\n // Only the visible text (and optional title) change; `datetime` is the\n // immutable machine-readable source and is never rewritten.\n this.element.textContent = formatted;\n\n const title = this.#title(date);\n if (title !== null) this.element.setAttribute(\"title\", title);\n\n this.dispatch(\"format\", { detail: { formatted } });\n }\n\n /**\n * Parses the UTC `datetime` attribute into a {@link Date}, or `null`. Whitespace\n * around the attribute value is tolerated.\n */\n #parse(): Date | null {\n const raw = this.element.getAttribute(\"datetime\");\n if (!raw) return null;\n const ms = Date.parse(this.#asUtc(raw.trim()));\n return Number.isNaN(ms) ? null : new Date(ms);\n }\n\n /**\n * Reads a timezone-less date-time as UTC — the input contract of this\n * controller — since `Date.parse` would otherwise read `\"2026-06-08T12:30:00\"` in\n * the *runtime's* local zone, contradicting \"the server emits UTC\". Values that\n * already carry `Z` or a `±hh:mm` offset (and bare `YYYY-MM-DD` dates, already\n * parsed as UTC) are returned unchanged.\n *\n * HTML accepts a space where ISO 8601 wants `T`, and `Date.parse` of that form is\n * left to each engine, so a whole value shaped that way is normalized to the `T`\n * separator first. The pattern is anchored: a value trailing anything else — a\n * zone word such as `\"2026-06-08 12:30:00 UTC\"` — is handed to `Date.parse` as\n * authored instead of being turned into a string nothing can parse.\n */\n #asUtc(value: string): string {\n const isoLike = value.replace(\n /^(\\d{4}-\\d{2}-\\d{2}) (\\d{2}:\\d{2}(?::\\d{2}(?:\\.\\d+)?)?(?:Z|[+-]\\d{2}:?\\d{2})?)$/,\n \"$1T$2\",\n );\n const hasTime = /T\\d{2}:\\d{2}/.test(isoLike);\n const hasZone = /(Z|[+-]\\d{2}:?\\d{2})$/.test(isoLike);\n return hasTime && !hasZone ? `${isoLike}Z` : isoLike;\n }\n\n /**\n * Builds the optional detailed `title`. `titleFormat` is an `Intl` style\n * keyword applied to *both* date and time; empty (the default) adds no title.\n */\n #title(date: Date): string | null {\n if (this.titleFormatValue.length === 0) return null;\n return this.#applyFormat(date, this.titleFormatValue, this.titleFormatValue);\n }\n\n /**\n * Formats `date` with `Intl.DateTimeFormat`, including each style only when it\n * is a valid keyword (so a consumer can show date-only or time-only by clearing\n * the other). Returns `null` when neither style is usable or `Intl` throws, so\n * the caller can leave the authored text untouched.\n */\n #applyFormat(date: Date, dateStyle: string, timeStyle: string): string | null {\n // `toStyle` yields `undefined` for an empty/invalid keyword; assigning it is\n // equivalent to omitting the option, so a consumer can show date- or time-only.\n const options: Intl.DateTimeFormatOptions = {\n dateStyle: toStyle(dateStyle),\n timeStyle: toStyle(timeStyle),\n };\n if (options.dateStyle === undefined && options.timeStyle === undefined) return null;\n if (this.timeZoneValue.length > 0) options.timeZone = this.timeZoneValue;\n\n try {\n return new Intl.DateTimeFormat(this.#locale, options).format(date);\n } catch {\n // An invalid locale / timeZone (or unsupported style) must not break the\n // page; the authored absolute text remains as the graceful fallback.\n return null;\n }\n }\n\n /** Locale precedence: the value, then the nearest `lang` up the ancestor chain. */\n get #locale(): string | undefined {\n return this.localeValue || this.element.closest(\"[lang]\")?.getAttribute(\"lang\") || undefined;\n }\n}\n"]}
@@ -15,13 +15,20 @@ import { Controller } from '@hotwired/stimulus';
15
15
  * data-stimeo--menu-target="menu" hidden>
16
16
  * <li role="none">
17
17
  * <button role="menuitem" tabindex="-1"
18
- * data-stimeo--menu-target="item"
19
- * data-action="click->stimeo--menu#activate
20
- * keydown->stimeo--menu#onItemKeydown">…</button>
18
+ * data-stimeo--menu-target="item">…</button>
21
19
  * </li>
22
20
  * </ul>
23
21
  * </div>
24
22
  *
23
+ * The trigger's two actions are required. Items need none: their click and key
24
+ * handling is delegated from the controller element, so an item added, moved, or
25
+ * server-rendered after connect works without the consumer wiring anything onto
26
+ * it. The per-element form (`click->stimeo--menu#activate` /
27
+ * `keydown->stimeo--menu#onItemKeydown`) is supported alongside it. A
28
+ * focus-moving key runs once (the action claims it and the delegate stands
29
+ * down), and `activate`, reachable on both paths, claims the click so one
30
+ * gesture activates once. See {@link #onDelegatedItemClick}.
31
+ *
25
32
  * Implements the WAI-ARIA APG **Menu Button** pattern (a button that opens a menu
26
33
  * of commands). Unlike `stimeo--dropdown` (a disclosure for arbitrary content),
27
34
  * this is a true `role="menu"` widget with roving focus across `role="menuitem"`
@@ -42,14 +49,14 @@ import { Controller } from '@hotwired/stimulus';
42
49
  * - `Escape` closes and returns focus to the trigger. While open the menu is a
43
50
  * layer on the shared {@link EscapeLayer} stack; it claims a press only while
44
51
  * focus is inside the controller or fell to the body, so one keypress closes
45
- * exactly one layer (the shared layered-Escape contract) and a newer layer
46
- * (e.g. a tooltip shown over an item) is dismissed first.
52
+ * exactly one layer and a newer layer (e.g. a tooltip shown over an item) is
53
+ * dismissed first.
47
54
  * - A click outside the controller closes the menu without moving focus away
48
55
  * from the clicked element.
49
56
  *
50
- * Roving focus skips items that are not navigable — `hidden`, natively
51
- * `disabled`, or `aria-disabled="true"` — so the keyboard never lands focus on an
52
- * inert command (matching `stimeo--command-palette` / `stimeo--toolbar`).
57
+ * Roving focus skips `hidden` and natively `disabled` items. An
58
+ * `aria-disabled="true"` item remains discoverable by arrow-key focus, while its
59
+ * activation is suppressed in the capture phase.
53
60
  */
54
61
  declare class MenuController extends Controller<HTMLElement> {
55
62
  #private;
@@ -60,7 +67,7 @@ declare class MenuController extends Controller<HTMLElement> {
60
67
  readonly itemTargets: HTMLButtonElement[];
61
68
  readonly hasTriggerTarget: boolean;
62
69
  readonly hasMenuTarget: boolean;
63
- /** Starts closed and registers delegated listeners. */
70
+ /** Starts closed and registers activation / outside-click listeners. */
64
71
  connect(): void;
65
72
  /** Releases the listeners, stack membership, and any pending Tab-close task. */
66
73
  disconnect(): void;
@@ -81,8 +88,19 @@ declare class MenuController extends Controller<HTMLElement> {
81
88
  onTriggerKeydown(event: KeyboardEvent): void;
82
89
  /** Implements roving focus and closing keys inside the menu. */
83
90
  onItemKeydown(event: KeyboardEvent): void;
84
- /** Closes the menu after an item is activated. Bound via `data-action`. */
85
- activate(): void;
91
+ /**
92
+ * Closes the menu after an item is activated. Bound via `data-action`, and
93
+ * also reached from the delegated listener.
94
+ *
95
+ * Markup that carries the per-element action *and* gets the delegate would run
96
+ * this twice for one gesture. `close()` writes the state hooks (`hidden`,
97
+ * `aria-expanded`), and an identical reassign still queues a MutationRecord,
98
+ * so a second pass is observable to anyone watching them. The event is
99
+ * therefore claimed: the path that gets there first does the work, the other
100
+ * one finds it claimed and returns. A programmatic call with no event always
101
+ * runs.
102
+ */
103
+ activate(event?: Event): void;
86
104
  }
87
105
 
88
106
  export { MenuController };
@@ -2,6 +2,12 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/menu_controller.ts
4
4
 
5
+ // src/utils/arrow_step.ts
6
+ function isReservedArrowChord(event, allow = []) {
7
+ if (!event.key.startsWith("Arrow")) return false;
8
+ return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
9
+ }
10
+
5
11
  // src/utils/escape_layer.ts
6
12
  function claimsWhileFocusWithin(element) {
7
13
  return () => {
@@ -159,18 +165,30 @@ var MenuController = class extends Controller {
159
165
  #timers = new SafeTimeout();
160
166
  /** Escape-stack membership while open; the shared resolver dismisses via it. */
161
167
  #escapeLayer = new EscapeLayer();
162
- /** Starts closed and registers delegated listeners. */
168
+ /**
169
+ * The item a click started on, recorded in the capture pass. Read once by the
170
+ * delegated bubble listener, which must not re-derive it: consumer handlers run
171
+ * in between and may detach the item.
172
+ */
173
+ #clickOwner = null;
174
+ /** Clicks already turned into an activation, so the two paths run it once. */
175
+ #activated = /* @__PURE__ */ new WeakSet();
176
+ /** Starts closed and registers activation / outside-click listeners. */
163
177
  connect() {
164
178
  this.close();
165
179
  this.element.addEventListener("click", this.#onItemClickCapture, true);
166
- document.addEventListener("click", this.#onOutsideClick);
180
+ this.element.addEventListener("click", this.#onDelegatedItemClick);
181
+ this.element.addEventListener("keydown", this.#onDelegatedItemKeydown);
182
+ document.addEventListener("click", this.#onOutsideClick, true);
167
183
  }
168
184
  /** Releases the listeners, stack membership, and any pending Tab-close task. */
169
185
  disconnect() {
170
186
  this.#timers.clearAll();
171
187
  this.#escapeLayer.deactivate();
172
188
  this.element.removeEventListener("click", this.#onItemClickCapture, true);
173
- document.removeEventListener("click", this.#onOutsideClick);
189
+ this.element.removeEventListener("click", this.#onDelegatedItemClick);
190
+ this.element.removeEventListener("keydown", this.#onDelegatedItemKeydown);
191
+ document.removeEventListener("click", this.#onOutsideClick, true);
174
192
  }
175
193
  /** Toggles the menu open/closed. Bound via `data-action` (click). */
176
194
  toggle() {
@@ -209,6 +227,8 @@ var MenuController = class extends Controller {
209
227
  * open and then immediately re-toggle the menu.
210
228
  */
211
229
  onTriggerKeydown(event) {
230
+ if (event.defaultPrevented) return;
231
+ if (isReservedArrowChord(event)) return;
212
232
  if (event.key === "ArrowDown") {
213
233
  event.preventDefault();
214
234
  this.open();
@@ -221,8 +241,18 @@ var MenuController = class extends Controller {
221
241
  }
222
242
  /** Implements roving focus and closing keys inside the menu. */
223
243
  onItemKeydown(event) {
244
+ this.#handleItemKeydown(event, event.currentTarget);
245
+ }
246
+ /**
247
+ * The roving-focus body, shared by the per-element action and its delegated
248
+ * twin. `from` is the item the key belongs to: the bound element for the
249
+ * action, the resolved owner of the event target for the delegate.
250
+ */
251
+ #handleItemKeydown(event, from) {
252
+ if (event.defaultPrevented) return;
253
+ if (isReservedArrowChord(event)) return;
224
254
  const items = this.#navigableItems;
225
- const currentIndex = items.indexOf(event.currentTarget);
255
+ const currentIndex = items.indexOf(from);
226
256
  switch (event.key) {
227
257
  case "ArrowDown":
228
258
  event.preventDefault();
@@ -246,8 +276,23 @@ var MenuController = class extends Controller {
246
276
  break;
247
277
  }
248
278
  }
249
- /** Closes the menu after an item is activated. Bound via `data-action`. */
250
- activate() {
279
+ /**
280
+ * Closes the menu after an item is activated. Bound via `data-action`, and
281
+ * also reached from the delegated listener.
282
+ *
283
+ * Markup that carries the per-element action *and* gets the delegate would run
284
+ * this twice for one gesture. `close()` writes the state hooks (`hidden`,
285
+ * `aria-expanded`), and an identical reassign still queues a MutationRecord,
286
+ * so a second pass is observable to anyone watching them. The event is
287
+ * therefore claimed: the path that gets there first does the work, the other
288
+ * one finds it claimed and returns. A programmatic call with no event always
289
+ * runs.
290
+ */
291
+ activate(event) {
292
+ if (event !== void 0) {
293
+ if (this.#activated.has(event)) return;
294
+ this.#activated.add(event);
295
+ }
251
296
  this.#closeAndRestore();
252
297
  }
253
298
  /** Closes and returns focus to the trigger (Escape / item-activation path). */
@@ -260,16 +305,59 @@ var MenuController = class extends Controller {
260
305
  if (this.#isOpen && !this.element.contains(event.target)) this.close();
261
306
  };
262
307
  /**
263
- * Captures clicks so `aria-disabled` commands cannot reach consumer handlers.
264
- * Native Enter/Space activation also synthesizes a click and is blocked here.
308
+ * Delegated twin of {@link onItemKeydown}, bound on the controller element.
309
+ *
310
+ * An item that arrives *after* connect — Overflow Menu moves toolbar controls
311
+ * into this menu at runtime — carries whatever `data-action` its author wrote,
312
+ * and that is exactly the binding a consumer forgets, because the markup that
313
+ * declares the item lives nowhere near the menu. Listening on the container
314
+ * makes membership in the `item` target enough to be operable. The per-element
315
+ * action stays supported and is not double-handled: see
316
+ * {@link #handleItemKeydown}.
317
+ */
318
+ #onDelegatedItemKeydown = (event) => {
319
+ const item = this.#itemFrom(event.target);
320
+ if (item === null) return;
321
+ this.#handleItemKeydown(event, item);
322
+ };
323
+ /**
324
+ * Delegated twin of {@link activate}, for the same reason as the keydown one.
325
+ *
326
+ * It works from the item the capture pass recorded, not from a fresh lookup:
327
+ * by the time a click bubbles up here, a consumer handler may already have
328
+ * detached the row it lives in, and `itemTargets` is a live query that would
329
+ * then resolve nothing — the activation would be lost and the menu left open
330
+ * over an item that no longer exists. `aria-disabled` items never reach this
331
+ * listener at all: the capture pass stops the click outright.
332
+ */
333
+ #onDelegatedItemClick = (event) => {
334
+ const item = this.#clickOwner;
335
+ this.#clickOwner = null;
336
+ if (item === null) return;
337
+ this.activate(event);
338
+ };
339
+ /** The item target that is, or contains, `node`; `null` when it is neither. */
340
+ #itemFrom(node) {
341
+ if (!(node instanceof Node)) return null;
342
+ return this.itemTargets.find((item) => item === node || item.contains(node)) ?? null;
343
+ }
344
+ /**
345
+ * First look at every click inside the controller, and the only point at which
346
+ * the DOM is still the one the user clicked. Two jobs: record which item owns
347
+ * the click for the delegate above, and stop an `aria-disabled` command before
348
+ * it reaches consumer handlers (native Enter/Space activation synthesizes a
349
+ * click and is blocked here too).
265
350
  */
266
351
  #onItemClickCapture = (event) => {
267
352
  const target = event.target;
268
- if (!(target instanceof Node)) return;
269
- const disabled = this.itemTargets.some(
270
- (item) => item.getAttribute("aria-disabled") === "true" && item.contains(target)
271
- );
272
- if (!disabled) return;
353
+ if (!(target instanceof Node)) {
354
+ this.#clickOwner = null;
355
+ return;
356
+ }
357
+ const item = this.#itemFrom(target);
358
+ this.#clickOwner = item;
359
+ if (item === null || item.getAttribute("aria-disabled") !== "true") return;
360
+ this.#clickOwner = null;
273
361
  event.preventDefault();
274
362
  event.stopImmediatePropagation();
275
363
  };
@@ -287,13 +375,12 @@ var MenuController = class extends Controller {
287
375
  return this.itemTargets.filter((item) => this.#isNavigable(item));
288
376
  }
289
377
  /**
290
- * An item can take roving focus unless it is `hidden`, `aria-disabled="true"`,
291
- * or a natively `disabled` form control. CSS-only visibility is not detectable
292
- * here and is the consumer's responsibility.
378
+ * An item can take roving focus unless it is `hidden` or a natively `disabled`
379
+ * form control. `aria-disabled` remains focusable for discoverability; capture
380
+ * suppresses activation. CSS-only visibility is the consumer's responsibility.
293
381
  */
294
382
  #isNavigable(item) {
295
383
  if (item.hasAttribute("hidden")) return false;
296
- if (item.getAttribute("aria-disabled") === "true") return false;
297
384
  return !item.disabled;
298
385
  }
299
386
  /** Whether the menu is currently visible. */