stimeo-ui 0.15.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 (102) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +178 -0
  3. data/README.md +120 -0
  4. data/dist/cable/index.js +63 -15
  5. data/dist/controllers/accordion_controller.js +98 -9
  6. data/dist/controllers/announcer_controller.js +8 -1
  7. data/dist/controllers/auto_submit_controller.js +43 -2
  8. data/dist/controllers/avatar_controller.js +1 -1
  9. data/dist/controllers/breadcrumb_controller.js +9 -8
  10. data/dist/controllers/bulk_select_controller.js +7 -6
  11. data/dist/controllers/calendar_controller.js +323 -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 +54 -10
  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 +56 -11
  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 +205 -58
  25. data/dist/controllers/data_grid_controller.js +195 -29
  26. data/dist/controllers/date_range_picker_controller.js +139 -27
  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 +14 -1
  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 +305 -46
  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 +16 -2
  41. data/dist/controllers/highlight_controller.js +19 -2
  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/masonry_controller.js +0 -2
  49. data/dist/controllers/menu_controller.js +45 -16
  50. data/dist/controllers/menubar_controller.js +58 -24
  51. data/dist/controllers/meter_controller.js +9 -5
  52. data/dist/controllers/multi_select_controller.js +278 -104
  53. data/dist/controllers/navigation_menu_controller.js +48 -15
  54. data/dist/controllers/nested_form_controller.js +37 -8
  55. data/dist/controllers/network_status_controller.js +9 -1
  56. data/dist/controllers/number_input_controller.js +124 -21
  57. data/dist/controllers/optimistic_controller.js +14 -1
  58. data/dist/controllers/otp_controller.js +198 -55
  59. data/dist/controllers/overflow_indicator_controller.js +84 -13
  60. data/dist/controllers/overflow_menu_controller.js +112 -41
  61. data/dist/controllers/pagination_controller.js +74 -28
  62. data/dist/controllers/password_reveal_controller.js +59 -2
  63. data/dist/controllers/persist_controller.js +30 -8
  64. data/dist/controllers/pointer_drag_controller.js +131 -52
  65. data/dist/controllers/popover_controller.js +45 -11
  66. data/dist/controllers/portal_controller.js +6 -2
  67. data/dist/controllers/preview_guard_controller.js +16 -1
  68. data/dist/controllers/progress_controller.js +8 -4
  69. data/dist/controllers/radio_group_controller.js +42 -17
  70. data/dist/controllers/range_slider_controller.js +88 -42
  71. data/dist/controllers/rating_controller.js +39 -15
  72. data/dist/controllers/read_more_controller.js +69 -2
  73. data/dist/controllers/resizable_controller.js +82 -22
  74. data/dist/controllers/scroll_area_controller.js +5 -1
  75. data/dist/controllers/scroll_visibility_controller.js +0 -1
  76. data/dist/controllers/scrollspy_controller.js +6 -0
  77. data/dist/controllers/separator_controller.js +66 -37
  78. data/dist/controllers/sidebar_controller.js +77 -18
  79. data/dist/controllers/skeleton_controller.js +6 -1
  80. data/dist/controllers/slider_controller.js +82 -47
  81. data/dist/controllers/smart_sticky_header_controller.js +11 -1
  82. data/dist/controllers/sortable_controller.js +17 -2
  83. data/dist/controllers/spinner_controller.js +10 -2
  84. data/dist/controllers/step_indicator_controller.js +18 -17
  85. data/dist/controllers/stepper_controller.js +101 -19
  86. data/dist/controllers/stick_to_bottom_controller.js +75 -5
  87. data/dist/controllers/submit_once_controller.js +16 -3
  88. data/dist/controllers/switch_controller.js +101 -10
  89. data/dist/controllers/tabs_controller.js +21 -2
  90. data/dist/controllers/tags_input_controller.js +209 -59
  91. data/dist/controllers/theme_controller.js +64 -14
  92. data/dist/controllers/time_picker_controller.js +23 -8
  93. data/dist/controllers/toast_controller.js +337 -54
  94. data/dist/controllers/toggle_group_controller.js +159 -23
  95. data/dist/controllers/toolbar_controller.js +32 -0
  96. data/dist/controllers/tooltip_controller.js +39 -13
  97. data/dist/controllers/transition_controller.js +4 -0
  98. data/dist/controllers/tree_view_controller.js +169 -16
  99. data/dist/index.js +4588 -1535
  100. data/dist/positioning/index.js +2 -0
  101. data/lib/stimeo/ui/version.rb +1 -1
  102. metadata +2 -2
@@ -18,6 +18,29 @@ 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 writeFields(container, values, { name, form = "" }) {
23
+ const current = [...container.children];
24
+ const submits = current.length === values.length && current.every(
25
+ (child, index) => child instanceof HTMLInputElement && child.type === "hidden" && child.value === values[index] && child.name === name && (child.getAttribute("form") ?? "") === form
26
+ );
27
+ if (submits) return false;
28
+ container.replaceChildren(
29
+ ...values.map((value) => {
30
+ const input = document.createElement("input");
31
+ input.type = "hidden";
32
+ input.name = name;
33
+ input.value = value;
34
+ if (form !== "") input.setAttribute("form", form);
35
+ return input;
36
+ })
37
+ );
38
+ return true;
39
+ }
40
+ function commitField(target) {
41
+ target.dispatchEvent(new Event("change", { bubbles: true }));
42
+ }
43
+
21
44
  // src/utils/interactive_host.ts
22
45
  var INTERACTIVE_HOST_SELECTOR = "button, input, select, textarea, label, a[href], area[href], summary, details, audio[controls], video[controls], iframe, object, embed";
23
46
  function isInteractiveHost(element) {
@@ -70,48 +93,71 @@ var MicrotaskCoalescer = class {
70
93
 
71
94
  // src/controllers/data_grid_controller.ts
72
95
  var SORT_CYCLE = ["none", "ascending", "descending"];
96
+ var OBSERVED_ATTRIBUTES = ["aria-selected", "data-value"];
97
+ function setAttributeIfChanged(element, name, value) {
98
+ if (element.getAttribute(name) !== value) element.setAttribute(name, value);
99
+ }
100
+ function sameSelection(a, b) {
101
+ if (a.size !== b.size) return false;
102
+ for (const [row, value] of a) {
103
+ if (!b.has(row) || b.get(row) !== value) return false;
104
+ }
105
+ return true;
106
+ }
73
107
  function nextSortDirection(current) {
74
108
  const index = SORT_CYCLE.indexOf(current);
75
109
  const from = index < 0 ? 0 : index;
76
110
  return SORT_CYCLE[(from + 1) % SORT_CYCLE.length] ?? "ascending";
77
111
  }
78
112
  var DataGridController = class extends Controller {
79
- static targets = ["columnHeader", "row", "cell"];
113
+ static targets = ["columnHeader", "row", "cell", "fields"];
80
114
  static values = {
81
- selection: { type: String, default: "none" }
115
+ selection: { type: String, default: "none" },
116
+ name: { type: String, default: "rows[]" },
117
+ form: { type: String, default: "" }
82
118
  };
83
119
  static actions = ["onKeydown", "sort", "toggleSelect"];
84
- static events = ["selectionchange", "sort"];
120
+ static events = ["selectionchange", "sort", "reconcile"];
85
121
  /**
86
- * Collapses the per-element target callbacks of one DOM mutation into a single
87
- * baseline pass, and refuses to run before `connect()` or after `disconnect()`.
122
+ * Collapses the target callbacks, Value changes and observed row writes of one
123
+ * DOM mutation into a single pass, and refuses to run before `connect()` or
124
+ * after `disconnect()`.
88
125
  *
89
126
  * Stimulus reports every target one at a time, so an ungated pass would re-walk
90
127
  * the whole grid once per authored cell on mount and once per streamed cell
91
128
  * afterwards — quadratic in the cell count both times.
92
129
  */
93
- #reconcile = new MicrotaskCoalescer(() => this.#restoreBaseline());
130
+ #reconcile = new MicrotaskCoalescer(() => this.#reconcileRows());
131
+ /** Watches the row attributes a page can rewrite in place; set while connected. */
132
+ #observer = null;
133
+ /** The selection last settled: on connect, by the user, or by a reported pass. */
134
+ #settled = /* @__PURE__ */ new Map();
94
135
  /**
95
- * Establishes a single tab stop across all navigable cells/headers and brings
96
- * the rows to their baseline.
136
+ * Establishes a single tab stop across all navigable cells/headers, brings the
137
+ * rows to their baseline, and settles the selection without reporting it.
97
138
  *
98
- * Normalizing here rather than leaving it to {@link selectionValueChanged}
99
- * guarantees exactly one pass per mount: a re-attached element reuses its
100
- * cached Stimulus context, whose value observer already knows the `selection`
101
- * attribute, so the Value callback does not fire a second time.
139
+ * The whole baseline runs here rather than in the Value callbacks: Stimulus
140
+ * delivers those before `connect()`, where the pass is refused, and a
141
+ * re-attached element reuses its cached context, whose value observer does not
142
+ * fire again.
102
143
  */
103
144
  connect() {
104
145
  this.#restoreBaseline();
146
+ this.#settled = this.#selection();
105
147
  this.#reconcile.activate();
148
+ this.#observeRows();
106
149
  }
107
- /** Closes the reconcile window so a queued pass cannot run against a detached tree. */
150
+ /** Releases the row observer and drops a queued pass, so neither outlives the element. */
108
151
  disconnect() {
109
152
  this.#reconcile.cancel();
153
+ this.#observer?.disconnect();
154
+ this.#observer = null;
110
155
  }
111
156
  /**
112
- * Rebuilds both DOM-owned baselines from the live grid: exactly one navigable
113
- * cell is in the Tab sequence, and every selectable row carries an explicit
114
- * `aria-selected`.
157
+ * Rebuilds the DOM-owned baselines from the live grid: exactly one navigable
158
+ * cell is in the Tab sequence, `aria-multiselectable` follows `selection`, every
159
+ * selectable row carries an explicit `aria-selected`, and the fields mirror the
160
+ * selected rows.
115
161
  *
116
162
  * The tab stop keeps whichever cell already holds it, so a rebuild triggered by
117
163
  * an unrelated row arriving does not throw the user's position away; only when
@@ -124,21 +170,60 @@ var DataGridController = class extends Controller {
124
170
  const cells = this.#navigableCells();
125
171
  const active = cells.find((cell) => cell.tabIndex === 0) ?? cells[0];
126
172
  if (active) this.#setActiveCell(active, { focus: false }, cells);
173
+ this.#syncSelectable();
174
+ const pageWrote = this.#takePageRecords();
127
175
  this.#normalizeSelection();
176
+ this.#dropOwnRecords(pageWrote);
177
+ this.#mirrorFields(false);
178
+ }
179
+ /**
180
+ * The pass the page's changes run: the baselines are rebuilt, then a selection
181
+ * that moved is reported. The report comes last, so rows a subscriber rewrites
182
+ * are the next pass's to settle.
183
+ */
184
+ #reconcileRows() {
185
+ this.#restoreBaseline();
186
+ this.#reportMove();
187
+ }
188
+ /**
189
+ * Reports the selection as `reconcile` when it differs from the one last
190
+ * settled. The settled selection is replaced before dispatching, so a move a
191
+ * subscriber makes is measured against what it was told.
192
+ */
193
+ #reportMove() {
194
+ const selection = this.#selection();
195
+ if (sameSelection(selection, this.#settled)) return;
196
+ this.#settled = selection;
197
+ this.dispatch("reconcile", { detail: { rows: [...selection.keys()] } });
128
198
  }
129
199
  /**
130
- * Keeps `aria-multiselectable` in step with the `selection` Value. Fires on connect
131
- * (so it self-heals after a Turbo morph) and on any runtime change, so the ARIA
132
- * never drifts from the selection logic, which reads `selectionValue` live.
200
+ * Re-renders `aria-multiselectable`, the rows and the fields when application
201
+ * code (or a Turbo morph) changes `selection` at runtime; a selection the new
202
+ * mode collapses is reported as `reconcile`.
133
203
  */
134
204
  selectionValueChanged() {
135
- this.#syncSelectable();
136
- this.#normalizeSelection();
205
+ this.#reconcile.schedule();
206
+ }
207
+ /** Rebuilds the submitted fields when the public name changes at runtime. */
208
+ nameValueChanged() {
209
+ this.#reconcile.schedule();
210
+ }
211
+ /** Repoints the submitted fields when the owning form changes at runtime. */
212
+ formValueChanged() {
213
+ this.#reconcile.schedule();
214
+ }
215
+ /** Seeds a fields container inserted after connect from the current selection. */
216
+ fieldsTargetConnected() {
217
+ this.#reconcile.schedule();
137
218
  }
138
219
  /** Re-establishes the baselines for a row added after connect. */
139
220
  rowTargetConnected() {
140
221
  this.#reconcile.schedule();
141
222
  }
223
+ /** Settles the selection again when a row leaves the grid or drops its target token. */
224
+ rowTargetDisconnected() {
225
+ this.#reconcile.schedule();
226
+ }
142
227
  /** Re-establishes the tab stop when a cell joins the grid after connect. */
143
228
  cellTargetConnected() {
144
229
  this.#reconcile.schedule();
@@ -165,6 +250,8 @@ var DataGridController = class extends Controller {
165
250
  * A grid that declares `selection="none"` has no selectable rows, so the
166
251
  * attribute is removed rather than written: in ARIA its absence is what "not
167
252
  * selectable" looks like.
253
+ *
254
+ * @stimeoRenderRoot
168
255
  */
169
256
  #normalizeSelection() {
170
257
  const rows = this.rowTargets;
@@ -176,9 +263,9 @@ var DataGridController = class extends Controller {
176
263
  const first = rows.find((row) => row.getAttribute("aria-selected") === "true");
177
264
  for (const row of rows) {
178
265
  if (single) {
179
- row.setAttribute("aria-selected", row === first ? "true" : "false");
266
+ setAttributeIfChanged(row, "aria-selected", row === first ? "true" : "false");
180
267
  } else if (row.getAttribute("aria-selected") !== "true") {
181
- row.setAttribute("aria-selected", "false");
268
+ setAttributeIfChanged(row, "aria-selected", "false");
182
269
  }
183
270
  }
184
271
  }
@@ -186,10 +273,12 @@ var DataGridController = class extends Controller {
186
273
  * Mirrors `selection="multiple"` onto `aria-multiselectable` (APG Grid) so SRs
187
274
  * announce that more than one row can be selected; cleared for single/none so a
188
275
  * grid never carries a misleading attribute.
276
+ *
277
+ * @stimeoRenderRoot
189
278
  */
190
279
  #syncSelectable() {
191
280
  if (this.selectionValue === "multiple") {
192
- this.element.setAttribute("aria-multiselectable", "true");
281
+ setAttributeIfChanged(this.element, "aria-multiselectable", "true");
193
282
  } else {
194
283
  this.element.removeAttribute("aria-multiselectable");
195
284
  }
@@ -288,7 +377,12 @@ var DataGridController = class extends Controller {
288
377
  if (control && host.contains(control)) return control;
289
378
  return isInteractiveHost(source) ? source : null;
290
379
  }
291
- /** Performs a header's sort or a cell row's selection toggle on activation. */
380
+ /**
381
+ * Performs a header's sort or a cell row's selection toggle on activation.
382
+ *
383
+ * @stimeoRuntimeOnly `selection` decides whether this one activation toggles the row; the sort it
384
+ * cycles does not depend on it.
385
+ */
292
386
  #activate(cell) {
293
387
  if (this.columnHeaderTargets.includes(cell)) {
294
388
  this.#cycleSort(cell);
@@ -306,17 +400,89 @@ var DataGridController = class extends Controller {
306
400
  }
307
401
  this.dispatch("sort", { detail: { column: header, direction } });
308
402
  }
309
- /** Toggles a row's `aria-selected`, honoring single vs. multiple selection. */
403
+ /**
404
+ * Toggles a row's `aria-selected`, honoring single vs. multiple selection.
405
+ *
406
+ * @stimeoRuntimeOnly `selection` decides how this one toggle treats the other rows.
407
+ */
310
408
  #toggleRow(row) {
311
409
  const selected = row.getAttribute("aria-selected") === "true";
410
+ const pageWrote = this.#takePageRecords();
312
411
  if (this.selectionValue === "single") {
313
412
  for (const other of this.rowTargets) {
314
- if (other !== row) other.setAttribute("aria-selected", "false");
413
+ if (other !== row) setAttributeIfChanged(other, "aria-selected", "false");
315
414
  }
316
415
  }
317
416
  row.setAttribute("aria-selected", selected ? "false" : "true");
318
- const rows = this.rowTargets.filter((r) => r.getAttribute("aria-selected") === "true");
319
- this.dispatch("selectionchange", { detail: { rows } });
417
+ this.#dropOwnRecords(pageWrote);
418
+ this.#settled = this.#selection();
419
+ this.#mirrorFields(true);
420
+ this.dispatch("selectionchange", { detail: { rows: [...this.#settled.keys()] } });
421
+ }
422
+ /** The selected rows in DOM order, each with the `data-value` it submits, if any. */
423
+ #selection() {
424
+ return new Map(
425
+ this.rowTargets.filter((row) => row.getAttribute("aria-selected") === "true").map((row) => [row, row.dataset.value])
426
+ );
427
+ }
428
+ /**
429
+ * Watches the rows' `aria-selected` and `data-value`. Every write the grid makes
430
+ * to them sits between a take and a drop of the observer's queue, so each record
431
+ * the callback receives is the page's, and only a record on one of the grid's own
432
+ * rows schedules a pass.
433
+ */
434
+ #observeRows() {
435
+ const observer = new MutationObserver((records) => {
436
+ if (this.#concernsRows(records)) this.#reconcile.schedule();
437
+ });
438
+ observer.observe(this.element, {
439
+ subtree: true,
440
+ attributes: true,
441
+ attributeFilter: OBSERVED_ATTRIBUTES
442
+ });
443
+ this.#observer = observer;
444
+ }
445
+ /**
446
+ * Empties the observer's queue before the grid writes to the rows, and says
447
+ * whether the page had queued anything about them. Those records still owe a
448
+ * pass: a toggle in `multiple` mode rewrites only its own row, so it does not fold
449
+ * in what the page wrote to the others.
450
+ */
451
+ #takePageRecords() {
452
+ const records = this.#observer?.takeRecords() ?? [];
453
+ return records.length > 0 && this.#concernsRows(records);
454
+ }
455
+ /**
456
+ * Whether any record is about one of this grid's own rows. A widget nested in a
457
+ * cell writes the same attributes on its own elements, and those are no reason
458
+ * to reconcile the grid.
459
+ */
460
+ #concernsRows(records) {
461
+ const rows = new Set(this.rowTargets);
462
+ return records.some((record) => rows.has(record.target));
463
+ }
464
+ /**
465
+ * Drops the records the grid's own writes just queued, so the observer never
466
+ * takes them for the page's, then schedules the pass the page's records owe.
467
+ */
468
+ #dropOwnRecords(pageWrote) {
469
+ this.#observer?.takeRecords();
470
+ if (pageWrote) this.#reconcile.schedule();
471
+ }
472
+ /**
473
+ * Mirrors the selected rows' `data-value` into the optional fields container.
474
+ * A selected row without one submits nothing, so a grid whose rows carry no
475
+ * value submits an empty set rather than a row of blanks.
476
+ *
477
+ * @stimeoRenderRoot
478
+ */
479
+ #mirrorFields(notify) {
480
+ if (!this.hasFieldsTarget) return;
481
+ const values = this.rowTargets.filter((row) => row.getAttribute("aria-selected") === "true").map((row) => row.dataset.value).filter((value) => value !== void 0);
482
+ const options = { name: this.nameValue, form: this.formValue };
483
+ if (writeFields(this.fieldsTarget, values, options) && notify) {
484
+ commitField(this.fieldsTarget);
485
+ }
320
486
  }
321
487
  /**
322
488
  * Makes `cell` the single tabbable cell (roving) and optionally focuses it.
@@ -114,6 +114,16 @@ function monthLabelFormatter(locale) {
114
114
  return intlFormatter(Intl.DateTimeFormat, locale, MONTH_LABEL_OPTIONS, "en");
115
115
  }
116
116
 
117
+ // src/utils/field_mirror.ts
118
+ function writeField(field, value) {
119
+ if (field.value === value) return false;
120
+ field.value = value;
121
+ return true;
122
+ }
123
+ function commitField(target) {
124
+ target.dispatchEvent(new Event("change", { bubbles: true }));
125
+ }
126
+
117
127
  // src/utils/locale.ts
118
128
  function resolveLocale(element, declared = "") {
119
129
  return declared || element.closest("[lang]")?.getAttribute("lang") || void 0;
@@ -219,6 +229,11 @@ function parseStringList(raw, fallback = []) {
219
229
  return parsed.filter((entry) => typeof entry === "string");
220
230
  }
221
231
 
232
+ // src/utils/target_selector.ts
233
+ function targetSelector(identifier, name) {
234
+ return `[data-${identifier}-target~="${name}"]`;
235
+ }
236
+
222
237
  // src/controllers/date_range_picker_controller.ts
223
238
  var GRID_SIZE = 42;
224
239
  var DateRangePickerController = class extends Controller {
@@ -233,7 +248,7 @@ var DateRangePickerController = class extends Controller {
233
248
  locale: { type: String, default: "" }
234
249
  };
235
250
  static actions = ["applyPreset", "next", "onKeydown", "prev", "previewTo", "selectDate"];
236
- static events = ["change", "monthchange"];
251
+ static events = ["change", "monthchange", "reconcile"];
237
252
  /** The month currently rendered, as `YYYY-MM`. */
238
253
  #viewMonth = "";
239
254
  /** The confirmed range endpoints (ISO), or "" when unset. */
@@ -247,6 +262,13 @@ var DateRangePickerController = class extends Controller {
247
262
  #focusedDate = /* @__PURE__ */ new Date();
248
263
  /** Deferred focus after an async month transition (cancelled on teardown). */
249
264
  #focusTimer = new SafeTimeout();
265
+ /**
266
+ * How many times the confirmed range has moved — a commit or a narrowing — so
267
+ * a report still pending for a range a listener has since replaced can tell.
268
+ */
269
+ #rangeMoves = 0;
270
+ /** How many month transitions have started, so one a listener starts mid-paint keeps its focus. */
271
+ #transitions = 0;
250
272
  /** The declared unavailable dates, indexed for the per-cell paint lookup. */
251
273
  #disabledDates = /* @__PURE__ */ new Set();
252
274
  /** Rewinds an unfinished selection before Turbo snapshots the page. */
@@ -260,17 +282,22 @@ var DateRangePickerController = class extends Controller {
260
282
  #repaint = new MicrotaskCoalescer(() => {
261
283
  this.#render();
262
284
  });
263
- /** Seeds and normalizes the range from optional hidden fields, then paints the grid. */
285
+ /**
286
+ * Seeds the range from optional hidden fields — ordered, and narrowed to the
287
+ * days that can be chosen — then paints the grid, which writes the range back
288
+ * to the fields silently.
289
+ */
264
290
  connect() {
265
291
  this.#repaint.activate();
266
292
  this.#beforeCache.activate();
267
293
  const authoredStart = this.hasStartFieldTarget ? normalizeISO(this.startFieldTarget.value) : "";
268
294
  const authoredEnd = this.hasEndFieldTarget ? normalizeISO(this.endFieldTarget.value) : "";
269
- [this.#startDate, this.#endDate] = orderRange(authoredStart, authoredEnd);
295
+ [this.#startDate, this.#endDate] = this.#selectableRange(
296
+ ...orderRange(authoredStart, authoredEnd)
297
+ );
270
298
  this.#pendingStart = "";
271
299
  this.#previewDate = "";
272
300
  this.#announcedMonth = null;
273
- this.#commitFields();
274
301
  const anchor = parseISODateString(this.#startDate) ?? this.#clampToBounds(/* @__PURE__ */ new Date()) ?? /* @__PURE__ */ new Date();
275
302
  this.#focusedDate = anchor;
276
303
  this.#viewMonth = toISOMonthString(anchor);
@@ -345,16 +372,11 @@ var DateRangePickerController = class extends Controller {
345
372
  if (!intersection) return;
346
373
  const { start, end } = intersection;
347
374
  if (!this.#isSelectable(start) || !this.#isSelectable(end)) return;
348
- this.#startDate = start;
349
- this.#endDate = end;
350
- this.#pendingStart = "";
351
- this.#previewDate = "";
375
+ const move = this.#setConfirmed(start, end);
352
376
  const endDate = parseISODateString(end);
353
377
  if (endDate) this.#focusedDate = endDate;
354
- this.#commitFields();
355
- this.#transitionTo(toISOMonthString(parseISODateString(end) ?? /* @__PURE__ */ new Date()), end);
356
- this.#announce();
357
- this.dispatch("change", { detail: { start, end } });
378
+ this.#transitionTo(toISOMonthString(parseISODateString(end) ?? /* @__PURE__ */ new Date()), end, true);
379
+ this.#reportConfirmed(move);
358
380
  }
359
381
  /** Grid keyboard navigation, selection (Enter/Space), and Escape-to-cancel. */
360
382
  onKeydown(event) {
@@ -413,7 +435,7 @@ var DateRangePickerController = class extends Controller {
413
435
  }
414
436
  /** Records a chosen date as either the pending start or the confirmed end. */
415
437
  #choose(date) {
416
- if (!this.#pendingStart) {
438
+ if (!this.#pendingStart || !this.#isSelectable(this.#pendingStart)) {
417
439
  this.#pendingStart = date;
418
440
  this.#previewDate = date;
419
441
  const parsed = parseISODateString(date);
@@ -422,14 +444,32 @@ var DateRangePickerController = class extends Controller {
422
444
  return;
423
445
  }
424
446
  const [start, end] = date < this.#pendingStart ? [date, this.#pendingStart] : [this.#pendingStart, date];
447
+ const move = this.#setConfirmed(start, end);
448
+ this.#render(true);
449
+ this.#reportConfirmed(move);
450
+ }
451
+ /**
452
+ * Makes `start`–`end` the confirmed range, ending any selection in progress,
453
+ * ahead of the paint that shows it.
454
+ *
455
+ * @returns The move count the range is now at, which its `change` checks.
456
+ */
457
+ #setConfirmed(start, end) {
425
458
  this.#startDate = start;
426
459
  this.#endDate = end;
427
460
  this.#pendingStart = "";
428
461
  this.#previewDate = "";
429
- this.#commitFields();
430
- this.#render();
431
- this.#announce();
432
- this.dispatch("change", { detail: { start, end } });
462
+ this.#rangeMoves += 1;
463
+ return this.#rangeMoves;
464
+ }
465
+ /**
466
+ * Dispatches `change` for the range confirmed at `move`, once its paint has
467
+ * reported it through the fields. A listener of those reports that moved the
468
+ * range on has had the range it moved to reported instead.
469
+ */
470
+ #reportConfirmed(move) {
471
+ if (move !== this.#rangeMoves) return;
472
+ this.dispatch("change", { detail: { start: this.#startDate, end: this.#endDate } });
433
473
  }
434
474
  /** Moves roving focus to `date`, transitioning the month when needed. */
435
475
  #moveFocusTo(date) {
@@ -438,12 +478,21 @@ var DateRangePickerController = class extends Controller {
438
478
  if (this.#pendingStart && this.#isSelectable(iso)) this.#previewDate = iso;
439
479
  this.#transitionTo(toISOMonthString(date), iso);
440
480
  }
441
- /** Renders `month`, then focuses the cell for `dateStr` (deferred if async). */
442
- #transitionTo(month, dateStr) {
481
+ /**
482
+ * Renders `month`, then focuses the cell for `dateStr` (deferred if async).
483
+ * A listener of the paint's reports that started another move has placed
484
+ * focus itself, so this one leaves it there.
485
+ *
486
+ * @param confirmed - Whether the paint shows a range the user has just confirmed.
487
+ */
488
+ #transitionTo(month, dateStr, confirmed = false) {
443
489
  const isTransition = month !== this.#viewMonth;
444
490
  this.#focusTimer.clearAll();
445
491
  this.#viewMonth = month;
446
- this.#render();
492
+ this.#transitions += 1;
493
+ const transition = this.#transitions;
494
+ this.#render(confirmed);
495
+ if (transition !== this.#transitions) return;
447
496
  const focusCell = () => {
448
497
  this.cellTargets.find((c) => c.getAttribute("data-date") === dateStr)?.focus();
449
498
  };
@@ -465,15 +514,25 @@ var DateRangePickerController = class extends Controller {
465
514
  this.#render();
466
515
  }
467
516
  /**
468
- * Builds the six-week grid and binds range/roving/disabled state per cell.
517
+ * Builds the six-week grid and binds range/roving/disabled state per cell,
518
+ * mirrors the confirmed range to the hidden fields, then reports what the
519
+ * paint moved.
520
+ *
521
+ * @param confirmed - Whether the paint shows a range the user has just
522
+ * confirmed, which the status then announces and each moved field reports.
469
523
  *
470
524
  * @stimeoRenderRoot
471
525
  */
472
- #render() {
526
+ #render(confirmed = false) {
473
527
  const info = parseISOMonthString(this.#viewMonth);
474
528
  if (!info) return;
475
529
  const { year, month } = info;
530
+ if (this.#pendingStart && !this.#isSelectable(this.#pendingStart)) {
531
+ this.#pendingStart = "";
532
+ this.#previewDate = "";
533
+ }
476
534
  if (this.#previewDate && !this.#isSelectable(this.#previewDate)) this.#previewDate = "";
535
+ const narrowed = this.#narrowConfirmedRange();
477
536
  if (this.hasMonthLabelTarget) {
478
537
  const formatter = monthLabelFormatter(resolveLocale(this.element, this.localeValue));
479
538
  this.monthLabelTarget.textContent = formatter.format(new Date(year, month - 1, 1));
@@ -513,11 +572,54 @@ var DateRangePickerController = class extends Controller {
513
572
  const fallback = cells.find((cell) => cell.getAttribute("data-outside") === "false") ?? cells[0];
514
573
  fallback?.setAttribute("tabindex", "0");
515
574
  }
575
+ const move = this.#rangeMoves;
576
+ const moved = this.#mirrorFields();
577
+ if (confirmed) {
578
+ this.#announce();
579
+ for (const [field, value] of moved) {
580
+ if (field.value === value) commitField(field);
581
+ }
582
+ }
516
583
  const previous = this.#announcedMonth;
517
584
  this.#announcedMonth = this.#viewMonth;
518
585
  if (previous !== null && previous !== this.#viewMonth) {
519
586
  this.dispatch("monthchange", { detail: { month: this.#viewMonth } });
520
587
  }
588
+ if (narrowed && move === this.#rangeMoves) {
589
+ this.dispatch("reconcile", { detail: { start: this.#startDate, end: this.#endDate } });
590
+ }
591
+ }
592
+ /**
593
+ * Narrows the confirmed range to days that can still be chosen. The paint
594
+ * mirrors it to the fields without reporting a commit.
595
+ *
596
+ * @returns Whether the range moved.
597
+ */
598
+ #narrowConfirmedRange() {
599
+ const [start, end] = this.#selectableRange(this.#startDate, this.#endDate);
600
+ if (start === this.#startDate && end === this.#endDate) return false;
601
+ this.#startDate = start;
602
+ this.#endDate = end;
603
+ this.#rangeMoves += 1;
604
+ return true;
605
+ }
606
+ /**
607
+ * The range `start`–`end` with each end moved inward to the nearest day that
608
+ * can be chosen, or two empty strings when none of its days can be. A lone
609
+ * endpoint is the one-day range it names, and stays lone.
610
+ */
611
+ #selectableRange(start, end) {
612
+ let from = parseISODateString(start || end);
613
+ let to = parseISODateString(end || start);
614
+ if (!from || !to) return [start, end];
615
+ while (from.getTime() <= to.getTime() && !this.#isSelectable(toISODateString(from))) {
616
+ from = addDays(from, 1);
617
+ }
618
+ while (to.getTime() >= from.getTime() && !this.#isSelectable(toISODateString(to))) {
619
+ to = addDays(to, -1);
620
+ }
621
+ if (from.getTime() > to.getTime()) return ["", ""];
622
+ return [start ? toISODateString(from) : "", end ? toISODateString(to) : ""];
521
623
  }
522
624
  /** The ordered [start, end] pair to paint: the preview while selecting, else confirmed. */
523
625
  #visualRange() {
@@ -527,10 +629,20 @@ var DateRangePickerController = class extends Controller {
527
629
  }
528
630
  return [this.#startDate, this.#endDate];
529
631
  }
530
- /** Writes the confirmed range to the hidden fields. */
531
- #commitFields() {
532
- if (this.hasStartFieldTarget) this.startFieldTarget.value = this.#startDate;
533
- if (this.hasEndFieldTarget) this.endFieldTarget.value = this.#endDate;
632
+ /**
633
+ * Writes the confirmed range to the hidden fields.
634
+ *
635
+ * @returns Each field whose value moved, with the value written to it.
636
+ */
637
+ #mirrorFields() {
638
+ const moved = [];
639
+ if (this.hasStartFieldTarget && writeField(this.startFieldTarget, this.#startDate)) {
640
+ moved.push([this.startFieldTarget, this.#startDate]);
641
+ }
642
+ if (this.hasEndFieldTarget && writeField(this.endFieldTarget, this.#endDate)) {
643
+ moved.push([this.endFieldTarget, this.#endDate]);
644
+ }
645
+ return moved;
534
646
  }
535
647
  /** Announces the confirmed range in the live status region. */
536
648
  #announce() {
@@ -590,7 +702,7 @@ var DateRangePickerController = class extends Controller {
590
702
  /** Resolves the cell element from an event target, or null. */
591
703
  #cellFrom(target) {
592
704
  return target?.closest(
593
- "[data-stimeo--date-range-picker-target='cell']"
705
+ targetSelector(this.identifier, "cell")
594
706
  ) ?? null;
595
707
  }
596
708
  };