stimeo-ui 0.5.0 → 0.6.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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -0
  3. data/dist/controllers/aspect_ratio_controller.js +19 -11
  4. data/dist/controllers/avatar_controller.js +195 -40
  5. data/dist/controllers/carousel_controller.js +85 -9
  6. data/dist/controllers/checkbox_controller.js +136 -25
  7. data/dist/controllers/color_picker_controller.js +35 -9
  8. data/dist/controllers/date_range_picker_controller.js +157 -30
  9. data/dist/controllers/file_dropzone_controller.js +26 -3
  10. data/dist/controllers/idle_controller.js +27 -5
  11. data/dist/controllers/menubar_controller.js +5 -3
  12. data/dist/controllers/multi_select_controller.js +460 -151
  13. data/dist/controllers/number_input_controller.js +275 -51
  14. data/dist/controllers/overflow_menu_controller.js +4 -0
  15. data/dist/controllers/pagination_controller.js +33 -0
  16. data/dist/controllers/password_strength_controller.js +20 -2
  17. data/dist/controllers/persist_controller.js +24 -5
  18. data/dist/controllers/radio_group_controller.js +540 -56
  19. data/dist/controllers/rating_controller.js +272 -89
  20. data/dist/controllers/resizable_controller.js +33 -0
  21. data/dist/controllers/roving_controller.js +60 -5
  22. data/dist/controllers/scroll_area_controller.js +154 -22
  23. data/dist/controllers/scroll_visibility_controller.js +33 -0
  24. data/dist/controllers/tags_input_controller.js +356 -120
  25. data/dist/controllers/time_picker_controller.js +296 -107
  26. data/dist/controllers/toggle_group_controller.js +378 -55
  27. data/dist/controllers/toolbar_controller.js +5 -3
  28. data/dist/controllers/tree_view_controller.js +5 -3
  29. data/dist/index.js +2637 -781
  30. data/lib/stimeo/ui/version.rb +1 -1
  31. metadata +2 -2
@@ -1,17 +1,89 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
+ // src/controllers/checkbox_controller.ts
4
+
5
+ // src/utils/microtask_coalescer.ts
6
+ var MicrotaskCoalescer = class {
7
+ #run;
8
+ #queued = false;
9
+ #active = false;
10
+ #generation = 0;
11
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
12
+ constructor(run) {
13
+ this.#run = run;
14
+ }
15
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
16
+ activate() {
17
+ this.#active = true;
18
+ }
19
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
20
+ cancel() {
21
+ this.#active = false;
22
+ this.#queued = false;
23
+ this.#generation += 1;
24
+ }
25
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
26
+ schedule() {
27
+ if (!this.#active || this.#queued) return;
28
+ this.#queued = true;
29
+ const generation = this.#generation;
30
+ queueMicrotask(() => {
31
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
32
+ this.#queued = false;
33
+ this.#run();
34
+ });
35
+ }
36
+ };
37
+
3
38
  // src/controllers/checkbox_controller.ts
4
39
  var CheckboxController = class extends Controller {
5
40
  static targets = ["parent", "child"];
6
41
  static actions = ["onChildChange", "onParentChange"];
7
- static events = ["change"];
8
- /** Reflects the initial aggregate (e.g. from server-rendered child states). */
9
- connect() {
10
- if (this.childTargets.length > 0) {
11
- this.#syncFromChildren(false);
12
- } else {
13
- this.element.setAttribute("data-state", this.#aggregate());
42
+ static events = ["change", "reconcile"];
43
+ /** Collapses every lifecycle signal from one DOM update into one derived pass. */
44
+ #reconcile = new MicrotaskCoalescer(() => this.#reconcileFromChildren());
45
+ /** Aggregate this root last settled on, so a derived repair is reported once. */
46
+ #committedState = null;
47
+ /** Watches authored checked-attribute changes on retained target elements. */
48
+ #checkedObserver = new MutationObserver((records) => {
49
+ if (records.some((record) => this.#isManagedCheckbox(record.target))) {
50
+ this.#reconcile.schedule();
14
51
  }
52
+ });
53
+ /** Reflects the initial aggregate and starts retained-DOM reconciliation. */
54
+ connect() {
55
+ this.#reconcile.activate();
56
+ this.#syncFromChildren();
57
+ this.#checkedObserver.observe(this.element, {
58
+ attributes: true,
59
+ attributeFilter: ["checked"],
60
+ subtree: true
61
+ });
62
+ this.element.addEventListener("turbo:morph-element", this.#onMorph);
63
+ document.addEventListener("reset", this.#onReset, true);
64
+ }
65
+ /** Releases the observer, global reset listener, and every pending reconciliation. */
66
+ disconnect() {
67
+ this.#reconcile.cancel();
68
+ this.#checkedObserver.disconnect();
69
+ this.element.removeEventListener("turbo:morph-element", this.#onMorph);
70
+ document.removeEventListener("reset", this.#onReset, true);
71
+ }
72
+ /** Reconciles the aggregate for a parent added or replaced at runtime. */
73
+ parentTargetConnected() {
74
+ this.#reconcile.schedule();
75
+ }
76
+ /** Reconciles the aggregate after a parent target leaves the group. */
77
+ parentTargetDisconnected() {
78
+ this.#reconcile.schedule();
79
+ }
80
+ /** Reconciles the aggregate for a child added at runtime. */
81
+ childTargetConnected() {
82
+ this.#reconcile.schedule();
83
+ }
84
+ /** Reconciles the aggregate after a child leaves the group. */
85
+ childTargetDisconnected() {
86
+ this.#reconcile.schedule();
15
87
  }
16
88
  /** Cascades the parent's state to every child. Bound via `data-action` (change). */
17
89
  onParentChange() {
@@ -20,35 +92,74 @@ var CheckboxController = class extends Controller {
20
92
  for (const child of this.childTargets) {
21
93
  child.checked = checked;
22
94
  }
23
- this.parentTarget.indeterminate = false;
24
- const state = checked ? "all" : "none";
25
- this.element.setAttribute("data-state", state);
26
- this.dispatch("change", { detail: { checked, indeterminate: false, state } });
95
+ this.#reflect(checked ? "all" : "none", true);
96
+ const detail = this.#settledDetail();
97
+ if (detail) this.dispatch("change", { detail });
27
98
  }
28
99
  /** Recomputes the parent from its children. Bound via `data-action` (change). */
29
100
  onChildChange() {
30
- this.#syncFromChildren(true);
101
+ this.#syncFromChildren();
102
+ const detail = this.#settledDetail();
103
+ if (detail) this.dispatch("change", { detail });
104
+ }
105
+ /**
106
+ * Announces an aggregate this pass derived rather than the user. `change` stays
107
+ * reserved for the two public actions, so automation never reads a repair as an edit.
108
+ */
109
+ #reconcileFromChildren() {
110
+ const previous = this.#committedState;
111
+ this.#syncFromChildren();
112
+ if (this.#committedState === previous) return;
113
+ const detail = this.#settledDetail();
114
+ if (detail) this.dispatch("reconcile", { detail });
31
115
  }
32
116
  /**
33
117
  * Derives the parent's `checked`/`indeterminate` and the root `data-state` from
34
- * the children, optionally dispatching `change`.
118
+ * the children. Writing state and reporting it are separate so the caller — not
119
+ * a flag threaded through the write — decides which event describes the cause.
35
120
  */
36
- #syncFromChildren(dispatch) {
37
- const state = this.#aggregate();
38
- if (this.hasParentTarget) {
121
+ #syncFromChildren() {
122
+ this.#reflect(this.#aggregate(), this.childTargets.length > 0);
123
+ }
124
+ /**
125
+ * Reflects one aggregate state.
126
+ *
127
+ * @param writeParent - whether the state is authoritative over the `parent`
128
+ * target's own `checked` / `indeterminate`.
129
+ */
130
+ #reflect(state, writeParent) {
131
+ if (writeParent && this.hasParentTarget) {
39
132
  this.parentTarget.checked = state === "all";
40
133
  this.parentTarget.indeterminate = state === "partial";
41
134
  }
42
135
  this.element.setAttribute("data-state", state);
43
- if (dispatch) {
44
- this.dispatch("change", {
45
- detail: {
46
- checked: this.hasParentTarget ? this.parentTarget.checked : state === "all",
47
- indeterminate: this.hasParentTarget ? this.parentTarget.indeterminate : false,
48
- state
49
- }
50
- });
51
- }
136
+ this.#committedState = state;
137
+ }
138
+ /** The settled aggregate as event detail, or `null` before anything has settled. */
139
+ #settledDetail() {
140
+ const state = this.#committedState;
141
+ if (state === null) return null;
142
+ return { checked: state === "all", indeterminate: state === "partial", state };
143
+ }
144
+ /** Reconciles retained targets after Turbo has finished morphing their live state. */
145
+ #onMorph = () => {
146
+ this.#reconcile.schedule();
147
+ };
148
+ /** Reconciles after a non-cancelled reset restores any managed checkbox. */
149
+ #onReset = (event) => {
150
+ const form = event.target;
151
+ if (!(form instanceof HTMLFormElement) || !this.#hasCheckboxOwnedBy(form)) return;
152
+ queueMicrotask(() => {
153
+ if (!event.defaultPrevented) this.#reconcile.schedule();
154
+ });
155
+ };
156
+ /** Whether a form owns at least one current parent or child target. */
157
+ #hasCheckboxOwnedBy(form) {
158
+ return [...this.parentTargets, ...this.childTargets].some((checkbox) => checkbox.form === form);
159
+ }
160
+ /** Whether an observed attribute mutation belongs to this controller's target set. */
161
+ #isManagedCheckbox(node) {
162
+ return this.parentTargets.some((checkbox) => checkbox === node) || this.childTargets.some((checkbox) => checkbox === node);
52
163
  }
53
164
  /**
54
165
  * Computes the aggregate state. With children it counts them; with none it
@@ -74,7 +74,7 @@ var ColorPickerController = class extends Controller {
74
74
  logicalTrack: { type: Boolean, default: false }
75
75
  };
76
76
  static actions = ["onHexInput", "onKeydown", "onPointerDown"];
77
- static events = ["change"];
77
+ static events = ["change", "reconcile"];
78
78
  /** Whether the consumer declared a mirroring track and the direction mirrors it. */
79
79
  get #mirrored() {
80
80
  return this.logicalTrackValue && isRtl(this.element);
@@ -83,14 +83,14 @@ var ColorPickerController = class extends Controller {
83
83
  #color = { hue: 0, saturation: 0, lightness: 0, alpha: 100 };
84
84
  /** Aborts in-progress pointer-drag listeners on drag end / teardown. */
85
85
  #dragAbort = null;
86
- /** Seeds the model from the initial hex value and renders every surface. */
86
+ /** Color the last repaint settled on, so a configuration-driven move is reported once. */
87
+ #committedHex = null;
87
88
  /**
88
89
  * Collapses a morph that swaps render inputs into one repaint, and refuses the
89
90
  * pass Stimulus delivers before `connect()`.
90
91
  */
91
- #repaint = new MicrotaskCoalescer(() => {
92
- this.#render();
93
- });
92
+ #repaint = new MicrotaskCoalescer(() => this.#reconcileColor());
93
+ /** Seeds the model from the initial hex value and renders every surface. */
94
94
  connect() {
95
95
  this.#repaint.activate();
96
96
  const parsed = hexToHsla(this.valueValue);
@@ -181,12 +181,24 @@ var ColorPickerController = class extends Controller {
181
181
  return;
182
182
  }
183
183
  this.#color = this.alphaValue ? parsed : { ...parsed, alpha: 100 };
184
- this.#render();
184
+ this.#commitColor();
185
185
  }
186
186
  /** Clamps and snaps one channel to an integer, then re-renders + emits change. */
187
187
  #setChannel(channel, raw, min, max) {
188
188
  this.#color[channel] = Math.round(Math.min(max, Math.max(min, raw)));
189
+ this.#commitColor();
190
+ }
191
+ /**
192
+ * Renders the model and reports a color the user actually moved. A key pressed
193
+ * at a bound, a pointer that lands on the step already showing, and a re-confirmed
194
+ * hex all leave the committed color where it was, so no `change` describes them.
195
+ */
196
+ #commitColor() {
197
+ const previous = this.#committedHex;
189
198
  this.#render();
199
+ if (this.#committedHex !== previous) {
200
+ this.dispatch("change", { detail: this.#settledDetail() });
201
+ }
190
202
  }
191
203
  /**
192
204
  * Reflects the model onto sliders, the hex input, preview, and form field.
@@ -202,14 +214,28 @@ var ColorPickerController = class extends Controller {
202
214
  slider.setAttribute("aria-valuetext", valueText(channel, value));
203
215
  }
204
216
  const hex = this.#hexString();
217
+ this.#committedHex = hex;
205
218
  if (this.hasHexTarget) this.hexTarget.value = hex;
206
219
  for (const field of this.fieldTargets) field.value = hex;
207
220
  for (const preview of this.previewTargets) preview.style.setProperty(COLOR_PROPERTY, hex);
208
221
  this.element.style.setProperty(COLOR_PROPERTY, hex);
222
+ }
223
+ /**
224
+ * Repaints after `alpha` changed at runtime and reports a color this controller
225
+ * settled on. Disabling alpha drops it from the model, so the committed color can
226
+ * move without a user edit; `change` stays reserved for the picker's own actions.
227
+ */
228
+ #reconcileColor() {
229
+ const previous = this.#committedHex;
230
+ this.#render();
231
+ if (previous !== null && this.#committedHex !== previous) {
232
+ this.dispatch("reconcile", { detail: this.#settledDetail() });
233
+ }
234
+ }
235
+ /** The settled color as event detail, shared by both report paths. */
236
+ #settledDetail() {
209
237
  const rgb = hslToRgb(this.#color.hue, this.#color.saturation, this.#color.lightness);
210
- this.dispatch("change", {
211
- detail: { value: hex, rgba: { ...rgb, a: this.#color.alpha / 100 } }
212
- });
238
+ return { value: this.#hexString(), rgba: { ...rgb, a: this.#color.alpha / 100 } };
213
239
  }
214
240
  /** The current color as `#RRGGBB`, or `#RRGGBBAA` when alpha is enabled. */
215
241
  #hexString() {
@@ -18,6 +18,35 @@ 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/before_cache_reset.ts
22
+ var BeforeCacheReset = class _BeforeCacheReset {
23
+ /** Every subscribed instance, iterated by the one shared document listener. */
24
+ static #subscribers = /* @__PURE__ */ new Set();
25
+ /** The shared listener; installed while at least one instance is subscribed. */
26
+ static #onBeforeCache = () => {
27
+ for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
28
+ };
29
+ #rewind;
30
+ /** @param rewind - the pass that returns this controller's state to its initial form. */
31
+ constructor(rewind) {
32
+ this.#rewind = rewind;
33
+ }
34
+ /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
35
+ activate() {
36
+ const first = _BeforeCacheReset.#subscribers.size === 0;
37
+ _BeforeCacheReset.#subscribers.add(this);
38
+ if (first) {
39
+ document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
40
+ }
41
+ }
42
+ /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
43
+ deactivate() {
44
+ _BeforeCacheReset.#subscribers.delete(this);
45
+ if (_BeforeCacheReset.#subscribers.size > 0) return;
46
+ document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
47
+ }
48
+ };
49
+
21
50
  // src/utils/dates.ts
22
51
  function toISODateString(date) {
23
52
  const year = date.getFullYear();
@@ -138,16 +167,34 @@ var SafeTimeout = class extends TimerRegistry {
138
167
  }
139
168
  };
140
169
 
170
+ // src/utils/string_list.ts
171
+ function parseStringList(raw, fallback = []) {
172
+ const text = raw.trim();
173
+ if (text.length === 0) return [...fallback];
174
+ let parsed;
175
+ try {
176
+ parsed = JSON.parse(text);
177
+ } catch {
178
+ return [...fallback];
179
+ }
180
+ if (!Array.isArray(parsed)) return [...fallback];
181
+ return parsed.filter((entry) => typeof entry === "string");
182
+ }
183
+
141
184
  // src/controllers/date_range_picker_controller.ts
142
185
  var GRID_SIZE = 42;
143
186
  var DateRangePickerController = class extends Controller {
144
187
  static targets = ["grid", "monthLabel", "cell", "status", "startField", "endField"];
145
188
  static values = {
146
189
  min: { type: String, default: "" },
147
- max: { type: String, default: "" }
190
+ max: { type: String, default: "" },
191
+ // A JSON list read through `parseStringList` rather than Stimulus's `Array`
192
+ // type: that reader throws out of the value observer before any callback
193
+ // runs, so one malformed attribute would stop the picker from connecting.
194
+ disabledDates: { type: String, default: "" }
148
195
  };
149
196
  static actions = ["applyPreset", "next", "onKeydown", "prev", "previewTo", "selectDate"];
150
- static events = ["change"];
197
+ static events = ["change", "monthchange"];
151
198
  /** The month currently rendered, as `YYYY-MM`. */
152
199
  #viewMonth = "";
153
200
  /** The confirmed range endpoints (ISO), or "" when unset. */
@@ -161,7 +208,12 @@ var DateRangePickerController = class extends Controller {
161
208
  #focusedDate = /* @__PURE__ */ new Date();
162
209
  /** Deferred focus after an async month transition (cancelled on teardown). */
163
210
  #focusTimer = new SafeTimeout();
164
- /** Seeds the range from any pre-filled hidden fields and renders the grid. */
211
+ /** The declared unavailable dates, indexed for the per-cell paint lookup. */
212
+ #disabledDates = /* @__PURE__ */ new Set();
213
+ /** Rewinds an unfinished selection before Turbo snapshots the page. */
214
+ #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
215
+ /** Last painted month, or `null` until the initial paint has settled. */
216
+ #announcedMonth = null;
165
217
  /**
166
218
  * Collapses a morph that swaps render inputs into one repaint, and refuses the
167
219
  * pass Stimulus delivers before `connect()`.
@@ -169,10 +221,17 @@ var DateRangePickerController = class extends Controller {
169
221
  #repaint = new MicrotaskCoalescer(() => {
170
222
  this.#render();
171
223
  });
224
+ /** Seeds and normalizes the range from optional hidden fields, then paints the grid. */
172
225
  connect() {
173
226
  this.#repaint.activate();
174
- this.#startDate = this.hasStartFieldTarget ? normalizeISO(this.startFieldTarget.value) : "";
175
- this.#endDate = this.hasEndFieldTarget ? normalizeISO(this.endFieldTarget.value) : "";
227
+ this.#beforeCache.activate();
228
+ const authoredStart = this.hasStartFieldTarget ? normalizeISO(this.startFieldTarget.value) : "";
229
+ const authoredEnd = this.hasEndFieldTarget ? normalizeISO(this.endFieldTarget.value) : "";
230
+ [this.#startDate, this.#endDate] = orderRange(authoredStart, authoredEnd);
231
+ this.#pendingStart = "";
232
+ this.#previewDate = "";
233
+ this.#announcedMonth = null;
234
+ this.#commitFields();
176
235
  const anchor = parseISODateString(this.#startDate) ?? this.#clampToBounds(/* @__PURE__ */ new Date()) ?? /* @__PURE__ */ new Date();
177
236
  this.#focusedDate = anchor;
178
237
  this.#viewMonth = toISOMonthString(anchor);
@@ -182,6 +241,7 @@ var DateRangePickerController = class extends Controller {
182
241
  /** Cancels any pending deferred focus so it never fires on a detached element. */
183
242
  disconnect() {
184
243
  this.#repaint.cancel();
244
+ this.#beforeCache.deactivate();
185
245
  this.#focusTimer.clearAll();
186
246
  }
187
247
  /** Repaints when application code (or a Turbo morph) changes `min` at runtime. */
@@ -192,6 +252,11 @@ var DateRangePickerController = class extends Controller {
192
252
  maxValueChanged() {
193
253
  this.#repaint.schedule();
194
254
  }
255
+ /** Re-indexes the unavailable dates and repaints when the declared set changes. */
256
+ disabledDatesValueChanged() {
257
+ this.#disabledDates = new Set(parseStringList(this.disabledDatesValue));
258
+ this.#repaint.schedule();
259
+ }
195
260
  /** Navigates to the previous month. */
196
261
  prev(event) {
197
262
  event?.preventDefault();
@@ -207,7 +272,7 @@ var DateRangePickerController = class extends Controller {
207
272
  const cell = this.#cellFrom(event.target);
208
273
  if (!cell) return;
209
274
  const date = cell.getAttribute("data-date");
210
- if (!date || cell.getAttribute("aria-disabled") === "true") return;
275
+ if (!date || !this.#isSelectable(date)) return;
211
276
  this.#choose(date);
212
277
  }
213
278
  /** Previews the range up to a hovered/focused cell while selecting. */
@@ -216,28 +281,27 @@ var DateRangePickerController = class extends Controller {
216
281
  if (!cell) return;
217
282
  const date = cell.getAttribute("data-date");
218
283
  if (!date) return;
284
+ let shouldRender = false;
219
285
  if (event.type.startsWith("focus")) {
220
286
  const parsed = parseISODateString(date);
221
287
  if (parsed) this.#focusedDate = parsed;
222
- if (!this.#pendingStart && cell.getAttribute("tabindex") !== "0") {
223
- this.#render();
224
- }
288
+ shouldRender = cell.getAttribute("tabindex") !== "0";
225
289
  }
226
- if (this.#pendingStart) {
290
+ if (this.#pendingStart && this.#isSelectable(date) && this.#previewDate !== date) {
227
291
  this.#previewDate = date;
228
- this.#render();
292
+ shouldRender = true;
229
293
  }
294
+ if (shouldRender) this.#render();
230
295
  }
231
296
  /** Applies a named preset (`today` / `last7` / `last30` / `thisMonth`). */
232
297
  applyPreset(event) {
233
298
  const button = event.target?.closest("[data-range]");
234
- const name = button?.getAttribute("data-range");
235
- if (!name) return;
236
- const range = computePreset(name);
299
+ const range = computePreset(button?.getAttribute("data-range") ?? "");
237
300
  if (!range) return;
238
- const start = this.#clampISO(range.start);
239
- const end = this.#clampISO(range.end);
240
- if (!start || !end) return;
301
+ const intersection = this.#intersectRange(range);
302
+ if (!intersection) return;
303
+ const { start, end } = intersection;
304
+ if (!this.#isSelectable(start) || !this.#isSelectable(end)) return;
241
305
  this.#startDate = start;
242
306
  this.#endDate = end;
243
307
  this.#pendingStart = "";
@@ -255,12 +319,12 @@ var DateRangePickerController = class extends Controller {
255
319
  if (isReservedArrowChord(event)) return;
256
320
  const cell = this.#cellFrom(event.target);
257
321
  if (!cell) return;
258
- const dateStr = cell.getAttribute("data-date");
259
- const date = dateStr ? parseISODateString(dateStr) : null;
322
+ const dateStr = cell.getAttribute("data-date") ?? "";
323
+ const date = parseISODateString(dateStr);
260
324
  if (!date) return;
261
325
  if (event.key === "Enter" || event.key === " ") {
262
326
  event.preventDefault();
263
- if (dateStr && cell.getAttribute("aria-disabled") !== "true") this.#choose(dateStr);
327
+ if (this.#isSelectable(dateStr)) this.#choose(dateStr);
264
328
  return;
265
329
  }
266
330
  if (event.key === "Escape") {
@@ -293,10 +357,10 @@ var DateRangePickerController = class extends Controller {
293
357
  next = addDays(date, 6 - date.getDay());
294
358
  break;
295
359
  case "PageUp":
296
- next = shiftMonthClamped(date, -1);
360
+ next = event.shiftKey ? shiftYearClamped(date, -1) : shiftMonthClamped(date, -1);
297
361
  break;
298
362
  case "PageDown":
299
- next = shiftMonthClamped(date, 1);
363
+ next = event.shiftKey ? shiftYearClamped(date, 1) : shiftMonthClamped(date, 1);
300
364
  break;
301
365
  default:
302
366
  return;
@@ -327,12 +391,14 @@ var DateRangePickerController = class extends Controller {
327
391
  /** Moves roving focus to `date`, transitioning the month when needed. */
328
392
  #moveFocusTo(date) {
329
393
  this.#focusedDate = date;
330
- if (this.#pendingStart) this.#previewDate = toISODateString(date);
331
- this.#transitionTo(toISOMonthString(date), toISODateString(date));
394
+ const iso = toISODateString(date);
395
+ if (this.#pendingStart && this.#isSelectable(iso)) this.#previewDate = iso;
396
+ this.#transitionTo(toISOMonthString(date), iso);
332
397
  }
333
398
  /** Renders `month`, then focuses the cell for `dateStr` (deferred if async). */
334
399
  #transitionTo(month, dateStr) {
335
400
  const isTransition = month !== this.#viewMonth;
401
+ this.#focusTimer.clearAll();
336
402
  this.#viewMonth = month;
337
403
  this.#render();
338
404
  const focusCell = () => {
@@ -364,17 +430,19 @@ var DateRangePickerController = class extends Controller {
364
430
  const info = parseISOMonthString(this.#viewMonth);
365
431
  if (!info) return;
366
432
  const { year, month } = info;
433
+ if (this.#previewDate && !this.#isSelectable(this.#previewDate)) this.#previewDate = "";
367
434
  if (this.hasMonthLabelTarget) {
368
435
  const lang = document.documentElement.lang || "en";
369
- const formatter = new Intl.DateTimeFormat(lang, { month: "long", year: "numeric" });
436
+ const formatter = monthFormatter(lang);
370
437
  this.monthLabelTarget.textContent = formatter.format(new Date(year, month - 1, 1));
371
438
  }
372
439
  const [rangeStart, rangeEnd] = this.#visualRange();
373
440
  const days = gridDays(year, month);
374
441
  const focusedStr = toISODateString(this.#focusedDate);
375
442
  const todayStr = toISODateString(/* @__PURE__ */ new Date());
443
+ const cells = this.cellTargets;
376
444
  for (let i = 0; i < GRID_SIZE; i++) {
377
- const el = this.cellTargets[i];
445
+ const el = cells[i];
378
446
  const date = days[i];
379
447
  if (!el || !date) continue;
380
448
  const iso = toISODateString(date);
@@ -383,8 +451,8 @@ var DateRangePickerController = class extends Controller {
383
451
  el.setAttribute("data-outside", String(date.getMonth() !== month - 1));
384
452
  el.setAttribute("data-today", String(iso === todayStr));
385
453
  el.setAttribute("tabindex", iso === focusedStr ? "0" : "-1");
386
- if (this.#outOfBounds(iso)) el.setAttribute("aria-disabled", "true");
387
- else el.removeAttribute("aria-disabled");
454
+ if (this.#isSelectable(iso)) el.removeAttribute("aria-disabled");
455
+ else el.setAttribute("aria-disabled", "true");
388
456
  const isStart = !!rangeStart && iso === rangeStart;
389
457
  const isEnd = !!rangeEnd && iso === rangeEnd && rangeEnd !== rangeStart;
390
458
  const inside = !!rangeStart && !!rangeEnd && iso > rangeStart && iso < rangeEnd;
@@ -398,6 +466,16 @@ var DateRangePickerController = class extends Controller {
398
466
  )
399
467
  );
400
468
  }
469
+ for (const extra of cells.slice(GRID_SIZE)) extra.setAttribute("tabindex", "-1");
470
+ if (!cells.some((cell) => cell.getAttribute("tabindex") === "0")) {
471
+ const fallback = cells.find((cell) => cell.getAttribute("data-outside") === "false") ?? cells[0];
472
+ fallback?.setAttribute("tabindex", "0");
473
+ }
474
+ const previous = this.#announcedMonth;
475
+ this.#announcedMonth = this.#viewMonth;
476
+ if (previous !== null && previous !== this.#viewMonth) {
477
+ this.dispatch("monthchange", { detail: { month: this.#viewMonth } });
478
+ }
401
479
  }
402
480
  /** The ordered [start, end] pair to paint: the preview while selecting, else confirmed. */
403
481
  #visualRange() {
@@ -418,15 +496,25 @@ var DateRangePickerController = class extends Controller {
418
496
  this.statusTarget.textContent = `${this.#startDate} \u2013 ${this.#endDate}`;
419
497
  }
420
498
  }
499
+ /**
500
+ * True when `iso` may be chosen as a range endpoint.
501
+ *
502
+ * The single availability question in the controller: the grid paint, the
503
+ * preview, and both commit paths ask it, so a cell's `aria-disabled` and what
504
+ * a click on that cell does are the same decision rather than two that have to
505
+ * be kept in step.
506
+ */
507
+ #isSelectable(iso) {
508
+ return !this.#outOfBounds(iso) && !this.#disabledDates.has(iso);
509
+ }
421
510
  /** True when `iso` falls outside the `[min, max]` bounds. */
422
511
  #outOfBounds(iso) {
423
512
  if (this.minValue && iso < this.minValue) return true;
424
513
  if (this.maxValue && iso > this.maxValue) return true;
425
514
  return false;
426
515
  }
427
- /** Clamps an ISO date string into `[min, max]`, or "" when unparseable. */
516
+ /** Clamps a generated ISO date string into `[min, max]`. */
428
517
  #clampISO(iso) {
429
- if (!iso) return "";
430
518
  if (this.minValue && iso < this.minValue) return this.minValue;
431
519
  if (this.maxValue && iso > this.maxValue) return this.maxValue;
432
520
  return iso;
@@ -436,6 +524,27 @@ var DateRangePickerController = class extends Controller {
436
524
  const clamped = this.#clampISO(toISODateString(date));
437
525
  return parseISODateString(clamped);
438
526
  }
527
+ /**
528
+ * Intersects a preset with `[min, max]`, rejecting a disjoint interval.
529
+ *
530
+ * Only the bounds narrow a preset here: `disabledDates` excludes single days,
531
+ * not sub-intervals, so it cannot shrink one to a still-contiguous range. A
532
+ * day it excludes is still refused as an endpoint — the caller checks that —
533
+ * but a preset that merely spans one keeps its span.
534
+ */
535
+ #intersectRange(range) {
536
+ const start = this.minValue && range.start < this.minValue ? this.minValue : range.start;
537
+ const end = this.maxValue && range.end > this.maxValue ? this.maxValue : range.end;
538
+ return start <= end ? { start, end } : null;
539
+ }
540
+ /** Removes provisional range state before Turbo freezes a cached snapshot. */
541
+ #rewindForCache() {
542
+ this.#focusTimer.clearAll();
543
+ if (!this.#pendingStart && !this.#previewDate) return;
544
+ this.#pendingStart = "";
545
+ this.#previewDate = "";
546
+ this.#render();
547
+ }
439
548
  /** Resolves the cell element from an event target, or null. */
440
549
  #cellFrom(target) {
441
550
  return target?.closest(
@@ -454,6 +563,12 @@ function shiftMonthClamped(date, delta) {
454
563
  target.setDate(Math.min(date.getDate(), lastDay));
455
564
  return target;
456
565
  }
566
+ function shiftYearClamped(date, delta) {
567
+ const target = new Date(date.getFullYear() + delta, date.getMonth(), 1);
568
+ const lastDay = new Date(target.getFullYear(), target.getMonth() + 1, 0).getDate();
569
+ target.setDate(Math.min(date.getDate(), lastDay));
570
+ return target;
571
+ }
457
572
  function gridDays(year, month) {
458
573
  const first = new Date(year, month - 1, 1);
459
574
  const start = new Date(first);
@@ -470,6 +585,18 @@ function normalizeISO(value) {
470
585
  const date = parseISODateString(value.trim());
471
586
  return date ? toISODateString(date) : "";
472
587
  }
588
+ function orderRange(start, end) {
589
+ return start && end && end < start ? [end, start] : [start, end];
590
+ }
591
+ function monthFormatter(locale) {
592
+ const options = { month: "long", year: "numeric" };
593
+ try {
594
+ return new Intl.DateTimeFormat(locale, options);
595
+ } catch (error) {
596
+ if (!(error instanceof RangeError)) throw error;
597
+ return new Intl.DateTimeFormat("en", options);
598
+ }
599
+ }
473
600
  function computePreset(name) {
474
601
  const today = /* @__PURE__ */ new Date();
475
602
  const todayStr = toISODateString(today);