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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +160 -0
  3. data/dist/controllers/accordion_controller.js +10 -0
  4. data/dist/controllers/alert_dialog_controller.js +32 -5
  5. data/dist/controllers/announcer_controller.js +255 -20
  6. data/dist/controllers/breadcrumb_controller.js +225 -13
  7. data/dist/controllers/calendar_controller.js +89 -22
  8. data/dist/controllers/carousel_controller.js +47 -6
  9. data/dist/controllers/collapsible_controller.js +2 -2
  10. data/dist/controllers/color_picker_controller.js +92 -7
  11. data/dist/controllers/combobox_controller.js +162 -23
  12. data/dist/controllers/command_palette_controller.js +226 -22
  13. data/dist/controllers/confirm_controller.js +32 -5
  14. data/dist/controllers/context_menu_controller.js +32 -10
  15. data/dist/controllers/countdown_controller.js +112 -12
  16. data/dist/controllers/data_grid_controller.js +82 -4
  17. data/dist/controllers/date_range_picker_controller.js +77 -3
  18. data/dist/controllers/dialog_controller.js +32 -5
  19. data/dist/controllers/drawer_controller.js +32 -5
  20. data/dist/controllers/editable_controller.js +1 -0
  21. data/dist/controllers/empty_state_controller.js +24 -10
  22. data/dist/controllers/focus_controller.js +32 -5
  23. data/dist/controllers/form_validation_controller.js +1 -1
  24. data/dist/controllers/frame_loading_controller.js +177 -15
  25. data/dist/controllers/intersection_controller.js +36 -11
  26. data/dist/controllers/lazy_frame_controller.js +31 -10
  27. data/dist/controllers/listbox_controller.js +257 -53
  28. data/dist/controllers/local_time_controller.js +102 -8
  29. data/dist/controllers/menu_controller.js +104 -17
  30. data/dist/controllers/menubar_controller.js +415 -63
  31. data/dist/controllers/meter_controller.js +145 -26
  32. data/dist/controllers/multi_select_controller.js +312 -29
  33. data/dist/controllers/navigation_menu_controller.js +154 -27
  34. data/dist/controllers/network_status_controller.js +28 -8
  35. data/dist/controllers/number_input_controller.js +7 -0
  36. data/dist/controllers/otp_controller.js +18 -1
  37. data/dist/controllers/overflow_indicator_controller.js +81 -13
  38. data/dist/controllers/overflow_menu_controller.js +408 -57
  39. data/dist/controllers/pagination_controller.js +163 -32
  40. data/dist/controllers/persist_controller.js +6 -6
  41. data/dist/controllers/pointer_drag_controller.js +9 -1
  42. data/dist/controllers/popover_controller.js +2 -2
  43. data/dist/controllers/progress_controller.js +116 -9
  44. data/dist/controllers/radio_group_controller.js +22 -3
  45. data/dist/controllers/range_slider_controller.js +100 -9
  46. data/dist/controllers/rating_controller.js +69 -2
  47. data/dist/controllers/read_more_controller.js +63 -19
  48. data/dist/controllers/relative_time_controller.js +133 -12
  49. data/dist/controllers/resizable_controller.js +65 -1
  50. data/dist/controllers/roving_controller.js +17 -2
  51. data/dist/controllers/scroll_area_controller.js +86 -12
  52. data/dist/controllers/scroll_restore_controller.js +1 -1
  53. data/dist/controllers/scroll_visibility_controller.js +33 -3
  54. data/dist/controllers/scrollspy_controller.js +346 -73
  55. data/dist/controllers/separator_controller.js +9 -0
  56. data/dist/controllers/sidebar_controller.js +37 -8
  57. data/dist/controllers/skeleton_controller.js +73 -20
  58. data/dist/controllers/slider_controller.js +49 -8
  59. data/dist/controllers/sortable_controller.js +34 -3
  60. data/dist/controllers/spinner_controller.js +228 -27
  61. data/dist/controllers/step_indicator_controller.js +82 -5
  62. data/dist/controllers/stick_to_bottom_controller.js +61 -10
  63. data/dist/controllers/sticky_observer_controller.js +32 -11
  64. data/dist/controllers/switch_controller.js +1 -0
  65. data/dist/controllers/tabs_controller.js +26 -3
  66. data/dist/controllers/tags_input_controller.js +22 -2
  67. data/dist/controllers/theme_controller.js +22 -3
  68. data/dist/controllers/time_picker_controller.js +20 -1
  69. data/dist/controllers/toast_controller.js +4 -5
  70. data/dist/controllers/toggle_group_controller.js +23 -2
  71. data/dist/controllers/toolbar_controller.js +230 -31
  72. data/dist/controllers/tree_view_controller.js +467 -51
  73. data/dist/index.js +4507 -907
  74. data/lib/stimeo/ui/version.rb +2 -3
  75. metadata +2 -2
@@ -2,6 +2,45 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/listbox_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
+
11
+ // src/utils/microtask_coalescer.ts
12
+ var MicrotaskCoalescer = class {
13
+ #run;
14
+ #queued = false;
15
+ #active = false;
16
+ #generation = 0;
17
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
18
+ constructor(run) {
19
+ this.#run = run;
20
+ }
21
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
22
+ activate() {
23
+ this.#active = true;
24
+ }
25
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
26
+ cancel() {
27
+ this.#active = false;
28
+ this.#queued = false;
29
+ this.#generation += 1;
30
+ }
31
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
32
+ schedule() {
33
+ if (!this.#active || this.#queued) return;
34
+ this.#queued = true;
35
+ const generation = this.#generation;
36
+ queueMicrotask(() => {
37
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
38
+ this.#queued = false;
39
+ this.#run();
40
+ });
41
+ }
42
+ };
43
+
5
44
  // src/utils/option_scroll.ts
6
45
  function scrollOptionIntoView(list, option) {
7
46
  if (list.scrollHeight <= list.clientHeight) return;
@@ -67,27 +106,135 @@ var SafeTimeout = class extends TimerRegistry {
67
106
  }
68
107
  };
69
108
 
109
+ // src/utils/typeahead.ts
110
+ var TYPEAHEAD_RESET_MS = 500;
111
+ var Typeahead = class {
112
+ /** Timer registry for the pending idle reset; private so `reset()` is the only exit. */
113
+ #timers = new SafeTimeout();
114
+ /** Idle window before the query resets, in milliseconds. */
115
+ #resetMs;
116
+ /** The accumulated lowercase query, empty when idle. */
117
+ #query = "";
118
+ /** Id of the pending reset timer, `0` when none is scheduled. */
119
+ #timerId = 0;
120
+ /** @param options - Overrides for the idle window. */
121
+ constructor({ resetMs = TYPEAHEAD_RESET_MS } = {}) {
122
+ this.#resetMs = resetMs;
123
+ }
124
+ /** The query a search would currently run with; empty while idle. */
125
+ get query() {
126
+ return this.#query;
127
+ }
128
+ /**
129
+ * Folds `key` into the query, restarts the idle window, and returns the query to
130
+ * search with. A repeated character collapses the query to that one character.
131
+ */
132
+ push(key) {
133
+ const char = key.toLowerCase();
134
+ const repeated = this.#query.length > 0 && [...this.#query].every((c) => c === char);
135
+ this.#query = repeated ? char : this.#query + char;
136
+ this.#timers.clear(this.#timerId);
137
+ this.#timerId = this.#timers.set(() => this.reset(), this.#resetMs);
138
+ return this.#query;
139
+ }
140
+ /** Clears the query and cancels the pending idle reset. */
141
+ reset() {
142
+ this.#query = "";
143
+ this.#timers.clear(this.#timerId);
144
+ this.#timerId = 0;
145
+ }
146
+ };
147
+ function isTypeaheadKey(event) {
148
+ return event.key.length === 1 && event.key !== " " && !event.ctrlKey && !event.metaKey && !event.altKey && !event.isComposing;
149
+ }
150
+ function typeaheadLabel(element, fallbackText) {
151
+ const label = element.getAttribute("aria-label")?.trim();
152
+ if (label) return label.toLowerCase();
153
+ const text = element.textContent ?? "";
154
+ return text.trim().toLowerCase();
155
+ }
156
+ function findTypeaheadMatch(items, from, query, label = (item) => typeaheadLabel(item)) {
157
+ if (query === "") return -1;
158
+ const count = items.length;
159
+ for (let step = 1; step <= count; step += 1) {
160
+ const index = ((from + step) % count + count) % count;
161
+ const candidate = items[index];
162
+ if (candidate && label(candidate).startsWith(query)) return index;
163
+ }
164
+ return -1;
165
+ }
166
+
70
167
  // src/controllers/listbox_controller.ts
71
- var TYPEAHEAD_TIMEOUT = 500;
72
168
  var ListboxController = class extends Controller {
73
169
  static targets = ["trigger", "value", "list", "option", "field"];
74
170
  static actions = ["close", "onTriggerKeydown", "open", "select", "toggle"];
75
171
  static events = ["change"];
76
- /** Index of the active option, or -1 when none is active. */
77
- #activeIndex = -1;
78
- /** Accumulated typeahead query, reset after {@link TYPEAHEAD_TIMEOUT} ms. */
79
- #typeahead = "";
80
- #typeaheadTimer = 0;
81
- #timers = new SafeTimeout();
82
- /** Starts closed and registers the outside-click listener. */
172
+ /** Stable ID of the active option; DOM targets are resolved afresh before use. */
173
+ #activeId = null;
174
+ /** Target ID order captured while an option is active, used only for removal fallback. */
175
+ #activeOrder = [];
176
+ #connected = false;
177
+ /** Collapses one mutation batch of target callbacks into a single pass. */
178
+ #reconcile = new MicrotaskCoalescer(() => this.#reconcileActive());
179
+ /** Accumulated typeahead query and its idle-reset timer. */
180
+ #typeahead = new Typeahead();
181
+ /** Establishes the ARIA baseline, starts closed, and listens for outside clicks. */
83
182
  connect() {
183
+ this.#normalizeSelection();
84
184
  this.close();
85
- document.addEventListener("click", this.#onOutsideClick);
185
+ document.addEventListener("click", this.#onOutsideClick, true);
186
+ this.#connected = true;
187
+ this.#reconcile.activate();
188
+ }
189
+ /**
190
+ * Establishes an inactive baseline for a late option, re-resolves active
191
+ * identity, and re-applies the selection baseline.
192
+ */
193
+ optionTargetConnected(option) {
194
+ option.removeAttribute("data-active");
195
+ if (!this.#connected) return;
196
+ this.#normalizeSelection();
197
+ this.#queueOptionReconciliation();
198
+ }
199
+ /** Removes controller-owned active state and reconciles the surviving targets. */
200
+ optionTargetDisconnected(option) {
201
+ option.removeAttribute("data-active");
202
+ if (this.#connected) this.#queueOptionReconciliation();
203
+ }
204
+ /**
205
+ * Brings the authored DOM to the shape the APG requires, and derives the state
206
+ * that follows from the initial selection.
207
+ *
208
+ * Three things happen, and only these three — which option is chosen is the
209
+ * author's, and is never changed:
210
+ *
211
+ * 1. Every option gets an explicit value. An absent `aria-selected` means "not
212
+ * selectable" in ARIA, so a forgotten attribute hides a selectable option
213
+ * from assistive technology.
214
+ * 2. At most one stays `true`. The first in DOM order wins, since that is the
215
+ * only deterministic reading of "which one did the author mean".
216
+ * 3. The trigger label and the hidden field are derived from that selection.
217
+ * Without this the widget announces a choice it does not submit: the popup
218
+ * says "Banana", the trigger still says "Choose…", and the form posts "".
219
+ *
220
+ * No `change` fires — nothing changed, this is the initial state being told
221
+ * properly. The scan is the `option` target set: a `role="option"` without the
222
+ * target is outside the contract and is neither counted nor written.
223
+ */
224
+ #normalizeSelection() {
225
+ const options = this.optionTargets;
226
+ const selected = options.find((option) => option.getAttribute("aria-selected") === "true");
227
+ for (const option of options) {
228
+ option.setAttribute("aria-selected", option === selected ? "true" : "false");
229
+ }
230
+ if (selected) this.#applySelection(selected);
86
231
  }
87
232
  /** Removes the document listener and clears the typeahead timer. */
88
233
  disconnect() {
89
- document.removeEventListener("click", this.#onOutsideClick);
90
- this.#timers.clearAll();
234
+ this.#connected = false;
235
+ this.#reconcile.cancel();
236
+ document.removeEventListener("click", this.#onOutsideClick, true);
237
+ this.#typeahead.reset();
91
238
  }
92
239
  /**
93
240
  * Toggles the list on a real mouse click. Keyboard activation of the
@@ -102,9 +249,14 @@ var ListboxController = class extends Controller {
102
249
  this.close();
103
250
  }
104
251
  }
105
- /** Routes trigger keyboard interaction per the APG select-only model. */
252
+ /** Yields claimed keys; otherwise routes the APG select-only keyboard model. */
106
253
  onTriggerKeydown(event) {
107
- const length = this.optionTargets.length;
254
+ if (event.defaultPrevented) return;
255
+ if (isReservedArrowChord(event)) return;
256
+ if (!this.#isClosed) this.#reconcileActive();
257
+ const options = this.optionTargets;
258
+ const length = options.length;
259
+ const activeIndex = this.#findActiveIndex(options);
108
260
  if (this.#isClosed) {
109
261
  switch (event.key) {
110
262
  case "Enter":
@@ -123,13 +275,11 @@ var ListboxController = class extends Controller {
123
275
  switch (event.key) {
124
276
  case "ArrowDown":
125
277
  event.preventDefault();
126
- this.#setActive(this.#activeIndex < 0 ? 0 : (this.#activeIndex + 1) % length);
278
+ this.#setActive(activeIndex < 0 ? 0 : (activeIndex + 1) % length);
127
279
  break;
128
280
  case "ArrowUp":
129
281
  event.preventDefault();
130
- this.#setActive(
131
- this.#activeIndex < 0 ? length - 1 : (this.#activeIndex - 1 + length) % length
132
- );
282
+ this.#setActive(activeIndex < 0 ? length - 1 : (activeIndex - 1 + length) % length);
133
283
  break;
134
284
  case "Home":
135
285
  event.preventDefault();
@@ -145,7 +295,7 @@ var ListboxController = class extends Controller {
145
295
  this.#commitActive();
146
296
  break;
147
297
  case "Escape":
148
- if (event.defaultPrevented || event.isComposing) break;
298
+ if (event.isComposing) break;
149
299
  event.preventDefault();
150
300
  this.close();
151
301
  this.triggerTarget.focus();
@@ -154,9 +304,9 @@ var ListboxController = class extends Controller {
154
304
  this.close();
155
305
  break;
156
306
  default:
157
- if (this.#isPrintable(event)) {
307
+ if (isTypeaheadKey(event)) {
158
308
  event.preventDefault();
159
- this.#typeaheadTo(event.key);
309
+ this.#typeaheadTo(options, activeIndex, event.key);
160
310
  }
161
311
  break;
162
312
  }
@@ -164,7 +314,7 @@ var ListboxController = class extends Controller {
164
314
  /** Selects the clicked option and closes, returning focus to the trigger. */
165
315
  select(event) {
166
316
  const option = event.currentTarget.closest('[role="option"]');
167
- if (!option) return;
317
+ if (!option || !this.optionTargets.includes(option)) return;
168
318
  this.#selectOption(option);
169
319
  this.close();
170
320
  this.triggerTarget.focus();
@@ -189,28 +339,41 @@ var ListboxController = class extends Controller {
189
339
  this.listTarget.hidden = true;
190
340
  this.triggerTarget.setAttribute("aria-expanded", "false");
191
341
  this.#setActive(-1);
192
- this.#resetTypeahead();
342
+ this.#typeahead.reset();
193
343
  }
194
344
  /** Commits the active option (keyboard) and closes, returning focus. */
195
345
  #commitActive() {
196
- const option = this.#activeIndex < 0 ? void 0 : this.optionTargets[this.#activeIndex];
346
+ this.#reconcileActive();
347
+ const options = this.optionTargets;
348
+ const activeIndex = this.#findActiveIndex(options);
349
+ const option = activeIndex < 0 ? void 0 : options[activeIndex];
197
350
  if (option) this.#selectOption(option);
198
351
  this.close();
199
352
  this.triggerTarget.focus();
200
353
  }
201
354
  /** Applies selection: `aria-selected`, trigger label, hidden field, `change`. */
202
355
  #selectOption(option) {
356
+ const { value, fieldChanged } = this.#applySelection(option);
357
+ if (fieldChanged) {
358
+ this.fieldTarget.dispatchEvent(new Event("change", { bubbles: true }));
359
+ }
360
+ this.dispatch("change", { detail: { value, option } });
361
+ }
362
+ /**
363
+ * Writes `option` into `aria-selected`, the trigger label and the hidden field.
364
+ * Emits nothing — {@link #normalizeSelection} reuses this at connect, where the
365
+ * state is being described rather than changed.
366
+ */
367
+ #applySelection(option) {
203
368
  for (const candidate of this.optionTargets) {
204
369
  candidate.setAttribute("aria-selected", candidate === option ? "true" : "false");
205
370
  }
206
371
  const label = (option.textContent ?? "").trim();
207
372
  const value = option.dataset.value ?? label;
208
373
  if (this.hasValueTarget) this.valueTarget.textContent = label;
209
- if (this.hasFieldTarget && this.fieldTarget.value !== value) {
210
- this.fieldTarget.value = value;
211
- this.fieldTarget.dispatchEvent(new Event("change", { bubbles: true }));
212
- }
213
- this.dispatch("change", { detail: { value, option } });
374
+ const fieldChanged = this.hasFieldTarget && this.fieldTarget.value !== value;
375
+ if (fieldChanged) this.fieldTarget.value = value;
376
+ return { value, fieldChanged };
214
377
  }
215
378
  /**
216
379
  * Marks the option at `index` active via `data-active` and the trigger's
@@ -218,47 +381,88 @@ var ListboxController = class extends Controller {
218
381
  * set to empty, per the APG).
219
382
  */
220
383
  #setActive(index) {
221
- this.#activeIndex = index;
222
- const active = index < 0 ? null : this.optionTargets[index];
223
- for (const option of this.optionTargets) {
384
+ const options = this.optionTargets;
385
+ const active = index < 0 ? null : options[index] ?? null;
386
+ for (const option of options) {
387
+ const marked = option.hasAttribute("data-active");
224
388
  if (option === active) {
225
- option.setAttribute("data-active", "");
226
- } else {
389
+ if (!marked) option.setAttribute("data-active", "");
390
+ } else if (marked) {
227
391
  option.removeAttribute("data-active");
228
392
  }
229
393
  }
230
394
  if (active?.id) {
395
+ this.#activeId = active.id;
231
396
  this.triggerTarget.setAttribute("aria-activedescendant", active.id);
232
397
  } else {
398
+ this.#activeId = null;
233
399
  this.triggerTarget.removeAttribute("aria-activedescendant");
234
400
  }
401
+ this.#activeOrder = active ? options.map((option) => option.id).filter(Boolean) : [];
235
402
  if (active && this.hasListTarget) scrollOptionIntoView(this.listTarget, active);
236
403
  }
237
- /** Appends a character to the typeahead query and activates the first match. */
238
- #typeaheadTo(char) {
239
- this.#timers.clear(this.#typeaheadTimer);
240
- this.#typeahead += char.toLowerCase();
241
- this.#typeaheadTimer = this.#timers.set(() => {
242
- this.#typeahead = "";
243
- }, TYPEAHEAD_TIMEOUT);
244
- const index = this.optionTargets.findIndex(
245
- (option) => (option.textContent ?? "").trim().toLowerCase().startsWith(this.#typeahead)
246
- );
247
- if (index !== -1) this.#setActive(index);
404
+ /** Resolves active state against the current target collection. */
405
+ #reconcileActive() {
406
+ if (!this.hasTriggerTarget || this.#isClosed) {
407
+ if (this.hasTriggerTarget) this.#setActive(-1);
408
+ return;
409
+ }
410
+ const options = this.optionTargets;
411
+ const currentIndex = this.#findActiveIndex(options);
412
+ if (currentIndex >= 0) {
413
+ const active = options[currentIndex] ?? null;
414
+ const marked = options.filter((option) => option.hasAttribute("data-active"));
415
+ const stateMatches = this.#activeId === (active?.id || null) && marked.length === 1 && marked[0] === active && this.triggerTarget.getAttribute("aria-activedescendant") === (active?.id || null);
416
+ if (stateMatches) {
417
+ this.#activeOrder = options.map((option) => option.id).filter(Boolean);
418
+ } else {
419
+ this.#setActive(currentIndex);
420
+ }
421
+ return;
422
+ }
423
+ const activeId = this.triggerTarget.getAttribute("aria-activedescendant") ?? this.#activeId;
424
+ this.#setActive(activeId ? this.#findFallbackIndex(options, activeId) : -1);
425
+ }
426
+ /** Finds the live target carrying the stable ID, or the active marker for an ID-less option. */
427
+ #findActiveIndex(options) {
428
+ const activeId = (this.hasTriggerTarget ? this.triggerTarget.getAttribute("aria-activedescendant") : null) ?? this.#activeId;
429
+ if (activeId) return options.findIndex((option) => option.id === activeId);
430
+ return options.findIndex((option) => option.hasAttribute("data-active"));
248
431
  }
249
- /** Clears the typeahead query and its pending reset timer. */
250
- #resetTypeahead() {
251
- this.#timers.clear(this.#typeaheadTimer);
252
- this.#typeahead = "";
432
+ /** Chooses a surviving former successor, then a former predecessor. */
433
+ #findFallbackIndex(options, activeId) {
434
+ const oldIndex = this.#activeOrder.indexOf(activeId);
435
+ if (oldIndex < 0) return -1;
436
+ const indexesById = new Map(options.map((option, index) => [option.id, index]));
437
+ for (let index = oldIndex + 1; index < this.#activeOrder.length; index += 1) {
438
+ const fallback = indexesById.get(this.#activeOrder[index] ?? "");
439
+ if (fallback !== void 0) return fallback;
440
+ }
441
+ for (let index = oldIndex - 1; index >= 0; index -= 1) {
442
+ const fallback = indexesById.get(this.#activeOrder[index] ?? "");
443
+ if (fallback !== void 0) return fallback;
444
+ }
445
+ return -1;
253
446
  }
254
- /** Closes the list when a click lands outside the controller element. */
447
+ /** Coalesces all target callbacks from one MutationObserver batch. */
448
+ #queueOptionReconciliation() {
449
+ this.#reconcile.schedule();
450
+ }
451
+ /**
452
+ * Advances the typeahead query and activates the next matching option.
453
+ *
454
+ * The search resumes just after the active option so repeating a character
455
+ * cycles through the options starting with it, rather than re-activating the
456
+ * same first match on every press.
457
+ */
458
+ #typeaheadTo(options, activeIndex, char) {
459
+ const index = findTypeaheadMatch(options, activeIndex, this.#typeahead.push(char));
460
+ if (index !== -1) this.#setActive(index);
461
+ }
462
+ /** Closes on an outside click before an inside handler can detach its target. */
255
463
  #onOutsideClick = (event) => {
256
464
  if (!this.#isClosed && !this.element.contains(event.target)) this.close();
257
465
  };
258
- /** Whether `event.key` is a single printable character (no modifier chord). */
259
- #isPrintable(event) {
260
- return event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey;
261
- }
262
466
  /** Whether the list is currently hidden. */
263
467
  get #isClosed() {
264
468
  return !this.hasListTarget || this.listTarget.hidden !== false;
@@ -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