stimeo-ui 0.1.0-alpha.1

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 (238) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +72 -0
  3. package/dist/controllers/accordion_controller.d.ts +59 -0
  4. package/dist/controllers/accordion_controller.js +76 -0
  5. package/dist/controllers/accordion_controller.js.map +1 -0
  6. package/dist/controllers/announcer_controller.d.ts +78 -0
  7. package/dist/controllers/announcer_controller.js +184 -0
  8. package/dist/controllers/announcer_controller.js.map +1 -0
  9. package/dist/controllers/aspect_ratio_controller.d.ts +39 -0
  10. package/dist/controllers/aspect_ratio_controller.js +36 -0
  11. package/dist/controllers/aspect_ratio_controller.js.map +1 -0
  12. package/dist/controllers/auto_submit_controller.d.ts +74 -0
  13. package/dist/controllers/auto_submit_controller.js +147 -0
  14. package/dist/controllers/auto_submit_controller.js.map +1 -0
  15. package/dist/controllers/avatar_controller.d.ts +50 -0
  16. package/dist/controllers/avatar_controller.js +66 -0
  17. package/dist/controllers/avatar_controller.js.map +1 -0
  18. package/dist/controllers/breadcrumb_controller.d.ts +60 -0
  19. package/dist/controllers/breadcrumb_controller.js +123 -0
  20. package/dist/controllers/breadcrumb_controller.js.map +1 -0
  21. package/dist/controllers/bulk_select_controller.d.ts +67 -0
  22. package/dist/controllers/bulk_select_controller.js +104 -0
  23. package/dist/controllers/bulk_select_controller.js.map +1 -0
  24. package/dist/controllers/calendar_controller.d.ts +100 -0
  25. package/dist/controllers/calendar_controller.js +394 -0
  26. package/dist/controllers/calendar_controller.js.map +1 -0
  27. package/dist/controllers/character_counter_controller.d.ts +63 -0
  28. package/dist/controllers/character_counter_controller.js +179 -0
  29. package/dist/controllers/character_counter_controller.js.map +1 -0
  30. package/dist/controllers/checkbox_controller.d.ts +52 -0
  31. package/dist/controllers/checkbox_controller.js +73 -0
  32. package/dist/controllers/checkbox_controller.js.map +1 -0
  33. package/dist/controllers/combobox_controller.d.ts +76 -0
  34. package/dist/controllers/combobox_controller.js +186 -0
  35. package/dist/controllers/combobox_controller.js.map +1 -0
  36. package/dist/controllers/command_palette_controller.d.ts +103 -0
  37. package/dist/controllers/command_palette_controller.js +381 -0
  38. package/dist/controllers/command_palette_controller.js.map +1 -0
  39. package/dist/controllers/conditional_fields_controller.d.ts +56 -0
  40. package/dist/controllers/conditional_fields_controller.js +112 -0
  41. package/dist/controllers/conditional_fields_controller.js.map +1 -0
  42. package/dist/controllers/confirm_controller.d.ts +83 -0
  43. package/dist/controllers/confirm_controller.js +276 -0
  44. package/dist/controllers/confirm_controller.js.map +1 -0
  45. package/dist/controllers/context_menu_controller.d.ts +81 -0
  46. package/dist/controllers/context_menu_controller.js +112 -0
  47. package/dist/controllers/context_menu_controller.js.map +1 -0
  48. package/dist/controllers/countdown_controller.d.ts +93 -0
  49. package/dist/controllers/countdown_controller.js +202 -0
  50. package/dist/controllers/countdown_controller.js.map +1 -0
  51. package/dist/controllers/dialog_controller.d.ts +57 -0
  52. package/dist/controllers/dialog_controller.js +207 -0
  53. package/dist/controllers/dialog_controller.js.map +1 -0
  54. package/dist/controllers/direct_upload_controller.d.ts +77 -0
  55. package/dist/controllers/direct_upload_controller.js +212 -0
  56. package/dist/controllers/direct_upload_controller.js.map +1 -0
  57. package/dist/controllers/dirty_form_controller.d.ts +52 -0
  58. package/dist/controllers/dirty_form_controller.js +128 -0
  59. package/dist/controllers/dirty_form_controller.js.map +1 -0
  60. package/dist/controllers/dropdown_controller.d.ts +49 -0
  61. package/dist/controllers/dropdown_controller.js +66 -0
  62. package/dist/controllers/dropdown_controller.js.map +1 -0
  63. package/dist/controllers/empty_state_controller.d.ts +60 -0
  64. package/dist/controllers/empty_state_controller.js +67 -0
  65. package/dist/controllers/empty_state_controller.js.map +1 -0
  66. package/dist/controllers/flash_controller.d.ts +63 -0
  67. package/dist/controllers/flash_controller.js +221 -0
  68. package/dist/controllers/flash_controller.js.map +1 -0
  69. package/dist/controllers/focus_controller.d.ts +69 -0
  70. package/dist/controllers/focus_controller.js +216 -0
  71. package/dist/controllers/focus_controller.js.map +1 -0
  72. package/dist/controllers/form_field_controller.d.ts +79 -0
  73. package/dist/controllers/form_field_controller.js +154 -0
  74. package/dist/controllers/form_field_controller.js.map +1 -0
  75. package/dist/controllers/form_validation_controller.d.ts +114 -0
  76. package/dist/controllers/form_validation_controller.js +202 -0
  77. package/dist/controllers/form_validation_controller.js.map +1 -0
  78. package/dist/controllers/frame_loading_controller.d.ts +58 -0
  79. package/dist/controllers/frame_loading_controller.js +177 -0
  80. package/dist/controllers/frame_loading_controller.js.map +1 -0
  81. package/dist/controllers/highlight_controller.d.ts +50 -0
  82. package/dist/controllers/highlight_controller.js +107 -0
  83. package/dist/controllers/highlight_controller.js.map +1 -0
  84. package/dist/controllers/hover_card_controller.d.ts +79 -0
  85. package/dist/controllers/hover_card_controller.js +165 -0
  86. package/dist/controllers/hover_card_controller.js.map +1 -0
  87. package/dist/controllers/idle_controller.d.ts +51 -0
  88. package/dist/controllers/idle_controller.js +141 -0
  89. package/dist/controllers/idle_controller.js.map +1 -0
  90. package/dist/controllers/input_mask_controller.d.ts +74 -0
  91. package/dist/controllers/input_mask_controller.js +166 -0
  92. package/dist/controllers/input_mask_controller.js.map +1 -0
  93. package/dist/controllers/lazy_frame_controller.d.ts +53 -0
  94. package/dist/controllers/lazy_frame_controller.js +68 -0
  95. package/dist/controllers/lazy_frame_controller.js.map +1 -0
  96. package/dist/controllers/listbox_controller.d.ts +88 -0
  97. package/dist/controllers/listbox_controller.js +256 -0
  98. package/dist/controllers/listbox_controller.js.map +1 -0
  99. package/dist/controllers/local_time_controller.d.ts +60 -0
  100. package/dist/controllers/local_time_controller.js +81 -0
  101. package/dist/controllers/local_time_controller.js.map +1 -0
  102. package/dist/controllers/menu_controller.d.ts +81 -0
  103. package/dist/controllers/menu_controller.js +134 -0
  104. package/dist/controllers/menu_controller.js.map +1 -0
  105. package/dist/controllers/meter_controller.d.ts +93 -0
  106. package/dist/controllers/meter_controller.js +96 -0
  107. package/dist/controllers/meter_controller.js.map +1 -0
  108. package/dist/controllers/nested_form_controller.d.ts +85 -0
  109. package/dist/controllers/nested_form_controller.js +131 -0
  110. package/dist/controllers/nested_form_controller.js.map +1 -0
  111. package/dist/controllers/network_status_controller.d.ts +49 -0
  112. package/dist/controllers/network_status_controller.js +126 -0
  113. package/dist/controllers/network_status_controller.js.map +1 -0
  114. package/dist/controllers/number_input_controller.d.ts +93 -0
  115. package/dist/controllers/number_input_controller.js +306 -0
  116. package/dist/controllers/number_input_controller.js.map +1 -0
  117. package/dist/controllers/otp_controller.d.ts +68 -0
  118. package/dist/controllers/otp_controller.js +201 -0
  119. package/dist/controllers/otp_controller.js.map +1 -0
  120. package/dist/controllers/overflow_indicator_controller.d.ts +62 -0
  121. package/dist/controllers/overflow_indicator_controller.js +169 -0
  122. package/dist/controllers/overflow_indicator_controller.js.map +1 -0
  123. package/dist/controllers/overflow_menu_controller.d.ts +64 -0
  124. package/dist/controllers/overflow_menu_controller.js +274 -0
  125. package/dist/controllers/overflow_menu_controller.js.map +1 -0
  126. package/dist/controllers/pagination_controller.d.ts +69 -0
  127. package/dist/controllers/pagination_controller.js +89 -0
  128. package/dist/controllers/pagination_controller.js.map +1 -0
  129. package/dist/controllers/password_strength_controller.d.ts +67 -0
  130. package/dist/controllers/password_strength_controller.js +175 -0
  131. package/dist/controllers/password_strength_controller.js.map +1 -0
  132. package/dist/controllers/persist_controller.d.ts +63 -0
  133. package/dist/controllers/persist_controller.js +259 -0
  134. package/dist/controllers/persist_controller.js.map +1 -0
  135. package/dist/controllers/popover_controller.d.ts +63 -0
  136. package/dist/controllers/popover_controller.js +94 -0
  137. package/dist/controllers/popover_controller.js.map +1 -0
  138. package/dist/controllers/portal_controller.d.ts +58 -0
  139. package/dist/controllers/portal_controller.js +63 -0
  140. package/dist/controllers/portal_controller.js.map +1 -0
  141. package/dist/controllers/preview_guard_controller.d.ts +47 -0
  142. package/dist/controllers/preview_guard_controller.js +69 -0
  143. package/dist/controllers/preview_guard_controller.js.map +1 -0
  144. package/dist/controllers/progress_controller.d.ts +85 -0
  145. package/dist/controllers/progress_controller.js +93 -0
  146. package/dist/controllers/progress_controller.js.map +1 -0
  147. package/dist/controllers/radio_group_controller.d.ts +49 -0
  148. package/dist/controllers/radio_group_controller.js +128 -0
  149. package/dist/controllers/radio_group_controller.js.map +1 -0
  150. package/dist/controllers/rating_controller.d.ts +82 -0
  151. package/dist/controllers/rating_controller.js +179 -0
  152. package/dist/controllers/rating_controller.js.map +1 -0
  153. package/dist/controllers/relative_time_controller.d.ts +48 -0
  154. package/dist/controllers/relative_time_controller.js +129 -0
  155. package/dist/controllers/relative_time_controller.js.map +1 -0
  156. package/dist/controllers/reset_before_cache_controller.d.ts +54 -0
  157. package/dist/controllers/reset_before_cache_controller.js +62 -0
  158. package/dist/controllers/reset_before_cache_controller.js.map +1 -0
  159. package/dist/controllers/resizable_controller.d.ts +81 -0
  160. package/dist/controllers/resizable_controller.js +163 -0
  161. package/dist/controllers/resizable_controller.js.map +1 -0
  162. package/dist/controllers/roving_controller.d.ts +63 -0
  163. package/dist/controllers/roving_controller.js +116 -0
  164. package/dist/controllers/roving_controller.js.map +1 -0
  165. package/dist/controllers/scroll_area_controller.d.ts +45 -0
  166. package/dist/controllers/scroll_area_controller.js +183 -0
  167. package/dist/controllers/scroll_area_controller.js.map +1 -0
  168. package/dist/controllers/scroll_visibility_controller.d.ts +72 -0
  169. package/dist/controllers/scroll_visibility_controller.js +103 -0
  170. package/dist/controllers/scroll_visibility_controller.js.map +1 -0
  171. package/dist/controllers/scrollspy_controller.d.ts +65 -0
  172. package/dist/controllers/scrollspy_controller.js +171 -0
  173. package/dist/controllers/scrollspy_controller.js.map +1 -0
  174. package/dist/controllers/skeleton_controller.d.ts +50 -0
  175. package/dist/controllers/skeleton_controller.js +125 -0
  176. package/dist/controllers/skeleton_controller.js.map +1 -0
  177. package/dist/controllers/slider_controller.d.ts +76 -0
  178. package/dist/controllers/slider_controller.js +109 -0
  179. package/dist/controllers/slider_controller.js.map +1 -0
  180. package/dist/controllers/spinner_controller.d.ts +62 -0
  181. package/dist/controllers/spinner_controller.js +164 -0
  182. package/dist/controllers/spinner_controller.js.map +1 -0
  183. package/dist/controllers/step_indicator_controller.d.ts +55 -0
  184. package/dist/controllers/step_indicator_controller.js +55 -0
  185. package/dist/controllers/step_indicator_controller.js.map +1 -0
  186. package/dist/controllers/stepper_controller.d.ts +66 -0
  187. package/dist/controllers/stepper_controller.js +78 -0
  188. package/dist/controllers/stepper_controller.js.map +1 -0
  189. package/dist/controllers/stick_to_bottom_controller.d.ts +61 -0
  190. package/dist/controllers/stick_to_bottom_controller.js +100 -0
  191. package/dist/controllers/stick_to_bottom_controller.js.map +1 -0
  192. package/dist/controllers/sticky_observer_controller.d.ts +54 -0
  193. package/dist/controllers/sticky_observer_controller.js +53 -0
  194. package/dist/controllers/sticky_observer_controller.js.map +1 -0
  195. package/dist/controllers/submit_once_controller.d.ts +66 -0
  196. package/dist/controllers/submit_once_controller.js +206 -0
  197. package/dist/controllers/submit_once_controller.js.map +1 -0
  198. package/dist/controllers/switch_controller.d.ts +43 -0
  199. package/dist/controllers/switch_controller.js +50 -0
  200. package/dist/controllers/switch_controller.js.map +1 -0
  201. package/dist/controllers/tabs_controller.d.ts +48 -0
  202. package/dist/controllers/tabs_controller.js +63 -0
  203. package/dist/controllers/tabs_controller.js.map +1 -0
  204. package/dist/controllers/textarea_autosize_controller.d.ts +47 -0
  205. package/dist/controllers/textarea_autosize_controller.js +72 -0
  206. package/dist/controllers/textarea_autosize_controller.js.map +1 -0
  207. package/dist/controllers/theme_controller.d.ts +70 -0
  208. package/dist/controllers/theme_controller.js +154 -0
  209. package/dist/controllers/theme_controller.js.map +1 -0
  210. package/dist/controllers/toast_controller.d.ts +123 -0
  211. package/dist/controllers/toast_controller.js +310 -0
  212. package/dist/controllers/toast_controller.js.map +1 -0
  213. package/dist/controllers/toggle_group_controller.d.ts +56 -0
  214. package/dist/controllers/toggle_group_controller.js +130 -0
  215. package/dist/controllers/toggle_group_controller.js.map +1 -0
  216. package/dist/controllers/toolbar_controller.d.ts +59 -0
  217. package/dist/controllers/toolbar_controller.js +113 -0
  218. package/dist/controllers/toolbar_controller.js.map +1 -0
  219. package/dist/controllers/tooltip_controller.d.ts +74 -0
  220. package/dist/controllers/tooltip_controller.js +165 -0
  221. package/dist/controllers/tooltip_controller.js.map +1 -0
  222. package/dist/controllers/transition_controller.d.ts +86 -0
  223. package/dist/controllers/transition_controller.js +203 -0
  224. package/dist/controllers/transition_controller.js.map +1 -0
  225. package/dist/index.d.ts +1931 -0
  226. package/dist/index.js +12241 -0
  227. package/dist/index.js.map +1 -0
  228. package/dist/inspector/cli.d.ts +149 -0
  229. package/dist/inspector/cli.js +663 -0
  230. package/dist/inspector/cli.js.map +1 -0
  231. package/dist/inspector/cli_bin.d.ts +2 -0
  232. package/dist/inspector/cli_bin.js +665 -0
  233. package/dist/inspector/cli_bin.js.map +1 -0
  234. package/dist/inspector/manifest.json +2235 -0
  235. package/dist/positioning/index.d.ts +226 -0
  236. package/dist/positioning/index.js +145 -0
  237. package/dist/positioning/index.js.map +1 -0
  238. package/package.json +93 -0
@@ -0,0 +1,256 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/listbox_controller.ts
4
+
5
+ // src/utils/safe_timeout.ts
6
+ var TimerRegistry = class {
7
+ /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
8
+ ids = /* @__PURE__ */ new Set();
9
+ /**
10
+ * Cancels a single tracked timer.
11
+ *
12
+ * No-ops if the id is unknown (already cleared, fired, or never owned by this
13
+ * registry), so callers can clear defensively without guarding.
14
+ */
15
+ clear(id) {
16
+ if (this.ids.delete(id)) {
17
+ this.cancel(id);
18
+ }
19
+ }
20
+ /**
21
+ * Cancels every tracked timer. Call this from a controller's `disconnect()`
22
+ * to guarantee no timer outlives the element.
23
+ */
24
+ clearAll() {
25
+ for (const id of this.ids) {
26
+ this.cancel(id);
27
+ }
28
+ this.ids.clear();
29
+ }
30
+ /** Number of timers currently tracked (pending). */
31
+ get size() {
32
+ return this.ids.size;
33
+ }
34
+ };
35
+ var SafeTimeout = class extends TimerRegistry {
36
+ /**
37
+ * Schedules `callback` after `delay` ms and returns the timer id.
38
+ *
39
+ * The id is removed from the registry automatically when the timeout fires,
40
+ * so {@link TimerRegistry.size | size} reflects only still-pending timers.
41
+ */
42
+ set(callback, delay) {
43
+ const id = this.schedule(() => {
44
+ this.ids.delete(id);
45
+ callback();
46
+ }, delay);
47
+ this.ids.add(id);
48
+ return id;
49
+ }
50
+ schedule(callback, delay) {
51
+ return window.setTimeout(callback, delay);
52
+ }
53
+ cancel(id) {
54
+ window.clearTimeout(id);
55
+ }
56
+ };
57
+
58
+ // src/controllers/listbox_controller.ts
59
+ var TYPEAHEAD_TIMEOUT = 500;
60
+ var ListboxController = class extends Controller {
61
+ static targets = ["trigger", "value", "list", "option", "field"];
62
+ static actions = ["close", "onTriggerKeydown", "open", "select", "toggle"];
63
+ static events = ["change"];
64
+ /** Index of the active option, or -1 when none is active. */
65
+ #activeIndex = -1;
66
+ /** Accumulated typeahead query, reset after {@link TYPEAHEAD_TIMEOUT} ms. */
67
+ #typeahead = "";
68
+ #typeaheadTimer = 0;
69
+ #timers = new SafeTimeout();
70
+ /** Starts closed and registers the outside-click listener. */
71
+ connect() {
72
+ this.close();
73
+ document.addEventListener("click", this.#onOutsideClick);
74
+ }
75
+ /** Removes the document listener and clears the typeahead timer. */
76
+ disconnect() {
77
+ document.removeEventListener("click", this.#onOutsideClick);
78
+ this.#timers.clearAll();
79
+ }
80
+ /**
81
+ * Toggles the list on a real mouse click. Keyboard activation of the
82
+ * `<button>` also fires a click (`detail === 0`); the keydown handler already
83
+ * drives that, so the synthetic click is ignored to avoid double-toggling.
84
+ */
85
+ toggle(event) {
86
+ if (event.detail === 0) return;
87
+ if (this.#isClosed) {
88
+ this.open();
89
+ } else {
90
+ this.close();
91
+ }
92
+ }
93
+ /** Routes trigger keyboard interaction per the APG select-only model. */
94
+ onTriggerKeydown(event) {
95
+ const length = this.optionTargets.length;
96
+ if (this.#isClosed) {
97
+ switch (event.key) {
98
+ case "Enter":
99
+ case " ":
100
+ case "ArrowDown":
101
+ case "ArrowUp":
102
+ event.preventDefault();
103
+ this.open();
104
+ break;
105
+ }
106
+ return;
107
+ }
108
+ if (length === 0 && event.key !== "Escape" && event.key !== "Tab") {
109
+ return;
110
+ }
111
+ switch (event.key) {
112
+ case "ArrowDown":
113
+ event.preventDefault();
114
+ this.#setActive(this.#activeIndex < 0 ? 0 : (this.#activeIndex + 1) % length);
115
+ break;
116
+ case "ArrowUp":
117
+ event.preventDefault();
118
+ this.#setActive(
119
+ this.#activeIndex < 0 ? length - 1 : (this.#activeIndex - 1 + length) % length
120
+ );
121
+ break;
122
+ case "Home":
123
+ event.preventDefault();
124
+ this.#setActive(0);
125
+ break;
126
+ case "End":
127
+ event.preventDefault();
128
+ this.#setActive(length - 1);
129
+ break;
130
+ case "Enter":
131
+ case " ":
132
+ event.preventDefault();
133
+ this.#commitActive();
134
+ break;
135
+ case "Escape":
136
+ event.preventDefault();
137
+ this.close();
138
+ this.triggerTarget.focus();
139
+ break;
140
+ case "Tab":
141
+ this.close();
142
+ break;
143
+ default:
144
+ if (this.#isPrintable(event)) {
145
+ event.preventDefault();
146
+ this.#typeaheadTo(event.key);
147
+ }
148
+ break;
149
+ }
150
+ }
151
+ /** Selects the clicked option and closes, returning focus to the trigger. */
152
+ select(event) {
153
+ const option = event.currentTarget.closest('[role="option"]');
154
+ if (!option) return;
155
+ this.#selectOption(option);
156
+ this.close();
157
+ this.triggerTarget.focus();
158
+ }
159
+ /** Opens the list and activates the selected option (else the first). */
160
+ open() {
161
+ if (!this.hasListTarget) return;
162
+ this.listTarget.hidden = false;
163
+ this.triggerTarget.setAttribute("aria-expanded", "true");
164
+ if (this.optionTargets.length === 0) {
165
+ this.#setActive(-1);
166
+ return;
167
+ }
168
+ const selected = this.optionTargets.findIndex(
169
+ (option) => option.getAttribute("aria-selected") === "true"
170
+ );
171
+ this.#setActive(selected === -1 ? 0 : selected);
172
+ }
173
+ /** Closes the list, clears the active option, and resets the typeahead buffer. */
174
+ close() {
175
+ if (!this.hasListTarget) return;
176
+ this.listTarget.hidden = true;
177
+ this.triggerTarget.setAttribute("aria-expanded", "false");
178
+ this.#setActive(-1);
179
+ this.#resetTypeahead();
180
+ }
181
+ /** Commits the active option (keyboard) and closes, returning focus. */
182
+ #commitActive() {
183
+ const option = this.#activeIndex < 0 ? void 0 : this.optionTargets[this.#activeIndex];
184
+ if (option) this.#selectOption(option);
185
+ this.close();
186
+ this.triggerTarget.focus();
187
+ }
188
+ /** Applies selection: `aria-selected`, trigger label, hidden field, `change`. */
189
+ #selectOption(option) {
190
+ for (const candidate of this.optionTargets) {
191
+ candidate.setAttribute("aria-selected", candidate === option ? "true" : "false");
192
+ }
193
+ const label = (option.textContent ?? "").trim();
194
+ const value = option.dataset.value ?? label;
195
+ if (this.hasValueTarget) this.valueTarget.textContent = label;
196
+ if (this.hasFieldTarget && this.fieldTarget.value !== value) {
197
+ this.fieldTarget.value = value;
198
+ this.fieldTarget.dispatchEvent(new Event("change", { bubbles: true }));
199
+ }
200
+ this.dispatch("change", { detail: { value, option } });
201
+ }
202
+ /**
203
+ * Marks the option at `index` active via `data-active` and the trigger's
204
+ * `aria-activedescendant`. Pass `-1` to clear it (the attribute is removed, not
205
+ * set to empty, per the APG).
206
+ */
207
+ #setActive(index) {
208
+ this.#activeIndex = index;
209
+ const active = index < 0 ? null : this.optionTargets[index];
210
+ for (const option of this.optionTargets) {
211
+ if (option === active) {
212
+ option.setAttribute("data-active", "");
213
+ } else {
214
+ option.removeAttribute("data-active");
215
+ }
216
+ }
217
+ if (active?.id) {
218
+ this.triggerTarget.setAttribute("aria-activedescendant", active.id);
219
+ } else {
220
+ this.triggerTarget.removeAttribute("aria-activedescendant");
221
+ }
222
+ }
223
+ /** Appends a character to the typeahead query and activates the first match. */
224
+ #typeaheadTo(char) {
225
+ this.#timers.clear(this.#typeaheadTimer);
226
+ this.#typeahead += char.toLowerCase();
227
+ this.#typeaheadTimer = this.#timers.set(() => {
228
+ this.#typeahead = "";
229
+ }, TYPEAHEAD_TIMEOUT);
230
+ const index = this.optionTargets.findIndex(
231
+ (option) => (option.textContent ?? "").trim().toLowerCase().startsWith(this.#typeahead)
232
+ );
233
+ if (index !== -1) this.#setActive(index);
234
+ }
235
+ /** Clears the typeahead query and its pending reset timer. */
236
+ #resetTypeahead() {
237
+ this.#timers.clear(this.#typeaheadTimer);
238
+ this.#typeahead = "";
239
+ }
240
+ /** Closes the list when a click lands outside the controller element. */
241
+ #onOutsideClick = (event) => {
242
+ if (!this.#isClosed && !this.element.contains(event.target)) this.close();
243
+ };
244
+ /** Whether `event.key` is a single printable character (no modifier chord). */
245
+ #isPrintable(event) {
246
+ return event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey;
247
+ }
248
+ /** Whether the list is currently hidden. */
249
+ get #isClosed() {
250
+ return !this.hasListTarget || this.listTarget.hidden !== false;
251
+ }
252
+ };
253
+
254
+ export { ListboxController };
255
+ //# sourceMappingURL=listbox_controller.js.map
256
+ //# sourceMappingURL=listbox_controller.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/utils/safe_timeout.ts","../../src/controllers/listbox_controller.ts"],"names":[],"mappings":"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","file":"listbox_controller.js","sourcesContent":["/**\n * Self-cleaning timer registries shared by Stimeo controllers.\n *\n * Stimulus controllers frequently schedule `setTimeout` / `setInterval` work\n * (auto-dismiss, debouncing, polling). When the element leaves the DOM — a\n * Turbo Drive navigation, a Turbo Stream replacement, or any `disconnect()` —\n * orphaned timers keep firing against a detached controller, leaking memory and\n * mutating stale state. {@link SafeTimeout} and {@link SafeInterval} track every\n * timer they create so a single {@link TimerRegistry.clearAll | clearAll()} call\n * in `disconnect()` tears them all down.\n *\n * These are intentionally low-level primitives: they own *registration and\n * cleanup only*. Higher-level policy (pause/resume, remaining-time accounting)\n * stays in the individual controllers so per-widget semantics are not flattened\n * into a lowest-common-denominator helper.\n */\n\n/**\n * Shared registry bookkeeping for the timeout/interval variants.\n *\n * Subclasses provide the scheduling primitive ({@link schedule}) and its matching\n * canceller ({@link cancel}); this base owns the set of live ids plus the\n * per-id and bulk teardown shared by both.\n */\nabstract class TimerRegistry {\n /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */\n protected readonly ids = new Set<number>();\n\n /** Schedules the underlying platform timer and returns its id. */\n protected abstract schedule(callback: () => void, delay: number): number;\n\n /** Cancels the underlying platform timer for `id`. */\n protected abstract cancel(id: number): void;\n\n /**\n * Cancels a single tracked timer.\n *\n * No-ops if the id is unknown (already cleared, fired, or never owned by this\n * registry), so callers can clear defensively without guarding.\n */\n clear(id: number): void {\n if (this.ids.delete(id)) {\n this.cancel(id);\n }\n }\n\n /**\n * Cancels every tracked timer. Call this from a controller's `disconnect()`\n * to guarantee no timer outlives the element.\n */\n clearAll(): void {\n for (const id of this.ids) {\n this.cancel(id);\n }\n this.ids.clear();\n }\n\n /** Number of timers currently tracked (pending). */\n get size(): number {\n return this.ids.size;\n }\n}\n\n/**\n * `setTimeout` wrapper that auto-forgets each timer once it fires and supports\n * bulk teardown on disconnect.\n *\n * @example\n * ```ts\n * #timers = new SafeTimeout();\n *\n * connect() {\n * this.#timers.set(() => this.dismiss(), 5000);\n * }\n *\n * disconnect() {\n * this.#timers.clearAll();\n * }\n * ```\n */\nexport class SafeTimeout extends TimerRegistry {\n /**\n * Schedules `callback` after `delay` ms and returns the timer id.\n *\n * The id is removed from the registry automatically when the timeout fires,\n * so {@link TimerRegistry.size | size} reflects only still-pending timers.\n */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(() => {\n this.ids.delete(id);\n callback();\n }, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setTimeout(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearTimeout(id);\n }\n}\n\n/**\n * `setInterval` wrapper that tracks every interval for bulk teardown on\n * disconnect. Unlike {@link SafeTimeout}, intervals are retained until they are\n * explicitly cleared because they fire repeatedly.\n *\n * @example\n * ```ts\n * #intervals = new SafeInterval();\n *\n * connect() {\n * this.#intervals.set(() => this.tick(), 1000);\n * }\n *\n * disconnect() {\n * this.#intervals.clearAll();\n * }\n * ```\n */\nexport class SafeInterval extends TimerRegistry {\n /** Schedules a repeating `callback` every `delay` ms and returns the timer id. */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(callback, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setInterval(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearInterval(id);\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { SafeTimeout } from \"../utils/safe_timeout\";\n\n/** How long (ms) typed characters accumulate into one typeahead query. */\nconst TYPEAHEAD_TIMEOUT = 500;\n\n/**\n * Headless, accessible select-only listbox behavior.\n *\n * Markup contract (identifier: `stimeo--listbox`):\n * <div data-controller=\"stimeo--listbox\">\n * <span id=\"lb-label\">Favorite fruit</span>\n * <button type=\"button\" role=\"combobox\" aria-haspopup=\"listbox\"\n * aria-expanded=\"false\" aria-controls=\"lb-list\" aria-activedescendant=\"\"\n * aria-labelledby=\"lb-label lb-value\"\n * data-stimeo--listbox-target=\"trigger\"\n * data-action=\"click->stimeo--listbox#toggle\n * keydown->stimeo--listbox#onTriggerKeydown\">\n * <span id=\"lb-value\" data-stimeo--listbox-target=\"value\">Choose…</span>\n * </button>\n * <ul id=\"lb-list\" role=\"listbox\" aria-label=\"Options\" hidden\n * data-stimeo--listbox-target=\"list\">\n * <li id=\"opt-1\" role=\"option\" aria-selected=\"false\" data-value=\"1\"\n * data-stimeo--listbox-target=\"option\"\n * data-action=\"click->stimeo--listbox#select\">Option 1</li>\n * <!-- more options -->\n * </ul>\n * <input type=\"hidden\" data-stimeo--listbox-target=\"field\" />\n * </div>\n *\n * Implements the WAI-ARIA APG **Listbox** pattern in its collapsed\n * (Select-Only Combobox) form. Focus stays on the trigger; the active option is\n * tracked with `aria-activedescendant` rather than by moving DOM focus. For a\n * text-filtered popup use {@link ComboboxController | Combobox}; for multiple\n * selection use Multi-Select.\n *\n * @remarks\n * Behavior only. Static placement is the consumer's CSS; dynamic placement is\n * delegated to the opt-in `stimeo-ui/positioning` module. The look is keyed off\n * `aria-selected` / `data-active`. Because `role=\"combobox\"` is not named by its\n * contents, give the trigger an accessible name via `aria-labelledby` (a visible\n * label plus the value span) or `aria-label`.\n *\n * Behavior provided:\n * - Open/close the list, syncing `aria-expanded` and the list's `hidden`.\n * - `ArrowDown`/`ArrowUp` (wrapping), `Home`/`End`, and printable-character\n * typeahead move the active option; opening picks the selected option (else\n * the first).\n * - Single selection syncs `aria-selected`, reflects the label into the trigger\n * value and the field's value, and dispatches `stimeo--listbox:change` plus a\n * native bubbling `change` on the field when its value actually changed.\n * Making the field a validatable mirror (`<input type=\"text\" hidden required>`\n * instead of `type=\"hidden\"`) lets `stimeo--form-validation` enforce native\n * constraints on the committed value with no extra JavaScript.\n * - `Enter`/`Space` select and close; `Escape` and outside click / `Tab` close;\n * closing via select/Escape returns focus to the trigger.\n */\nexport class ListboxController extends Controller<HTMLElement> {\n static override targets = [\"trigger\", \"value\", \"list\", \"option\", \"field\"];\n static actions = [\"close\", \"onTriggerKeydown\", \"open\", \"select\", \"toggle\"] as const;\n static events = [\"change\"] as const;\n\n declare readonly triggerTarget: HTMLElement;\n declare readonly valueTarget: HTMLElement;\n declare readonly listTarget: HTMLElement;\n declare readonly optionTargets: HTMLElement[];\n declare readonly fieldTarget: HTMLInputElement;\n declare readonly hasTriggerTarget: boolean;\n declare readonly hasValueTarget: boolean;\n declare readonly hasListTarget: boolean;\n declare readonly hasFieldTarget: boolean;\n\n /** Index of the active option, or -1 when none is active. */\n #activeIndex = -1;\n /** Accumulated typeahead query, reset after {@link TYPEAHEAD_TIMEOUT} ms. */\n #typeahead = \"\";\n #typeaheadTimer = 0;\n readonly #timers = new SafeTimeout();\n\n /** Starts closed and registers the outside-click listener. */\n override connect(): void {\n this.close();\n document.addEventListener(\"click\", this.#onOutsideClick);\n }\n\n /** Removes the document listener and clears the typeahead timer. */\n override disconnect(): void {\n document.removeEventListener(\"click\", this.#onOutsideClick);\n this.#timers.clearAll();\n }\n\n /**\n * Toggles the list on a real mouse click. Keyboard activation of the\n * `<button>` also fires a click (`detail === 0`); the keydown handler already\n * drives that, so the synthetic click is ignored to avoid double-toggling.\n */\n toggle(event: MouseEvent): void {\n if (event.detail === 0) return;\n if (this.#isClosed) {\n this.open();\n } else {\n this.close();\n }\n }\n\n /** Routes trigger keyboard interaction per the APG select-only model. */\n onTriggerKeydown(event: KeyboardEvent): void {\n const length = this.optionTargets.length;\n\n if (this.#isClosed) {\n switch (event.key) {\n case \"Enter\":\n case \" \":\n case \"ArrowDown\":\n case \"ArrowUp\":\n event.preventDefault();\n this.open();\n break;\n default:\n break;\n }\n return;\n }\n\n // With no options, only Escape/Tab are meaningful; ignore navigation and\n // typeahead so the active index can never become NaN (`% 0`).\n if (length === 0 && event.key !== \"Escape\" && event.key !== \"Tab\") {\n return;\n }\n\n switch (event.key) {\n case \"ArrowDown\":\n event.preventDefault();\n this.#setActive(this.#activeIndex < 0 ? 0 : (this.#activeIndex + 1) % length);\n break;\n case \"ArrowUp\":\n event.preventDefault();\n this.#setActive(\n this.#activeIndex < 0 ? length - 1 : (this.#activeIndex - 1 + length) % length,\n );\n break;\n case \"Home\":\n event.preventDefault();\n this.#setActive(0);\n break;\n case \"End\":\n event.preventDefault();\n this.#setActive(length - 1);\n break;\n case \"Enter\":\n case \" \":\n event.preventDefault();\n this.#commitActive();\n break;\n case \"Escape\":\n event.preventDefault();\n this.close();\n this.triggerTarget.focus();\n break;\n case \"Tab\":\n // Let focus leave naturally; just don't keep a stale popup open.\n this.close();\n break;\n default:\n if (this.#isPrintable(event)) {\n event.preventDefault();\n this.#typeaheadTo(event.key);\n }\n break;\n }\n }\n\n /** Selects the clicked option and closes, returning focus to the trigger. */\n select(event: Event): void {\n const option = (event.currentTarget as HTMLElement).closest<HTMLElement>('[role=\"option\"]');\n if (!option) return;\n this.#selectOption(option);\n this.close();\n this.triggerTarget.focus();\n }\n\n /** Opens the list and activates the selected option (else the first). */\n open(): void {\n if (!this.hasListTarget) return;\n this.listTarget.hidden = false;\n this.triggerTarget.setAttribute(\"aria-expanded\", \"true\");\n if (this.optionTargets.length === 0) {\n // An empty listbox has nothing to activate; leave activedescendant cleared.\n this.#setActive(-1);\n return;\n }\n const selected = this.optionTargets.findIndex(\n (option) => option.getAttribute(\"aria-selected\") === \"true\",\n );\n this.#setActive(selected === -1 ? 0 : selected);\n }\n\n /** Closes the list, clears the active option, and resets the typeahead buffer. */\n close(): void {\n if (!this.hasListTarget) return;\n this.listTarget.hidden = true;\n this.triggerTarget.setAttribute(\"aria-expanded\", \"false\");\n this.#setActive(-1);\n this.#resetTypeahead();\n }\n\n /** Commits the active option (keyboard) and closes, returning focus. */\n #commitActive(): void {\n const option = this.#activeIndex < 0 ? undefined : this.optionTargets[this.#activeIndex];\n if (option) this.#selectOption(option);\n this.close();\n this.triggerTarget.focus();\n }\n\n /** Applies selection: `aria-selected`, trigger label, hidden field, `change`. */\n #selectOption(option: HTMLElement): void {\n for (const candidate of this.optionTargets) {\n candidate.setAttribute(\"aria-selected\", candidate === option ? \"true\" : \"false\");\n }\n const label = (option.textContent ?? \"\").trim();\n const value = option.dataset.value ?? label;\n if (this.hasValueTarget) this.valueTarget.textContent = label;\n if (this.hasFieldTarget && this.fieldTarget.value !== value) {\n this.fieldTarget.value = value;\n // A native bubbling change (matching <select> semantics: only on an actual\n // value change) so form-level behaviors — validation re-checks, auto-submit\n // — hear the commit without knowing this widget.\n this.fieldTarget.dispatchEvent(new Event(\"change\", { bubbles: true }));\n }\n this.dispatch(\"change\", { detail: { value, option } });\n }\n\n /**\n * Marks the option at `index` active via `data-active` and the trigger's\n * `aria-activedescendant`. Pass `-1` to clear it (the attribute is removed, not\n * set to empty, per the APG).\n */\n #setActive(index: number): void {\n this.#activeIndex = index;\n const active = index < 0 ? null : this.optionTargets[index];\n for (const option of this.optionTargets) {\n if (option === active) {\n option.setAttribute(\"data-active\", \"\");\n } else {\n option.removeAttribute(\"data-active\");\n }\n }\n if (active?.id) {\n this.triggerTarget.setAttribute(\"aria-activedescendant\", active.id);\n } else {\n this.triggerTarget.removeAttribute(\"aria-activedescendant\");\n }\n }\n\n /** Appends a character to the typeahead query and activates the first match. */\n #typeaheadTo(char: string): void {\n this.#timers.clear(this.#typeaheadTimer);\n this.#typeahead += char.toLowerCase();\n this.#typeaheadTimer = this.#timers.set(() => {\n this.#typeahead = \"\";\n }, TYPEAHEAD_TIMEOUT);\n const index = this.optionTargets.findIndex((option) =>\n (option.textContent ?? \"\").trim().toLowerCase().startsWith(this.#typeahead),\n );\n if (index !== -1) this.#setActive(index);\n }\n\n /** Clears the typeahead query and its pending reset timer. */\n #resetTypeahead(): void {\n this.#timers.clear(this.#typeaheadTimer);\n this.#typeahead = \"\";\n }\n\n /** Closes the list when a click lands outside the controller element. */\n readonly #onOutsideClick = (event: MouseEvent): void => {\n if (!this.#isClosed && !this.element.contains(event.target as Node)) this.close();\n };\n\n /** Whether `event.key` is a single printable character (no modifier chord). */\n #isPrintable(event: KeyboardEvent): boolean {\n return event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey;\n }\n\n /** Whether the list is currently hidden. */\n get #isClosed(): boolean {\n return !this.hasListTarget || this.listTarget.hidden !== false;\n }\n}\n"]}
@@ -0,0 +1,60 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ /**
4
+ * Headless local-time behavior: renders the UTC instant in a `<time datetime>`
5
+ * as an absolute, viewer-localized string via `Intl.DateTimeFormat`. No
6
+ * dedicated APG pattern; it follows the HTML `<time>` semantics.
7
+ *
8
+ * Markup contract (identifier: `stimeo--local-time`):
9
+ * <time datetime="2026-06-08T12:30:00Z"
10
+ * data-controller="stimeo--local-time"
11
+ * data-stimeo--local-time-date-style-value="medium"
12
+ * data-stimeo--local-time-time-style-value="short">2026-06-08 12:30 UTC</time>
13
+ *
14
+ * The server emits UTC (cache-friendly — it never needs to know the viewer's
15
+ * timezone), and the controller reformats the visible text into the viewer's
16
+ * locale/zone on connect. This is the *absolute* localization axis, distinct from
17
+ * {@link RelativeTimeController}'s "3 minutes ago".
18
+ *
19
+ * @remarks
20
+ * Behavior only. The machine-readable `datetime` (UTC) is left untouched so
21
+ * assistive tech and crawlers keep the canonical value while only the display
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.
26
+ */
27
+ declare class LocalTimeController extends Controller<HTMLElement> {
28
+ #private;
29
+ static values: {
30
+ locale: {
31
+ type: StringConstructor;
32
+ default: string;
33
+ };
34
+ timeZone: {
35
+ type: StringConstructor;
36
+ default: string;
37
+ };
38
+ dateStyle: {
39
+ type: StringConstructor;
40
+ default: string;
41
+ };
42
+ timeStyle: {
43
+ type: StringConstructor;
44
+ default: string;
45
+ };
46
+ titleFormat: {
47
+ type: StringConstructor;
48
+ default: string;
49
+ };
50
+ };
51
+ static events: readonly ["format"];
52
+ localeValue: string;
53
+ timeZoneValue: string;
54
+ dateStyleValue: string;
55
+ timeStyleValue: string;
56
+ titleFormatValue: string;
57
+ connect(): void;
58
+ }
59
+
60
+ export { LocalTimeController };
@@ -0,0 +1,81 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/local_time_controller.ts
4
+ var STYLES = /* @__PURE__ */ new Set(["full", "long", "medium", "short"]);
5
+ function toStyle(value) {
6
+ return STYLES.has(value) ? value : void 0;
7
+ }
8
+ var LocalTimeController = class extends Controller {
9
+ static values = {
10
+ locale: { type: String, default: "" },
11
+ timeZone: { type: String, default: "" },
12
+ dateStyle: { type: String, default: "medium" },
13
+ timeStyle: { type: String, default: "short" },
14
+ titleFormat: { type: String, default: "" }
15
+ };
16
+ static events = ["format"];
17
+ connect() {
18
+ const date = this.#parse();
19
+ if (date === null) return;
20
+ const formatted = this.#applyFormat(date, this.dateStyleValue, this.timeStyleValue);
21
+ if (formatted === null) return;
22
+ this.element.textContent = formatted;
23
+ const title = this.#title(date);
24
+ if (title !== null) this.element.setAttribute("title", title);
25
+ this.dispatch("format", { detail: { formatted } });
26
+ }
27
+ /** Parses the UTC `datetime` attribute into a {@link Date}, or `null`. */
28
+ #parse() {
29
+ const raw = this.element.getAttribute("datetime");
30
+ if (!raw) return null;
31
+ const ms = Date.parse(this.#asUtc(raw.trim()));
32
+ return Number.isNaN(ms) ? null : new Date(ms);
33
+ }
34
+ /**
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
37
+ * the *runtime's* local zone, contradicting "the server emits UTC". Values that
38
+ * already carry `Z` or a `±hh:mm` offset (and bare `YYYY-MM-DD` dates, already
39
+ * parsed as UTC) are returned unchanged.
40
+ */
41
+ #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;
45
+ }
46
+ /**
47
+ * Builds the optional detailed `title`. `titleFormat` is an `Intl` style
48
+ * keyword applied to *both* date and time; empty (the default) adds no title.
49
+ */
50
+ #title(date) {
51
+ if (this.titleFormatValue.length === 0) return null;
52
+ return this.#applyFormat(date, this.titleFormatValue, this.titleFormatValue);
53
+ }
54
+ /**
55
+ * Formats `date` with `Intl.DateTimeFormat`, including each style only when it
56
+ * is a valid keyword (so a consumer can show date-only or time-only by clearing
57
+ * the other). Returns `null` when neither style is usable or `Intl` throws, so
58
+ * the caller can leave the authored text untouched.
59
+ */
60
+ #applyFormat(date, dateStyle, timeStyle) {
61
+ const options = {
62
+ dateStyle: toStyle(dateStyle),
63
+ timeStyle: toStyle(timeStyle)
64
+ };
65
+ if (options.dateStyle === void 0 && options.timeStyle === void 0) return null;
66
+ if (this.timeZoneValue.length > 0) options.timeZone = this.timeZoneValue;
67
+ try {
68
+ return new Intl.DateTimeFormat(this.#locale, options).format(date);
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+ /** Locale precedence: the value, then the element's `lang`, then the document's. */
74
+ get #locale() {
75
+ return this.localeValue || this.element.lang || document.documentElement.lang || void 0;
76
+ }
77
+ };
78
+
79
+ export { LocalTimeController };
80
+ //# sourceMappingURL=local_time_controller.js.map
81
+ //# sourceMappingURL=local_time_controller.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/controllers/local_time_controller.ts"],"names":[],"mappings":"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","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"]}
@@ -0,0 +1,81 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ /**
4
+ * Headless, accessible menu button behavior.
5
+ *
6
+ * Markup contract (identifier: `stimeo--menu`):
7
+ * <div data-controller="stimeo--menu">
8
+ * <button data-stimeo--menu-target="trigger"
9
+ * data-action="click->stimeo--menu#toggle
10
+ * keydown->stimeo--menu#onTriggerKeydown"
11
+ * aria-haspopup="menu" aria-expanded="false" aria-controls="menu">
12
+ * Actions
13
+ * </button>
14
+ * <ul id="menu" role="menu" data-stimeo--menu-target="menu" hidden>
15
+ * <li role="none">
16
+ * <button role="menuitem" tabindex="-1"
17
+ * data-stimeo--menu-target="item"
18
+ * data-action="click->stimeo--menu#activate
19
+ * keydown->stimeo--menu#onItemKeydown">…</button>
20
+ * </li>
21
+ * </ul>
22
+ * </div>
23
+ *
24
+ * Implements the WAI-ARIA APG **Menu Button** pattern (a button that opens a menu
25
+ * of commands). Unlike `stimeo--dropdown` (a disclosure for arbitrary content),
26
+ * this is a true `role="menu"` widget with roving focus across `role="menuitem"`
27
+ * children.
28
+ *
29
+ * @remarks
30
+ * Behavior only — static placement is the consumer's responsibility (CSS), and
31
+ * viewport-edge collision handling is intentionally out of scope (a future shared
32
+ * positioning module will own it). State is exposed via `aria-expanded` and the
33
+ * menu's `hidden` attribute.
34
+ *
35
+ * Behavior provided:
36
+ * - Click the trigger to toggle; `ArrowDown`/`ArrowUp` open and focus the
37
+ * first/last item.
38
+ * - Within the menu, `ArrowDown`/`ArrowUp` move focus (wrapping), `Home`/`End`
39
+ * jump to the first/last item, `Escape`/`Tab` close and return focus to the
40
+ * trigger, and activating an item closes the menu.
41
+ * - A click outside the controller closes the menu.
42
+ *
43
+ * Roving focus skips items that are not navigable — `hidden`, natively
44
+ * `disabled`, or `aria-disabled="true"` — so the keyboard never lands focus on an
45
+ * inert command (matching `stimeo--command-palette` / `stimeo--toolbar`).
46
+ */
47
+ declare class MenuController extends Controller<HTMLElement> {
48
+ #private;
49
+ static targets: string[];
50
+ static actions: readonly ["activate", "close", "onItemKeydown", "onTriggerKeydown", "open", "toggle"];
51
+ readonly triggerTarget: HTMLButtonElement;
52
+ readonly menuTarget: HTMLElement;
53
+ readonly itemTargets: HTMLButtonElement[];
54
+ readonly hasTriggerTarget: boolean;
55
+ readonly hasMenuTarget: boolean;
56
+ /** Starts closed and registers the outside-click listener. */
57
+ connect(): void;
58
+ /** Removes the document-level listener registered in {@link connect}. */
59
+ disconnect(): void;
60
+ /** Toggles the menu open/closed. Bound via `data-action` (click). */
61
+ toggle(): void;
62
+ /** Opens the menu and reflects the expanded state on the trigger. */
63
+ open(): void;
64
+ /** Closes the menu and reflects the collapsed state on the trigger. */
65
+ close(): void;
66
+ /**
67
+ * Opens the menu with the keyboard per the APG (Down → first, Up → last).
68
+ *
69
+ * Enter/Space are intentionally not handled here: on a native `<button>`
70
+ * trigger the browser turns them into a click, which already runs
71
+ * {@link toggle} (open + focus first item). Handling them again here would
72
+ * open and then immediately re-toggle the menu.
73
+ */
74
+ onTriggerKeydown(event: KeyboardEvent): void;
75
+ /** Implements roving focus and closing keys inside the menu. */
76
+ onItemKeydown(event: KeyboardEvent): void;
77
+ /** Closes the menu after an item is activated. Bound via `data-action`. */
78
+ activate(): void;
79
+ }
80
+
81
+ export { MenuController };