stimeo-ui 0.2.1 → 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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +104 -0
  3. data/dist/controllers/accordion_controller.js +10 -0
  4. data/dist/controllers/breadcrumb_controller.js +225 -13
  5. data/dist/controllers/calendar_controller.js +89 -22
  6. data/dist/controllers/carousel_controller.js +47 -6
  7. data/dist/controllers/collapsible_controller.js +2 -2
  8. data/dist/controllers/color_picker_controller.js +46 -7
  9. data/dist/controllers/combobox_controller.js +162 -23
  10. data/dist/controllers/command_palette_controller.js +194 -17
  11. data/dist/controllers/context_menu_controller.js +32 -10
  12. data/dist/controllers/data_grid_controller.js +82 -4
  13. data/dist/controllers/date_range_picker_controller.js +27 -3
  14. data/dist/controllers/editable_controller.js +1 -0
  15. data/dist/controllers/form_validation_controller.js +1 -1
  16. data/dist/controllers/intersection_controller.js +36 -11
  17. data/dist/controllers/lazy_frame_controller.js +31 -10
  18. data/dist/controllers/listbox_controller.js +257 -53
  19. data/dist/controllers/local_time_controller.js +2 -2
  20. data/dist/controllers/menu_controller.js +104 -17
  21. data/dist/controllers/menubar_controller.js +415 -63
  22. data/dist/controllers/multi_select_controller.js +312 -29
  23. data/dist/controllers/navigation_menu_controller.js +154 -27
  24. data/dist/controllers/number_input_controller.js +7 -0
  25. data/dist/controllers/otp_controller.js +18 -1
  26. data/dist/controllers/overflow_indicator_controller.js +81 -13
  27. data/dist/controllers/overflow_menu_controller.js +381 -57
  28. data/dist/controllers/pagination_controller.js +163 -32
  29. data/dist/controllers/persist_controller.js +6 -6
  30. data/dist/controllers/pointer_drag_controller.js +9 -1
  31. data/dist/controllers/popover_controller.js +2 -2
  32. data/dist/controllers/radio_group_controller.js +22 -3
  33. data/dist/controllers/range_slider_controller.js +32 -6
  34. data/dist/controllers/rating_controller.js +16 -2
  35. data/dist/controllers/read_more_controller.js +63 -19
  36. data/dist/controllers/resizable_controller.js +65 -1
  37. data/dist/controllers/roving_controller.js +17 -2
  38. data/dist/controllers/scroll_area_controller.js +86 -12
  39. data/dist/controllers/scroll_restore_controller.js +1 -1
  40. data/dist/controllers/scroll_visibility_controller.js +33 -3
  41. data/dist/controllers/scrollspy_controller.js +346 -73
  42. data/dist/controllers/separator_controller.js +9 -0
  43. data/dist/controllers/skeleton_controller.js +1 -1
  44. data/dist/controllers/slider_controller.js +32 -6
  45. data/dist/controllers/sortable_controller.js +34 -3
  46. data/dist/controllers/spinner_controller.js +1 -1
  47. data/dist/controllers/stick_to_bottom_controller.js +2 -1
  48. data/dist/controllers/sticky_observer_controller.js +32 -11
  49. data/dist/controllers/switch_controller.js +1 -0
  50. data/dist/controllers/tabs_controller.js +26 -3
  51. data/dist/controllers/tags_input_controller.js +22 -2
  52. data/dist/controllers/theme_controller.js +22 -3
  53. data/dist/controllers/time_picker_controller.js +20 -1
  54. data/dist/controllers/toast_controller.js +4 -5
  55. data/dist/controllers/toggle_group_controller.js +23 -2
  56. data/dist/controllers/toolbar_controller.js +230 -31
  57. data/dist/controllers/tree_view_controller.js +467 -51
  58. data/dist/index.js +3514 -689
  59. data/lib/stimeo/ui/version.rb +2 -3
  60. metadata +2 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a4f40e999f04533e49b382f7a5165c17b2338e8d520c702c34523843c12eff4c
4
- data.tar.gz: 2bd4c18004fd4f92998513dd6a0d51975c7f9fcbe452fcef89477eb3f87edd7b
3
+ metadata.gz: dc8f7abf12a14555f6b1182d81844594d3bf86c772f115338d931e15eb35a43e
4
+ data.tar.gz: fee179d507f73e693b718e8563e3239d7a3fcad20d42d6db4624bdcbdf2a353c
5
5
  SHA512:
6
- metadata.gz: 06ed197e53937fa086971d86c22d66efa4aed0f2accf14000046711be6aa73c3f486671c3912264a4ce5ca7c6da97a9836d7fe79c1d3ce106e1b368e5013f0d1
7
- data.tar.gz: e49c3e2ccbc075def36be9d4e926d167fa4714cb66710389ad983ad7725f86e91d525fddfbb809090b4af09bc0cdaed44541466cb57391db53566119613f8863
6
+ metadata.gz: e68c6fd65748f2e637ddd8bd77364f74f4336549fdd2bb47803d5bd0e300c7838c0037850154d040f32aadb7ccab53879dc754acf8bbe99430ddedeb3b102b8f
7
+ data.tar.gz: 833febb4eb631e61d0d700e56a13cfb38fa59465757a4d0c5f9430a8f87967ef9a8cc993e3272922b4794ec88b9ab38c8244a1c7614a904cc1d539bc40cd3e81
data/CHANGELOG.md CHANGED
@@ -7,6 +7,108 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
  While the version is `0.x`, the public API (the `stimeo--*` data attributes) may
8
8
  change between releases.
9
9
 
10
+ ## [0.3.0] - 2026-08-09
11
+
12
+ Minor release focused on consistent keyboard and selection behavior, resilient
13
+ runtime DOM changes, and substantially stronger Inspector diagnostics. No
14
+ controller, target, value, action, event, or package export was removed. The
15
+ Inspector manifest advances from schema v5 to v8, so tools that read the raw
16
+ manifest must accept the new schema and fields.
17
+
18
+ ### Upgrade notes
19
+
20
+ - Run `stimeo check` against existing views before deploying. New structural
21
+ checks can report errors for incomplete optional target groups, missing
22
+ companion controllers, undeclared role-bearing targets, and invalid
23
+ cardinality. New ARIA checks can also add warnings without failing the command.
24
+ - In RTL containers, horizontal arrow keys now follow logical inline direction
25
+ across composite widgets. LTR behavior is unchanged.
26
+ - Slider, range-slider, and color-picker pointer/arrow mirroring remains opt-in:
27
+ set their new `logicalTrack` value only when the consuming CSS mirrors the
28
+ track in RTL.
29
+ - calendar's `stimeo--calendar:monthchange` no longer fires while the grid
30
+ settles on its initial month, so code that ran from that first event must now
31
+ run once at initialization. Listeners that re-apply per-date `aria-disabled`
32
+ marks are the common case.
33
+
34
+ ### Added
35
+
36
+ - breadcrumb: a public `update` action for reconciling collapsible state after
37
+ application-driven DOM changes.
38
+ - tree-view: a public `toggle` action for explicitly expanding or collapsing an
39
+ item from application markup.
40
+ - scrollspy: a `focusSection` value for opting into focus movement when a link
41
+ activates a section.
42
+ - slider, range-slider, color-picker: the `logicalTrack` value described above.
43
+ - Inspector schema v6-v8: conditional target requirements and the new
44
+ `missing-companion`, `undeclared-target`, `cardinality-violation`,
45
+ `forbidden-aria`, and `missing-conditional-target` diagnostics. Rules can now
46
+ depend on controller values, element/document conditions, contained targets,
47
+ and ARIA requirement severity.
48
+
49
+ ### Changed
50
+
51
+ - accordion, carousel, calendar, combobox, command-palette, context-menu,
52
+ data-grid, date-range-picker, listbox, menu, menubar, multi-select,
53
+ navigation-menu, OTP, radio-group, roving, tabs, tags-input, time-picker,
54
+ toggle-group, toolbar, tree-view, and related horizontal controls now resolve
55
+ Left/Right behavior from the effective text direction. Sortable geometry also
56
+ follows logical inline direction.
57
+ - Arrow-key handlers yield modified browser shortcuts, and composite handlers
58
+ yield events already consumed by a nested widget. IME composition continues to
59
+ take precedence over component shortcuts.
60
+ - Selection widgets normalize controller-owned `aria-selected` state, preserve a
61
+ single active/selected item where the pattern requires one, and reconcile that
62
+ state when options are inserted, removed, or replaced at runtime.
63
+ - menu and overflow-menu delegate item interaction from their containers, so
64
+ dynamically added items work without per-item actions. Existing action markup
65
+ remains compatible.
66
+ - `aria-disabled="true"` items remain discoverable by composite keyboard
67
+ navigation while activation, expansion, and consumer actions are suppressed.
68
+ - Outside pointer listeners for dismissible composites — click and context-menu
69
+ alike — run in the capture phase so nested application code cannot leave an
70
+ open layer stuck by stopping bubbling.
71
+ - Inspector accessible-name diagnostics now follow ARIA's Required, Recommended,
72
+ and conditional levels instead of treating every role alike.
73
+
74
+ ### Fixed
75
+
76
+ - combobox, command-palette, listbox, and multi-select no longer retain stale
77
+ active IDs, selection, chips, or hidden fields after Turbo morphs and runtime
78
+ target replacement. A disconnect/reconnect in the same task can no longer let
79
+ an old reconciliation microtask consume the fresh generation's pass.
80
+ - breadcrumb fails open when its optional disclosure targets are incomplete and
81
+ restores items that leave the collapsible set instead of leaving them hidden.
82
+ - menu, menubar, navigation-menu, overflow-menu, pagination, and tree-view now
83
+ recover focus consistently across empty lists, disabled fieldsets, hidden
84
+ ancestors, item banking, and runtime item removal.
85
+ - toolbar restores its single tab stop when an enclosing `<fieldset>` is
86
+ re-enabled, so unlocking a form brings the group back into the Tab sequence.
87
+ - multi-select keeps chip focus when the selection was made in an order other
88
+ than the option order. The chips are reconciled against the selected value set
89
+ rather than position by position, and their labels are paired by value.
90
+ - overflow-menu measures items adopted from server-rendered overflow in the bar
91
+ rather than inside the closed menu, so an already-overflowed bar no longer
92
+ returns every item to the bar on first paint.
93
+ - combobox commits a clicked option after its input target is removed instead of
94
+ throwing, matching the tolerance the popup already had for that state.
95
+ - color-picker falls back to the per-channel range when a slider omits
96
+ `aria-valuemin` / `aria-valuemax`, instead of pinning the channel at zero.
97
+ - calendar maintains exactly one roving tab stop while selection and available
98
+ days change.
99
+ - calendar reports `monthchange` from the month it paints, so a malformed
100
+ `month` value can no longer put a raw non-`YYYY-MM` string in `detail.month`.
101
+ The report is driven by the repaint rather than by the `month` value, so a
102
+ fallback month moved by `selected` or by selecting a neighbouring month's cell
103
+ is announced as it happens instead of arriving on a later repaint. Re-applying
104
+ the same month, as a Turbo cache restore does, stays silent.
105
+ - scrollspy re-evaluates links and sections after every supported entry path;
106
+ intersection-based controllers align fallback thresholds, hidden-layout
107
+ handling, and reduced-motion behavior.
108
+ - `stimeo catalog --json` and large `stimeo check --json` reports are no longer
109
+ truncated when stdout is piped; the CLI now lets buffered output flush before
110
+ exiting, and the build audit exercises the real piped catalog.
111
+
10
112
  ## [0.2.1] - 2026-07-26
11
113
 
12
114
  Patch release from a review of the disclosure and layout components, plus a
@@ -234,6 +336,8 @@ Initial public alpha: 101 behavior-only, accessible Stimulus controllers driven
234
336
  by `data-*` attributes, shipping no CSS. Published to npm (with provenance) and
235
337
  RubyGems.
236
338
 
339
+ [0.3.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.3.0
340
+ [0.2.1]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.2.1
237
341
  [0.2.0]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.2.0
238
342
  [0.1.0-beta.3]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.1.0-beta.3
239
343
  [0.1.0-beta.2]: https://github.com/taiyaky/stimeo-ui/releases/tag/v0.1.0-beta.2
@@ -1,5 +1,13 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
+ // src/controllers/accordion_controller.ts
4
+
5
+ // src/utils/arrow_step.ts
6
+ function isReservedArrowChord(event, allow = []) {
7
+ if (!event.key.startsWith("Arrow")) return false;
8
+ return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
9
+ }
10
+
3
11
  // src/controllers/accordion_controller.ts
4
12
  var AccordionController = class extends Controller {
5
13
  static targets = ["trigger", "panel"];
@@ -39,6 +47,8 @@ var AccordionController = class extends Controller {
39
47
  * `.focus()` lands on nothing and navigation appears to stall.
40
48
  */
41
49
  onKeydown(event) {
50
+ if (event.defaultPrevented) return;
51
+ if (isReservedArrowChord(event)) return;
42
52
  const current = event.currentTarget;
43
53
  if (this.triggerTargets.indexOf(current) === -1) return;
44
54
  const navigable = this.triggerTargets.filter((trigger) => trigger.closest("[hidden]") === null);
@@ -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;