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
@@ -2,6 +2,75 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/toolbar_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 isReservedArrowChord(event, allow = []) {
12
+ if (!event.key.startsWith("Arrow")) return false;
13
+ return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
14
+ }
15
+
16
+ // src/utils/composition_tracker.ts
17
+ var CompositionTracker = class {
18
+ #observedTargets = /* @__PURE__ */ new Set();
19
+ #activeTargets = /* @__PURE__ */ new Set();
20
+ #onStart;
21
+ #onEnd;
22
+ constructor(options = {}) {
23
+ this.#onStart = options.onStart;
24
+ this.#onEnd = options.onEnd;
25
+ }
26
+ /** Starts lifecycle tracking for `target`; repeated calls are idempotent. */
27
+ observe(target) {
28
+ if (this.#observedTargets.has(target)) return;
29
+ target.addEventListener("compositionstart", this.#handleStart);
30
+ target.addEventListener("compositionend", this.#handleEnd);
31
+ this.#observedTargets.add(target);
32
+ }
33
+ /** Stops tracking one target and clears any active composition it owned. */
34
+ unobserve(target) {
35
+ if (!this.#observedTargets.delete(target)) return;
36
+ target.removeEventListener("compositionstart", this.#handleStart);
37
+ target.removeEventListener("compositionend", this.#handleEnd);
38
+ this.#activeTargets.delete(target);
39
+ }
40
+ /** Releases every listener and clears state so reconnect starts cleanly. */
41
+ disconnect() {
42
+ for (const target of this.#observedTargets) {
43
+ target.removeEventListener("compositionstart", this.#handleStart);
44
+ target.removeEventListener("compositionend", this.#handleEnd);
45
+ }
46
+ this.#observedTargets.clear();
47
+ this.#activeTargets.clear();
48
+ }
49
+ /** True when lifecycle tracking or the current event reports composition. */
50
+ isComposing(event) {
51
+ return this.#activeTargets.size > 0 || event?.isComposing === true;
52
+ }
53
+ #handleStart = (event) => {
54
+ if (event.currentTarget) this.#activeTargets.add(event.currentTarget);
55
+ this.#onStart?.(event);
56
+ };
57
+ #handleEnd = (event) => {
58
+ if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);
59
+ this.#onEnd?.(event);
60
+ };
61
+ };
62
+
63
+ // src/utils/focus_candidate.ts
64
+ function inheritsFieldsetDisabled(control) {
65
+ let fieldset = control.closest("fieldset[disabled]");
66
+ while (fieldset) {
67
+ const legend = Array.from(fieldset.children).find((child) => child.tagName === "LEGEND");
68
+ if (!legend?.contains(control)) return true;
69
+ fieldset = fieldset.parentElement?.closest("fieldset[disabled]") ?? null;
70
+ }
71
+ return false;
72
+ }
73
+
5
74
  // src/utils/roving_tabindex.ts
6
75
  var RovingTabindex = class {
7
76
  /** Returns the current ordered item elements; called on every operation. */
@@ -42,6 +111,7 @@ function rovingMove(current, length, delta, wrap) {
42
111
  }
43
112
 
44
113
  // src/controllers/toolbar_controller.ts
114
+ var STATE_ATTRIBUTES = ["disabled", "hidden"];
45
115
  var ToolbarController = class extends Controller {
46
116
  static targets = ["control"];
47
117
  static values = {
@@ -50,61 +120,190 @@ var ToolbarController = class extends Controller {
50
120
  };
51
121
  static actions = ["onKeydown"];
52
122
  #roving = new RovingTabindex(() => this.controlTargets);
123
+ #composition = new CompositionTracker();
124
+ #observer = null;
53
125
  /**
54
- * Establishes the single tab stop: keep an existing one if it is still
55
- * navigable, else the first navigable control. Disabled / hidden controls are
56
- * skipped so the toolbar's lone Tab stop never lands on an unfocusable element
57
- * (which would make the whole group unreachable on Tab re-entry).
126
+ * Live between `connect()` and `disconnect()`. Stimulus reports the *initial*
127
+ * targets before `connect()` and re-reports them as disconnected after
128
+ * `disconnect()`; gating on this keeps the target callbacks from clobbering
129
+ * the authored Tab stop on mount and from resurrecting one after teardown.
58
130
  */
131
+ #connected = false;
59
132
  connect() {
60
- const active = this.#roving.activeIndex;
61
- const activeEl = active === -1 ? null : this.controlTargets[active];
62
- if (activeEl && this.#isNavigable(activeEl)) {
63
- this.#roving.setActive(active);
64
- return;
133
+ this.#ensureTabStop();
134
+ this.element.addEventListener("keydown", this.#onKeydown);
135
+ this.element.addEventListener("focusin", this.#onFocusin);
136
+ this.#composition.observe(this.element);
137
+ if (typeof MutationObserver !== "undefined") {
138
+ const observer = new MutationObserver(() => this.#ensureTabStop());
139
+ observer.observe(this.element, {
140
+ subtree: true,
141
+ childList: true,
142
+ attributes: true,
143
+ attributeFilter: STATE_ATTRIBUTES
144
+ });
145
+ for (let fieldset = this.element.parentElement?.closest("fieldset") ?? null; fieldset; fieldset = fieldset.parentElement?.closest("fieldset") ?? null) {
146
+ observer.observe(fieldset, { attributes: true, attributeFilter: ["disabled"] });
147
+ }
148
+ this.#observer = observer;
65
149
  }
66
- const first = this.#navigableControls[0];
67
- this.#roving.setActive(first ? this.controlTargets.indexOf(first) : -1);
150
+ this.#connected = true;
151
+ }
152
+ disconnect() {
153
+ this.#connected = false;
154
+ this.element.removeEventListener("keydown", this.#onKeydown);
155
+ this.element.removeEventListener("focusin", this.#onFocusin);
156
+ this.#composition.disconnect();
157
+ this.#observer?.disconnect();
158
+ this.#observer = null;
68
159
  }
69
- /** Arrow/Home/End move focus and the single tab stop. Bound via `data-action`. */
160
+ /**
161
+ * A control added at runtime is dropped out of the Tab sequence first — a
162
+ * fresh `<button>` is tabbable by default, which would make the group two Tab
163
+ * stops — and then the lone stop is re-established.
164
+ */
165
+ controlTargetConnected(control) {
166
+ if (!this.#connected) return;
167
+ control.tabIndex = -1;
168
+ this.#ensureTabStop();
169
+ }
170
+ /** Removing the control that held the Tab stop must not orphan the group. */
171
+ controlTargetDisconnected() {
172
+ if (!this.#connected) return;
173
+ this.#ensureTabStop();
174
+ }
175
+ /**
176
+ * Arrow/Home/End move focus and the single tab stop.
177
+ *
178
+ * Binding this per control with `data-action` is **optional** — the same
179
+ * handling runs from the container's delegated listener. It stays a declared
180
+ * action so per-control wiring keeps working; wiring both does not
181
+ * double-move, because the delegated pass then yields on `defaultPrevented`.
182
+ */
70
183
  onKeydown(event) {
71
- const navigable = this.#navigableControls;
72
- const current = navigable.indexOf(event.currentTarget);
73
- if (current === -1) return;
74
- const length = navigable.length;
75
- const wrap = this.wrapValue ? "wrap" : "clamp";
184
+ this.#handleKeydown(event);
185
+ }
186
+ /** Delegated counterpart of {@link onKeydown}; bound on the container. */
187
+ #onKeydown = (event) => {
188
+ this.#handleKeydown(event);
189
+ };
190
+ /**
191
+ * Syncs the Tab stop to a control focused by other means (click, programmatic
192
+ * `focus()`) so Tab re-entry returns there. A non-navigable control is ignored:
193
+ * the lone Tab stop must never sit on something the user cannot operate.
194
+ */
195
+ #onFocusin = (event) => {
196
+ const index = this.#indexOf(event.target);
197
+ const control = index === -1 ? void 0 : this.controlTargets[index];
198
+ if (!control || !this.#isNavigable(control)) return;
199
+ this.#roving.setActive(index);
200
+ };
201
+ #handleKeydown(event) {
202
+ if (event.defaultPrevented) return;
203
+ if (isReservedArrowChord(event)) return;
204
+ if (this.#composition.isComposing(event)) return;
205
+ this.#ensureTabStop();
206
+ const from = this.#indexOf(event.target);
207
+ if (from === -1) return;
76
208
  const vertical = this.orientationValue === "vertical";
77
- const forwardKey = vertical ? "ArrowDown" : "ArrowRight";
78
- const backwardKey = vertical ? "ArrowUp" : "ArrowLeft";
79
- let next;
209
+ const rtl = !vertical && isRtl(this.element);
210
+ const forwardKey = vertical ? "ArrowDown" : rtl ? "ArrowLeft" : "ArrowRight";
211
+ const backwardKey = vertical ? "ArrowUp" : rtl ? "ArrowRight" : "ArrowLeft";
212
+ let target;
80
213
  if (event.key === forwardKey) {
81
- next = rovingMove(current, length, 1, wrap);
214
+ target = this.#nextNavigable(from, 1);
82
215
  } else if (event.key === backwardKey) {
83
- next = rovingMove(current, length, -1, wrap);
216
+ target = this.#nextNavigable(from, -1);
84
217
  } else if (event.key === "Home") {
85
- next = 0;
218
+ target = this.#navigableControls[0];
86
219
  } else if (event.key === "End") {
87
- next = length - 1;
220
+ const navigable = this.#navigableControls;
221
+ target = navigable[navigable.length - 1];
88
222
  } else {
89
223
  return;
90
224
  }
91
225
  event.preventDefault();
92
- const target = navigable[next];
93
226
  if (target) this.#roving.setActive(this.controlTargets.indexOf(target), { focus: true });
94
227
  }
95
- /** Controls eligible for the roving tab stop (excludes disabled / hidden). */
228
+ /**
229
+ * Re-establishes the single Tab stop: keep the current one while it is still
230
+ * navigable, else hand it to the first navigable control. `-1` (no Tab stop at
231
+ * all) is reached only when every control is unavailable, and is recovered
232
+ * from as soon as one becomes navigable again. Idempotent by construction, so
233
+ * connect, the target callbacks, the observer, and keydown can all call it.
234
+ */
235
+ #ensureTabStop() {
236
+ const active = this.#roving.activeIndex;
237
+ const activeEl = active === -1 ? null : this.controlTargets[active];
238
+ if (activeEl && this.#isNavigable(activeEl)) {
239
+ this.#roving.setActive(active);
240
+ return;
241
+ }
242
+ const first = this.#navigableControls[0];
243
+ this.#roving.setActive(first ? this.controlTargets.indexOf(first) : -1);
244
+ }
245
+ /**
246
+ * First navigable control strictly in the `delta` direction from `fromIndex`,
247
+ * scanning the **full** control list rather than the navigable subset so a
248
+ * non-navigable origin is never a dead end. When the direction yields nothing
249
+ * the origin keeps focus; when the origin itself is not navigable the first
250
+ * navigable control is used instead, so an arrow key always escapes.
251
+ */
252
+ #nextNavigable(fromIndex, delta) {
253
+ const controls = this.controlTargets;
254
+ const length = controls.length;
255
+ const wrap = this.wrapValue ? "wrap" : "clamp";
256
+ let index = fromIndex;
257
+ for (let step = 0; step < length; step++) {
258
+ const next = rovingMove(index, length, delta, wrap);
259
+ if (next === index) break;
260
+ index = next;
261
+ const candidate = controls[index];
262
+ if (candidate && this.#isNavigable(candidate)) return candidate;
263
+ }
264
+ const origin = controls[fromIndex];
265
+ return origin && this.#isNavigable(origin) ? origin : this.#navigableControls[0];
266
+ }
267
+ /** Index in `controlTargets` of the control owning `target` (it or a descendant). */
268
+ #indexOf(target) {
269
+ const node = target;
270
+ if (!node) return -1;
271
+ return this.controlTargets.findIndex((control) => control === node || control.contains(node));
272
+ }
273
+ /** Controls eligible for the roving tab stop (excludes native disabled / hidden). */
96
274
  get #navigableControls() {
97
275
  return this.controlTargets.filter((control) => this.#isNavigable(control));
98
276
  }
99
277
  /**
100
- * A control can hold the tab stop unless it is `hidden`, `aria-disabled`, or a
101
- * natively `disabled` form control. CSS-only visibility cannot be detected
278
+ * A control can hold the tab stop unless it is `hidden` or a disabled form
279
+ * control. `aria-disabled` remains navigable per the APG; activation suppression
280
+ * belongs to the control itself or its owning behavior. Native disabledness is
281
+ * read from the `disabled` property, narrowed by an `in` check rather than
282
+ * asserted since `<a>` and `<div role="button">` are legitimate controls that
283
+ * are unaffected by a disabled fieldset. CSS-only visibility cannot be detected
102
284
  * headlessly and stays the consumer's responsibility.
103
285
  */
104
286
  #isNavigable(control) {
105
- if (control.hasAttribute("hidden")) return false;
106
- if (control.getAttribute("aria-disabled") === "true") return false;
107
- return !control.disabled;
287
+ if (this.#isHidden(control)) return false;
288
+ if (!("disabled" in control)) return true;
289
+ if (control.disabled === true) return false;
290
+ return !inheritsFieldsetDisabled(control);
291
+ }
292
+ /**
293
+ * Whether `control`, or anything between it and the toolbar root, is `hidden`.
294
+ *
295
+ * Reading only the control's own attribute lets an invisible control hold the
296
+ * single Tab stop, which takes the *whole* toolbar out of the Tab sequence — an
297
+ * ordinary `hidden` wrapper is enough. The walk stops at the root: a toolbar
298
+ * inside a `hidden` region is already out of the page's Tab order.
299
+ */
300
+ #isHidden(control) {
301
+ let node = control;
302
+ while (node && node !== this.element) {
303
+ if (node.hasAttribute("hidden")) return true;
304
+ node = node.parentElement;
305
+ }
306
+ return false;
108
307
  }
109
308
  };
110
309