stimeo-ui 0.14.0 → 0.16.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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +212 -0
  3. data/README.md +120 -0
  4. data/dist/cable/index.js +123 -29
  5. data/dist/controllers/accordion_controller.js +98 -9
  6. data/dist/controllers/announcer_controller.js +96 -62
  7. data/dist/controllers/auto_submit_controller.js +83 -7
  8. data/dist/controllers/avatar_controller.js +1 -1
  9. data/dist/controllers/breadcrumb_controller.js +38 -11
  10. data/dist/controllers/bulk_select_controller.js +7 -6
  11. data/dist/controllers/calendar_controller.js +340 -123
  12. data/dist/controllers/carousel_controller.js +263 -38
  13. data/dist/controllers/character_counter_controller.js +40 -2
  14. data/dist/controllers/checkbox_controller.js +81 -12
  15. data/dist/controllers/clipboard_controller.js +63 -5
  16. data/dist/controllers/collapsible_controller.js +99 -14
  17. data/dist/controllers/color_picker_controller.js +80 -34
  18. data/dist/controllers/combobox_controller.js +106 -16
  19. data/dist/controllers/command_palette_controller.js +35 -3
  20. data/dist/controllers/conditional_fields_controller.js +85 -17
  21. data/dist/controllers/confirm_controller.js +3 -0
  22. data/dist/controllers/context_menu_controller.js +32 -12
  23. data/dist/controllers/countdown_controller.js +129 -26
  24. data/dist/controllers/currency_input_controller.js +221 -67
  25. data/dist/controllers/data_grid_controller.js +195 -29
  26. data/dist/controllers/date_range_picker_controller.js +151 -30
  27. data/dist/controllers/dialog_controller.js +35 -8
  28. data/dist/controllers/direct_upload_controller.js +22 -4
  29. data/dist/controllers/dirty_form_controller.js +46 -13
  30. data/dist/controllers/dismissible_controller.js +1 -0
  31. data/dist/controllers/drawer_controller.js +54 -19
  32. data/dist/controllers/dropdown_controller.js +36 -9
  33. data/dist/controllers/editable_controller.js +34 -0
  34. data/dist/controllers/file_dropzone_controller.js +144 -51
  35. data/dist/controllers/filter_controller.js +20 -6
  36. data/dist/controllers/flash_controller.js +432 -71
  37. data/dist/controllers/focus_controller.js +1 -0
  38. data/dist/controllers/form_field_controller.js +7 -5
  39. data/dist/controllers/form_validation_controller.js +19 -13
  40. data/dist/controllers/frame_loading_controller.js +45 -8
  41. data/dist/controllers/highlight_controller.js +82 -25
  42. data/dist/controllers/hover_card_controller.js +40 -14
  43. data/dist/controllers/idle_controller.js +90 -5
  44. data/dist/controllers/input_mask_controller.js +65 -9
  45. data/dist/controllers/intersection_controller.js +3 -0
  46. data/dist/controllers/lazy_frame_controller.js +11 -2
  47. data/dist/controllers/listbox_controller.js +203 -45
  48. data/dist/controllers/local_time_controller.js +10 -5
  49. data/dist/controllers/masonry_controller.js +31 -15
  50. data/dist/controllers/menu_controller.js +45 -16
  51. data/dist/controllers/menubar_controller.js +58 -24
  52. data/dist/controllers/meter_controller.js +9 -5
  53. data/dist/controllers/multi_select_controller.js +278 -104
  54. data/dist/controllers/navigation_menu_controller.js +48 -15
  55. data/dist/controllers/nested_form_controller.js +37 -8
  56. data/dist/controllers/network_status_controller.js +9 -1
  57. data/dist/controllers/number_input_controller.js +124 -21
  58. data/dist/controllers/optimistic_controller.js +42 -5
  59. data/dist/controllers/otp_controller.js +198 -55
  60. data/dist/controllers/overflow_indicator_controller.js +115 -21
  61. data/dist/controllers/overflow_menu_controller.js +141 -44
  62. data/dist/controllers/pagination_controller.js +74 -28
  63. data/dist/controllers/password_reveal_controller.js +59 -2
  64. data/dist/controllers/persist_controller.js +30 -8
  65. data/dist/controllers/pointer_drag_controller.js +131 -52
  66. data/dist/controllers/popover_controller.js +45 -11
  67. data/dist/controllers/portal_controller.js +6 -2
  68. data/dist/controllers/preview_guard_controller.js +16 -1
  69. data/dist/controllers/progress_controller.js +8 -4
  70. data/dist/controllers/radio_group_controller.js +42 -17
  71. data/dist/controllers/range_slider_controller.js +88 -42
  72. data/dist/controllers/rating_controller.js +39 -15
  73. data/dist/controllers/read_more_controller.js +100 -7
  74. data/dist/controllers/reading_progress_controller.js +65 -19
  75. data/dist/controllers/relative_time_controller.js +10 -5
  76. data/dist/controllers/resizable_controller.js +82 -22
  77. data/dist/controllers/scroll_area_controller.js +75 -27
  78. data/dist/controllers/scroll_restore_controller.js +37 -16
  79. data/dist/controllers/scroll_visibility_controller.js +49 -30
  80. data/dist/controllers/scrollspy_controller.js +71 -26
  81. data/dist/controllers/separator_controller.js +66 -37
  82. data/dist/controllers/sidebar_controller.js +77 -18
  83. data/dist/controllers/skeleton_controller.js +6 -1
  84. data/dist/controllers/slider_controller.js +82 -47
  85. data/dist/controllers/smart_sticky_header_controller.js +60 -26
  86. data/dist/controllers/sortable_controller.js +17 -2
  87. data/dist/controllers/spinner_controller.js +10 -2
  88. data/dist/controllers/step_indicator_controller.js +18 -17
  89. data/dist/controllers/stepper_controller.js +101 -19
  90. data/dist/controllers/stick_to_bottom_controller.js +104 -8
  91. data/dist/controllers/submit_once_controller.js +45 -9
  92. data/dist/controllers/switch_controller.js +101 -10
  93. data/dist/controllers/tabs_controller.js +21 -2
  94. data/dist/controllers/tags_input_controller.js +209 -59
  95. data/dist/controllers/textarea_autosize_controller.js +29 -3
  96. data/dist/controllers/theme_controller.js +64 -14
  97. data/dist/controllers/time_picker_controller.js +23 -8
  98. data/dist/controllers/toast_controller.js +451 -105
  99. data/dist/controllers/toggle_group_controller.js +159 -23
  100. data/dist/controllers/toolbar_controller.js +32 -0
  101. data/dist/controllers/tooltip_controller.js +39 -13
  102. data/dist/controllers/transition_controller.js +4 -0
  103. data/dist/controllers/tree_view_controller.js +169 -16
  104. data/dist/index.js +5002 -1911
  105. data/dist/positioning/index.js +2 -0
  106. data/lib/stimeo/ui/version.rb +1 -1
  107. metadata +2 -2
@@ -35,6 +35,25 @@ function isReservedArrowChord(event, allow = []) {
35
35
  return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
36
36
  }
37
37
 
38
+ // src/utils/element_part.ts
39
+ function matchingPart(root, selector) {
40
+ return root.matches(selector) ? root : root.querySelector(selector);
41
+ }
42
+ function writeLabel(slot, text) {
43
+ if (slot.childElementCount === 0) {
44
+ slot.textContent = text;
45
+ return;
46
+ }
47
+ let first = null;
48
+ for (const node of Array.from(slot.childNodes)) {
49
+ if (node.nodeType !== Node.TEXT_NODE) continue;
50
+ if (first) node.remove();
51
+ else first = node;
52
+ }
53
+ if (first) first.nodeValue = text;
54
+ else slot.prepend(text);
55
+ }
56
+
38
57
  // src/utils/roving_tabindex.ts
39
58
  var RovingTabindex = class {
40
59
  /** Returns the current ordered item elements; called on every operation. */
@@ -82,7 +101,7 @@ var ChipRow = class {
82
101
  constructor(options) {
83
102
  this.#directionElement = options.directionElement;
84
103
  this.#getItems = options.getItems;
85
- this.#getButton = options.getButton ?? ((item) => item.querySelector("button"));
104
+ this.#getButton = options.getButton ?? ((item) => matchingPart(item, "button"));
86
105
  this.#onRemove = options.onRemove;
87
106
  this.#focusAfterEnd = options.focusAfterEnd;
88
107
  }
@@ -207,11 +226,19 @@ var ChipRow = class {
207
226
  };
208
227
 
209
228
  // src/utils/composition_tracker.ts
229
+ var COMPOSITION_INPUT_TYPES = /* @__PURE__ */ new Set([
230
+ "insertCompositionText",
231
+ "insertFromComposition",
232
+ "deleteCompositionText",
233
+ "deleteByComposition"
234
+ ]);
210
235
  var CompositionTracker = class {
211
236
  #observedTargets = /* @__PURE__ */ new Set();
212
237
  #activeTargets = /* @__PURE__ */ new Set();
213
238
  #onStart;
214
239
  #onEnd;
240
+ /** The field whose confirming `input` is still owed, while the window is open. */
241
+ #confirmedTarget = null;
215
242
  constructor(options = {}) {
216
243
  this.#onStart = options.onStart;
217
244
  this.#onEnd = options.onEnd;
@@ -221,6 +248,7 @@ var CompositionTracker = class {
221
248
  if (this.#observedTargets.has(target)) return;
222
249
  target.addEventListener("compositionstart", this.#handleStart);
223
250
  target.addEventListener("compositionend", this.#handleEnd);
251
+ target.addEventListener("keydown", this.#handleKeydown);
224
252
  this.#observedTargets.add(target);
225
253
  }
226
254
  /** Stops tracking one target and clears any active composition it owned. */
@@ -228,31 +256,77 @@ var CompositionTracker = class {
228
256
  if (!this.#observedTargets.delete(target)) return;
229
257
  target.removeEventListener("compositionstart", this.#handleStart);
230
258
  target.removeEventListener("compositionend", this.#handleEnd);
259
+ target.removeEventListener("keydown", this.#handleKeydown);
231
260
  this.#activeTargets.delete(target);
261
+ if (this.#confirmedTarget === target) this.#confirmedTarget = null;
232
262
  }
233
263
  /** Releases every listener and clears state so reconnect starts cleanly. */
234
264
  disconnect() {
235
265
  for (const target of this.#observedTargets) {
236
266
  target.removeEventListener("compositionstart", this.#handleStart);
237
267
  target.removeEventListener("compositionend", this.#handleEnd);
268
+ target.removeEventListener("keydown", this.#handleKeydown);
238
269
  }
239
270
  this.#observedTargets.clear();
240
271
  this.#activeTargets.clear();
272
+ this.#confirmedTarget = null;
241
273
  }
242
274
  /** True when lifecycle tracking or the current event reports composition. */
243
275
  isComposing(event) {
244
276
  return this.#activeTargets.size > 0 || event?.isComposing === true;
245
277
  }
278
+ /**
279
+ * Whether `event` is the `input` echoing the composition just confirmed.
280
+ *
281
+ * Asking closes the window either way, so one confirmation is folded at most
282
+ * once and a consumer asks once per `input`.
283
+ */
284
+ consumesConfirmedInput(event) {
285
+ const confirmed = this.#confirmedTarget;
286
+ this.#confirmedTarget = null;
287
+ if (confirmed === null || confirmed !== event.target) return false;
288
+ const inputType = event.inputType;
289
+ return !inputType || COMPOSITION_INPUT_TYPES.has(inputType);
290
+ }
246
291
  #handleStart = (event) => {
292
+ this.#confirmedTarget = null;
247
293
  if (event.currentTarget) this.#activeTargets.add(event.currentTarget);
248
294
  this.#onStart?.(event);
249
295
  };
250
296
  #handleEnd = (event) => {
251
297
  if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);
298
+ this.#confirmedTarget = event.target;
252
299
  this.#onEnd?.(event);
253
300
  };
301
+ /** A key on an observed field opens an edit of its own, so no echo is owed. */
302
+ #handleKeydown = () => {
303
+ this.#confirmedTarget = null;
304
+ };
254
305
  };
255
306
 
307
+ // src/utils/field_mirror.ts
308
+ function writeFields(container, values, { name, form = "" }) {
309
+ const current = [...container.children];
310
+ const submits = current.length === values.length && current.every(
311
+ (child, index) => child instanceof HTMLInputElement && child.type === "hidden" && child.value === values[index] && child.name === name && (child.getAttribute("form") ?? "") === form
312
+ );
313
+ if (submits) return false;
314
+ container.replaceChildren(
315
+ ...values.map((value) => {
316
+ const input = document.createElement("input");
317
+ input.type = "hidden";
318
+ input.name = name;
319
+ input.value = value;
320
+ if (form !== "") input.setAttribute("form", form);
321
+ return input;
322
+ })
323
+ );
324
+ return true;
325
+ }
326
+ function commitField(target) {
327
+ target.dispatchEvent(new Event("change", { bubbles: true }));
328
+ }
329
+
256
330
  // src/utils/microtask_coalescer.ts
257
331
  var MicrotaskCoalescer = class {
258
332
  #run;
@@ -286,6 +360,85 @@ var MicrotaskCoalescer = class {
286
360
  }
287
361
  };
288
362
 
363
+ // src/utils/target_selector.ts
364
+ function targetSelector(identifier, name) {
365
+ return `[data-${identifier}-target~="${name}"]`;
366
+ }
367
+
368
+ // src/utils/template_row.ts
369
+ function cloneTemplateRoot(template) {
370
+ const root = template.content.firstElementChild;
371
+ return root instanceof HTMLElement ? root.cloneNode(true) : null;
372
+ }
373
+ var TemplateRow = class {
374
+ #options;
375
+ #warned = false;
376
+ constructor(options) {
377
+ this.#options = options;
378
+ }
379
+ /** The attribute selector for one declared part, in this controller's namespace. */
380
+ selector(name) {
381
+ return targetSelector(this.#options.identifier, name);
382
+ }
383
+ /** Re-arms the once-per-connection diagnostic. */
384
+ connect() {
385
+ this.#warned = false;
386
+ }
387
+ /**
388
+ * Names one missing part on the console, at most once per connection, and
389
+ * returns `null` so a caller can hand it straight back.
390
+ */
391
+ report(missing) {
392
+ return this.#say(`lacks ${missing}`);
393
+ }
394
+ /** Writes one diagnostic per connection, saying what the template got wrong. */
395
+ #say(problem) {
396
+ if (this.#warned) return null;
397
+ this.#warned = true;
398
+ const { identifier, outcome, noun } = this.#options;
399
+ console.warn(`Stimeo UI: "${identifier}" ${outcome} because its ${noun} ${problem}.`);
400
+ return null;
401
+ }
402
+ /**
403
+ * Clones the row and resolves its parts, or names what the template got wrong
404
+ * and returns `null`. `values` fills the button's authored `aria-label`.
405
+ */
406
+ instantiate(template, values) {
407
+ const { root: rootName, required, optional, button: buttonName } = this.#options;
408
+ const root = cloneTemplateRoot(template);
409
+ if (!root?.matches(this.selector(rootName))) {
410
+ return this.report(`${this.#article(rootName)} "${rootName}" root`);
411
+ }
412
+ const count = template.content.children.length;
413
+ if (count !== 1) {
414
+ return this.#say(`holds ${count} elements, and the row has to be its only one`);
415
+ }
416
+ const slots = {};
417
+ for (const name2 of required) {
418
+ const part = this.#resolve(root, name2);
419
+ if (!part) return this.report(`${this.#article(name2)} "${name2}" target`);
420
+ slots[name2] = part;
421
+ }
422
+ for (const name2 of optional ?? []) slots[name2] = this.#resolve(root, name2);
423
+ const named = this.#resolve(root, buttonName);
424
+ if (!(named instanceof HTMLButtonElement)) {
425
+ return this.report(`${this.#article(buttonName)} "${buttonName}" target <button>`);
426
+ }
427
+ const button = named;
428
+ const name = button.getAttribute("aria-label")?.trim() ?? "";
429
+ if (name === "") return this.report(`a non-empty aria-label on its "${buttonName}" target`);
430
+ button.setAttribute("aria-label", fillTemplate(name, values));
431
+ return { root, slots, button };
432
+ }
433
+ /** The row may be its own part; otherwise the first descendant carrying the target. */
434
+ #resolve(root, name) {
435
+ return matchingPart(root, this.selector(name));
436
+ }
437
+ #article(name) {
438
+ return /^[aeiou]/i.test(name) ? "an" : "a";
439
+ }
440
+ };
441
+
289
442
  // src/controllers/tags_input_controller.ts
290
443
  var TagsInputController = class extends Controller {
291
444
  static targets = ["input", "tags", "tag", "tagTemplate", "label", "remove", "fields"];
@@ -294,6 +447,7 @@ var TagsInputController = class extends Controller {
294
447
  max: { type: Number, default: 0 },
295
448
  allowDuplicates: { type: Boolean, default: false },
296
449
  name: { type: String, default: "tags[]" },
450
+ form: { type: String, default: "" },
297
451
  announceText: { type: String, default: "" },
298
452
  announceRemovedText: { type: String, default: "" }
299
453
  };
@@ -301,14 +455,21 @@ var TagsInputController = class extends Controller {
301
455
  static events = ["change", "reconcile", "reject"];
302
456
  /** Last reconciled tag order, separating user edits from DOM/Turbo repair. */
303
457
  #tagValues = [];
304
- /** Whether this connection already reported its unusable chip template. */
305
- #warnedTemplate = false;
458
+ /** Builds one chip from the authored template and owns its diagnostic. */
459
+ #rows = new TemplateRow({
460
+ identifier: this.identifier,
461
+ root: "tag",
462
+ required: ["label"],
463
+ button: "remove",
464
+ outcome: "added no tag",
465
+ noun: "chip template"
466
+ });
306
467
  /** Collapses one target/Value mutation batch into one final-DOM repair pass. */
307
468
  #reconcile = new MicrotaskCoalescer(() => this.#reconcileTags());
308
469
  #chipRow = new ChipRow({
309
470
  directionElement: this.element,
310
471
  getItems: () => this.tagTargets,
311
- getButton: (tag) => tag.querySelector('button[data-stimeo--tags-input-target~="remove"]'),
472
+ getButton: (tag) => matchingPart(tag, `button${this.#rows.selector("remove")}`),
312
473
  onRemove: (index) => this.#removeAt(index),
313
474
  focusAfterEnd: () => this.#focusInput()
314
475
  });
@@ -316,7 +477,7 @@ var TagsInputController = class extends Controller {
316
477
  #composition = new CompositionTracker();
317
478
  /** Wires tag-list keyboard navigation and removal, and seeds the single Tab stop. */
318
479
  connect() {
319
- this.#warnedTemplate = false;
480
+ this.#rows.connect();
320
481
  if (this.hasInputTarget) this.#composition.observe(this.inputTarget);
321
482
  if (this.hasTagsTarget) this.#chipRow.connect(this.tagsTarget);
322
483
  const tags = this.#values;
@@ -363,6 +524,10 @@ var TagsInputController = class extends Controller {
363
524
  nameValueChanged() {
364
525
  this.#reconcile.schedule();
365
526
  }
527
+ /** Repoints submitted fields when the owning form changes at runtime. */
528
+ formValueChanged() {
529
+ this.#reconcile.schedule();
530
+ }
366
531
  /** Recomputes the full hook when the cap changes at runtime. */
367
532
  maxValueChanged() {
368
533
  this.#reconcile.schedule();
@@ -393,7 +558,11 @@ var TagsInputController = class extends Controller {
393
558
  }
394
559
  }
395
560
  }
396
- /** Validates and adds the current input value as a tag, then clears the input. */
561
+ /**
562
+ * Validates and adds the current input value as a tag, then clears the input.
563
+ *
564
+ * @stimeoRuntimeOnly `max` and `allowDuplicates` decide whether this one entry is taken.
565
+ */
397
566
  #commitInput() {
398
567
  const value = this.inputTarget.value.trim();
399
568
  if (value === "") {
@@ -411,53 +580,31 @@ var TagsInputController = class extends Controller {
411
580
  if (!this.#appendTag(value)) return;
412
581
  this.inputTarget.value = "";
413
582
  const tags = this.#values;
414
- this.#syncState(tags);
583
+ this.#syncState(tags, true);
415
584
  this.#tagValues = tags;
416
585
  this.#announceTransition(true, value, tags.length);
417
586
  this.dispatch("change", { detail: { tags } });
418
587
  }
419
- /** Builds one chip from the template and appends it to the tag list. */
420
- #appendTag(value) {
421
- if (!this.hasTagsTarget) return false;
422
- if (!this.hasTagTemplateTarget) return this.#warnTemplate('a "tagTemplate" target');
423
- const fragment = this.tagTemplateTarget.content.cloneNode(true);
424
- const tag = fragment.querySelector('[data-stimeo--tags-input-target~="tag"]');
425
- const label = fragment.querySelector('[data-stimeo--tags-input-target~="label"]');
426
- const button = fragment.querySelector(
427
- 'button[data-stimeo--tags-input-target~="remove"]'
428
- );
429
- const removeName = button?.getAttribute("aria-label")?.trim() ?? "";
430
- if (!tag) return this.#warnTemplate('a "tag" target');
431
- if (!label) return this.#warnTemplate('a "label" target');
432
- if (!button) return this.#warnTemplate('a "remove" target <button>');
433
- if (removeName === "") {
434
- return this.#warnTemplate('a non-empty aria-label on its "remove" target');
435
- }
436
- tag.dataset.value = value;
437
- label.textContent = value;
438
- button.setAttribute("aria-label", fillTemplate(removeName, { label: value, value }));
439
- button.tabIndex = -1;
440
- this.tagsTarget.appendChild(fragment);
441
- return true;
442
- }
443
588
  /**
444
- * Reports an unusable chip template to the author, once per connection.
589
+ * Builds one chip from the template and appends it to the tag list.
445
590
  *
446
- * The commit itself stays a no-op — nothing about the input, the tag set, the
447
- * hidden fields, the announcement, or the events changes. Without this line
448
- * the only symptom is a field that accepts no tags at all, and the two causes
449
- * the Inspector cannot see statically (a name that renders empty from a
450
- * missing translation, a server-rendered template) would have no diagnostic
451
- * anywhere.
591
+ * A refused row leaves the commit a no-op — nothing about the input, the tag
592
+ * set, the hidden fields, the announcement, or the events changes — and the
593
+ * row reports why on the console once per connection.
452
594
  */
453
- #warnTemplate(missing) {
454
- if (!this.#warnedTemplate) {
455
- this.#warnedTemplate = true;
456
- console.warn(
457
- `Stimeo UI: "${this.identifier}" added no tag because its chip template lacks ${missing}.`
458
- );
595
+ #appendTag(value) {
596
+ if (!this.hasTagsTarget) return false;
597
+ if (!this.hasTagTemplateTarget) {
598
+ this.#rows.report('a "tagTemplate" target');
599
+ return false;
459
600
  }
460
- return false;
601
+ const row = this.#rows.instantiate(this.tagTemplateTarget, { label: value, value });
602
+ if (!row) return false;
603
+ row.root.dataset.value = value;
604
+ writeLabel(row.slots.label, value);
605
+ row.button.tabIndex = -1;
606
+ this.tagsTarget.appendChild(row.root);
607
+ return true;
461
608
  }
462
609
  /** Removes the tag at `index`, then applies the interaction-origin focus policy. */
463
610
  #removeAt(index, focus = "neighbor") {
@@ -466,7 +613,7 @@ var TagsInputController = class extends Controller {
466
613
  const value = tag.dataset.value ?? "";
467
614
  tag.remove();
468
615
  const tags = this.#values;
469
- this.#syncState(tags);
616
+ this.#syncState(tags, true);
470
617
  this.#tagValues = tags;
471
618
  this.#announceTransition(false, value, tags.length);
472
619
  this.dispatch("change", { detail: { tags } });
@@ -480,21 +627,20 @@ var TagsInputController = class extends Controller {
480
627
  #focusInput() {
481
628
  if (this.hasInputTarget) this.inputTarget.focus();
482
629
  }
483
- /** Rebuilds the hidden form fields, the `full` flag, and the roving Tab stop. */
484
- #syncState(values) {
630
+ /**
631
+ * Rebuilds the hidden form fields, the `full` flag, and the roving Tab stop.
632
+ *
633
+ * @stimeoRenderRoot
634
+ */
635
+ #syncState(values, notify = false) {
485
636
  if (this.hasFieldsTarget) {
486
- this.fieldsTarget.replaceChildren(
487
- ...values.map((value) => {
488
- const input = document.createElement("input");
489
- input.type = "hidden";
490
- input.name = this.nameValue;
491
- input.value = value;
492
- return input;
493
- })
494
- );
637
+ const options = { name: this.nameValue, form: this.formValue };
638
+ if (writeFields(this.fieldsTarget, values, options) && notify) {
639
+ commitField(this.fieldsTarget);
640
+ }
495
641
  }
496
642
  const full = this.maxValue > 0 && values.length >= this.maxValue;
497
- this.element.toggleAttribute("data-stimeo--tags-input-full", full);
643
+ this.element.toggleAttribute(`data-${this.identifier}-full`, full);
498
644
  this.#chipRow.ensureTabStop();
499
645
  }
500
646
  /** Repairs derived state after DOM/Turbo changes and reports a changed tag order. */
@@ -507,7 +653,11 @@ var TagsInputController = class extends Controller {
507
653
  this.#tagValues = tags;
508
654
  if (changed) this.dispatch("reconcile", { detail: { tags } });
509
655
  }
510
- /** Sends one localized tag transition through the page's shared announcer. */
656
+ /**
657
+ * Sends one localized tag transition through the page's shared announcer.
658
+ *
659
+ * @stimeoRuntimeOnly The texts word the one announcement of this change.
660
+ */
511
661
  #announceTransition(added, value, count) {
512
662
  const template = added ? this.announceTextValue : this.announceRemovedTextValue;
513
663
  announce(fillTemplate(template, { label: value, value, count }));
@@ -8,10 +8,15 @@ var LayoutObserver = class {
8
8
  #resizeObserverFactory;
9
9
  #resizeObserver = null;
10
10
  #observingViewport = false;
11
+ #loadContainer = null;
11
12
  /** Stable bound handler so add/removeEventListener target the same reference. */
12
13
  #handleViewportResize = () => {
13
14
  this.#callback();
14
15
  };
16
+ /** Stable bound handler for the capture-phase `load`; see {@link observeDescendantLoads}. */
17
+ #handleDescendantLoad = () => {
18
+ this.#callback();
19
+ };
15
20
  constructor(callback, options = {}) {
16
21
  this.#callback = callback;
17
22
  this.#resizeObserverFactory = options.resizeObserverFactory ?? (typeof ResizeObserver === "undefined" ? null : (cb) => new ResizeObserver(cb));
@@ -47,14 +52,35 @@ var LayoutObserver = class {
47
52
  window.removeEventListener("resize", this.#handleViewportResize);
48
53
  }
49
54
  /**
50
- * Releases every observation: disconnects the {@link ResizeObserver} and
51
- * removes the viewport listener. Safe to call multiple times. Call this from a
52
- * controller's `disconnect()`.
55
+ * Starts reporting a `load` from anywhere inside `container` — an image or a
56
+ * frame settling changes the box it sits in, and it measures as zero high until
57
+ * then. `load` does not bubble, so the subscription is a capture-phase listener
58
+ * on the container itself and nothing the caller spells.
59
+ *
60
+ * **One container at a time.** A further call moves the observation, so a widget
61
+ * whose content element is swapped at runtime releases the element it let go by
62
+ * naming the new one — there is no second place for the release to drift from.
63
+ */
64
+ observeDescendantLoads(container) {
65
+ this.unobserveDescendantLoads();
66
+ this.#loadContainer = container;
67
+ container.addEventListener("load", this.#handleDescendantLoad, true);
68
+ }
69
+ /** Stops reporting descendant loads without affecting element or viewport observation. */
70
+ unobserveDescendantLoads() {
71
+ this.#loadContainer?.removeEventListener("load", this.#handleDescendantLoad, true);
72
+ this.#loadContainer = null;
73
+ }
74
+ /**
75
+ * Releases every observation: disconnects the {@link ResizeObserver} and removes
76
+ * the viewport and descendant-load listeners. Safe to call multiple times. Call
77
+ * this from a controller's `disconnect()`.
53
78
  */
54
79
  disconnect() {
55
80
  this.#resizeObserver?.disconnect();
56
81
  this.#resizeObserver = null;
57
82
  this.unobserveViewport();
83
+ this.unobserveDescendantLoads();
58
84
  }
59
85
  };
60
86
 
@@ -113,20 +113,20 @@ var ThemeController = class extends Controller {
113
113
  target: { type: String, default: DEFAULT_TARGET }
114
114
  };
115
115
  static actions = ["set", "toggle"];
116
- static events = ["change"];
116
+ static events = ["change", "reconcile"];
117
117
  /** The OS dark-mode query, watched so `system` tracks live changes. */
118
118
  #media = null;
119
- /** Gate for the target callbacks, which Stimulus runs before `connect()`. */
119
+ /** Gate for the target and `mode` callbacks, which Stimulus runs before `connect()`. */
120
120
  #connected = false;
121
121
  /** The `target` declaration after validation; the default when unparsable. */
122
122
  #targetSelector = DEFAULT_TARGET;
123
123
  /** Owns the single Tab stop across the option set (APG radiogroup). */
124
124
  #roving = new RovingTabindex(() => this.optionTargets);
125
125
  /**
126
- * The pair last reported, so a move can be told from a repeat. Neither side is
127
- * readable after the fact — assigning the Value updates the mode before any
128
- * comparison, and the OS query has already flipped by the time it notifies —
129
- * so what was reported has to be kept rather than recomputed.
126
+ * The pair on screen as last reported or re-seeded, so a move can be told from a
127
+ * repeat. Neither side is readable after the fact — assigning the Value updates the
128
+ * mode before any comparison, and the OS query has already flipped by the time it
129
+ * notifies — so what was reported has to be kept rather than recomputed.
130
130
  */
131
131
  #published = {
132
132
  mode: DEFAULT_MODE,
@@ -189,13 +189,32 @@ var ThemeController = class extends Controller {
189
189
  targetValueChanged() {
190
190
  this.#targetSelector = validSelector(this.element, this.targetValue, DEFAULT_TARGET);
191
191
  }
192
+ /**
193
+ * Re-renders for a `mode` declaration changed after connect — a Turbo morph
194
+ * re-rendering the server's markup over the live element, or a script.
195
+ *
196
+ * A stored choice outranks the declaration: a Value that disagrees with it is written
197
+ * back to it, so a morph that brings the server's default never overrides what the
198
+ * user saved. Without one, the declaration is applied. Either way storage is left
199
+ * alone, and a pair on screen that moved is reported as `reconcile`, not `change` —
200
+ * the page moved the declaration, not a selection. The reported pair is re-seeded
201
+ * first, so the next selection is compared with what is on screen.
202
+ */
203
+ modeValueChanged() {
204
+ if (!this.#connected) return;
205
+ if (this.modeValue === this.#published.mode) return;
206
+ const stored = this.#readStored();
207
+ if (stored !== null && this.modeValue !== stored) this.modeValue = stored;
208
+ this.#applyTheme();
209
+ this.#reconcileControls();
210
+ }
192
211
  /** Re-derives the single Tab stop and ARIA for an option set that changed. */
193
212
  optionTargetConnected() {
194
- if (this.#connected) this.#syncControls();
213
+ if (this.#connected) this.#reconcileControls();
195
214
  }
196
215
  /** Re-derives them again when an option leaves, so a Tab stop always remains. */
197
216
  optionTargetDisconnected() {
198
- if (this.#connected) this.#syncControls();
217
+ if (this.#connected) this.#reconcileControls();
199
218
  }
200
219
  /**
201
220
  * Selects the mode the activated option declares.
@@ -227,18 +246,45 @@ var ThemeController = class extends Controller {
227
246
  #commit() {
228
247
  this.#applyTheme();
229
248
  this.#syncControls();
249
+ const moved = this.#settle();
250
+ if (moved) this.dispatch("change", { detail: moved });
251
+ }
252
+ /**
253
+ * Re-derives the controls from the mode on screen for a change the page made — a
254
+ * `mode` declaration or an option set — and reports a pair that moved since the
255
+ * last report as `reconcile`. An option coming or going moves neither half of the
256
+ * pair, so of these changes only a declaration reports.
257
+ */
258
+ #reconcileControls() {
259
+ this.#syncControls();
260
+ const moved = this.#settle();
261
+ if (moved) this.dispatch("reconcile", { detail: moved });
262
+ }
263
+ /**
264
+ * Takes the pair on screen as the reported one and returns it when it differs
265
+ * from the one reported before, or `null` when neither half moved.
266
+ *
267
+ * The baseline moves before anything is dispatched, so a listener that selects
268
+ * another mode is measured from the pair it was just told about. What is
269
+ * returned is a copy: the baseline has to survive a listener that writes to what
270
+ * it was handed, or the next unchanged operation reads as a move.
271
+ */
272
+ #settle() {
230
273
  const next = this.#current;
231
274
  const last = this.#published;
232
275
  this.#published = next;
233
- if (last.mode !== next.mode || last.resolved !== next.resolved) {
234
- this.dispatch("change", { detail: { ...next } });
235
- }
276
+ if (last.mode === next.mode && last.resolved === next.resolved) return null;
277
+ return { ...next };
236
278
  }
237
- /** The pair the `change` detail carries, read from current state. */
279
+ /** The pair the `change` and `reconcile` details carry, read from current state. */
238
280
  get #current() {
239
281
  return { mode: this.#mode, resolved: this.#resolved() };
240
282
  }
241
- /** Writes `data-theme` + `color-scheme` (the resolved theme) onto the target. */
283
+ /**
284
+ * Writes `data-theme` + `color-scheme` (the resolved theme) onto the target.
285
+ *
286
+ * @stimeoRenderRoot
287
+ */
242
288
  #applyTheme() {
243
289
  const root = this.#targetElement();
244
290
  if (!root) return;
@@ -246,7 +292,11 @@ var ThemeController = class extends Controller {
246
292
  root.setAttribute("data-theme", resolved);
247
293
  root.style.setProperty("color-scheme", resolved);
248
294
  }
249
- /** Keeps the radiogroup (aria-checked + roving tabindex) or toggle (aria-pressed) in sync. */
295
+ /**
296
+ * Keeps the radiogroup (aria-checked + roving tabindex) or toggle (aria-pressed) in sync.
297
+ *
298
+ * @stimeoRenderRoot
299
+ */
250
300
  #syncControls() {
251
301
  const options = this.optionTargets;
252
302
  if (options.length > 0) {
@@ -18,6 +18,16 @@ function isReservedArrowChord(event, allow = []) {
18
18
  return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
19
19
  }
20
20
 
21
+ // src/utils/field_mirror.ts
22
+ function writeField(field, value) {
23
+ if (field.value === value) return false;
24
+ field.value = value;
25
+ return true;
26
+ }
27
+ function commitField(target) {
28
+ target.dispatchEvent(new Event("change", { bubbles: true }));
29
+ }
30
+
21
31
  // src/utils/microtask_coalescer.ts
22
32
  var MicrotaskCoalescer = class {
23
33
  #run;
@@ -51,6 +61,11 @@ var MicrotaskCoalescer = class {
51
61
  }
52
62
  };
53
63
 
64
+ // src/utils/target_selector.ts
65
+ function targetSelector(identifier, name) {
66
+ return `[data-${identifier}-target~="${name}"]`;
67
+ }
68
+
54
69
  // src/controllers/time_picker_controller.ts
55
70
  var AM = 0;
56
71
  var PM = 1;
@@ -68,7 +83,10 @@ var TimePickerController = class extends Controller {
68
83
  };
69
84
  static actions = ["onKeydown"];
70
85
  static events = ["change", "reconcile"];
71
- /** Collapses target, Value, and retained-attribute morphs into one silent render. */
86
+ /**
87
+ * Collapses target, Value, and retained-attribute morphs into one render that
88
+ * dispatches no `change`.
89
+ */
72
90
  #reconcile = new MicrotaskCoalescer(() => this.#reconcileDom());
73
91
  /** Canonical state; the displayed hour and meridiem are derived from this value. */
74
92
  #state = { hour: 0, minute: 0, second: 0 };
@@ -133,7 +151,7 @@ var TimePickerController = class extends Controller {
133
151
  onKeydown(event) {
134
152
  if (isReservedArrowChord(event)) return;
135
153
  const segment = event.target?.closest(
136
- "[data-stimeo--time-picker-target~='segment']"
154
+ targetSelector(this.identifier, "segment")
137
155
  );
138
156
  if (!segment || !this.segmentTargets.includes(segment)) return;
139
157
  const kind = this.#kindOf(segment);
@@ -180,7 +198,7 @@ var TimePickerController = class extends Controller {
180
198
  /** Clears direct-entry state when Tab, Shift+Tab, or pointer focus leaves a segment. */
181
199
  #onFocusOut = (event) => {
182
200
  const segment = event.target?.closest(
183
- "[data-stimeo--time-picker-target~='segment']"
201
+ targetSelector(this.identifier, "segment")
184
202
  );
185
203
  if (!segment || !this.segmentTargets.includes(segment)) return;
186
204
  this.#clearTypeBuffer();
@@ -316,8 +334,7 @@ var TimePickerController = class extends Controller {
316
334
  #commitRender() {
317
335
  this.#renderSegments();
318
336
  const value = this.#composedValue;
319
- const fieldChanged = this.#writeField(value);
320
- if (fieldChanged) this.fieldTarget.dispatchEvent(new Event("change", { bubbles: true }));
337
+ if (this.#writeField(value)) commitField(this.fieldTarget);
321
338
  if (value !== this.#lastValue) this.dispatch("change", { detail: { value } });
322
339
  this.#lastValue = value;
323
340
  }
@@ -336,9 +353,7 @@ var TimePickerController = class extends Controller {
336
353
  }
337
354
  /** Writes a composed value to the optional form field and reports whether it changed. */
338
355
  #writeField(value) {
339
- if (!this.hasFieldTarget || this.fieldTarget.value === value) return false;
340
- this.fieldTarget.value = value;
341
- return true;
356
+ return this.hasFieldTarget && writeField(this.fieldTarget, value);
342
357
  }
343
358
  /** The canonical form value composed as `HH:MM[:SS]`. */
344
359
  get #composedValue() {