stimeo-ui 0.2.0 → 0.3.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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +163 -0
  3. data/dist/controllers/accordion_controller.js +10 -0
  4. data/dist/controllers/alert_dialog_controller.js +318 -0
  5. data/dist/controllers/breadcrumb_controller.js +225 -13
  6. data/dist/controllers/calendar_controller.js +89 -22
  7. data/dist/controllers/carousel_controller.js +313 -0
  8. data/dist/controllers/clipboard_controller.js +144 -0
  9. data/dist/controllers/collapsible_controller.js +327 -0
  10. data/dist/controllers/color_picker_controller.js +252 -0
  11. data/dist/controllers/combobox_controller.js +162 -23
  12. data/dist/controllers/command_palette_controller.js +194 -17
  13. data/dist/controllers/context_menu_controller.js +32 -10
  14. data/dist/controllers/count_up_controller.js +8 -1
  15. data/dist/controllers/currency_input_controller.js +147 -0
  16. data/dist/controllers/data_grid_controller.js +246 -0
  17. data/dist/controllers/date_range_picker_controller.js +441 -0
  18. data/dist/controllers/dismissible_controller.js +117 -0
  19. data/dist/controllers/drawer_controller.js +630 -0
  20. data/dist/controllers/editable_controller.js +169 -0
  21. data/dist/controllers/file_dropzone_controller.js +165 -0
  22. data/dist/controllers/filter_controller.js +86 -0
  23. data/dist/controllers/flash_controller.js +36 -5
  24. data/dist/controllers/form_validation_controller.js +1 -1
  25. data/dist/controllers/highlight_controller.js +6 -4
  26. data/dist/controllers/intersection_controller.js +67 -19
  27. data/dist/controllers/lazy_frame_controller.js +54 -11
  28. data/dist/controllers/listbox_controller.js +257 -53
  29. data/dist/controllers/local_time_controller.js +2 -2
  30. data/dist/controllers/masonry_controller.js +142 -0
  31. data/dist/controllers/menu_controller.js +104 -17
  32. data/dist/controllers/menubar_controller.js +785 -0
  33. data/dist/controllers/multi_select_controller.js +755 -0
  34. data/dist/controllers/navigation_menu_controller.js +511 -0
  35. data/dist/controllers/number_input_controller.js +7 -0
  36. data/dist/controllers/otp_controller.js +18 -1
  37. data/dist/controllers/overflow_indicator_controller.js +246 -27
  38. data/dist/controllers/overflow_menu_controller.js +381 -57
  39. data/dist/controllers/pagination_controller.js +163 -32
  40. data/dist/controllers/password_reveal_controller.js +117 -0
  41. data/dist/controllers/persist_controller.js +6 -6
  42. data/dist/controllers/pointer_drag_controller.js +9 -1
  43. data/dist/controllers/popover_controller.js +2 -2
  44. data/dist/controllers/radio_group_controller.js +22 -3
  45. data/dist/controllers/range_slider_controller.js +192 -0
  46. data/dist/controllers/rating_controller.js +16 -2
  47. data/dist/controllers/read_more_controller.js +238 -0
  48. data/dist/controllers/resizable_controller.js +65 -1
  49. data/dist/controllers/roving_controller.js +17 -2
  50. data/dist/controllers/scroll_area_controller.js +101 -14
  51. data/dist/controllers/scroll_restore_controller.js +93 -0
  52. data/dist/controllers/scroll_visibility_controller.js +40 -6
  53. data/dist/controllers/scrollspy_controller.js +369 -74
  54. data/dist/controllers/separator_controller.js +96 -0
  55. data/dist/controllers/sidebar_controller.js +761 -0
  56. data/dist/controllers/skeleton_controller.js +1 -1
  57. data/dist/controllers/slider_controller.js +32 -6
  58. data/dist/controllers/sortable_controller.js +34 -3
  59. data/dist/controllers/spinner_controller.js +1 -1
  60. data/dist/controllers/stepper_controller.js +28 -12
  61. data/dist/controllers/stick_to_bottom_controller.js +9 -4
  62. data/dist/controllers/sticky_observer_controller.js +109 -20
  63. data/dist/controllers/switch_controller.js +1 -0
  64. data/dist/controllers/tabs_controller.js +26 -3
  65. data/dist/controllers/tags_input_controller.js +295 -0
  66. data/dist/controllers/theme_controller.js +42 -13
  67. data/dist/controllers/time_picker_controller.js +231 -0
  68. data/dist/controllers/toast_controller.js +40 -14
  69. data/dist/controllers/toggle_group_controller.js +23 -2
  70. data/dist/controllers/toolbar_controller.js +230 -31
  71. data/dist/controllers/transition_controller.js +153 -38
  72. data/dist/controllers/tree_view_controller.js +691 -0
  73. data/dist/index.js +4256 -915
  74. data/lib/stimeo/ui/version.rb +2 -3
  75. metadata +28 -2
@@ -2,6 +2,61 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/breadcrumb_controller.ts
4
4
 
5
+ // src/utils/blur_deferral.ts
6
+ var BlurDeferral = class {
7
+ /** Elements currently holding an update back, mapped to their `blur` listener. */
8
+ #pending = /* @__PURE__ */ new Map();
9
+ /** Called after a pending element blurs and has been detached. */
10
+ #onRelease;
11
+ /** @param onRelease - Invoked once `element` actually blurs; never on `release`. */
12
+ constructor(onRelease) {
13
+ this.#onRelease = onRelease;
14
+ }
15
+ /** Number of elements currently holding an update back. */
16
+ get size() {
17
+ return this.#pending.size;
18
+ }
19
+ /** Snapshot of the pending elements, safe to iterate while releasing them. */
20
+ get elements() {
21
+ return [...this.#pending.keys()];
22
+ }
23
+ /** Whether `element` is currently holding an update back. */
24
+ has(element) {
25
+ return this.#pending.has(element);
26
+ }
27
+ /** Holds an update back until `element` blurs. Idempotent (no stacked listeners). */
28
+ defer(element) {
29
+ if (this.#pending.has(element)) return;
30
+ const onBlur = () => {
31
+ this.#detach(element);
32
+ this.#onRelease(element);
33
+ };
34
+ this.#pending.set(element, onBlur);
35
+ element.addEventListener("blur", onBlur);
36
+ }
37
+ /** Defers `element` as the only pending entry, cancelling any others. */
38
+ deferOnly(element) {
39
+ for (const pending of this.elements) {
40
+ if (pending !== element) this.#detach(pending);
41
+ }
42
+ this.defer(element);
43
+ }
44
+ /** Cancels `element`'s deferral without completing it; no-ops when not pending. */
45
+ release(element) {
46
+ this.#detach(element);
47
+ }
48
+ /** Cancels every deferral without completing any of them. */
49
+ releaseAll() {
50
+ for (const element of this.elements) this.#detach(element);
51
+ }
52
+ /** Removes the `blur` listener for `element` and forgets it. */
53
+ #detach(element) {
54
+ const onBlur = this.#pending.get(element);
55
+ if (onBlur) element.removeEventListener("blur", onBlur);
56
+ this.#pending.delete(element);
57
+ }
58
+ };
59
+
5
60
  // src/utils/layout_observer.ts
6
61
  var LayoutObserver = class {
7
62
  #callback;
@@ -59,37 +114,152 @@ var LayoutObserver = class {
59
114
  };
60
115
 
61
116
  // src/controllers/breadcrumb_controller.ts
117
+ var OVERFLOW_EPSILON = 1;
62
118
  var BreadcrumbController = class extends Controller {
63
119
  static targets = ["list", "collapsible", "ellipsis", "trigger"];
64
- static actions = ["toggle"];
120
+ static actions = ["toggle", "update"];
65
121
  static events = ["toggle"];
122
+ /** Guards target callbacks that can fire outside the connected window. */
123
+ #connected = false;
66
124
  /** Whether the trail currently overflows its container. */
67
125
  #overflowing = false;
68
126
  /** Whether the user has expanded the collapsed items via the disclosure. */
69
127
  #expanded = false;
70
- #layout = new LayoutObserver(() => this.#update());
128
+ /** The list element currently observed (resize + mutations), if any. */
129
+ #observedList = null;
130
+ #listMutations = null;
131
+ #layout = new LayoutObserver(() => this.update());
132
+ #onListMutation = () => {
133
+ this.update();
134
+ };
135
+ /** Holds the ellipsis hide back while it contains focus; re-renders on blur. */
136
+ #deferredHide = new BlurDeferral(() => {
137
+ this.#render();
138
+ });
71
139
  /** Starts observing for overflow and renders the initial state. */
72
140
  connect() {
73
- if (this.hasListTarget) this.#layout.observe(this.listTarget);
141
+ this.#connected = true;
142
+ this.#expanded = this.#initialExpanded();
74
143
  this.#layout.observeViewport();
75
- this.#update();
144
+ this.#syncListObservation();
145
+ this.update();
76
146
  }
77
- /** Releases the resize observation (Turbo navigation included). */
147
+ /** Releases the resize/mutation observation (Turbo navigation included). */
78
148
  disconnect() {
149
+ this.#connected = false;
150
+ this.#deferredHide.releaseAll();
151
+ this.#stopObservingList();
79
152
  this.#layout.disconnect();
80
153
  }
81
- /** Expands or re-collapses the trail and dispatches `toggle`. */
154
+ listTargetConnected() {
155
+ this.#resync();
156
+ }
157
+ listTargetDisconnected() {
158
+ this.#resync();
159
+ }
160
+ collapsibleTargetConnected() {
161
+ this.#resync();
162
+ }
163
+ /**
164
+ * Re-measures when the disclosure set gains or loses a member. The set is a
165
+ * precondition for collapsing at all, so it needs the same watching the list
166
+ * and items get: without these callbacks a swap that breaks or completes the
167
+ * set would only take effect at the next resize or list mutation, which may
168
+ * never come.
169
+ */
170
+ ellipsisTargetConnected() {
171
+ this.#resync();
172
+ }
173
+ ellipsisTargetDisconnected() {
174
+ this.#resync();
175
+ }
176
+ triggerTargetConnected() {
177
+ this.#resync();
178
+ }
179
+ triggerTargetDisconnected() {
180
+ this.#resync();
181
+ }
182
+ /**
183
+ * Hands `hidden` back when an element stops being a `collapsible` target.
184
+ *
185
+ * The marker is the source of truth for what may be collapsed, so an element
186
+ * that loses it while the controller is live is an always-visible item again —
187
+ * and `#render` only walks the *current* targets, so nothing else would ever
188
+ * clear the `hidden` this controller put there.
189
+ *
190
+ * The `#connected` guard is load-bearing, not defensive. Stimulus fires this
191
+ * callback for **every** target during teardown as well (`Context.disconnect()`
192
+ * stops the target observer after `disconnect()` has run), and there the
193
+ * collapsed DOM must survive untouched so Turbo's cache restores the trail in
194
+ * the state the user left it (see the ownership note in {@link BreadcrumbController}).
195
+ *
196
+ * @param element - the element that just left the `collapsible` target set
197
+ */
198
+ collapsibleTargetDisconnected(element) {
199
+ if (this.#connected) element.hidden = false;
200
+ this.#resync();
201
+ }
202
+ /**
203
+ * Expands or re-collapses the trail and dispatches `toggle`.
204
+ *
205
+ * No-op while the trail fits: there is nothing collapsed to reveal, and the
206
+ * (hidden) trigger would otherwise keep a stale `aria-expanded="true"` that
207
+ * surfaces pre-expanded the next time the trail overflows. No event is
208
+ * dispatched in that case.
209
+ */
82
210
  toggle() {
211
+ if (!this.#connected || !this.#overflowing) return;
83
212
  this.#expanded = !this.#expanded;
84
213
  this.#render();
85
214
  this.dispatch("toggle", { detail: { expanded: this.#expanded } });
86
215
  }
87
- /** Re-measures overflow and re-renders (e.g. on resize). */
88
- #update() {
216
+ /**
217
+ * Re-measures overflow and re-renders. Wired to resizes and list mutations;
218
+ * exposed as an action so consumers can re-measure after a change the
219
+ * observers cannot see (e.g. a web font swap that only alters text width).
220
+ */
221
+ update() {
222
+ if (!this.#connected) return;
89
223
  this.#overflowing = this.#measureOverflow();
90
224
  if (!this.#overflowing) this.#expanded = false;
91
225
  this.#render();
92
226
  }
227
+ /** Re-attaches the list observation (targets may have been swapped) and re-measures. */
228
+ #resync() {
229
+ if (!this.#connected) return;
230
+ this.#syncListObservation();
231
+ this.update();
232
+ }
233
+ /** Reads the expanded state back from its DOM home (the trigger's `aria-expanded`). */
234
+ #initialExpanded() {
235
+ return this.hasTriggerTarget && this.triggerTarget.getAttribute("aria-expanded") === "true";
236
+ }
237
+ /**
238
+ * Points the resize + mutation observation at the current `list` target.
239
+ *
240
+ * The list is re-resolved rather than captured at connect so a target swapped
241
+ * at runtime (Turbo Stream replacement of the `<ol>`) is observed too. Only
242
+ * `childList` / `subtree` / `characterData` are watched — deliberately **not**
243
+ * `attributes`, which the controller's own `hidden` writes would re-trigger.
244
+ */
245
+ #syncListObservation() {
246
+ const next = this.hasListTarget ? this.listTarget : null;
247
+ if (next === this.#observedList) return;
248
+ this.#stopObservingList();
249
+ if (!next) return;
250
+ this.#observedList = next;
251
+ this.#layout.observe(next);
252
+ if (typeof MutationObserver !== "undefined") {
253
+ this.#listMutations = new MutationObserver(this.#onListMutation);
254
+ this.#listMutations.observe(next, { childList: true, subtree: true, characterData: true });
255
+ }
256
+ }
257
+ #stopObservingList() {
258
+ if (this.#observedList) this.#layout.unobserve(this.#observedList);
259
+ this.#observedList = null;
260
+ this.#listMutations?.disconnect();
261
+ this.#listMutations = null;
262
+ }
93
263
  /**
94
264
  * Measures overflow against the **fully expanded** layout so hiding items does
95
265
  * not make the condition oscillate. Reveals every item, then compares the list's
@@ -100,21 +270,63 @@ var BreadcrumbController = class extends Controller {
100
270
  * check is independent of any padding/border on the host — comparing against the
101
271
  * host's `clientWidth` would over-report the available space by its padding and
102
272
  * miss real overflow in a padded container.
273
+ *
274
+ * With no `collapsible` items there is nothing to collapse, so the trail is
275
+ * never reported as overflowing (which also keeps the disclosure out of the
276
+ * tab order — a button controlling nothing).
277
+ *
278
+ * The full disclosure set is a precondition: collapsing without an `ellipsis`
279
+ * *and* a `trigger` would hide items behind a control that does not exist, so
280
+ * an incomplete set reports "not overflowing" and `#render` degrades the trail
281
+ * to a plain breadcrumb. Losing layout is recoverable; losing the path is not.
103
282
  */
104
283
  #measureOverflow() {
105
- if (!this.hasListTarget) return false;
284
+ if (!this.hasListTarget || this.collapsibleTargets.length === 0) return false;
285
+ if (!this.hasEllipsisTarget || !this.hasTriggerTarget) return false;
106
286
  for (const item of this.collapsibleTargets) item.hidden = false;
107
- if (this.hasEllipsisTarget) this.ellipsisTarget.hidden = true;
108
- return this.listTarget.scrollWidth > this.listTarget.clientWidth;
287
+ this.ellipsisTarget.hidden = true;
288
+ return this.listTarget.scrollWidth > this.listTarget.clientWidth + OVERFLOW_EPSILON;
109
289
  }
110
290
  /** Applies the collapsed/expanded state to the items, ellipsis, and trigger. */
111
291
  #render() {
112
292
  const collapsed = this.#overflowing && !this.#expanded;
293
+ const showEllipsis = this.#overflowing && this.collapsibleTargets.length > 0;
294
+ const rescueFocus = collapsed && this.hasTriggerTarget && this.collapsibleTargets.some((i) => this.#holdsFocus(i));
113
295
  for (const item of this.collapsibleTargets) item.hidden = collapsed;
114
- if (this.hasEllipsisTarget) this.ellipsisTarget.hidden = !this.#overflowing;
296
+ if (this.hasEllipsisTarget) this.#applyEllipsisVisibility(showEllipsis);
115
297
  if (this.hasTriggerTarget) {
116
- this.triggerTarget.setAttribute("aria-expanded", this.#expanded ? "true" : "false");
298
+ const expanded = this.#overflowing && this.#expanded;
299
+ this.triggerTarget.setAttribute("aria-expanded", expanded ? "true" : "false");
300
+ }
301
+ if (rescueFocus) this.triggerTarget.focus();
302
+ }
303
+ /**
304
+ * Shows or hides the ellipsis item, deferring a hide that would blur the user.
305
+ *
306
+ * Hiding the element that currently holds focus drops focus to `<body>`, so the
307
+ * hide waits for that element's `blur` and is re-applied then (the shared
308
+ * `BlurDeferral` registry).
309
+ */
310
+ #applyEllipsisVisibility(show) {
311
+ const ellipsis = this.ellipsisTarget;
312
+ if (show) {
313
+ this.#deferredHide.releaseAll();
314
+ ellipsis.hidden = false;
315
+ return;
316
+ }
317
+ const active = document.activeElement;
318
+ if (active instanceof HTMLElement && ellipsis.contains(active)) {
319
+ ellipsis.hidden = false;
320
+ this.#deferredHide.deferOnly(active);
321
+ return;
117
322
  }
323
+ this.#deferredHide.releaseAll();
324
+ ellipsis.hidden = true;
325
+ }
326
+ /** Whether `element` contains (or is) the currently focused element. */
327
+ #holdsFocus(element) {
328
+ const active = document.activeElement;
329
+ return active instanceof HTMLElement && element.contains(active);
118
330
  }
119
331
  };
120
332
 
@@ -2,6 +2,22 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/calendar_controller.ts
4
4
 
5
+ // src/utils/logical_scroll.ts
6
+ function isRtl(element) {
7
+ return window.getComputedStyle(element).direction === "rtl";
8
+ }
9
+
10
+ // src/utils/arrow_step.ts
11
+ function logicalArrowKey(key, element) {
12
+ if (key !== "ArrowRight" && key !== "ArrowLeft") return key;
13
+ if (!isRtl(element)) return key;
14
+ return key === "ArrowRight" ? "ArrowLeft" : "ArrowRight";
15
+ }
16
+ function isReservedArrowChord(event, allow = []) {
17
+ if (!event.key.startsWith("Arrow")) return false;
18
+ return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
19
+ }
20
+
5
21
  // src/utils/dates.ts
6
22
  function toISODateString(date) {
7
23
  const year = date.getFullYear();
@@ -32,6 +48,9 @@ function parseISOMonthString(monthStr) {
32
48
  if (month < 1 || month > 12) return null;
33
49
  return { year: Number(y), month };
34
50
  }
51
+ function toISOMonthString(date) {
52
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
53
+ }
35
54
 
36
55
  // src/utils/safe_timeout.ts
37
56
  var TimerRegistry = class {
@@ -87,6 +106,7 @@ var SafeTimeout = class extends TimerRegistry {
87
106
  };
88
107
 
89
108
  // src/controllers/calendar_controller.ts
109
+ var OWNED_DISABLED = "data-stimeo--calendar-owns-disabled";
90
110
  var CalendarController = class extends Controller {
91
111
  static targets = ["label", "grid", "day"];
92
112
  static values = {
@@ -107,6 +127,13 @@ var CalendarController = class extends Controller {
107
127
  * controller never steals focus after the element leaves the DOM (Turbo).
108
128
  */
109
129
  #focusTimer = new SafeTimeout();
130
+ /**
131
+ * The painted month the last `monthchange` reported, or `null` before the first
132
+ * paint. Settling on the initial month — whether it comes from the attribute or
133
+ * is derived here — is the grid describing itself, not a navigation, so it must
134
+ * not reach a listener that refetches inventory or pushes history.
135
+ */
136
+ #announcedMonth = null;
110
137
  connect() {
111
138
  if (!this.monthValue) {
112
139
  const today = /* @__PURE__ */ new Date();
@@ -129,7 +156,6 @@ var CalendarController = class extends Controller {
129
156
  if (!this.monthValue) return;
130
157
  this.#syncFocusedDateWithMonth();
131
158
  this.render();
132
- this.dispatch("monthchange", { detail: { month: this.monthValue } });
133
159
  }
134
160
  /**
135
161
  * Stimulus lifecycle callback triggered automatically when the selectedValue changes.
@@ -156,25 +182,25 @@ var CalendarController = class extends Controller {
156
182
  }
157
183
  /** Handles day selection when a gridcell is clicked. */
158
184
  selectByClick(event) {
159
- const target = event.target;
160
- if (!target) return;
161
- const dayElement = target.closest("[data-stimeo--calendar-target='day']");
185
+ const dayElement = event.target?.closest(
186
+ "[data-stimeo--calendar-target='day']"
187
+ );
162
188
  if (!dayElement) return;
163
189
  this.selectDayElement(dayElement);
164
190
  }
165
191
  /** Handles grid cell keyboard navigation and triggers selection. */
166
192
  onKeydown(event) {
167
- const target = event.target;
168
- if (!target) return;
169
- const dayElement = target.closest("[data-stimeo--calendar-target='day']");
193
+ if (event.defaultPrevented) return;
194
+ if (isReservedArrowChord(event)) return;
195
+ const dayElement = event.target?.closest(
196
+ "[data-stimeo--calendar-target='day']"
197
+ );
170
198
  if (!dayElement) return;
171
- const dateStr = dayElement.getAttribute("data-date");
172
- if (!dateStr) return;
173
- const date = parseISODateString(dateStr);
199
+ const date = parseISODateString(dayElement.getAttribute("data-date") ?? "");
174
200
  if (!date) return;
175
201
  let handled = true;
176
202
  let nextDate = new Date(date);
177
- switch (event.key) {
203
+ switch (logicalArrowKey(event.key, this.element)) {
178
204
  case "ArrowLeft":
179
205
  nextDate.setDate(nextDate.getDate() - 1);
180
206
  break;
@@ -209,6 +235,10 @@ var CalendarController = class extends Controller {
209
235
  break;
210
236
  case "t":
211
237
  case "T": {
238
+ if (event.ctrlKey || event.metaKey || event.altKey) {
239
+ handled = false;
240
+ break;
241
+ }
212
242
  const now = /* @__PURE__ */ new Date();
213
243
  nextDate = new Date(now.getFullYear(), now.getMonth(), now.getDate());
214
244
  break;
@@ -227,25 +257,50 @@ var CalendarController = class extends Controller {
227
257
  this.#focusAndNavigateToDate(nextDate);
228
258
  }
229
259
  }
230
- /** Renders the grid days and updates the month/year label. */
231
- render() {
260
+ /**
261
+ * First day of the month the grid paints: the `month` Value when it parses,
262
+ * otherwise the focused date's month.
263
+ *
264
+ * A malformed `month` falls back instead of stopping the paint: every cell
265
+ * keeps its `aria-selected` and the grid keeps its tab stop, so a Value typo
266
+ * still leaves the grid reachable by Tab and the author can see what they
267
+ * typed. Every consumer of "which month is on screen" reads it from here, so
268
+ * the paint and the `monthchange` report cannot name different months.
269
+ */
270
+ #paintedMonthStart() {
232
271
  const monthInfo = parseISOMonthString(this.monthValue);
233
- if (!monthInfo) return;
234
- const { year, month } = monthInfo;
272
+ return monthInfo ? new Date(monthInfo.year, monthInfo.month - 1, 1) : new Date(this.focusedDate.getFullYear(), this.focusedDate.getMonth(), 1);
273
+ }
274
+ /**
275
+ * Renders the grid days and updates the month/year label, then reports the
276
+ * painted month once it differs from the one already announced.
277
+ *
278
+ * The report belongs to the paint, not to the `month` Value: the Value is only
279
+ * one of the things that decide the month on screen. While a malformed `month`
280
+ * falls back, changing `selected` and selecting a neighbouring month's cell
281
+ * move the painted month too, and both repaint from here. Reading the report
282
+ * off the paint keeps the event naming the month the grid shows, in the
283
+ * `YYYY-MM` the detail contract promises, whichever route repainted.
284
+ */
285
+ render() {
286
+ const monthStart = this.#paintedMonthStart();
287
+ const year = monthStart.getFullYear();
288
+ const month = monthStart.getMonth() + 1;
235
289
  if (this.hasLabelTarget) {
236
290
  const lang = document.documentElement.lang || "en";
237
- const labelDate = new Date(year, month - 1, 1);
238
291
  const formatter = new Intl.DateTimeFormat(lang, { month: "long", year: "numeric" });
239
- this.labelTarget.textContent = formatter.format(labelDate);
292
+ this.labelTarget.textContent = formatter.format(monthStart);
240
293
  }
241
294
  const days = this.#calculateGridDays(year, month);
242
295
  const dayElements = this.dayTargets;
243
- for (let i = 0; i < 42; i++) {
244
- const el = dayElements[i];
296
+ for (const [index, date] of days.entries()) {
297
+ const el = dayElements[index];
245
298
  if (!el) continue;
246
- const date = days[i];
247
- if (!date) continue;
248
299
  const dateStr = toISODateString(date);
300
+ if (el.getAttribute("data-date") !== dateStr) {
301
+ el.removeAttribute("aria-disabled");
302
+ el.removeAttribute(OWNED_DISABLED);
303
+ }
249
304
  el.setAttribute("data-date", dateStr);
250
305
  el.textContent = String(date.getDate());
251
306
  const isOutside = date.getFullYear() !== year || date.getMonth() !== month - 1;
@@ -258,11 +313,23 @@ var CalendarController = class extends Controller {
258
313
  el.setAttribute("tabindex", isFocused ? "0" : "-1");
259
314
  const isDisabled = this.#isDateOutOfBounds(dateStr);
260
315
  if (isDisabled) {
316
+ if (!el.hasAttribute("aria-disabled")) el.setAttribute(OWNED_DISABLED, "");
261
317
  el.setAttribute("aria-disabled", "true");
262
- } else {
318
+ } else if (el.hasAttribute(OWNED_DISABLED)) {
263
319
  el.removeAttribute("aria-disabled");
320
+ el.removeAttribute(OWNED_DISABLED);
264
321
  }
265
322
  }
323
+ if (!dayElements.some((el) => el.getAttribute("tabindex") === "0")) {
324
+ const fallback = dayElements.find((el) => el.getAttribute("data-outside") === "false") ?? dayElements[0];
325
+ fallback?.setAttribute("tabindex", "0");
326
+ }
327
+ const painted = toISOMonthString(monthStart);
328
+ const previous = this.#announcedMonth;
329
+ this.#announcedMonth = painted;
330
+ if (previous !== null && previous !== painted) {
331
+ this.dispatch("monthchange", { detail: { month: painted } });
332
+ }
266
333
  }
267
334
  selectDayElement(dayElement) {
268
335
  if (dayElement.getAttribute("aria-disabled") === "true") return;