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
@@ -85,123 +85,215 @@ function monthLabelFormatter(locale) {
85
85
  return intlFormatter(Intl.DateTimeFormat, locale, MONTH_LABEL_OPTIONS, "en");
86
86
  }
87
87
 
88
- // src/utils/safe_timeout.ts
89
- var TimerRegistry = class {
90
- /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
91
- ids = /* @__PURE__ */ new Set();
92
- /**
93
- * Cancels a single tracked timer.
94
- *
95
- * No-ops if the id is unknown (already cleared, fired, or never owned by this
96
- * registry), so callers can clear defensively without guarding.
97
- */
98
- clear(id) {
99
- if (this.ids.delete(id)) {
100
- this.cancel(id);
101
- }
102
- }
103
- /**
104
- * Cancels every tracked timer. Call this from a controller's `disconnect()`
105
- * to guarantee no timer outlives the element.
106
- */
107
- clearAll() {
108
- for (const id of this.ids) {
109
- this.cancel(id);
110
- }
111
- this.ids.clear();
112
- }
113
- /** Number of timers currently tracked (pending). */
114
- get size() {
115
- return this.ids.size;
88
+ // src/utils/field_mirror.ts
89
+ function writeField(field, value) {
90
+ if (field.value === value) return false;
91
+ field.value = value;
92
+ return true;
93
+ }
94
+ function commitField(target) {
95
+ target.dispatchEvent(new Event("change", { bubbles: true }));
96
+ }
97
+
98
+ // src/utils/locale.ts
99
+ function resolveLocale(element, declared = "") {
100
+ return declared || element.closest("[lang]")?.getAttribute("lang") || void 0;
101
+ }
102
+
103
+ // src/utils/microtask_coalescer.ts
104
+ var MicrotaskCoalescer = class {
105
+ #run;
106
+ #queued = false;
107
+ #active = false;
108
+ #generation = 0;
109
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
110
+ constructor(run) {
111
+ this.#run = run;
116
112
  }
117
- };
118
- var SafeTimeout = class extends TimerRegistry {
119
- /**
120
- * Schedules `callback` after `delay` ms and returns the timer id.
121
- *
122
- * The id is removed from the registry automatically when the timeout fires,
123
- * so {@link TimerRegistry.size | size} reflects only still-pending timers.
124
- */
125
- set(callback, delay) {
126
- const id = this.schedule(() => {
127
- this.ids.delete(id);
128
- callback();
129
- }, delay);
130
- this.ids.add(id);
131
- return id;
113
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
114
+ activate() {
115
+ this.#active = true;
132
116
  }
133
- schedule(callback, delay) {
134
- return window.setTimeout(callback, delay);
117
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
118
+ cancel() {
119
+ this.#active = false;
120
+ this.#queued = false;
121
+ this.#generation += 1;
135
122
  }
136
- cancel(id) {
137
- window.clearTimeout(id);
123
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
124
+ schedule() {
125
+ if (!this.#active || this.#queued) return;
126
+ this.#queued = true;
127
+ const generation = this.#generation;
128
+ queueMicrotask(() => {
129
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
130
+ this.#queued = false;
131
+ this.#run();
132
+ });
138
133
  }
139
134
  };
140
135
 
136
+ // src/utils/target_selector.ts
137
+ function targetSelector(identifier, name) {
138
+ return `[data-${identifier}-target~="${name}"]`;
139
+ }
140
+
141
141
  // src/controllers/calendar_controller.ts
142
- var OWNED_DISABLED = "data-stimeo--calendar-owns-disabled";
142
+ var OWNED_DISABLED = "owns-disabled";
143
143
  var CalendarController = class extends Controller {
144
- static targets = ["label", "grid", "day"];
144
+ /** The marker above, in the namespace this controller is registered under. */
145
+ get #ownedDisabled() {
146
+ return `data-${this.identifier}-${OWNED_DISABLED}`;
147
+ }
148
+ static targets = ["label", "grid", "day", "field", "monthField"];
145
149
  static values = {
146
150
  month: { type: String, default: "" },
147
151
  selected: { type: String, default: "" },
148
152
  min: { type: String, default: "" },
149
153
  max: { type: String, default: "" },
150
- weekStart: { type: Number, default: 0 }
154
+ weekStart: { type: Number, default: 0 },
151
155
  // 0 = Sunday, 1 = Monday, etc.
156
+ locale: { type: String, default: "" }
152
157
  };
153
158
  static actions = ["next", "onKeydown", "prev", "selectByClick"];
154
- static events = ["monthchange", "select"];
159
+ static events = ["monthchange", "reconcile", "select"];
155
160
  /** The date currently receiving focus in the grid (local time). */
156
161
  focusedDate = /* @__PURE__ */ new Date();
157
162
  /**
158
- * Deferred focus moves scheduled after an asynchronous month transition.
159
- * Tracked so {@link disconnect} can cancel any pending move and a detached
160
- * controller never steals focus after the element leaves the DOM (Turbo).
163
+ * Whether `connect()` has run and `disconnect()` has not since. The paints
164
+ * before and during `connect()` describe the grid, so they report nothing.
161
165
  */
162
- #focusTimer = new SafeTimeout();
166
+ #connected = false;
163
167
  /**
164
- * The painted month the last `monthchange` reported, or `null` before the first
165
- * paint. Settling on the initial month — whether it comes from the attribute or
166
- * is derived here — is the grid describing itself, not a navigation, so it must
167
- * not reach a listener that refetches inventory or pushes history.
168
+ * The selection the last paint published — a `YYYY-MM-DD` day, or `""` — which
169
+ * the next move of the selection is measured from. A pick records its own day
170
+ * here before painting it.
168
171
  */
169
- #announcedMonth = null;
172
+ #published = "";
173
+ /**
174
+ * How many times a pick or a change the page made has moved the selection, so
175
+ * a report still pending for a selection a listener has since replaced can tell.
176
+ */
177
+ #selectionMoves = 0;
178
+ /**
179
+ * The month the last paint showed (`YYYY-MM`), or `""` before the first paint.
180
+ * Only a connected grid reports a paint that moves it: settling on the initial
181
+ * month — before `connect()` or in it, from the attribute or derived here — is
182
+ * the grid describing itself, not a navigation, so it must not reach a
183
+ * listener that refetches inventory or pushes history.
184
+ *
185
+ * While `month` is empty this is also the month the next paint shows, so the
186
+ * grid stays where it is; the first `connect()` of this instance sets it to the
187
+ * month the grid opens on.
188
+ */
189
+ #paintedMonth = "";
190
+ /**
191
+ * Whether this instance has connected before. Only its first connection picks
192
+ * the month a grid with no `month` opens on. A later one keeps the month on
193
+ * screen, and so do the paints Stimulus runs before it.
194
+ */
195
+ #opened = false;
196
+ /**
197
+ * Whether the paint about to run was asked for by this grid's own controls.
198
+ * The `month` and `selected` Values are shared with the page — application
199
+ * code and a Turbo morph write them too — so the field mirrors take their
200
+ * "did the user commit this" answer from the route, not from the Value.
201
+ */
202
+ #movedByUser = false;
203
+ /**
204
+ * Repaints the month on screen after `min`, `max` or `weekStart` changed.
205
+ *
206
+ * A morph that swaps several of them repaints once, and the delivery Stimulus
207
+ * makes before `connect()` is refused, because the grid is painted on
208
+ * connect. None of these Values decides the month on screen; `min` and `max`
209
+ * decide whether the requested day is published.
210
+ */
211
+ #repaint = new MicrotaskCoalescer(() => {
212
+ this.#repaintKeepingFocus();
213
+ });
214
+ /**
215
+ * The cell DOM focus was on, its day and the month on screen, recorded by the
216
+ * first repaint of a batch for the pass that settles focus after it.
217
+ */
218
+ #focusOrigin = null;
219
+ /**
220
+ * Settles DOM focus once, after the last repaint of a batch. The callbacks of
221
+ * one mutation repaint one by one and each reads every Value as it stands, so
222
+ * a `selected` or `locale` callback can paint a month before that month's own
223
+ * callback has moved the tab stop into it. Outside a connection no pass is
224
+ * scheduled, so a grid that is not connected moves no focus.
225
+ */
226
+ #settleFocus = new MicrotaskCoalescer(() => {
227
+ this.#restoreFocus();
228
+ });
170
229
  connect() {
171
- if (!this.monthValue) {
172
- const today = /* @__PURE__ */ new Date();
173
- const monthStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}`;
174
- this.monthValue = monthStr;
175
- return;
230
+ this.#repaint.activate();
231
+ this.#settleFocus.activate();
232
+ this.#focusOrigin = null;
233
+ if (!this.monthValue && !this.#opened) {
234
+ this.#paintedMonth = toISOMonthString(parseISODateString(this.#selection) ?? /* @__PURE__ */ new Date());
176
235
  }
236
+ this.#opened = true;
177
237
  this.#initializeFocusedDate();
178
- this.render();
238
+ this.#render();
239
+ this.#connected = true;
179
240
  }
180
- /** Cancels any pending deferred focus so it never fires on a detached element. */
241
+ /**
242
+ * Drops a pending repaint and a pending focus move, and stops reporting, so
243
+ * nothing reaches for a grid that is not connected.
244
+ */
181
245
  disconnect() {
182
- this.#focusTimer.clearAll();
246
+ this.#connected = false;
247
+ this.#repaint.cancel();
248
+ this.#settleFocus.cancel();
183
249
  }
184
250
  /**
185
- * Stimulus lifecycle callback triggered automatically when the monthValue changes.
186
- * Forces a re-render of the date grid and updates labels.
251
+ * Paints the month `month` names, whether application code, a morph or the
252
+ * grid's own navigation wrote it; focus in the grid follows to the tab stop.
187
253
  */
188
254
  monthValueChanged() {
189
255
  if (!this.monthValue) return;
190
256
  this.#syncFocusedDateWithMonth();
191
- this.render();
257
+ this.#repaintKeepingFocus();
192
258
  }
193
259
  /**
194
- * Stimulus lifecycle callback triggered automatically when the selectedValue changes.
195
- * Re-renders grid cells to update `aria-selected` indicators.
260
+ * Repaints the label when application code changes `locale` at runtime.
261
+ *
262
+ * Stimulus runs this for the initial Value too, before `connect()`. The paint
263
+ * it triggers is idempotent — the month comes from `month` (or, while that is
264
+ * still empty, from `focusedDate`) — and a grid that has not connected
265
+ * reports nothing, so that paint never reports a month.
266
+ */
267
+ localeValueChanged() {
268
+ this.#repaintKeepingFocus();
269
+ }
270
+ /**
271
+ * Repaints `aria-selected` and the field when the requested day changes, and
272
+ * moves the tab stop to the day the grid now publishes. A request `min` /
273
+ * `max` withhold is not published, so the tab stop stays where it was.
196
274
  */
197
275
  selectedValueChanged() {
198
- if (this.selectedValue) {
199
- const selected = parseISODateString(this.selectedValue);
200
- if (selected) {
201
- this.focusedDate = selected;
202
- }
203
- }
204
- this.render();
276
+ const selection = parseISODateString(this.#selection);
277
+ if (selection) this.focusedDate = selection;
278
+ this.#repaintKeepingFocus();
279
+ }
280
+ /** Repaints when `min` changes after connect, so the disabled days and the selection follow. */
281
+ minValueChanged() {
282
+ this.#repaint.schedule();
283
+ }
284
+ /** Repaints when `max` changes after connect, so the disabled days and the selection follow. */
285
+ maxValueChanged() {
286
+ this.#repaint.schedule();
287
+ }
288
+ /**
289
+ * Lays the grid out again when `weekStart` changes after connect, so every
290
+ * date moves to the column of its weekday.
291
+ *
292
+ * Only the day cells are painted: column headers are author markup, so the
293
+ * author updates them together with the Value.
294
+ */
295
+ weekStartValueChanged() {
296
+ this.#repaint.schedule();
205
297
  }
206
298
  /** Navigates to the previous month. */
207
299
  prev(event) {
@@ -216,7 +308,7 @@ var CalendarController = class extends Controller {
216
308
  /** Handles day selection when a gridcell is clicked. */
217
309
  selectByClick(event) {
218
310
  const dayElement = event.target?.closest(
219
- "[data-stimeo--calendar-target='day']"
311
+ targetSelector(this.identifier, "day")
220
312
  );
221
313
  if (!dayElement) return;
222
314
  this.selectDayElement(dayElement);
@@ -226,7 +318,7 @@ var CalendarController = class extends Controller {
226
318
  if (event.defaultPrevented) return;
227
319
  if (isReservedArrowChord(event)) return;
228
320
  const dayElement = event.target?.closest(
229
- "[data-stimeo--calendar-target='day']"
321
+ targetSelector(this.identifier, "day")
230
322
  );
231
323
  if (!dayElement) return;
232
324
  const date = parseISODateString(dayElement.getAttribute("data-date") ?? "");
@@ -291,8 +383,8 @@ var CalendarController = class extends Controller {
291
383
  }
292
384
  }
293
385
  /**
294
- * First day of the month the grid paints: the `month` Value when it parses,
295
- * otherwise the focused date's month.
386
+ * First day of the month the grid paints: the `month` Value when it parses —
387
+ * while it is empty, the month on screen — otherwise the focused date's month.
296
388
  *
297
389
  * A malformed `month` falls back instead of stopping the paint: every cell
298
390
  * keeps its `aria-selected` and the grid keeps its tab stop, so a Value typo
@@ -301,12 +393,22 @@ var CalendarController = class extends Controller {
301
393
  * the paint and the `monthchange` report cannot name different months.
302
394
  */
303
395
  #paintedMonthStart() {
304
- const monthInfo = parseISOMonthString(this.monthValue);
396
+ const monthInfo = parseISOMonthString(this.#monthRequest);
305
397
  return monthInfo ? new Date(monthInfo.year, monthInfo.month - 1, 1) : new Date(this.focusedDate.getFullYear(), this.focusedDate.getMonth(), 1);
306
398
  }
307
399
  /**
308
- * Renders the grid days and updates the month/year label, then reports the
309
- * painted month once it differs from the one already announced.
400
+ * The month the grid is asked to paint: `month`, or while that is empty the
401
+ * month on screen, which `connect()` opens on the month of the published
402
+ * selection, else on the current one.
403
+ */
404
+ get #monthRequest() {
405
+ return this.monthValue || this.#paintedMonth;
406
+ }
407
+ /**
408
+ * Renders the grid days and updates the month/year label, mirrors both
409
+ * fields, then reports each field the user's move moved and — once connected
410
+ * — a painted month that differs from the last one painted, and a published
411
+ * selection that a change the page made moved.
310
412
  *
311
413
  * The report belongs to the paint, not to the `month` Value: the Value is only
312
414
  * one of the things that decide the month on screen. While a malformed `month`
@@ -314,24 +416,36 @@ var CalendarController = class extends Controller {
314
416
  * move the painted month too, and both repaint from here. Reading the report
315
417
  * off the paint keeps the event naming the month the grid shows, in the
316
418
  * `YYYY-MM` the detail contract promises, whichever route repainted.
419
+ *
420
+ * @stimeoRenderRoot
317
421
  */
318
- render() {
422
+ #render() {
319
423
  const monthStart = this.#paintedMonthStart();
320
424
  const year = monthStart.getFullYear();
321
425
  const month = monthStart.getMonth() + 1;
426
+ const selection = this.#selection;
322
427
  if (this.hasLabelTarget) {
323
- const lang = document.documentElement.lang || "en";
324
- this.labelTarget.textContent = monthLabelFormatter(lang).format(monthStart);
428
+ const locale = resolveLocale(this.element, this.localeValue);
429
+ this.labelTarget.textContent = monthLabelFormatter(locale).format(monthStart);
325
430
  }
326
431
  const days = this.#calculateGridDays(year, month);
327
432
  const dayElements = this.dayTargets;
433
+ const consumerDisabled = /* @__PURE__ */ new Map();
434
+ for (const el of dayElements) {
435
+ const shown = el.getAttribute("data-date");
436
+ const value = el.getAttribute("aria-disabled");
437
+ if (!shown || value === null || el.hasAttribute(this.#ownedDisabled)) continue;
438
+ consumerDisabled.set(shown, value);
439
+ }
328
440
  for (const [index, date] of days.entries()) {
329
441
  const el = dayElements[index];
330
442
  if (!el) continue;
331
443
  const dateStr = toISODateString(date);
332
444
  if (el.getAttribute("data-date") !== dateStr) {
333
445
  el.removeAttribute("aria-disabled");
334
- el.removeAttribute(OWNED_DISABLED);
446
+ el.removeAttribute(this.#ownedDisabled);
447
+ const kept = consumerDisabled.get(dateStr);
448
+ if (kept !== void 0) el.setAttribute("aria-disabled", kept);
335
449
  }
336
450
  el.setAttribute("data-date", dateStr);
337
451
  el.textContent = String(date.getDate());
@@ -339,17 +453,16 @@ var CalendarController = class extends Controller {
339
453
  el.setAttribute("data-outside", String(isOutside));
340
454
  const todayStr = toISODateString(/* @__PURE__ */ new Date());
341
455
  el.setAttribute("data-today", String(dateStr === todayStr));
342
- const isSelected = dateStr === this.selectedValue;
343
- el.setAttribute("aria-selected", String(isSelected));
456
+ el.setAttribute("aria-selected", String(dateStr === selection));
344
457
  const isFocused = toISODateString(this.focusedDate) === dateStr;
345
458
  el.setAttribute("tabindex", isFocused ? "0" : "-1");
346
459
  const isDisabled = this.#isDateOutOfBounds(dateStr);
347
460
  if (isDisabled) {
348
- if (!el.hasAttribute("aria-disabled")) el.setAttribute(OWNED_DISABLED, "");
461
+ if (!el.hasAttribute("aria-disabled")) el.setAttribute(this.#ownedDisabled, "");
349
462
  el.setAttribute("aria-disabled", "true");
350
- } else if (el.hasAttribute(OWNED_DISABLED)) {
463
+ } else if (el.hasAttribute(this.#ownedDisabled)) {
351
464
  el.removeAttribute("aria-disabled");
352
- el.removeAttribute(OWNED_DISABLED);
465
+ el.removeAttribute(this.#ownedDisabled);
353
466
  }
354
467
  }
355
468
  if (!dayElements.some((el) => el.getAttribute("tabindex") === "0")) {
@@ -357,50 +470,151 @@ var CalendarController = class extends Controller {
357
470
  fallback?.setAttribute("tabindex", "0");
358
471
  }
359
472
  const painted = toISOMonthString(monthStart);
360
- const previous = this.#announcedMonth;
361
- this.#announcedMonth = painted;
362
- if (previous !== null && previous !== painted) {
473
+ const previous = this.#paintedMonth;
474
+ this.#paintedMonth = painted;
475
+ const reconciled = this.#settleSelection(selection);
476
+ const move = this.#selectionMoves;
477
+ for (const [field, value] of this.#mirrorFields(painted, selection, reconciled)) {
478
+ if (field.value === value) commitField(field);
479
+ }
480
+ if (this.#connected && previous !== painted && this.#paintedMonth === painted) {
363
481
  this.dispatch("monthchange", { detail: { month: painted } });
364
482
  }
483
+ if (reconciled && move === this.#selectionMoves) {
484
+ this.dispatch("reconcile", { detail: { date: selection } });
485
+ }
486
+ }
487
+ /**
488
+ * Records the selection a paint publishes and says whether it moved from the
489
+ * one published before, which a connected grid reports as `reconcile`. A pick
490
+ * records its own day before its paint, so that paint finds no move, and
491
+ * nothing counts before the grid connects. A move counts as a move of the
492
+ * selection.
493
+ */
494
+ #settleSelection(selection) {
495
+ const published = this.#published;
496
+ this.#published = selection;
497
+ if (!this.#connected || selection === published) return false;
498
+ this.#selectionMoves += 1;
499
+ return true;
500
+ }
501
+ /**
502
+ * Repaints the grid for a Value callback or a pick. The first repaint of a
503
+ * batch records where DOM focus stood on a day, for the settle pass; focus
504
+ * outside the grid is left alone.
505
+ */
506
+ #repaintKeepingFocus() {
507
+ if (!this.#focusOrigin) {
508
+ const cell = this.dayTargets.find((day) => day === document.activeElement);
509
+ if (cell) {
510
+ this.#focusOrigin = {
511
+ cell,
512
+ date: cell.getAttribute("data-date"),
513
+ month: this.#paintedMonth
514
+ };
515
+ this.#settleFocus.schedule();
516
+ }
517
+ }
518
+ this.#render();
519
+ }
520
+ /**
521
+ * Puts DOM focus back on a day it can stay on once a batch of repaints has
522
+ * landed.
523
+ *
524
+ * While the painted month stays, a bound only toggles `aria-disabled` and a
525
+ * disabled day stays focusable, so nothing is called (`focus()` would scroll
526
+ * the day into view); `weekStart` moves every date to another cell, so focus
527
+ * follows its date, or goes to the tab stop once the date has left the grid.
528
+ * When the painted month moves, every cell shows another day, and focus goes
529
+ * to the tab stop of the new month. Focus a listener of the paint's events
530
+ * moved off the recorded cell stays where it went.
531
+ */
532
+ #restoreFocus() {
533
+ const origin = this.#focusOrigin;
534
+ this.#focusOrigin = null;
535
+ if (!origin || document.activeElement !== origin.cell) return;
536
+ const moved = this.#paintedMonth !== origin.month;
537
+ if (!moved && origin.cell.getAttribute("data-date") === origin.date) return;
538
+ const destination = (moved ? void 0 : this.dayTargets.find((cell) => cell.getAttribute("data-date") === origin.date)) ?? this.dayTargets.find((cell) => cell.getAttribute("tabindex") === "0");
539
+ destination?.focus();
540
+ }
541
+ /** Fills a selected-day field inserted or replaced at runtime with the published selection. */
542
+ fieldTargetConnected(field) {
543
+ writeField(field, this.#selection);
544
+ }
545
+ /** Fills a painted-month field inserted or replaced at runtime. */
546
+ monthFieldTargetConnected(field) {
547
+ writeField(field, toISOMonthString(this.#paintedMonthStart()));
548
+ }
549
+ /**
550
+ * Mirrors the painted month and the published selection into their optional
551
+ * fields.
552
+ *
553
+ * @param reconciled - Whether a change the page made moved the selection in
554
+ * this paint, which `reconcile` reports rather than the field.
555
+ * @returns Each field this grid's own controls moved, with the value written
556
+ * to it, for the paint to report.
557
+ */
558
+ #mirrorFields(painted, selection, reconciled) {
559
+ const byUser = this.#movedByUser;
560
+ this.#movedByUser = false;
561
+ const moved = [];
562
+ if (this.hasFieldTarget && writeField(this.fieldTarget, selection) && !reconciled) {
563
+ moved.push([this.fieldTarget, selection]);
564
+ }
565
+ if (this.hasMonthFieldTarget && writeField(this.monthFieldTarget, painted)) {
566
+ moved.push([this.monthFieldTarget, painted]);
567
+ }
568
+ return byUser ? moved : [];
365
569
  }
366
570
  selectDayElement(dayElement) {
367
571
  if (dayElement.getAttribute("aria-disabled") === "true") return;
368
572
  const dateStr = dayElement.getAttribute("data-date");
369
- if (!dateStr) return;
573
+ if (!dateStr || this.#isDateOutOfBounds(dateStr)) return;
370
574
  this.selectedValue = dateStr;
371
575
  const selected = parseISODateString(dateStr);
372
576
  if (selected) this.focusedDate = selected;
373
- this.render();
577
+ this.#movedByUser = true;
578
+ this.#selectionMoves += 1;
579
+ const move = this.#selectionMoves;
580
+ this.#published = dateStr;
581
+ this.#repaintKeepingFocus();
582
+ if (move !== this.#selectionMoves) return;
374
583
  this.dispatch("select", { detail: { date: dateStr } });
375
584
  }
376
585
  #focusAndNavigateToDate(date) {
377
586
  this.focusedDate = date;
378
587
  const targetMonthStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
379
- const isMonthTransition = targetMonthStr !== this.monthValue;
380
- if (isMonthTransition) {
588
+ this.#movedByUser = true;
589
+ if (targetMonthStr !== this.#monthRequest) {
381
590
  this.monthValue = targetMonthStr;
382
- } else {
383
- this.render();
384
- }
385
- const focusTarget = () => {
386
- const dateStr = toISODateString(date);
387
- const targetEl = this.dayTargets.find((el) => el.getAttribute("data-date") === dateStr);
388
- targetEl?.focus();
389
- };
390
- if (isMonthTransition) {
391
- this.#focusTimer.set(focusTarget, 0);
392
- } else {
393
- focusTarget();
591
+ return;
394
592
  }
593
+ this.#render();
594
+ const dateStr = toISODateString(date);
595
+ this.dayTargets.find((el) => el.getAttribute("data-date") === dateStr)?.focus();
395
596
  }
396
597
  #isDateOutOfBounds(dateStr) {
397
598
  if (this.minValue && dateStr < this.minValue) return true;
398
599
  if (this.maxValue && dateStr > this.maxValue) return true;
399
600
  return false;
400
601
  }
602
+ /** The day `selected` requests: the Value when it names a real day, else `""`. */
603
+ get #requestedDay() {
604
+ return parseISODateString(this.selectedValue) ? this.selectedValue : "";
605
+ }
606
+ /** The selection published for `requested`: the day while `min` / `max` allow it, else `""`. */
607
+ #publishable(requested) {
608
+ return requested && !this.#isDateOutOfBounds(requested) ? requested : "";
609
+ }
610
+ /** The selection the grid publishes now. */
611
+ get #selection() {
612
+ return this.#publishable(this.#requestedDay);
613
+ }
401
614
  #shiftMonth(delta) {
402
- const monthInfo = parseISOMonthString(this.monthValue);
615
+ const monthInfo = parseISOMonthString(this.#monthRequest);
403
616
  if (!monthInfo) return;
617
+ this.#movedByUser = true;
404
618
  const nextMonthDate = new Date(monthInfo.year, monthInfo.month - 1 + delta, 1);
405
619
  this.monthValue = `${nextMonthDate.getFullYear()}-${String(nextMonthDate.getMonth() + 1).padStart(2, "0")}`;
406
620
  }
@@ -439,15 +653,18 @@ var CalendarController = class extends Controller {
439
653
  target.setDate(start.getDate() + 6);
440
654
  return target;
441
655
  }
656
+ /**
657
+ * Opens the tab stop on the published selection, else on today when the shown
658
+ * month holds it, else on the 1st of the shown month. A selection the grid
659
+ * does not show falls back to the 1st in the paint.
660
+ */
442
661
  #initializeFocusedDate() {
443
- if (this.selectedValue) {
444
- const selected = parseISODateString(this.selectedValue);
445
- if (selected) {
446
- this.focusedDate = selected;
447
- return;
448
- }
662
+ const selection = parseISODateString(this.#selection);
663
+ if (selection) {
664
+ this.focusedDate = selection;
665
+ return;
449
666
  }
450
- const monthInfo = parseISOMonthString(this.monthValue);
667
+ const monthInfo = parseISOMonthString(this.#monthRequest);
451
668
  if (monthInfo) {
452
669
  const today = /* @__PURE__ */ new Date();
453
670
  if (today.getFullYear() === monthInfo.year && today.getMonth() === monthInfo.month - 1) {