stimeo-ui 0.16.0 → 0.18.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 (117) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +136 -0
  3. data/README.md +27 -11
  4. data/dist/cable/index.js +384 -449
  5. data/dist/controllers/accordion_controller.js +174 -55
  6. data/dist/controllers/alert_dialog_controller.js +903 -235
  7. data/dist/controllers/announcer_controller.js +238 -205
  8. data/dist/controllers/aspect_ratio_controller.js +0 -10
  9. data/dist/controllers/auto_submit_controller.js +414 -145
  10. data/dist/controllers/avatar_controller.js +185 -107
  11. data/dist/controllers/breadcrumb_controller.js +0 -130
  12. data/dist/controllers/bulk_select_controller.js +282 -58
  13. data/dist/controllers/calendar_controller.js +228 -260
  14. data/dist/controllers/carousel_controller.js +536 -436
  15. data/dist/controllers/character_counter_controller.js +493 -207
  16. data/dist/controllers/checkbox_controller.js +38 -74
  17. data/dist/controllers/clipboard_controller.js +261 -149
  18. data/dist/controllers/collapsible_controller.js +304 -120
  19. data/dist/controllers/color_picker_controller.js +178 -146
  20. data/dist/controllers/combobox_controller.js +522 -156
  21. data/dist/controllers/command_palette_controller.js +1156 -395
  22. data/dist/controllers/conditional_fields_controller.js +300 -171
  23. data/dist/controllers/confirm_controller.js +980 -262
  24. data/dist/controllers/context_menu_controller.js +346 -153
  25. data/dist/controllers/count_up_controller.js +73 -21
  26. data/dist/controllers/countdown_controller.js +281 -158
  27. data/dist/controllers/currency_input_controller.js +575 -225
  28. data/dist/controllers/data_grid_controller.js +248 -214
  29. data/dist/controllers/date_range_picker_controller.js +338 -235
  30. data/dist/controllers/dialog_controller.js +899 -237
  31. data/dist/controllers/direct_upload_controller.js +97 -167
  32. data/dist/controllers/dirty_form_controller.js +72 -69
  33. data/dist/controllers/dismissible_controller.js +196 -24
  34. data/dist/controllers/drawer_controller.js +974 -355
  35. data/dist/controllers/dropdown_controller.js +339 -96
  36. data/dist/controllers/editable_controller.js +315 -88
  37. data/dist/controllers/empty_state_controller.js +79 -58
  38. data/dist/controllers/file_dropzone_controller.js +322 -211
  39. data/dist/controllers/filter_controller.js +77 -42
  40. data/dist/controllers/flash_controller.js +238 -347
  41. data/dist/controllers/focus_controller.js +679 -240
  42. data/dist/controllers/form_field_controller.js +156 -132
  43. data/dist/controllers/form_validation_controller.js +31 -97
  44. data/dist/controllers/frame_loading_controller.js +275 -217
  45. data/dist/controllers/highlight_controller.js +107 -75
  46. data/dist/controllers/hover_card_controller.js +327 -130
  47. data/dist/controllers/idle_controller.js +271 -101
  48. data/dist/controllers/input_mask_controller.js +483 -97
  49. data/dist/controllers/intersection_controller.js +150 -106
  50. data/dist/controllers/lazy_frame_controller.js +24 -68
  51. data/dist/controllers/listbox_controller.js +523 -240
  52. data/dist/controllers/local_time_controller.js +102 -77
  53. data/dist/controllers/masonry_controller.js +80 -96
  54. data/dist/controllers/menu_controller.js +302 -188
  55. data/dist/controllers/menubar_controller.js +232 -345
  56. data/dist/controllers/meter_controller.js +417 -87
  57. data/dist/controllers/multi_select_controller.js +785 -393
  58. data/dist/controllers/navigation_menu_controller.js +190 -250
  59. data/dist/controllers/nested_form_controller.js +396 -192
  60. data/dist/controllers/network_status_controller.js +268 -62
  61. data/dist/controllers/number_input_controller.js +610 -303
  62. data/dist/controllers/optimistic_controller.js +45 -87
  63. data/dist/controllers/otp_controller.js +658 -255
  64. data/dist/controllers/overflow_indicator_controller.js +122 -113
  65. data/dist/controllers/overflow_menu_controller.js +470 -295
  66. data/dist/controllers/pagination_controller.js +332 -123
  67. data/dist/controllers/password_reveal_controller.js +392 -121
  68. data/dist/controllers/password_strength_controller.js +111 -158
  69. data/dist/controllers/persist_controller.js +165 -111
  70. data/dist/controllers/pointer_drag_controller.js +434 -188
  71. data/dist/controllers/popover_controller.js +269 -113
  72. data/dist/controllers/portal_controller.js +215 -76
  73. data/dist/controllers/preview_guard_controller.js +243 -116
  74. data/dist/controllers/progress_controller.js +391 -78
  75. data/dist/controllers/radio_group_controller.js +278 -211
  76. data/dist/controllers/range_slider_controller.js +220 -113
  77. data/dist/controllers/rating_controller.js +458 -211
  78. data/dist/controllers/read_more_controller.js +138 -63
  79. data/dist/controllers/reading_progress_controller.js +166 -137
  80. data/dist/controllers/relative_time_controller.js +211 -112
  81. data/dist/controllers/{reset_before_cache_controller.js → reset_on_restore_controller.js} +103 -38
  82. data/dist/controllers/resizable_controller.js +299 -150
  83. data/dist/controllers/roving_controller.js +49 -88
  84. data/dist/controllers/scroll_area_controller.js +490 -246
  85. data/dist/controllers/scroll_restore_controller.js +14 -55
  86. data/dist/controllers/scroll_visibility_controller.js +432 -149
  87. data/dist/controllers/scrollspy_controller.js +193 -248
  88. data/dist/controllers/separator_controller.js +285 -142
  89. data/dist/controllers/sidebar_controller.js +1069 -404
  90. data/dist/controllers/skeleton_controller.js +259 -101
  91. data/dist/controllers/slider_controller.js +187 -86
  92. data/dist/controllers/smart_sticky_header_controller.js +173 -66
  93. data/dist/controllers/sortable_controller.js +26 -125
  94. data/dist/controllers/spinner_controller.js +288 -173
  95. data/dist/controllers/step_indicator_controller.js +105 -46
  96. data/dist/controllers/stepper_controller.js +120 -73
  97. data/dist/controllers/stick_to_bottom_controller.js +275 -140
  98. data/dist/controllers/sticky_observer_controller.js +220 -49
  99. data/dist/controllers/submit_once_controller.js +376 -227
  100. data/dist/controllers/switch_controller.js +201 -84
  101. data/dist/controllers/tabs_controller.js +41 -30
  102. data/dist/controllers/tags_input_controller.js +293 -173
  103. data/dist/controllers/textarea_autosize_controller.js +108 -74
  104. data/dist/controllers/theme_controller.js +140 -116
  105. data/dist/controllers/time_picker_controller.js +331 -127
  106. data/dist/controllers/toast_controller.js +244 -324
  107. data/dist/controllers/toggle_group_controller.js +252 -191
  108. data/dist/controllers/toolbar_controller.js +219 -120
  109. data/dist/controllers/tooltip_controller.js +158 -124
  110. data/dist/controllers/transition_controller.js +123 -119
  111. data/dist/controllers/tree_view_controller.js +283 -369
  112. data/dist/index.js +8573 -10410
  113. data/dist/positioning/index.js +154 -47
  114. data/lib/generators/stimeo/install/install_generator.rb +7 -2
  115. data/lib/generators/stimeo/install/templates/stimeo.js +19 -6
  116. data/lib/stimeo/ui/version.rb +1 -1
  117. metadata +3 -3
@@ -1,24 +1,48 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
- // src/controllers/calendar_controller.ts
3
+ var FOCUS_EVENTS = /* @__PURE__ */ new Set(["blur", "focus", "focusin", "focusout"]);
4
+ var POINTER_EVENTS = /* @__PURE__ */ new Set(["mouseenter", "mouseleave", "pointerenter", "pointerleave"]);
5
+ function stateReasonFor(event) {
6
+ if (!event) return "api";
7
+ if (FOCUS_EVENTS.has(event.type)) return "focus";
8
+ if (POINTER_EVENTS.has(event.type)) return "pointer";
9
+ return "user";
10
+ }
11
+
12
+ function actionSource(source) {
13
+ if (source instanceof HTMLElement) {
14
+ return { event: null, host: source, origin: source, reason: "api" };
15
+ }
16
+ const event = source ?? null;
17
+ return {
18
+ event,
19
+ host: event?.currentTarget instanceof Element ? event.currentTarget : null,
20
+ origin: event?.target instanceof Element ? event.target : null,
21
+ reason: stateReasonFor(event)
22
+ };
23
+ }
4
24
 
5
- // src/utils/logical_scroll.ts
6
25
  function isRtl(element) {
7
26
  return window.getComputedStyle(element).direction === "rtl";
8
27
  }
9
28
 
10
- // src/utils/arrow_step.ts
11
29
  function logicalArrowKey(key, element) {
12
30
  if (key !== "ArrowRight" && key !== "ArrowLeft") return key;
13
31
  if (!isRtl(element)) return key;
14
32
  return key === "ArrowRight" ? "ArrowLeft" : "ArrowRight";
15
33
  }
34
+ function gridDirectionElement(grid, root) {
35
+ const table = grid.closest("table");
36
+ return table !== null && root.contains(table) ? table : grid;
37
+ }
16
38
  function isReservedArrowChord(event, allow = []) {
17
39
  if (!event.key.startsWith("Arrow")) return false;
18
40
  return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
19
41
  }
42
+ function hasModifierChord(event) {
43
+ return event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
44
+ }
20
45
 
21
- // src/utils/intl_format.ts
22
46
  var formatters = /* @__PURE__ */ new WeakMap();
23
47
  function intlFormatter(factory, locale, options, fallbackLocale) {
24
48
  const cache = cacheFor(factory);
@@ -47,7 +71,6 @@ function construct(factory, locale, options, fallbackLocale) {
47
71
  }
48
72
  }
49
73
 
50
- // src/utils/dates.ts
51
74
  function toISODateString(date) {
52
75
  const year = date.getFullYear();
53
76
  const month = String(date.getMonth() + 1).padStart(2, "0");
@@ -80,12 +103,35 @@ function parseISOMonthString(monthStr) {
80
103
  function toISOMonthString(date) {
81
104
  return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
82
105
  }
106
+ function monthGridDays(year, month, weekStart) {
107
+ const first = new Date(year, month - 1, 1);
108
+ const offset = (first.getDay() - weekStart + 7) % 7;
109
+ const current = new Date(first);
110
+ current.setDate(first.getDate() - offset);
111
+ const days = [];
112
+ for (let i = 0; i < 42; i++) {
113
+ days.push(new Date(current));
114
+ current.setDate(current.getDate() + 1);
115
+ }
116
+ return days;
117
+ }
118
+ function dateInMonthClamped(date, year, month) {
119
+ const target = new Date(year, month - 1, 1);
120
+ const lastDay = new Date(target.getFullYear(), target.getMonth() + 1, 0).getDate();
121
+ target.setDate(Math.min(date.getDate(), lastDay));
122
+ return target;
123
+ }
124
+ function shiftMonthClamped(date, delta) {
125
+ return dateInMonthClamped(date, date.getFullYear(), date.getMonth() + 1 + delta);
126
+ }
127
+ function shiftYearClamped(date, delta) {
128
+ return dateInMonthClamped(date, date.getFullYear() + delta, date.getMonth() + 1);
129
+ }
83
130
  var MONTH_LABEL_OPTIONS = { month: "long", year: "numeric" };
84
131
  function monthLabelFormatter(locale) {
85
132
  return intlFormatter(Intl.DateTimeFormat, locale, MONTH_LABEL_OPTIONS, "en");
86
133
  }
87
134
 
88
- // src/utils/field_mirror.ts
89
135
  function writeField(field, value) {
90
136
  if (field.value === value) return false;
91
137
  field.value = value;
@@ -95,32 +141,29 @@ function commitField(target) {
95
141
  target.dispatchEvent(new Event("change", { bubbles: true }));
96
142
  }
97
143
 
98
- // src/utils/locale.ts
99
144
  function resolveLocale(element, declared = "") {
100
145
  return declared || element.closest("[lang]")?.getAttribute("lang") || void 0;
101
146
  }
102
147
 
103
- // src/utils/microtask_coalescer.ts
104
148
  var MicrotaskCoalescer = class {
105
149
  #run;
106
150
  #queued = false;
107
151
  #active = false;
108
152
  #generation = 0;
109
- /** @param run - the single reconciliation pass, invoked at most once per batch. */
110
153
  constructor(run) {
111
154
  this.#run = run;
112
155
  }
113
- /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
114
156
  activate() {
115
157
  this.#active = true;
116
158
  }
117
- /** Closes the window and drops any pending pass; call from `disconnect()`. */
118
159
  cancel() {
119
160
  this.#active = false;
120
161
  this.#queued = false;
121
162
  this.#generation += 1;
122
163
  }
123
- /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
164
+ get pending() {
165
+ return this.#queued;
166
+ }
124
167
  schedule() {
125
168
  if (!this.#active || this.#queued) return;
126
169
  this.#queued = true;
@@ -133,15 +176,95 @@ var MicrotaskCoalescer = class {
133
176
  }
134
177
  };
135
178
 
136
- // src/utils/target_selector.ts
179
+ var ListenerSet = class {
180
+ #abort = new AbortController();
181
+ add(target, type, handler, options) {
182
+ target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
183
+ }
184
+ dispose() {
185
+ this.#abort.abort();
186
+ this.#abort = new AbortController();
187
+ }
188
+ };
189
+
190
+ var MorphRenderWatcher = class {
191
+ #listeners = new ListenerSet();
192
+ #pass;
193
+ #element = null;
194
+ #onMorph = () => this.#pass.schedule();
195
+ constructor(run) {
196
+ this.#pass = new MicrotaskCoalescer(run);
197
+ }
198
+ observe(element) {
199
+ if (this.#element === element) return;
200
+ this.disconnect();
201
+ this.#element = element;
202
+ this.#pass.activate();
203
+ this.#listeners.add(element, "turbo:morph-element", this.#onMorph);
204
+ }
205
+ schedule() {
206
+ this.#pass.schedule();
207
+ }
208
+ get pending() {
209
+ return this.#pass.pending;
210
+ }
211
+ disconnect() {
212
+ this.#listeners.dispose();
213
+ this.#pass.cancel();
214
+ this.#element = null;
215
+ }
216
+ };
217
+
218
+ function matchesNumberBounds(value, bounds) {
219
+ if (!Number.isFinite(value)) {
220
+ const direction = value === Infinity ? "positive" : value === -Infinity ? "negative" : null;
221
+ if (direction === null) return false;
222
+ if (bounds.allowInfinity !== "both" && bounds.allowInfinity !== direction) return false;
223
+ }
224
+ if (bounds.min !== void 0 && value < bounds.min) return false;
225
+ if (bounds.max !== void 0 && value > bounds.max) return false;
226
+ if (bounds.exclusiveMin !== void 0 && value <= bounds.exclusiveMin) return false;
227
+ if (bounds.integer && !Number.isInteger(value)) return false;
228
+ if (bounds.allowedValues !== void 0 && !bounds.allowedValues.includes(value)) return false;
229
+ return true;
230
+ }
231
+
232
+ function readNumber(raw, fallback, bounds) {
233
+ return matchesNumberBounds(raw, bounds) ? raw : fallback;
234
+ }
235
+
236
+ var NumberValueReader = class {
237
+ #lastRejected = /* @__PURE__ */ new Map();
238
+ read(owner, name, raw, fallback, bounds) {
239
+ const resolved = readNumber(raw, fallback, bounds);
240
+ if (matchesNumberBounds(raw, bounds)) {
241
+ this.#lastRejected.delete(name);
242
+ return resolved;
243
+ }
244
+ const attribute = `data-${owner.identifier}-${name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}-value`;
245
+ const literal = owner.element.getAttribute(attribute);
246
+ if (literal === null) {
247
+ this.#lastRejected.delete(name);
248
+ return resolved;
249
+ }
250
+ if (this.#lastRejected.get(name) !== literal) {
251
+ this.#lastRejected.set(name, literal);
252
+ console.warn(
253
+ `Stimeo UI: "${owner.identifier}" has an invalid number Value "${name}" declaration ${JSON.stringify(literal)}; using ${fallback}.`
254
+ );
255
+ }
256
+ return resolved;
257
+ }
258
+ };
259
+
137
260
  function targetSelector(identifier, name) {
138
261
  return `[data-${identifier}-target~="${name}"]`;
139
262
  }
140
263
 
141
- // src/controllers/calendar_controller.ts
142
264
  var OWNED_DISABLED = "owns-disabled";
143
- var CalendarController = class extends Controller {
144
- /** The marker above, in the namespace this controller is registered under. */
265
+ var CalendarController = class _CalendarController extends Controller {
266
+ #numbers = new NumberValueReader();
267
+ #fieldWrites = /* @__PURE__ */ new WeakMap();
145
268
  get #ownedDisabled() {
146
269
  return `data-${this.identifier}-${OWNED_DISABLED}`;
147
270
  }
@@ -152,82 +275,31 @@ var CalendarController = class extends Controller {
152
275
  min: { type: String, default: "" },
153
276
  max: { type: String, default: "" },
154
277
  weekStart: { type: Number, default: 0 },
155
- // 0 = Sunday, 1 = Monday, etc.
156
278
  locale: { type: String, default: "" }
157
279
  };
158
- static actions = ["next", "onKeydown", "prev", "selectByClick"];
280
+ static valueConstraints = {
281
+ weekStart: { finite: true, integer: true, min: 0, max: 6 }
282
+ };
283
+ static actions = ["next", "onKeydown", "prev", "select"];
159
284
  static events = ["monthchange", "reconcile", "select"];
160
- /** The date currently receiving focus in the grid (local time). */
161
285
  focusedDate = /* @__PURE__ */ new Date();
162
- /**
163
- * Whether `connect()` has run and `disconnect()` has not since. The paints
164
- * before and during `connect()` describe the grid, so they report nothing.
165
- */
166
286
  #connected = false;
167
- /**
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.
171
- */
172
287
  #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
288
  #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
289
  #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
- */
290
+ #monthMoves = 0;
291
+ #paintedDates = /* @__PURE__ */ new WeakMap();
195
292
  #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(() => {
293
+ #ownMove = false;
294
+ #repaint = new MorphRenderWatcher(() => {
212
295
  this.#repaintKeepingFocus();
213
296
  });
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
297
  #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
298
  #settleFocus = new MicrotaskCoalescer(() => {
227
299
  this.#restoreFocus();
228
300
  });
229
301
  connect() {
230
- this.#repaint.activate();
302
+ this.#repaint.observe(this.element);
231
303
  this.#settleFocus.activate();
232
304
  this.#focusOrigin = null;
233
305
  if (!this.monthValue && !this.#opened) {
@@ -238,85 +310,55 @@ var CalendarController = class extends Controller {
238
310
  this.#render();
239
311
  this.#connected = true;
240
312
  }
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
- */
245
313
  disconnect() {
246
314
  this.#connected = false;
247
- this.#repaint.cancel();
315
+ this.#repaint.disconnect();
248
316
  this.#settleFocus.cancel();
249
317
  }
250
- /**
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.
253
- */
254
318
  monthValueChanged() {
255
319
  if (!this.monthValue) return;
256
320
  this.#syncFocusedDateWithMonth();
257
321
  this.#repaintKeepingFocus();
258
322
  }
259
- /**
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
323
  localeValueChanged() {
268
324
  this.#repaintKeepingFocus();
269
325
  }
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.
274
- */
275
326
  selectedValueChanged() {
276
327
  const selection = parseISODateString(this.#selection);
277
328
  if (selection) this.focusedDate = selection;
278
329
  this.#repaintKeepingFocus();
279
330
  }
280
- /** Repaints when `min` changes after connect, so the disabled days and the selection follow. */
281
331
  minValueChanged() {
282
332
  this.#repaint.schedule();
283
333
  }
284
- /** Repaints when `max` changes after connect, so the disabled days and the selection follow. */
285
334
  maxValueChanged() {
286
335
  this.#repaint.schedule();
287
336
  }
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
337
  weekStartValueChanged() {
296
338
  this.#repaint.schedule();
297
339
  }
298
- /** Navigates to the previous month. */
299
340
  prev(event) {
300
341
  if (event) event.preventDefault();
301
342
  this.#shiftMonth(-1);
302
343
  }
303
- /** Navigates to the next month. */
304
344
  next(event) {
305
345
  if (event) event.preventDefault();
306
346
  this.#shiftMonth(1);
307
347
  }
308
- /** Handles day selection when a gridcell is clicked. */
309
- selectByClick(event) {
310
- const dayElement = event.target?.closest(
311
- targetSelector(this.identifier, "day")
312
- );
313
- if (!dayElement) return;
314
- this.selectDayElement(dayElement);
348
+ select(source) {
349
+ const { origin, reason } = actionSource(source);
350
+ if (origin && origin.closest(`[data-controller~="${this.identifier}"]`) !== this.element)
351
+ return;
352
+ const day = origin?.closest(targetSelector(this.identifier, "day"));
353
+ if (!day || !this.dayTargets.includes(day)) return;
354
+ this.#selectDay(day, reason);
315
355
  }
316
- /** Handles grid cell keyboard navigation and triggers selection. */
317
356
  onKeydown(event) {
318
357
  if (event.defaultPrevented) return;
319
358
  if (isReservedArrowChord(event)) return;
359
+ if ((event.key === "Home" || event.key === "End") && hasModifierChord(event)) return;
360
+ if ((event.key === "PageUp" || event.key === "PageDown") && (event.ctrlKey || event.altKey || event.metaKey))
361
+ return;
320
362
  const dayElement = event.target?.closest(
321
363
  targetSelector(this.identifier, "day")
322
364
  );
@@ -325,7 +367,8 @@ var CalendarController = class extends Controller {
325
367
  if (!date) return;
326
368
  let handled = true;
327
369
  let nextDate = new Date(date);
328
- switch (logicalArrowKey(event.key, this.element)) {
370
+ const directionElement = this.hasGridTarget ? gridDirectionElement(this.gridTarget, this.element) : this.element;
371
+ switch (logicalArrowKey(event.key, directionElement)) {
329
372
  case "ArrowLeft":
330
373
  nextDate.setDate(nextDate.getDate() - 1);
331
374
  break;
@@ -340,16 +383,16 @@ var CalendarController = class extends Controller {
340
383
  break;
341
384
  case "PageUp":
342
385
  if (event.shiftKey) {
343
- nextDate = this.#calculateShiftedYearDate(date, -1);
386
+ nextDate = shiftYearClamped(date, -1);
344
387
  } else {
345
- nextDate = this.#calculateShiftedMonthDate(date, -1);
388
+ nextDate = shiftMonthClamped(date, -1);
346
389
  }
347
390
  break;
348
391
  case "PageDown":
349
392
  if (event.shiftKey) {
350
- nextDate = this.#calculateShiftedYearDate(date, 1);
393
+ nextDate = shiftYearClamped(date, 1);
351
394
  } else {
352
- nextDate = this.#calculateShiftedMonthDate(date, 1);
395
+ nextDate = shiftMonthClamped(date, 1);
353
396
  }
354
397
  break;
355
398
  case "Home":
@@ -371,7 +414,7 @@ var CalendarController = class extends Controller {
371
414
  case "Enter":
372
415
  case " ":
373
416
  event.preventDefault();
374
- this.selectDayElement(dayElement);
417
+ this.#selectDay(dayElement, stateReasonFor(event));
375
418
  return;
376
419
  default:
377
420
  handled = false;
@@ -382,43 +425,13 @@ var CalendarController = class extends Controller {
382
425
  this.#focusAndNavigateToDate(nextDate);
383
426
  }
384
427
  }
385
- /**
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.
388
- *
389
- * A malformed `month` falls back instead of stopping the paint: every cell
390
- * keeps its `aria-selected` and the grid keeps its tab stop, so a Value typo
391
- * still leaves the grid reachable by Tab and the author can see what they
392
- * typed. Every consumer of "which month is on screen" reads it from here, so
393
- * the paint and the `monthchange` report cannot name different months.
394
- */
395
428
  #paintedMonthStart() {
396
429
  const monthInfo = parseISOMonthString(this.#monthRequest);
397
430
  return monthInfo ? new Date(monthInfo.year, monthInfo.month - 1, 1) : new Date(this.focusedDate.getFullYear(), this.focusedDate.getMonth(), 1);
398
431
  }
399
- /**
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
432
  get #monthRequest() {
405
433
  return this.monthValue || this.#paintedMonth;
406
434
  }
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.
412
- *
413
- * The report belongs to the paint, not to the `month` Value: the Value is only
414
- * one of the things that decide the month on screen. While a malformed `month`
415
- * falls back, changing `selected` and selecting a neighbouring month's cell
416
- * move the painted month too, and both repaint from here. Reading the report
417
- * off the paint keeps the event naming the month the grid shows, in the
418
- * `YYYY-MM` the detail contract promises, whichever route repainted.
419
- *
420
- * @stimeoRenderRoot
421
- */
422
435
  #render() {
423
436
  const monthStart = this.#paintedMonthStart();
424
437
  const year = monthStart.getFullYear();
@@ -428,7 +441,7 @@ var CalendarController = class extends Controller {
428
441
  const locale = resolveLocale(this.element, this.localeValue);
429
442
  this.labelTarget.textContent = monthLabelFormatter(locale).format(monthStart);
430
443
  }
431
- const days = this.#calculateGridDays(year, month);
444
+ const days = monthGridDays(year, month, this.#safeWeekStart);
432
445
  const dayElements = this.dayTargets;
433
446
  const consumerDisabled = /* @__PURE__ */ new Map();
434
447
  for (const el of dayElements) {
@@ -447,6 +460,7 @@ var CalendarController = class extends Controller {
447
460
  const kept = consumerDisabled.get(dateStr);
448
461
  if (kept !== void 0) el.setAttribute("aria-disabled", kept);
449
462
  }
463
+ this.#paintedDates.set(el, dateStr);
450
464
  el.setAttribute("data-date", dateStr);
451
465
  el.textContent = String(date.getDate());
452
466
  const isOutside = date.getFullYear() !== year || date.getMonth() !== month - 1;
@@ -472,25 +486,20 @@ var CalendarController = class extends Controller {
472
486
  const painted = toISOMonthString(monthStart);
473
487
  const previous = this.#paintedMonth;
474
488
  this.#paintedMonth = painted;
489
+ if (previous !== painted) this.#monthMoves += 1;
490
+ const monthMove = this.#monthMoves;
475
491
  const reconciled = this.#settleSelection(selection);
476
492
  const move = this.#selectionMoves;
477
- for (const [field, value] of this.#mirrorFields(painted, selection, reconciled)) {
478
- if (field.value === value) commitField(field);
493
+ for (const [field, value, write] of this.#mirrorFields(painted, selection, reconciled)) {
494
+ if (field.value === value && this.#fieldWrites.get(field) === write) commitField(field);
479
495
  }
480
- if (this.#connected && previous !== painted && this.#paintedMonth === painted) {
496
+ if (this.#connected && previous !== painted && monthMove === this.#monthMoves) {
481
497
  this.dispatch("monthchange", { detail: { month: painted } });
482
498
  }
483
499
  if (reconciled && move === this.#selectionMoves) {
484
500
  this.dispatch("reconcile", { detail: { date: selection } });
485
501
  }
486
502
  }
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
503
  #settleSelection(selection) {
495
504
  const published = this.#published;
496
505
  this.#published = selection;
@@ -498,18 +507,13 @@ var CalendarController = class extends Controller {
498
507
  this.#selectionMoves += 1;
499
508
  return true;
500
509
  }
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
510
  #repaintKeepingFocus() {
507
511
  if (!this.#focusOrigin) {
508
512
  const cell = this.dayTargets.find((day) => day === document.activeElement);
509
513
  if (cell) {
510
514
  this.#focusOrigin = {
511
515
  cell,
512
- date: cell.getAttribute("data-date"),
516
+ date: cell.getAttribute("data-date") ?? this.#paintedDates.get(cell) ?? null,
513
517
  month: this.#paintedMonth
514
518
  };
515
519
  this.#settleFocus.schedule();
@@ -517,18 +521,6 @@ var CalendarController = class extends Controller {
517
521
  }
518
522
  this.#render();
519
523
  }
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
524
  #restoreFocus() {
533
525
  const origin = this.#focusOrigin;
534
526
  this.#focusOrigin = null;
@@ -538,57 +530,68 @@ var CalendarController = class extends Controller {
538
530
  const destination = (moved ? void 0 : this.dayTargets.find((cell) => cell.getAttribute("data-date") === origin.date)) ?? this.dayTargets.find((cell) => cell.getAttribute("tabindex") === "0");
539
531
  destination?.focus();
540
532
  }
541
- /** Fills a selected-day field inserted or replaced at runtime with the published selection. */
533
+ labelTargetConnected() {
534
+ this.#repaint.schedule();
535
+ }
536
+ labelTargetDisconnected() {
537
+ this.#repaint.schedule();
538
+ }
542
539
  fieldTargetConnected(field) {
543
- writeField(field, this.#selection);
540
+ this.#writeField(field, this.#selection);
541
+ }
542
+ fieldTargetDisconnected() {
543
+ this.#repaint.schedule();
544
544
  }
545
- /** Fills a painted-month field inserted or replaced at runtime. */
546
545
  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
- */
546
+ this.#writeField(field, toISOMonthString(this.#paintedMonthStart()));
547
+ }
548
+ monthFieldTargetDisconnected() {
549
+ this.#repaint.schedule();
550
+ }
551
+ #writeField(field, value) {
552
+ if (!writeField(field, value)) return false;
553
+ this.#fieldWrites.set(field, (this.#fieldWrites.get(field) ?? 0) + 1);
554
+ return true;
555
+ }
558
556
  #mirrorFields(painted, selection, reconciled) {
559
- const byUser = this.#movedByUser;
560
- this.#movedByUser = false;
557
+ const ownMove = this.#ownMove;
558
+ this.#ownMove = false;
561
559
  const moved = [];
562
- if (this.hasFieldTarget && writeField(this.fieldTarget, selection) && !reconciled) {
563
- moved.push([this.fieldTarget, selection]);
560
+ if (this.hasFieldTarget && this.#writeField(this.fieldTarget, selection) && !reconciled) {
561
+ moved.push([this.fieldTarget, selection, this.#fieldWrites.get(this.fieldTarget) ?? 0]);
564
562
  }
565
- if (this.hasMonthFieldTarget && writeField(this.monthFieldTarget, painted)) {
566
- moved.push([this.monthFieldTarget, painted]);
563
+ if (this.hasMonthFieldTarget && this.#writeField(this.monthFieldTarget, painted)) {
564
+ moved.push([
565
+ this.monthFieldTarget,
566
+ painted,
567
+ this.#fieldWrites.get(this.monthFieldTarget) ?? 0
568
+ ]);
567
569
  }
568
- return byUser ? moved : [];
570
+ return ownMove ? moved : [];
569
571
  }
570
- selectDayElement(dayElement) {
572
+ #selectDay(dayElement, reason) {
571
573
  if (dayElement.getAttribute("aria-disabled") === "true") return;
572
574
  const dateStr = dayElement.getAttribute("data-date");
573
575
  if (!dateStr || this.#isDateOutOfBounds(dateStr)) return;
574
576
  this.selectedValue = dateStr;
575
577
  const selected = parseISODateString(dateStr);
576
578
  if (selected) this.focusedDate = selected;
577
- this.#movedByUser = true;
578
- this.#selectionMoves += 1;
579
+ this.#ownMove = true;
580
+ const changed = dateStr !== this.#published;
581
+ if (changed) this.#selectionMoves += 1;
579
582
  const move = this.#selectionMoves;
580
583
  this.#published = dateStr;
581
584
  this.#repaintKeepingFocus();
582
- if (move !== this.#selectionMoves) return;
583
- this.dispatch("select", { detail: { date: dateStr } });
585
+ if (!changed || move !== this.#selectionMoves) return;
586
+ this.dispatch("select", { detail: { date: dateStr, reason } });
584
587
  }
585
588
  #focusAndNavigateToDate(date) {
586
589
  this.focusedDate = date;
587
590
  const targetMonthStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
588
- this.#movedByUser = true;
591
+ this.#ownMove = true;
589
592
  if (targetMonthStr !== this.#monthRequest) {
590
593
  this.monthValue = targetMonthStr;
591
- return;
594
+ if (targetMonthStr !== this.#paintedMonth) return;
592
595
  }
593
596
  this.#render();
594
597
  const dateStr = toISODateString(date);
@@ -599,50 +602,29 @@ var CalendarController = class extends Controller {
599
602
  if (this.maxValue && dateStr > this.maxValue) return true;
600
603
  return false;
601
604
  }
602
- /** The day `selected` requests: the Value when it names a real day, else `""`. */
603
605
  get #requestedDay() {
604
606
  return parseISODateString(this.selectedValue) ? this.selectedValue : "";
605
607
  }
606
- /** The selection published for `requested`: the day while `min` / `max` allow it, else `""`. */
607
608
  #publishable(requested) {
608
609
  return requested && !this.#isDateOutOfBounds(requested) ? requested : "";
609
610
  }
610
- /** The selection the grid publishes now. */
611
611
  get #selection() {
612
612
  return this.#publishable(this.#requestedDay);
613
613
  }
614
614
  #shiftMonth(delta) {
615
615
  const monthInfo = parseISOMonthString(this.#monthRequest);
616
616
  if (!monthInfo) return;
617
- this.#movedByUser = true;
617
+ this.#ownMove = true;
618
618
  const nextMonthDate = new Date(monthInfo.year, monthInfo.month - 1 + delta, 1);
619
619
  this.monthValue = `${nextMonthDate.getFullYear()}-${String(nextMonthDate.getMonth() + 1).padStart(2, "0")}`;
620
- }
621
- #calculateShiftedMonthDate(baseDate, delta) {
622
- const targetDate = new Date(baseDate.getFullYear(), baseDate.getMonth() + delta, 1);
623
- const lastDayInTarget = new Date(
624
- targetDate.getFullYear(),
625
- targetDate.getMonth() + 1,
626
- 0
627
- ).getDate();
628
- const clampedDay = Math.min(baseDate.getDate(), lastDayInTarget);
629
- targetDate.setDate(clampedDay);
630
- return targetDate;
631
- }
632
- #calculateShiftedYearDate(baseDate, delta) {
633
- const targetDate = new Date(baseDate.getFullYear() + delta, baseDate.getMonth(), 1);
634
- const lastDayInTarget = new Date(
635
- targetDate.getFullYear(),
636
- targetDate.getMonth() + 1,
637
- 0
638
- ).getDate();
639
- const clampedDay = Math.min(baseDate.getDate(), lastDayInTarget);
640
- targetDate.setDate(clampedDay);
641
- return targetDate;
620
+ if (this.monthValue === this.#paintedMonth) {
621
+ this.#syncFocusedDateWithMonth();
622
+ this.#repaintKeepingFocus();
623
+ }
642
624
  }
643
625
  #getStartOfWeekDate(date) {
644
626
  const currentDay = date.getDay();
645
- const shift = (currentDay - this.weekStartValue + 7) % 7;
627
+ const shift = (currentDay - this.#safeWeekStart + 7) % 7;
646
628
  const target = new Date(date);
647
629
  target.setDate(date.getDate() - shift);
648
630
  return target;
@@ -653,11 +635,6 @@ var CalendarController = class extends Controller {
653
635
  target.setDate(start.getDate() + 6);
654
636
  return target;
655
637
  }
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
- */
661
638
  #initializeFocusedDate() {
662
639
  const selection = parseISODateString(this.#selection);
663
640
  if (selection) {
@@ -682,29 +659,20 @@ var CalendarController = class extends Controller {
682
659
  if (today.getFullYear() === monthInfo.year && today.getMonth() === monthInfo.month - 1) {
683
660
  this.focusedDate = today;
684
661
  } else {
685
- const targetDate = new Date(monthInfo.year, monthInfo.month - 1, 1);
686
- const lastDayInTarget = new Date(monthInfo.year, monthInfo.month, 0).getDate();
687
- const clampedDay = Math.min(this.focusedDate.getDate(), lastDayInTarget);
688
- targetDate.setDate(clampedDay);
689
- this.focusedDate = targetDate;
662
+ this.focusedDate = dateInMonthClamped(this.focusedDate, monthInfo.year, monthInfo.month);
690
663
  }
691
664
  }
692
665
  }
693
- #calculateGridDays(year, month) {
694
- const firstDay = new Date(year, month - 1, 1);
695
- const dayOfWeek = firstDay.getDay();
696
- const offset = (dayOfWeek - this.weekStartValue + 7) % 7;
697
- const days = [];
698
- const current = new Date(firstDay);
699
- current.setDate(firstDay.getDate() - offset);
700
- for (let i = 0; i < 42; i++) {
701
- days.push(new Date(current));
702
- current.setDate(current.getDate() + 1);
703
- }
704
- return days;
666
+ get #safeWeekStart() {
667
+ return this.#numbers.read(
668
+ this,
669
+ "weekStart",
670
+ this.weekStartValue,
671
+ _CalendarController.values.weekStart.default,
672
+ _CalendarController.valueConstraints.weekStart
673
+ );
705
674
  }
706
675
  };
707
676
 
708
677
  export { CalendarController };
709
- //# sourceMappingURL=calendar_controller.js.map
710
678
  //# sourceMappingURL=calendar_controller.js.map