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
@@ -1,5 +1,40 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
+ // src/controllers/resizable_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
+
11
+ // src/utils/tabindex_loan.ts
12
+ var TabindexLoan = class {
13
+ #value;
14
+ #lent = /* @__PURE__ */ new Set();
15
+ /**
16
+ * @param value - the `tabindex` to lend. `"-1"` (the default) is
17
+ * programmatically focusable but not a Tab stop; `"0"` is a real Tab stop,
18
+ * which a scroll region with no focusable content of its own needs.
19
+ */
20
+ constructor(value = "-1") {
21
+ this.#value = value;
22
+ }
23
+ /** Lends `element` the value; no-ops when it already carries a `tabindex`. */
24
+ lend(element) {
25
+ if (element.hasAttribute("tabindex")) return;
26
+ element.setAttribute("tabindex", this.#value);
27
+ this.#lent.add(element);
28
+ }
29
+ /** Takes back every loan whose value is still the one that was lent. */
30
+ returnAll() {
31
+ for (const element of this.#lent) {
32
+ if (element.getAttribute("tabindex") === this.#value) element.removeAttribute("tabindex");
33
+ }
34
+ this.#lent.clear();
35
+ }
36
+ };
37
+
3
38
  // src/controllers/resizable_controller.ts
4
39
  var ResizableController = class extends Controller {
5
40
  static targets = ["primary", "secondary", "separator"];
@@ -11,18 +46,46 @@ var ResizableController = class extends Controller {
11
46
  };
12
47
  static actions = ["onKeydown", "onPointerDown", "toggle"];
13
48
  static events = ["change"];
14
- /** Track previously held value before collapse toggles. */
49
+ /** The value held before the current collapse, restored when toggling back. */
15
50
  #valueBeforeCollapse = 50;
16
51
  /** Aborts in-progress pointer-drag listeners when the drag ends or on teardown. */
17
52
  #dragAbort = null;
53
+ /** `tabindex` lent to a pane so `F6` can put focus on it; panes carry none. */
54
+ #paneTabindex = new TabindexLoan();
55
+ /** Releases the pane cycle listener bound in {@link connect}. */
56
+ #cycleAbort = null;
18
57
  connect() {
19
58
  this.#clampAndSync();
59
+ this.#cycleAbort = new AbortController();
60
+ this.element.addEventListener("keydown", this.#onCycleKeydown, {
61
+ signal: this.#cycleAbort.signal
62
+ });
20
63
  }
21
64
  /** Cancels any active pointer drag so listeners never leak past disconnect. */
22
65
  disconnect() {
23
66
  this.#dragAbort?.abort();
24
67
  this.#dragAbort = null;
68
+ this.#cycleAbort?.abort();
69
+ this.#cycleAbort = null;
70
+ this.#paneTabindex.returnAll();
25
71
  }
72
+ /** Moves focus to the next pane on `F6`, wrapping; entering at the first one. */
73
+ #onCycleKeydown = (event) => {
74
+ if (event.key !== "F6") return;
75
+ if (event.defaultPrevented) return;
76
+ if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
77
+ const panes = [];
78
+ if (this.hasPrimaryTarget) panes.push(this.primaryTarget);
79
+ if (this.hasSecondaryTarget) panes.push(this.secondaryTarget);
80
+ if (panes.length === 0) return;
81
+ const target = event.target;
82
+ const current = panes.findIndex((pane) => target instanceof Node && pane.contains(target));
83
+ const next = panes[(current + 1) % panes.length];
84
+ if (!next) return;
85
+ event.preventDefault();
86
+ this.#paneTabindex.lend(next);
87
+ next.focus();
88
+ };
26
89
  /**
27
90
  * Stimulus lifecycle callback when the valueValue changes.
28
91
  * Keeps CSS fractions and ARIA status completely aligned.
@@ -47,6 +110,7 @@ var ResizableController = class extends Controller {
47
110
  }
48
111
  /** Keydown adjustments for ArrowUp/Down/Left/Right and Home/End. */
49
112
  onKeydown(event) {
113
+ if (isReservedArrowChord(event)) return;
50
114
  if (!this.hasSeparatorTarget) return;
51
115
  const orientation = this.separatorTarget.getAttribute("aria-orientation") || "vertical";
52
116
  const isVertical = orientation === "vertical";
@@ -2,6 +2,17 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/roving_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
+
5
16
  // src/utils/roving_tabindex.ts
6
17
  var RovingTabindex = class {
7
18
  /** Returns the current ordered item elements; called on every operation. */
@@ -64,6 +75,7 @@ var RovingController = class extends Controller {
64
75
  /** Arrow keys move focus + the tab stop; Home/End jump to the ends. */
65
76
  #onKeydown = (event) => {
66
77
  if (event.defaultPrevented) return;
78
+ if (isReservedArrowChord(event)) return;
67
79
  const items = this.itemTargets;
68
80
  const current = this.#indexOf(event.target);
69
81
  if (current === -1) return;
@@ -72,10 +84,13 @@ var RovingController = class extends Controller {
72
84
  const orientation = this.orientationValue;
73
85
  const horizontal = orientation === "horizontal" || orientation === "both";
74
86
  const vertical = orientation === "vertical" || orientation === "both";
87
+ const rtl = horizontal && isRtl(this.element);
88
+ const forwardKey = rtl ? "ArrowLeft" : "ArrowRight";
89
+ const backwardKey = rtl ? "ArrowRight" : "ArrowLeft";
75
90
  let next;
76
- if (horizontal && event.key === "ArrowRight" || vertical && event.key === "ArrowDown") {
91
+ if (horizontal && event.key === forwardKey || vertical && event.key === "ArrowDown") {
77
92
  next = rovingMove(current, length, 1, wrap);
78
- } else if (horizontal && event.key === "ArrowLeft" || vertical && event.key === "ArrowUp") {
93
+ } else if (horizontal && event.key === backwardKey || vertical && event.key === "ArrowUp") {
79
94
  next = rovingMove(current, length, -1, wrap);
80
95
  } else if (this.homeEndValue && event.key === "Home") {
81
96
  next = 0;
@@ -72,6 +72,33 @@ function logicalScrollMetrics(element, horizontal) {
72
72
  return { position: Math.min(max, Math.max(0, position)), max };
73
73
  }
74
74
 
75
+ // src/utils/tabindex_loan.ts
76
+ var TabindexLoan = class {
77
+ #value;
78
+ #lent = /* @__PURE__ */ new Set();
79
+ /**
80
+ * @param value - the `tabindex` to lend. `"-1"` (the default) is
81
+ * programmatically focusable but not a Tab stop; `"0"` is a real Tab stop,
82
+ * which a scroll region with no focusable content of its own needs.
83
+ */
84
+ constructor(value = "-1") {
85
+ this.#value = value;
86
+ }
87
+ /** Lends `element` the value; no-ops when it already carries a `tabindex`. */
88
+ lend(element) {
89
+ if (element.hasAttribute("tabindex")) return;
90
+ element.setAttribute("tabindex", this.#value);
91
+ this.#lent.add(element);
92
+ }
93
+ /** Takes back every loan whose value is still the one that was lent. */
94
+ returnAll() {
95
+ for (const element of this.#lent) {
96
+ if (element.getAttribute("tabindex") === this.#value) element.removeAttribute("tabindex");
97
+ }
98
+ this.#lent.clear();
99
+ }
100
+ };
101
+
75
102
  // src/controllers/scroll_area_controller.ts
76
103
  var FOCUSABLE_SELECTOR = [
77
104
  "a[href]",
@@ -90,10 +117,12 @@ var ScrollAreaController = class extends Controller {
90
117
  };
91
118
  static events = ["reach"];
92
119
  #layout = new LayoutObserver(() => this.#update());
120
+ /** Re-checks the tab stop when the viewport's focusable content comes or goes. */
121
+ #content = null;
93
122
  /** Last edge reported via `reach`, so the event fires once per arrival. */
94
123
  #lastEdge = null;
95
124
  /** Whether this controller added `tabindex`, so teardown only removes its own. */
96
- #addedTabindex = false;
125
+ #tabindex = new TabindexLoan("0");
97
126
  /** Whether this controller added `role="region"`, for symmetric teardown. */
98
127
  #addedRole = false;
99
128
  #onScroll = () => {
@@ -104,6 +133,18 @@ var ScrollAreaController = class extends Controller {
104
133
  this.viewportTarget.addEventListener("scroll", this.#onScroll, { passive: true });
105
134
  this.#layout.observe(this.viewportTarget);
106
135
  this.#layout.observeViewport();
136
+ if (typeof MutationObserver !== "undefined") {
137
+ this.#content = new MutationObserver(() => {
138
+ if (!this.hasViewportTarget) return;
139
+ const vp = this.viewportTarget;
140
+ this.#syncKeyboardReach(vp, this.#syncOverflow(vp));
141
+ });
142
+ this.#content.observe(this.viewportTarget, {
143
+ subtree: true,
144
+ childList: true,
145
+ attributes: true
146
+ });
147
+ }
107
148
  this.#update();
108
149
  }
109
150
  disconnect() {
@@ -112,14 +153,15 @@ var ScrollAreaController = class extends Controller {
112
153
  this.#clearAddedAttributes(this.viewportTarget);
113
154
  }
114
155
  this.#layout.disconnect();
156
+ this.#content?.disconnect();
157
+ this.#content = null;
115
158
  this.#lastEdge = null;
116
159
  }
117
160
  /** Re-measures overflow and scroll position and reflects the state hooks. */
118
161
  #update() {
119
162
  if (!this.hasViewportTarget) return;
120
163
  const vp = this.viewportTarget;
121
- const overflowing = this.#measureOverflow(vp);
122
- this.element.setAttribute("data-overflow", overflowing ? "true" : "false");
164
+ const overflowing = this.#syncOverflow(vp);
123
165
  this.#syncKeyboardReach(vp, overflowing);
124
166
  const { position, progress } = this.#measurePosition(vp);
125
167
  this.element.setAttribute("data-scroll", position);
@@ -133,6 +175,21 @@ var ScrollAreaController = class extends Controller {
133
175
  }
134
176
  }
135
177
  /** Whether the viewport can scroll on the configured axis. */
178
+ /**
179
+ * Measures overflow and reflects the `data-overflow` hook.
180
+ *
181
+ * The write is skipped when the value is unchanged. An identical `setAttribute` still
182
+ * queues a MutationRecord, and markup that puts the viewport target on the controller
183
+ * element itself would then have the content observer trigger its own next callback.
184
+ */
185
+ #syncOverflow(vp) {
186
+ const overflowing = this.#measureOverflow(vp);
187
+ const next = overflowing ? "true" : "false";
188
+ if (this.element.getAttribute("data-overflow") !== next) {
189
+ this.element.setAttribute("data-overflow", next);
190
+ }
191
+ return overflowing;
192
+ }
136
193
  #measureOverflow(vp) {
137
194
  const o = this.orientationValue;
138
195
  const vertical = o !== "horizontal" && vp.scrollHeight > vp.clientHeight + EDGE_EPSILON;
@@ -160,10 +217,7 @@ var ScrollAreaController = class extends Controller {
160
217
  #syncKeyboardReach(vp, overflowing) {
161
218
  const wantsTabindex = overflowing && !this.#hasFocusableContent(vp);
162
219
  if (wantsTabindex) {
163
- if (!vp.hasAttribute("tabindex")) {
164
- vp.setAttribute("tabindex", "0");
165
- this.#addedTabindex = true;
166
- }
220
+ this.#tabindex.lend(vp);
167
221
  if (!vp.hasAttribute("role") && this.#hasAccessibleName(vp)) {
168
222
  vp.setAttribute("role", "region");
169
223
  this.#addedRole = true;
@@ -174,17 +228,37 @@ var ScrollAreaController = class extends Controller {
174
228
  }
175
229
  /** Removes (and resets the flags for) only the attributes this controller added. */
176
230
  #clearAddedAttributes(vp) {
177
- if (this.#addedTabindex) {
178
- vp.removeAttribute("tabindex");
179
- this.#addedTabindex = false;
180
- }
231
+ this.#tabindex.returnAll();
181
232
  if (this.#addedRole) {
182
233
  vp.removeAttribute("role");
183
234
  this.#addedRole = false;
184
235
  }
185
236
  }
237
+ /**
238
+ * Whether the viewport owns something the user can Tab to *right now*.
239
+ *
240
+ * The selector alone is not enough: a `display: none` button still matches it,
241
+ * so a viewport whose only control is revealed on demand would never get a tab
242
+ * stop — leaving it unreachable by keyboard exactly while it has nothing else to
243
+ * offer. Only rendered candidates count.
244
+ */
186
245
  #hasFocusableContent(vp) {
187
- return vp.querySelector(FOCUSABLE_SELECTOR) !== null;
246
+ return Array.from(vp.querySelectorAll(FOCUSABLE_SELECTOR)).some(
247
+ (el) => this.#isRendered(el)
248
+ );
249
+ }
250
+ /**
251
+ * Whether `el` is actually rendered, and so can hold focus.
252
+ *
253
+ * `checkVisibility()` answers this for every way CSS can remove a box, including
254
+ * a class-driven `display: none` that no attribute reveals. The `hidden` walk in
255
+ * front of it is not redundant: it is the one case a DOM-only environment with no
256
+ * layout engine has to be told about explicitly.
257
+ */
258
+ #isRendered(el) {
259
+ if (el.closest("[hidden]") !== null) return false;
260
+ const check = el.checkVisibility;
261
+ return typeof check === "function" ? check.call(el, { visibilityProperty: true }) : true;
188
262
  }
189
263
  #hasAccessibleName(vp) {
190
264
  return vp.hasAttribute("aria-label") || vp.hasAttribute("aria-labelledby");
@@ -6,7 +6,7 @@ var ScrollRestoreController = class extends Controller {
6
6
  key: { type: String, default: "" },
7
7
  axis: { type: String, default: "vertical" }
8
8
  };
9
- /** Pending rAF id used to coalesce scroll bursts into one save. */
9
+ /** Pending rAF id that coalesces scroll bursts into one save. */
10
10
  #rafId = null;
11
11
  /** Resolved storage key; empty disables persistence (no key and no id). */
12
12
  #storageKey = "";
@@ -7,6 +7,33 @@ function prefersReducedMotion() {
7
7
  return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
8
8
  }
9
9
 
10
+ // src/utils/tabindex_loan.ts
11
+ var TabindexLoan = class {
12
+ #value;
13
+ #lent = /* @__PURE__ */ new Set();
14
+ /**
15
+ * @param value - the `tabindex` to lend. `"-1"` (the default) is
16
+ * programmatically focusable but not a Tab stop; `"0"` is a real Tab stop,
17
+ * which a scroll region with no focusable content of its own needs.
18
+ */
19
+ constructor(value = "-1") {
20
+ this.#value = value;
21
+ }
22
+ /** Lends `element` the value; no-ops when it already carries a `tabindex`. */
23
+ lend(element) {
24
+ if (element.hasAttribute("tabindex")) return;
25
+ element.setAttribute("tabindex", this.#value);
26
+ this.#lent.add(element);
27
+ }
28
+ /** Takes back every loan whose value is still the one that was lent. */
29
+ returnAll() {
30
+ for (const element of this.#lent) {
31
+ if (element.getAttribute("tabindex") === this.#value) element.removeAttribute("tabindex");
32
+ }
33
+ this.#lent.clear();
34
+ }
35
+ };
36
+
10
37
  // src/controllers/scroll_visibility_controller.ts
11
38
  var ScrollVisibilityController = class extends Controller {
12
39
  static targets = ["element"];
@@ -18,7 +45,7 @@ var ScrollVisibilityController = class extends Controller {
18
45
  };
19
46
  static actions = ["toTop"];
20
47
  static events = ["change"];
21
- /** Pending rAF id used to coalesce scroll bursts into one measurement. */
48
+ /** Pending rAF id that coalesces scroll bursts into one measurement. */
22
49
  #rafId = null;
23
50
  /** Previous scroll position, for `direction` mode delta detection. */
24
51
  #lastScrollY = 0;
@@ -29,6 +56,8 @@ var ScrollVisibilityController = class extends Controller {
29
56
  * the window. Captured on connect so teardown detaches from the same source.
30
57
  */
31
58
  #scrollSource = window;
59
+ /** Focus targets this instance lent a `tabindex` to. */
60
+ #tabindex = new TabindexLoan();
32
61
  #onScroll = () => {
33
62
  if (this.#rafId !== null) return;
34
63
  this.#rafId = requestAnimationFrame(() => {
@@ -48,16 +77,17 @@ var ScrollVisibilityController = class extends Controller {
48
77
  cancelAnimationFrame(this.#rafId);
49
78
  this.#rafId = null;
50
79
  }
80
+ this.#tabindex.returnAll();
51
81
  this.#visible = null;
52
82
  }
53
83
  /** Scrolls the source to the top and, optionally, moves focus to a safe target. */
54
84
  toTop() {
55
- const behavior = prefersReducedMotion() ? "auto" : "smooth";
85
+ const behavior = prefersReducedMotion() ? "instant" : "smooth";
56
86
  this.#scrollSource.scrollTo({ top: 0, behavior });
57
87
  if (this.focusSelectorValue) {
58
88
  const target = document.querySelector(this.focusSelectorValue);
59
89
  if (target) {
60
- if (!target.hasAttribute("tabindex")) target.setAttribute("tabindex", "-1");
90
+ this.#tabindex.lend(target);
61
91
  target.focus();
62
92
  }
63
93
  }