stimeo-ui 0.2.0 → 0.2.1

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 (124) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/dist/controllers/accordion_controller.d.ts +3 -2
  3. package/dist/controllers/accordion_controller.js.map +1 -1
  4. package/dist/controllers/alert_dialog_controller.d.ts +64 -0
  5. package/dist/controllers/alert_dialog_controller.js +318 -0
  6. package/dist/controllers/alert_dialog_controller.js.map +1 -0
  7. package/dist/controllers/carousel_controller.d.ts +100 -0
  8. package/dist/controllers/carousel_controller.js +272 -0
  9. package/dist/controllers/carousel_controller.js.map +1 -0
  10. package/dist/controllers/clipboard_controller.d.ts +72 -0
  11. package/dist/controllers/clipboard_controller.js +144 -0
  12. package/dist/controllers/clipboard_controller.js.map +1 -0
  13. package/dist/controllers/collapsible_controller.d.ts +70 -0
  14. package/dist/controllers/collapsible_controller.js +327 -0
  15. package/dist/controllers/collapsible_controller.js.map +1 -0
  16. package/dist/controllers/color_picker_controller.d.ts +76 -0
  17. package/dist/controllers/color_picker_controller.js +213 -0
  18. package/dist/controllers/color_picker_controller.js.map +1 -0
  19. package/dist/controllers/count_up_controller.js +8 -1
  20. package/dist/controllers/count_up_controller.js.map +1 -1
  21. package/dist/controllers/currency_input_controller.d.ts +72 -0
  22. package/dist/controllers/currency_input_controller.js +147 -0
  23. package/dist/controllers/currency_input_controller.js.map +1 -0
  24. package/dist/controllers/data_grid_controller.d.ts +71 -0
  25. package/dist/controllers/data_grid_controller.js +168 -0
  26. package/dist/controllers/data_grid_controller.js.map +1 -0
  27. package/dist/controllers/date_range_picker_controller.d.ts +87 -0
  28. package/dist/controllers/date_range_picker_controller.js +417 -0
  29. package/dist/controllers/date_range_picker_controller.js.map +1 -0
  30. package/dist/controllers/dismissible_controller.d.ts +57 -0
  31. package/dist/controllers/dismissible_controller.js +117 -0
  32. package/dist/controllers/dismissible_controller.js.map +1 -0
  33. package/dist/controllers/drawer_controller.d.ts +96 -0
  34. package/dist/controllers/drawer_controller.js +630 -0
  35. package/dist/controllers/drawer_controller.js.map +1 -0
  36. package/dist/controllers/editable_controller.d.ts +73 -0
  37. package/dist/controllers/editable_controller.js +168 -0
  38. package/dist/controllers/editable_controller.js.map +1 -0
  39. package/dist/controllers/file_dropzone_controller.d.ts +92 -0
  40. package/dist/controllers/file_dropzone_controller.js +165 -0
  41. package/dist/controllers/file_dropzone_controller.js.map +1 -0
  42. package/dist/controllers/filter_controller.d.ts +60 -0
  43. package/dist/controllers/filter_controller.js +86 -0
  44. package/dist/controllers/filter_controller.js.map +1 -0
  45. package/dist/controllers/flash_controller.js +36 -5
  46. package/dist/controllers/flash_controller.js.map +1 -1
  47. package/dist/controllers/highlight_controller.js +6 -4
  48. package/dist/controllers/highlight_controller.js.map +1 -1
  49. package/dist/controllers/intersection_controller.js +41 -18
  50. package/dist/controllers/intersection_controller.js.map +1 -1
  51. package/dist/controllers/lazy_frame_controller.js +33 -11
  52. package/dist/controllers/lazy_frame_controller.js.map +1 -1
  53. package/dist/controllers/masonry_controller.d.ts +54 -0
  54. package/dist/controllers/masonry_controller.js +142 -0
  55. package/dist/controllers/masonry_controller.js.map +1 -0
  56. package/dist/controllers/menubar_controller.d.ts +62 -0
  57. package/dist/controllers/menubar_controller.js +433 -0
  58. package/dist/controllers/menubar_controller.js.map +1 -0
  59. package/dist/controllers/multi_select_controller.d.ts +115 -0
  60. package/dist/controllers/multi_select_controller.js +472 -0
  61. package/dist/controllers/multi_select_controller.js.map +1 -0
  62. package/dist/controllers/navigation_menu_controller.d.ts +99 -0
  63. package/dist/controllers/navigation_menu_controller.js +384 -0
  64. package/dist/controllers/navigation_menu_controller.js.map +1 -0
  65. package/dist/controllers/overflow_indicator_controller.d.ts +12 -4
  66. package/dist/controllers/overflow_indicator_controller.js +178 -27
  67. package/dist/controllers/overflow_indicator_controller.js.map +1 -1
  68. package/dist/controllers/password_reveal_controller.d.ts +49 -0
  69. package/dist/controllers/password_reveal_controller.js +117 -0
  70. package/dist/controllers/password_reveal_controller.js.map +1 -0
  71. package/dist/controllers/range_slider_controller.d.ts +87 -0
  72. package/dist/controllers/range_slider_controller.js +166 -0
  73. package/dist/controllers/range_slider_controller.js.map +1 -0
  74. package/dist/controllers/read_more_controller.d.ts +52 -0
  75. package/dist/controllers/read_more_controller.js +194 -0
  76. package/dist/controllers/read_more_controller.js.map +1 -0
  77. package/dist/controllers/scroll_area_controller.js +15 -2
  78. package/dist/controllers/scroll_area_controller.js.map +1 -1
  79. package/dist/controllers/scroll_restore_controller.d.ts +54 -0
  80. package/dist/controllers/scroll_restore_controller.js +93 -0
  81. package/dist/controllers/scroll_restore_controller.js.map +1 -0
  82. package/dist/controllers/scroll_visibility_controller.js +8 -4
  83. package/dist/controllers/scroll_visibility_controller.js.map +1 -1
  84. package/dist/controllers/scrollspy_controller.js +33 -11
  85. package/dist/controllers/scrollspy_controller.js.map +1 -1
  86. package/dist/controllers/separator_controller.d.ts +54 -0
  87. package/dist/controllers/separator_controller.js +87 -0
  88. package/dist/controllers/separator_controller.js.map +1 -0
  89. package/dist/controllers/sidebar_controller.d.ts +107 -0
  90. package/dist/controllers/sidebar_controller.js +761 -0
  91. package/dist/controllers/sidebar_controller.js.map +1 -0
  92. package/dist/controllers/stepper_controller.d.ts +4 -0
  93. package/dist/controllers/stepper_controller.js +28 -12
  94. package/dist/controllers/stepper_controller.js.map +1 -1
  95. package/dist/controllers/stick_to_bottom_controller.js +8 -4
  96. package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
  97. package/dist/controllers/sticky_observer_controller.d.ts +17 -4
  98. package/dist/controllers/sticky_observer_controller.js +88 -20
  99. package/dist/controllers/sticky_observer_controller.js.map +1 -1
  100. package/dist/controllers/tabs_controller.d.ts +3 -2
  101. package/dist/controllers/tabs_controller.js.map +1 -1
  102. package/dist/controllers/tags_input_controller.d.ts +91 -0
  103. package/dist/controllers/tags_input_controller.js +275 -0
  104. package/dist/controllers/tags_input_controller.js.map +1 -0
  105. package/dist/controllers/theme_controller.js +20 -10
  106. package/dist/controllers/theme_controller.js.map +1 -1
  107. package/dist/controllers/time_picker_controller.d.ts +67 -0
  108. package/dist/controllers/time_picker_controller.js +212 -0
  109. package/dist/controllers/time_picker_controller.js.map +1 -0
  110. package/dist/controllers/toast_controller.d.ts +1 -9
  111. package/dist/controllers/toast_controller.js +36 -9
  112. package/dist/controllers/toast_controller.js.map +1 -1
  113. package/dist/controllers/transition_controller.d.ts +14 -8
  114. package/dist/controllers/transition_controller.js +153 -38
  115. package/dist/controllers/transition_controller.js.map +1 -1
  116. package/dist/controllers/tree_view_controller.d.ts +54 -0
  117. package/dist/controllers/tree_view_controller.js +275 -0
  118. package/dist/controllers/tree_view_controller.js.map +1 -0
  119. package/dist/index.d.ts +27 -1818
  120. package/dist/index.js +811 -295
  121. package/dist/index.js.map +1 -1
  122. package/dist/inspector/examples.json +2 -2
  123. package/dist/inspector/manifest.json +15 -3
  124. package/package.json +1 -1
@@ -0,0 +1,54 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ /**
4
+ * Headless, accessible single-select tree view.
5
+ *
6
+ * Markup contract (identifier: `stimeo--tree-view`):
7
+ * <ul data-controller="stimeo--tree-view" role="tree" aria-label="Files">
8
+ * <li role="treeitem" aria-expanded="false" aria-selected="false" tabindex="0"
9
+ * data-stimeo--tree-view-target="item"
10
+ * data-action="keydown->stimeo--tree-view#onKeydown
11
+ * click->stimeo--tree-view#onClick">
12
+ * <span>src</span>
13
+ * <ul role="group" data-stimeo--tree-view-target="group" hidden>
14
+ * <li role="treeitem" aria-selected="false" tabindex="-1"
15
+ * data-stimeo--tree-view-target="item" data-action="…">…</li>
16
+ * </ul>
17
+ * </li>
18
+ * </ul>
19
+ *
20
+ * Implements the WAI-ARIA APG **Tree View** (single-select) pattern. Parent/child
21
+ * structure is read from the DOM nesting (`treeitem` → child `group`). The whole
22
+ * tree is one Tab stop (roving tabindex); arrows navigate visible items, expand /
23
+ * collapse, and move between parent and child.
24
+ *
25
+ * Behavior provided:
26
+ * - `ArrowDown`/`ArrowUp` move between visible items; `Home`/`End` jump to the
27
+ * first / last visible item; printable characters typeahead by label prefix.
28
+ * - `ArrowRight` expands a collapsed parent or steps into its first child;
29
+ * `ArrowLeft` collapses an expanded parent or steps to the parent item.
30
+ * - `Enter`/`Space`/click select the item (single selection via `aria-selected`).
31
+ * - `aria-expanded` and each child `group`'s `hidden` stay in sync, dispatching
32
+ * `stimeo--tree-view:toggle`; selection dispatches `stimeo--tree-view:select`.
33
+ */
34
+ declare class TreeViewController extends Controller<HTMLElement> {
35
+ #private;
36
+ static targets: string[];
37
+ static actions: readonly ["onClick", "onKeydown"];
38
+ static events: readonly ["select", "toggle"];
39
+ readonly itemTargets: HTMLElement[];
40
+ /** Establishes the single tab stop (keeps an existing one, else the first). */
41
+ connect(): void;
42
+ /** Clears the pending typeahead-reset timer. */
43
+ disconnect(): void;
44
+ /**
45
+ * Routes tree keyboard interaction. Because `treeitem`s nest, only the handler
46
+ * on the nearest item to the event target acts; the same keydown bubbling to an
47
+ * ancestor item's handler is ignored to avoid double moves / selections.
48
+ */
49
+ onKeydown(event: KeyboardEvent): void;
50
+ /** Selects the clicked item (nearest to the target only). */
51
+ onClick(event: Event): void;
52
+ }
53
+
54
+ export { TreeViewController };
@@ -0,0 +1,275 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/tree_view_controller.ts
4
+
5
+ // src/utils/roving_tabindex.ts
6
+ var RovingTabindex = class {
7
+ /** Returns the current ordered item elements; called on every operation. */
8
+ #getItems;
9
+ /**
10
+ * @param getItems - Returns the current ordered item elements. Called on every
11
+ * operation so the live target list is always used.
12
+ */
13
+ constructor(getItems) {
14
+ this.#getItems = getItems;
15
+ }
16
+ /** Index of the currently tabbable item (`tabindex="0"`), or `-1` if none. */
17
+ get activeIndex() {
18
+ return this.#getItems().findIndex((item) => item.tabIndex === 0);
19
+ }
20
+ /**
21
+ * Makes exactly the item at `index` tabbable (`tabindex="0"`) and removes every
22
+ * other item from the Tab sequence (`tabindex="-1"`). An out-of-range `index`
23
+ * (e.g. `-1`) leaves all items at `-1`, which a controller can use to express
24
+ * "nothing is currently tabbable".
25
+ *
26
+ * @param index - Position of the item to make tabbable.
27
+ * @param options - Pass `{ focus: true }` to also move DOM focus to that item.
28
+ */
29
+ setActive(index, { focus = false } = {}) {
30
+ const items = this.#getItems();
31
+ items.forEach((item, i) => {
32
+ item.tabIndex = i === index ? 0 : -1;
33
+ });
34
+ if (focus) items[index]?.focus();
35
+ }
36
+ };
37
+
38
+ // src/utils/safe_timeout.ts
39
+ var TimerRegistry = class {
40
+ /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
41
+ ids = /* @__PURE__ */ new Set();
42
+ /**
43
+ * Cancels a single tracked timer.
44
+ *
45
+ * No-ops if the id is unknown (already cleared, fired, or never owned by this
46
+ * registry), so callers can clear defensively without guarding.
47
+ */
48
+ clear(id) {
49
+ if (this.ids.delete(id)) {
50
+ this.cancel(id);
51
+ }
52
+ }
53
+ /**
54
+ * Cancels every tracked timer. Call this from a controller's `disconnect()`
55
+ * to guarantee no timer outlives the element.
56
+ */
57
+ clearAll() {
58
+ for (const id of this.ids) {
59
+ this.cancel(id);
60
+ }
61
+ this.ids.clear();
62
+ }
63
+ /** Number of timers currently tracked (pending). */
64
+ get size() {
65
+ return this.ids.size;
66
+ }
67
+ };
68
+ var SafeTimeout = class extends TimerRegistry {
69
+ /**
70
+ * Schedules `callback` after `delay` ms and returns the timer id.
71
+ *
72
+ * The id is removed from the registry automatically when the timeout fires,
73
+ * so {@link TimerRegistry.size | size} reflects only still-pending timers.
74
+ */
75
+ set(callback, delay) {
76
+ const id = this.schedule(() => {
77
+ this.ids.delete(id);
78
+ callback();
79
+ }, delay);
80
+ this.ids.add(id);
81
+ return id;
82
+ }
83
+ schedule(callback, delay) {
84
+ return window.setTimeout(callback, delay);
85
+ }
86
+ cancel(id) {
87
+ window.clearTimeout(id);
88
+ }
89
+ };
90
+
91
+ // src/controllers/tree_view_controller.ts
92
+ var TYPEAHEAD_TIMEOUT = 500;
93
+ var TreeViewController = class extends Controller {
94
+ static targets = ["item", "group"];
95
+ static actions = ["onClick", "onKeydown"];
96
+ static events = ["select", "toggle"];
97
+ #roving = new RovingTabindex(() => this.itemTargets);
98
+ #typeahead = "";
99
+ #typeaheadTimer = 0;
100
+ #timers = new SafeTimeout();
101
+ /** Establishes the single tab stop (keeps an existing one, else the first). */
102
+ connect() {
103
+ const active = this.#roving.activeIndex;
104
+ this.#roving.setActive(active === -1 ? 0 : active);
105
+ }
106
+ /** Clears the pending typeahead-reset timer. */
107
+ disconnect() {
108
+ this.#timers.clearAll();
109
+ }
110
+ /**
111
+ * Routes tree keyboard interaction. Because `treeitem`s nest, only the handler
112
+ * on the nearest item to the event target acts; the same keydown bubbling to an
113
+ * ancestor item's handler is ignored to avoid double moves / selections.
114
+ */
115
+ onKeydown(event) {
116
+ const item = event.currentTarget;
117
+ if (event.target.closest('[role="treeitem"]') !== item) return;
118
+ switch (event.key) {
119
+ case "ArrowDown":
120
+ event.preventDefault();
121
+ this.#moveBy(item, 1);
122
+ break;
123
+ case "ArrowUp":
124
+ event.preventDefault();
125
+ this.#moveBy(item, -1);
126
+ break;
127
+ case "ArrowRight":
128
+ event.preventDefault();
129
+ this.#expandOrEnter(item);
130
+ break;
131
+ case "ArrowLeft":
132
+ event.preventDefault();
133
+ this.#collapseOrLeave(item);
134
+ break;
135
+ case "Home":
136
+ event.preventDefault();
137
+ this.#focusEdge(0);
138
+ break;
139
+ case "End":
140
+ event.preventDefault();
141
+ this.#focusEdge(-1);
142
+ break;
143
+ case "Enter":
144
+ case " ":
145
+ event.preventDefault();
146
+ this.#select(item);
147
+ break;
148
+ default:
149
+ if (this.#isPrintable(event)) {
150
+ event.preventDefault();
151
+ this.#typeaheadTo(item, event.key);
152
+ }
153
+ break;
154
+ }
155
+ }
156
+ /** Selects the clicked item (nearest to the target only). */
157
+ onClick(event) {
158
+ const item = event.currentTarget;
159
+ if (event.target.closest('[role="treeitem"]') !== item) return;
160
+ this.#focusItem(item);
161
+ this.#select(item);
162
+ }
163
+ /** Moves focus to the next (`delta=1`) or previous visible item, if any. */
164
+ #moveBy(item, delta) {
165
+ const visible = this.#visibleItems;
166
+ const current = visible.indexOf(item);
167
+ const next = visible[current + delta];
168
+ if (next) this.#focusItem(next);
169
+ }
170
+ /** `ArrowRight`: expand a collapsed parent, else step into the first child. */
171
+ #expandOrEnter(item) {
172
+ const group = this.#childGroup(item);
173
+ if (!group) return;
174
+ if (this.#isExpanded(item)) {
175
+ const firstChild = group.querySelector(':scope > [role="treeitem"]');
176
+ if (firstChild) this.#focusItem(firstChild);
177
+ } else {
178
+ this.#setExpanded(item, true);
179
+ }
180
+ }
181
+ /** `ArrowLeft`: collapse an expanded parent, else step out to the parent item. */
182
+ #collapseOrLeave(item) {
183
+ if (this.#childGroup(item) && this.#isExpanded(item)) {
184
+ this.#setExpanded(item, false);
185
+ return;
186
+ }
187
+ const parent = this.#parentItem(item);
188
+ if (parent) this.#focusItem(parent);
189
+ }
190
+ /** Focuses the first (`0`) or last (`-1`) visible item. */
191
+ #focusEdge(index) {
192
+ const visible = this.#visibleItems;
193
+ const target = index < 0 ? visible[visible.length - 1] : visible[index];
194
+ if (target) this.#focusItem(target);
195
+ }
196
+ /** Applies single selection and dispatches `select`. */
197
+ #select(item) {
198
+ for (const candidate of this.itemTargets) {
199
+ candidate.setAttribute("aria-selected", candidate === item ? "true" : "false");
200
+ }
201
+ this.dispatch("select", { detail: { item } });
202
+ }
203
+ /** Toggles `aria-expanded` + the child group's `hidden`, dispatching `toggle`. */
204
+ #setExpanded(item, expanded) {
205
+ const group = this.#childGroup(item);
206
+ if (!group) return;
207
+ item.setAttribute("aria-expanded", String(expanded));
208
+ group.hidden = !expanded;
209
+ this.dispatch("toggle", { detail: { item, expanded } });
210
+ }
211
+ /** Makes `item` the single tab stop and moves DOM focus to it. */
212
+ #focusItem(item) {
213
+ const index = this.itemTargets.indexOf(item);
214
+ if (index !== -1) this.#roving.setActive(index, { focus: true });
215
+ }
216
+ /** Advances the typeahead buffer and focuses the next matching visible item. */
217
+ #typeaheadTo(item, char) {
218
+ this.#timers.clear(this.#typeaheadTimer);
219
+ this.#typeahead += char.toLowerCase();
220
+ this.#typeaheadTimer = this.#timers.set(() => {
221
+ this.#typeahead = "";
222
+ }, TYPEAHEAD_TIMEOUT);
223
+ const visible = this.#visibleItems;
224
+ const start = visible.indexOf(item);
225
+ for (let offset = 1; offset <= visible.length; offset += 1) {
226
+ const candidate = visible[(start + offset) % visible.length];
227
+ if (candidate && this.#label(candidate).startsWith(this.#typeahead)) {
228
+ this.#focusItem(candidate);
229
+ return;
230
+ }
231
+ }
232
+ }
233
+ /** The visible items: those with no collapsed (`hidden`) ancestor group. */
234
+ get #visibleItems() {
235
+ return this.itemTargets.filter((item) => {
236
+ let node = item.parentElement;
237
+ while (node && node !== this.element) {
238
+ if (node.matches('[role="group"]') && node.hidden) return false;
239
+ node = node.parentElement;
240
+ }
241
+ return true;
242
+ });
243
+ }
244
+ /** The child `group` owned directly by `item`, or `null` for a leaf. */
245
+ #childGroup(item) {
246
+ return item.querySelector(':scope > [role="group"]');
247
+ }
248
+ /** The nearest ancestor `treeitem`, or `null` at the root level. */
249
+ #parentItem(item) {
250
+ return item.parentElement?.closest('[role="treeitem"]') ?? null;
251
+ }
252
+ /** Whether a parent item is currently expanded. */
253
+ #isExpanded(item) {
254
+ return item.getAttribute("aria-expanded") === "true";
255
+ }
256
+ /** `item`'s own label text (excluding any nested child group), lowercased. */
257
+ #label(item) {
258
+ let text = "";
259
+ for (const node of Array.from(item.childNodes)) {
260
+ if (node.nodeType === Node.ELEMENT_NODE && node.matches('[role="group"]')) {
261
+ continue;
262
+ }
263
+ text += node.textContent ?? "";
264
+ }
265
+ return text.trim().toLowerCase();
266
+ }
267
+ /** Whether `event.key` is a single printable character (no modifier chord). */
268
+ #isPrintable(event) {
269
+ return event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey;
270
+ }
271
+ };
272
+
273
+ export { TreeViewController };
274
+ //# sourceMappingURL=tree_view_controller.js.map
275
+ //# sourceMappingURL=tree_view_controller.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/utils/roving_tabindex.ts","../../src/utils/safe_timeout.ts","../../src/controllers/tree_view_controller.ts"],"names":[],"mappings":"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","file":"tree_view_controller.js","sourcesContent":["/**\n * Low-level roving-tabindex primitive shared by composite-widget controllers.\n *\n * The APG roving-tabindex pattern keeps a composite widget a single Tab stop:\n * exactly one item is in the Tab sequence (`tabindex=\"0\"`) while the rest are\n * removed from it (`tabindex=\"-1\"`), and the arrow keys move both DOM focus and\n * that single tabbable position together. {@link RovingTabindex} owns *only* that\n * mechanical bookkeeping — \"which one item is tabbable, and move focus there\".\n *\n * It is intentionally **policy-free**. Orientation, wrapping vs. clamping,\n * selection-follows-focus, typeahead, and `Home`/`End` semantics differ per APG\n * pattern (Radio Group, Toolbar, Rating, …); folding them into one helper would\n * flatten those widgets to a lowest common denominator and lose each pattern's\n * correctness. Those decisions therefore stay in each controller, which calls\n * {@link RovingTabindex.setActive} with an index it computed itself (optionally\n * via the pure {@link rovingMove} helper).\n *\n * @remarks\n * Items are read lazily through a getter so a controller can add or remove\n * targets (Stimulus re-scans the DOM) without re-wiring this helper.\n */\nexport class RovingTabindex {\n /** Returns the current ordered item elements; called on every operation. */\n readonly #getItems: () => HTMLElement[];\n\n /**\n * @param getItems - Returns the current ordered item elements. Called on every\n * operation so the live target list is always used.\n */\n constructor(getItems: () => HTMLElement[]) {\n this.#getItems = getItems;\n }\n\n /** Index of the currently tabbable item (`tabindex=\"0\"`), or `-1` if none. */\n get activeIndex(): number {\n return this.#getItems().findIndex((item) => item.tabIndex === 0);\n }\n\n /**\n * Makes exactly the item at `index` tabbable (`tabindex=\"0\"`) and removes every\n * other item from the Tab sequence (`tabindex=\"-1\"`). An out-of-range `index`\n * (e.g. `-1`) leaves all items at `-1`, which a controller can use to express\n * \"nothing is currently tabbable\".\n *\n * @param index - Position of the item to make tabbable.\n * @param options - Pass `{ focus: true }` to also move DOM focus to that item.\n */\n setActive(index: number, { focus = false }: { focus?: boolean } = {}): void {\n const items = this.#getItems();\n items.forEach((item, i) => {\n item.tabIndex = i === index ? 0 : -1;\n });\n if (focus) items[index]?.focus();\n }\n}\n\n/** Edge behavior for {@link rovingMove}: cycle past the ends, or stop at them. */\nexport type RovingWrap = \"wrap\" | \"clamp\";\n\n/**\n * Pure helper that resolves the target index for a one-step directional move.\n *\n * Keyboard/orientation mapping stays in the caller: it decides that a key means\n * `delta` `+1` (next) or `-1` (previous) and whether the widget should `\"wrap\"`\n * (Radio Group, Toolbar) or `\"clamp\"` at the ends.\n *\n * @param current - The index focus is moving from.\n * @param length - Number of items in the set.\n * @param delta - `+1` to move to the next item, `-1` for the previous.\n * @param wrap - `\"wrap\"` cycles around the ends; `\"clamp\"` stops at them.\n * @returns The resolved index, or `-1` when there are no items.\n */\nexport function rovingMove(\n current: number,\n length: number,\n delta: number,\n wrap: RovingWrap,\n): number {\n if (length === 0) return -1;\n const next = current + delta;\n if (wrap === \"wrap\") return (next + length) % length;\n return Math.min(length - 1, Math.max(0, next));\n}\n","/**\n * Self-cleaning timer registries shared by Stimeo controllers.\n *\n * Stimulus controllers frequently schedule `setTimeout` / `setInterval` work\n * (auto-dismiss, debouncing, polling). When the element leaves the DOM — a\n * Turbo Drive navigation, a Turbo Stream replacement, or any `disconnect()` —\n * orphaned timers keep firing against a detached controller, leaking memory and\n * mutating stale state. {@link SafeTimeout} and {@link SafeInterval} track every\n * timer they create so a single {@link TimerRegistry.clearAll | clearAll()} call\n * in `disconnect()` tears them all down.\n *\n * These are intentionally low-level primitives: they own *registration and\n * cleanup only*. Higher-level policy (pause/resume, remaining-time accounting)\n * stays in the individual controllers so per-widget semantics are not flattened\n * into a lowest-common-denominator helper.\n */\n\n/**\n * Shared registry bookkeeping for the timeout/interval variants.\n *\n * Subclasses provide the scheduling primitive ({@link schedule}) and its matching\n * canceller ({@link cancel}); this base owns the set of live ids plus the\n * per-id and bulk teardown shared by both.\n */\nabstract class TimerRegistry {\n /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */\n protected readonly ids = new Set<number>();\n\n /** Schedules the underlying platform timer and returns its id. */\n protected abstract schedule(callback: () => void, delay: number): number;\n\n /** Cancels the underlying platform timer for `id`. */\n protected abstract cancel(id: number): void;\n\n /**\n * Cancels a single tracked timer.\n *\n * No-ops if the id is unknown (already cleared, fired, or never owned by this\n * registry), so callers can clear defensively without guarding.\n */\n clear(id: number): void {\n if (this.ids.delete(id)) {\n this.cancel(id);\n }\n }\n\n /**\n * Cancels every tracked timer. Call this from a controller's `disconnect()`\n * to guarantee no timer outlives the element.\n */\n clearAll(): void {\n for (const id of this.ids) {\n this.cancel(id);\n }\n this.ids.clear();\n }\n\n /** Number of timers currently tracked (pending). */\n get size(): number {\n return this.ids.size;\n }\n}\n\n/**\n * `setTimeout` wrapper that auto-forgets each timer once it fires and supports\n * bulk teardown on disconnect.\n *\n * @example\n * ```ts\n * #timers = new SafeTimeout();\n *\n * connect() {\n * this.#timers.set(() => this.dismiss(), 5000);\n * }\n *\n * disconnect() {\n * this.#timers.clearAll();\n * }\n * ```\n */\nexport class SafeTimeout extends TimerRegistry {\n /**\n * Schedules `callback` after `delay` ms and returns the timer id.\n *\n * The id is removed from the registry automatically when the timeout fires,\n * so {@link TimerRegistry.size | size} reflects only still-pending timers.\n */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(() => {\n this.ids.delete(id);\n callback();\n }, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setTimeout(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearTimeout(id);\n }\n}\n\n/**\n * `setInterval` wrapper that tracks every interval for bulk teardown on\n * disconnect. Unlike {@link SafeTimeout}, intervals are retained until they are\n * explicitly cleared because they fire repeatedly.\n *\n * @example\n * ```ts\n * #intervals = new SafeInterval();\n *\n * connect() {\n * this.#intervals.set(() => this.tick(), 1000);\n * }\n *\n * disconnect() {\n * this.#intervals.clearAll();\n * }\n * ```\n */\nexport class SafeInterval extends TimerRegistry {\n /** Schedules a repeating `callback` every `delay` ms and returns the timer id. */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(callback, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setInterval(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearInterval(id);\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { RovingTabindex } from \"../utils/roving_tabindex\";\nimport { SafeTimeout } from \"../utils/safe_timeout\";\n\n/** How long (ms) typed characters accumulate into one typeahead query. */\nconst TYPEAHEAD_TIMEOUT = 500;\n\n/**\n * Headless, accessible single-select tree view.\n *\n * Markup contract (identifier: `stimeo--tree-view`):\n * <ul data-controller=\"stimeo--tree-view\" role=\"tree\" aria-label=\"Files\">\n * <li role=\"treeitem\" aria-expanded=\"false\" aria-selected=\"false\" tabindex=\"0\"\n * data-stimeo--tree-view-target=\"item\"\n * data-action=\"keydown->stimeo--tree-view#onKeydown\n * click->stimeo--tree-view#onClick\">\n * <span>src</span>\n * <ul role=\"group\" data-stimeo--tree-view-target=\"group\" hidden>\n * <li role=\"treeitem\" aria-selected=\"false\" tabindex=\"-1\"\n * data-stimeo--tree-view-target=\"item\" data-action=\"…\">…</li>\n * </ul>\n * </li>\n * </ul>\n *\n * Implements the WAI-ARIA APG **Tree View** (single-select) pattern. Parent/child\n * structure is read from the DOM nesting (`treeitem` → child `group`). The whole\n * tree is one Tab stop (roving tabindex); arrows navigate visible items, expand /\n * collapse, and move between parent and child.\n *\n * Behavior provided:\n * - `ArrowDown`/`ArrowUp` move between visible items; `Home`/`End` jump to the\n * first / last visible item; printable characters typeahead by label prefix.\n * - `ArrowRight` expands a collapsed parent or steps into its first child;\n * `ArrowLeft` collapses an expanded parent or steps to the parent item.\n * - `Enter`/`Space`/click select the item (single selection via `aria-selected`).\n * - `aria-expanded` and each child `group`'s `hidden` stay in sync, dispatching\n * `stimeo--tree-view:toggle`; selection dispatches `stimeo--tree-view:select`.\n */\nexport class TreeViewController extends Controller<HTMLElement> {\n static override targets = [\"item\", \"group\"];\n static actions = [\"onClick\", \"onKeydown\"] as const;\n static events = [\"select\", \"toggle\"] as const;\n\n declare readonly itemTargets: HTMLElement[];\n\n readonly #roving = new RovingTabindex(() => this.itemTargets);\n #typeahead = \"\";\n #typeaheadTimer = 0;\n readonly #timers = new SafeTimeout();\n\n /** Establishes the single tab stop (keeps an existing one, else the first). */\n override connect(): void {\n const active = this.#roving.activeIndex;\n this.#roving.setActive(active === -1 ? 0 : active);\n }\n\n /** Clears the pending typeahead-reset timer. */\n override disconnect(): void {\n this.#timers.clearAll();\n }\n\n /**\n * Routes tree keyboard interaction. Because `treeitem`s nest, only the handler\n * on the nearest item to the event target acts; the same keydown bubbling to an\n * ancestor item's handler is ignored to avoid double moves / selections.\n */\n onKeydown(event: KeyboardEvent): void {\n const item = event.currentTarget as HTMLElement;\n if ((event.target as HTMLElement).closest('[role=\"treeitem\"]') !== item) return;\n\n switch (event.key) {\n case \"ArrowDown\":\n event.preventDefault();\n this.#moveBy(item, 1);\n break;\n case \"ArrowUp\":\n event.preventDefault();\n this.#moveBy(item, -1);\n break;\n case \"ArrowRight\":\n event.preventDefault();\n this.#expandOrEnter(item);\n break;\n case \"ArrowLeft\":\n event.preventDefault();\n this.#collapseOrLeave(item);\n break;\n case \"Home\":\n event.preventDefault();\n this.#focusEdge(0);\n break;\n case \"End\":\n event.preventDefault();\n this.#focusEdge(-1);\n break;\n case \"Enter\":\n case \" \":\n event.preventDefault();\n this.#select(item);\n break;\n default:\n if (this.#isPrintable(event)) {\n event.preventDefault();\n this.#typeaheadTo(item, event.key);\n }\n break;\n }\n }\n\n /** Selects the clicked item (nearest to the target only). */\n onClick(event: Event): void {\n const item = event.currentTarget as HTMLElement;\n if ((event.target as HTMLElement).closest('[role=\"treeitem\"]') !== item) return;\n this.#focusItem(item);\n this.#select(item);\n }\n\n /** Moves focus to the next (`delta=1`) or previous visible item, if any. */\n #moveBy(item: HTMLElement, delta: number): void {\n const visible = this.#visibleItems;\n const current = visible.indexOf(item);\n const next = visible[current + delta];\n if (next) this.#focusItem(next);\n }\n\n /** `ArrowRight`: expand a collapsed parent, else step into the first child. */\n #expandOrEnter(item: HTMLElement): void {\n const group = this.#childGroup(item);\n if (!group) return;\n if (this.#isExpanded(item)) {\n const firstChild = group.querySelector<HTMLElement>(':scope > [role=\"treeitem\"]');\n if (firstChild) this.#focusItem(firstChild);\n } else {\n this.#setExpanded(item, true);\n }\n }\n\n /** `ArrowLeft`: collapse an expanded parent, else step out to the parent item. */\n #collapseOrLeave(item: HTMLElement): void {\n if (this.#childGroup(item) && this.#isExpanded(item)) {\n this.#setExpanded(item, false);\n return;\n }\n const parent = this.#parentItem(item);\n if (parent) this.#focusItem(parent);\n }\n\n /** Focuses the first (`0`) or last (`-1`) visible item. */\n #focusEdge(index: number): void {\n const visible = this.#visibleItems;\n const target = index < 0 ? visible[visible.length - 1] : visible[index];\n if (target) this.#focusItem(target);\n }\n\n /** Applies single selection and dispatches `select`. */\n #select(item: HTMLElement): void {\n for (const candidate of this.itemTargets) {\n candidate.setAttribute(\"aria-selected\", candidate === item ? \"true\" : \"false\");\n }\n this.dispatch(\"select\", { detail: { item } });\n }\n\n /** Toggles `aria-expanded` + the child group's `hidden`, dispatching `toggle`. */\n #setExpanded(item: HTMLElement, expanded: boolean): void {\n const group = this.#childGroup(item);\n if (!group) return;\n item.setAttribute(\"aria-expanded\", String(expanded));\n group.hidden = !expanded;\n this.dispatch(\"toggle\", { detail: { item, expanded } });\n }\n\n /** Makes `item` the single tab stop and moves DOM focus to it. */\n #focusItem(item: HTMLElement): void {\n const index = this.itemTargets.indexOf(item);\n if (index !== -1) this.#roving.setActive(index, { focus: true });\n }\n\n /** Advances the typeahead buffer and focuses the next matching visible item. */\n #typeaheadTo(item: HTMLElement, char: string): void {\n this.#timers.clear(this.#typeaheadTimer);\n this.#typeahead += char.toLowerCase();\n this.#typeaheadTimer = this.#timers.set(() => {\n this.#typeahead = \"\";\n }, TYPEAHEAD_TIMEOUT);\n\n // Search from just after the current item, wrapping, so repeats cycle matches.\n const visible = this.#visibleItems;\n const start = visible.indexOf(item);\n for (let offset = 1; offset <= visible.length; offset += 1) {\n const candidate = visible[(start + offset) % visible.length];\n if (candidate && this.#label(candidate).startsWith(this.#typeahead)) {\n this.#focusItem(candidate);\n return;\n }\n }\n }\n\n /** The visible items: those with no collapsed (`hidden`) ancestor group. */\n get #visibleItems(): HTMLElement[] {\n return this.itemTargets.filter((item) => {\n let node = item.parentElement;\n while (node && node !== this.element) {\n if (node.matches('[role=\"group\"]') && (node as HTMLElement).hidden) return false;\n node = node.parentElement;\n }\n return true;\n });\n }\n\n /** The child `group` owned directly by `item`, or `null` for a leaf. */\n #childGroup(item: HTMLElement): HTMLElement | null {\n return item.querySelector<HTMLElement>(':scope > [role=\"group\"]');\n }\n\n /** The nearest ancestor `treeitem`, or `null` at the root level. */\n #parentItem(item: HTMLElement): HTMLElement | null {\n return item.parentElement?.closest<HTMLElement>('[role=\"treeitem\"]') ?? null;\n }\n\n /** Whether a parent item is currently expanded. */\n #isExpanded(item: HTMLElement): boolean {\n return item.getAttribute(\"aria-expanded\") === \"true\";\n }\n\n /** `item`'s own label text (excluding any nested child group), lowercased. */\n #label(item: HTMLElement): string {\n let text = \"\";\n for (const node of Array.from(item.childNodes)) {\n if (node.nodeType === Node.ELEMENT_NODE && (node as Element).matches('[role=\"group\"]')) {\n continue;\n }\n text += node.textContent ?? \"\";\n }\n return text.trim().toLowerCase();\n }\n\n /** Whether `event.key` is a single printable character (no modifier chord). */\n #isPrintable(event: KeyboardEvent): boolean {\n return event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey;\n }\n}\n"]}