stimeo-ui 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (227) hide show
  1. package/CHANGELOG.md +163 -0
  2. package/dist/cable/index.d.ts +16 -16
  3. package/dist/cable/index.js.map +1 -1
  4. package/dist/controllers/accordion_controller.d.ts +3 -2
  5. package/dist/controllers/accordion_controller.js +10 -0
  6. package/dist/controllers/accordion_controller.js.map +1 -1
  7. package/dist/controllers/alert_dialog_controller.d.ts +64 -0
  8. package/dist/controllers/alert_dialog_controller.js +318 -0
  9. package/dist/controllers/alert_dialog_controller.js.map +1 -0
  10. package/dist/controllers/breadcrumb_controller.d.ts +97 -10
  11. package/dist/controllers/breadcrumb_controller.js +225 -13
  12. package/dist/controllers/breadcrumb_controller.js.map +1 -1
  13. package/dist/controllers/calendar_controller.d.ts +11 -1
  14. package/dist/controllers/calendar_controller.js +89 -22
  15. package/dist/controllers/calendar_controller.js.map +1 -1
  16. package/dist/controllers/carousel_controller.d.ts +115 -0
  17. package/dist/controllers/carousel_controller.js +313 -0
  18. package/dist/controllers/carousel_controller.js.map +1 -0
  19. package/dist/controllers/clipboard_controller.d.ts +72 -0
  20. package/dist/controllers/clipboard_controller.js +144 -0
  21. package/dist/controllers/clipboard_controller.js.map +1 -0
  22. package/dist/controllers/collapsible_controller.d.ts +70 -0
  23. package/dist/controllers/collapsible_controller.js +327 -0
  24. package/dist/controllers/collapsible_controller.js.map +1 -0
  25. package/dist/controllers/color_picker_controller.d.ts +87 -0
  26. package/dist/controllers/color_picker_controller.js +252 -0
  27. package/dist/controllers/color_picker_controller.js.map +1 -0
  28. package/dist/controllers/combobox_controller.d.ts +14 -1
  29. package/dist/controllers/combobox_controller.js +162 -23
  30. package/dist/controllers/combobox_controller.js.map +1 -1
  31. package/dist/controllers/command_palette_controller.d.ts +2 -0
  32. package/dist/controllers/command_palette_controller.js +194 -17
  33. package/dist/controllers/command_palette_controller.js.map +1 -1
  34. package/dist/controllers/confirm_controller.js.map +1 -1
  35. package/dist/controllers/context_menu_controller.d.ts +7 -6
  36. package/dist/controllers/context_menu_controller.js +32 -10
  37. package/dist/controllers/context_menu_controller.js.map +1 -1
  38. package/dist/controllers/count_up_controller.js +8 -1
  39. package/dist/controllers/count_up_controller.js.map +1 -1
  40. package/dist/controllers/currency_input_controller.d.ts +72 -0
  41. package/dist/controllers/currency_input_controller.js +147 -0
  42. package/dist/controllers/currency_input_controller.js.map +1 -0
  43. package/dist/controllers/data_grid_controller.d.ts +89 -0
  44. package/dist/controllers/data_grid_controller.js +246 -0
  45. package/dist/controllers/data_grid_controller.js.map +1 -0
  46. package/dist/controllers/date_range_picker_controller.d.ts +88 -0
  47. package/dist/controllers/date_range_picker_controller.js +441 -0
  48. package/dist/controllers/date_range_picker_controller.js.map +1 -0
  49. package/dist/controllers/dialog_controller.js.map +1 -1
  50. package/dist/controllers/dismissible_controller.d.ts +57 -0
  51. package/dist/controllers/dismissible_controller.js +117 -0
  52. package/dist/controllers/dismissible_controller.js.map +1 -0
  53. package/dist/controllers/drawer_controller.d.ts +96 -0
  54. package/dist/controllers/drawer_controller.js +630 -0
  55. package/dist/controllers/drawer_controller.js.map +1 -0
  56. package/dist/controllers/dropdown_controller.d.ts +1 -2
  57. package/dist/controllers/dropdown_controller.js.map +1 -1
  58. package/dist/controllers/editable_controller.d.ts +73 -0
  59. package/dist/controllers/editable_controller.js +169 -0
  60. package/dist/controllers/editable_controller.js.map +1 -0
  61. package/dist/controllers/file_dropzone_controller.d.ts +92 -0
  62. package/dist/controllers/file_dropzone_controller.js +165 -0
  63. package/dist/controllers/file_dropzone_controller.js.map +1 -0
  64. package/dist/controllers/filter_controller.d.ts +60 -0
  65. package/dist/controllers/filter_controller.js +86 -0
  66. package/dist/controllers/filter_controller.js.map +1 -0
  67. package/dist/controllers/flash_controller.js +36 -5
  68. package/dist/controllers/flash_controller.js.map +1 -1
  69. package/dist/controllers/focus_controller.d.ts +1 -1
  70. package/dist/controllers/focus_controller.js.map +1 -1
  71. package/dist/controllers/form_field_controller.js.map +1 -1
  72. package/dist/controllers/form_validation_controller.js +1 -1
  73. package/dist/controllers/form_validation_controller.js.map +1 -1
  74. package/dist/controllers/highlight_controller.js +6 -4
  75. package/dist/controllers/highlight_controller.js.map +1 -1
  76. package/dist/controllers/hover_card_controller.d.ts +1 -1
  77. package/dist/controllers/hover_card_controller.js.map +1 -1
  78. package/dist/controllers/intersection_controller.js +67 -19
  79. package/dist/controllers/intersection_controller.js.map +1 -1
  80. package/dist/controllers/lazy_frame_controller.js +54 -11
  81. package/dist/controllers/lazy_frame_controller.js.map +1 -1
  82. package/dist/controllers/listbox_controller.d.ts +9 -2
  83. package/dist/controllers/listbox_controller.js +257 -53
  84. package/dist/controllers/listbox_controller.js.map +1 -1
  85. package/dist/controllers/local_time_controller.js +2 -2
  86. package/dist/controllers/local_time_controller.js.map +1 -1
  87. package/dist/controllers/masonry_controller.d.ts +54 -0
  88. package/dist/controllers/masonry_controller.js +142 -0
  89. package/dist/controllers/masonry_controller.js.map +1 -0
  90. package/dist/controllers/menu_controller.d.ts +29 -11
  91. package/dist/controllers/menu_controller.js +104 -17
  92. package/dist/controllers/menu_controller.js.map +1 -1
  93. package/dist/controllers/menubar_controller.d.ts +142 -0
  94. package/dist/controllers/menubar_controller.js +785 -0
  95. package/dist/controllers/menubar_controller.js.map +1 -0
  96. package/dist/controllers/meter_controller.js.map +1 -1
  97. package/dist/controllers/multi_select_controller.d.ts +141 -0
  98. package/dist/controllers/multi_select_controller.js +755 -0
  99. package/dist/controllers/multi_select_controller.js.map +1 -0
  100. package/dist/controllers/navigation_menu_controller.d.ts +146 -0
  101. package/dist/controllers/navigation_menu_controller.js +511 -0
  102. package/dist/controllers/navigation_menu_controller.js.map +1 -0
  103. package/dist/controllers/nested_form_controller.d.ts +4 -5
  104. package/dist/controllers/nested_form_controller.js.map +1 -1
  105. package/dist/controllers/number_input_controller.d.ts +1 -1
  106. package/dist/controllers/number_input_controller.js +7 -0
  107. package/dist/controllers/number_input_controller.js.map +1 -1
  108. package/dist/controllers/otp_controller.js +18 -1
  109. package/dist/controllers/otp_controller.js.map +1 -1
  110. package/dist/controllers/overflow_indicator_controller.d.ts +15 -6
  111. package/dist/controllers/overflow_indicator_controller.js +246 -27
  112. package/dist/controllers/overflow_indicator_controller.js.map +1 -1
  113. package/dist/controllers/overflow_menu_controller.d.ts +78 -19
  114. package/dist/controllers/overflow_menu_controller.js +381 -57
  115. package/dist/controllers/overflow_menu_controller.js.map +1 -1
  116. package/dist/controllers/pagination_controller.d.ts +36 -5
  117. package/dist/controllers/pagination_controller.js +163 -32
  118. package/dist/controllers/pagination_controller.js.map +1 -1
  119. package/dist/controllers/password_reveal_controller.d.ts +49 -0
  120. package/dist/controllers/password_reveal_controller.js +117 -0
  121. package/dist/controllers/password_reveal_controller.js.map +1 -0
  122. package/dist/controllers/persist_controller.js +6 -6
  123. package/dist/controllers/persist_controller.js.map +1 -1
  124. package/dist/controllers/pointer_drag_controller.d.ts +2 -3
  125. package/dist/controllers/pointer_drag_controller.js +9 -1
  126. package/dist/controllers/pointer_drag_controller.js.map +1 -1
  127. package/dist/controllers/popover_controller.d.ts +1 -2
  128. package/dist/controllers/popover_controller.js +2 -2
  129. package/dist/controllers/popover_controller.js.map +1 -1
  130. package/dist/controllers/portal_controller.d.ts +4 -4
  131. package/dist/controllers/portal_controller.js.map +1 -1
  132. package/dist/controllers/progress_controller.js.map +1 -1
  133. package/dist/controllers/radio_group_controller.js +22 -3
  134. package/dist/controllers/radio_group_controller.js.map +1 -1
  135. package/dist/controllers/range_slider_controller.d.ts +97 -0
  136. package/dist/controllers/range_slider_controller.js +192 -0
  137. package/dist/controllers/range_slider_controller.js.map +1 -0
  138. package/dist/controllers/rating_controller.js +16 -2
  139. package/dist/controllers/rating_controller.js.map +1 -1
  140. package/dist/controllers/read_more_controller.d.ts +52 -0
  141. package/dist/controllers/read_more_controller.js +238 -0
  142. package/dist/controllers/read_more_controller.js.map +1 -0
  143. package/dist/controllers/reading_progress_controller.js.map +1 -1
  144. package/dist/controllers/resizable_controller.d.ts +3 -1
  145. package/dist/controllers/resizable_controller.js +65 -1
  146. package/dist/controllers/resizable_controller.js.map +1 -1
  147. package/dist/controllers/roving_controller.js +17 -2
  148. package/dist/controllers/roving_controller.js.map +1 -1
  149. package/dist/controllers/scroll_area_controller.js +101 -14
  150. package/dist/controllers/scroll_area_controller.js.map +1 -1
  151. package/dist/controllers/scroll_restore_controller.d.ts +54 -0
  152. package/dist/controllers/scroll_restore_controller.js +93 -0
  153. package/dist/controllers/scroll_restore_controller.js.map +1 -0
  154. package/dist/controllers/scroll_visibility_controller.d.ts +6 -3
  155. package/dist/controllers/scroll_visibility_controller.js +40 -6
  156. package/dist/controllers/scroll_visibility_controller.js.map +1 -1
  157. package/dist/controllers/scrollspy_controller.d.ts +64 -15
  158. package/dist/controllers/scrollspy_controller.js +369 -74
  159. package/dist/controllers/scrollspy_controller.js.map +1 -1
  160. package/dist/controllers/separator_controller.d.ts +69 -0
  161. package/dist/controllers/separator_controller.js +96 -0
  162. package/dist/controllers/separator_controller.js.map +1 -0
  163. package/dist/controllers/sidebar_controller.d.ts +107 -0
  164. package/dist/controllers/sidebar_controller.js +761 -0
  165. package/dist/controllers/sidebar_controller.js.map +1 -0
  166. package/dist/controllers/skeleton_controller.js +1 -1
  167. package/dist/controllers/skeleton_controller.js.map +1 -1
  168. package/dist/controllers/slider_controller.d.ts +11 -1
  169. package/dist/controllers/slider_controller.js +32 -6
  170. package/dist/controllers/slider_controller.js.map +1 -1
  171. package/dist/controllers/sortable_controller.d.ts +3 -1
  172. package/dist/controllers/sortable_controller.js +34 -3
  173. package/dist/controllers/sortable_controller.js.map +1 -1
  174. package/dist/controllers/spinner_controller.js +1 -1
  175. package/dist/controllers/spinner_controller.js.map +1 -1
  176. package/dist/controllers/stepper_controller.d.ts +4 -0
  177. package/dist/controllers/stepper_controller.js +28 -12
  178. package/dist/controllers/stepper_controller.js.map +1 -1
  179. package/dist/controllers/stick_to_bottom_controller.d.ts +5 -6
  180. package/dist/controllers/stick_to_bottom_controller.js +9 -4
  181. package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
  182. package/dist/controllers/sticky_observer_controller.d.ts +17 -4
  183. package/dist/controllers/sticky_observer_controller.js +109 -20
  184. package/dist/controllers/sticky_observer_controller.js.map +1 -1
  185. package/dist/controllers/submit_once_controller.d.ts +2 -2
  186. package/dist/controllers/submit_once_controller.js.map +1 -1
  187. package/dist/controllers/switch_controller.js +1 -0
  188. package/dist/controllers/switch_controller.js.map +1 -1
  189. package/dist/controllers/tabs_controller.d.ts +13 -4
  190. package/dist/controllers/tabs_controller.js +26 -3
  191. package/dist/controllers/tabs_controller.js.map +1 -1
  192. package/dist/controllers/tags_input_controller.d.ts +91 -0
  193. package/dist/controllers/tags_input_controller.js +295 -0
  194. package/dist/controllers/tags_input_controller.js.map +1 -0
  195. package/dist/controllers/theme_controller.d.ts +1 -1
  196. package/dist/controllers/theme_controller.js +42 -13
  197. package/dist/controllers/theme_controller.js.map +1 -1
  198. package/dist/controllers/time_picker_controller.d.ts +67 -0
  199. package/dist/controllers/time_picker_controller.js +231 -0
  200. package/dist/controllers/time_picker_controller.js.map +1 -0
  201. package/dist/controllers/toast_controller.d.ts +5 -14
  202. package/dist/controllers/toast_controller.js +40 -14
  203. package/dist/controllers/toast_controller.js.map +1 -1
  204. package/dist/controllers/toggle_group_controller.js +23 -2
  205. package/dist/controllers/toggle_group_controller.js.map +1 -1
  206. package/dist/controllers/toolbar_controller.d.ts +53 -13
  207. package/dist/controllers/toolbar_controller.js +230 -31
  208. package/dist/controllers/toolbar_controller.js.map +1 -1
  209. package/dist/controllers/tooltip_controller.d.ts +1 -1
  210. package/dist/controllers/tooltip_controller.js.map +1 -1
  211. package/dist/controllers/transition_controller.d.ts +14 -8
  212. package/dist/controllers/transition_controller.js +153 -38
  213. package/dist/controllers/transition_controller.js.map +1 -1
  214. package/dist/controllers/tree_view_controller.d.ts +135 -0
  215. package/dist/controllers/tree_view_controller.js +691 -0
  216. package/dist/controllers/tree_view_controller.js.map +1 -0
  217. package/dist/index.d.ts +27 -1818
  218. package/dist/index.js +4256 -915
  219. package/dist/index.js.map +1 -1
  220. package/dist/inspector/cli.d.ts +375 -39
  221. package/dist/inspector/cli.js +228 -14
  222. package/dist/inspector/cli.js.map +1 -1
  223. package/dist/inspector/cli_bin.js +419 -48
  224. package/dist/inspector/cli_bin.js.map +1 -1
  225. package/dist/inspector/examples.json +26 -26
  226. package/dist/inspector/manifest.json +1054 -127
  227. package/package.json +5 -6
@@ -0,0 +1,691 @@
1
+ import { Controller } from '@hotwired/stimulus';
2
+
3
+ // src/controllers/tree_view_controller.ts
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/focus_candidate.ts
17
+ function inheritsFieldsetDisabled(control) {
18
+ let fieldset = control.closest("fieldset[disabled]");
19
+ while (fieldset) {
20
+ const legend = Array.from(fieldset.children).find((child) => child.tagName === "LEGEND");
21
+ if (!legend?.contains(control)) return true;
22
+ fieldset = fieldset.parentElement?.closest("fieldset[disabled]") ?? null;
23
+ }
24
+ return false;
25
+ }
26
+ function canTakeFocus(element) {
27
+ if (element.closest("[hidden]")) return false;
28
+ if (!("disabled" in element)) return true;
29
+ if (element.disabled) return false;
30
+ return !inheritsFieldsetDisabled(element);
31
+ }
32
+
33
+ // src/utils/roving_tabindex.ts
34
+ var RovingTabindex = class {
35
+ /** Returns the current ordered item elements; called on every operation. */
36
+ #getItems;
37
+ /**
38
+ * @param getItems - Returns the current ordered item elements. Called on every
39
+ * operation so the live target list is always used.
40
+ */
41
+ constructor(getItems) {
42
+ this.#getItems = getItems;
43
+ }
44
+ /** Index of the currently tabbable item (`tabindex="0"`), or `-1` if none. */
45
+ get activeIndex() {
46
+ return this.#getItems().findIndex((item) => item.tabIndex === 0);
47
+ }
48
+ /**
49
+ * Makes exactly the item at `index` tabbable (`tabindex="0"`) and removes every
50
+ * other item from the Tab sequence (`tabindex="-1"`). An out-of-range `index`
51
+ * (e.g. `-1`) leaves all items at `-1`, which a controller can use to express
52
+ * "nothing is currently tabbable".
53
+ *
54
+ * @param index - Position of the item to make tabbable.
55
+ * @param options - Pass `{ focus: true }` to also move DOM focus to that item.
56
+ */
57
+ setActive(index, { focus = false } = {}) {
58
+ const items = this.#getItems();
59
+ items.forEach((item, i) => {
60
+ item.tabIndex = i === index ? 0 : -1;
61
+ });
62
+ if (focus) items[index]?.focus();
63
+ }
64
+ };
65
+
66
+ // src/utils/safe_timeout.ts
67
+ var TimerRegistry = class {
68
+ /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
69
+ ids = /* @__PURE__ */ new Set();
70
+ /**
71
+ * Cancels a single tracked timer.
72
+ *
73
+ * No-ops if the id is unknown (already cleared, fired, or never owned by this
74
+ * registry), so callers can clear defensively without guarding.
75
+ */
76
+ clear(id) {
77
+ if (this.ids.delete(id)) {
78
+ this.cancel(id);
79
+ }
80
+ }
81
+ /**
82
+ * Cancels every tracked timer. Call this from a controller's `disconnect()`
83
+ * to guarantee no timer outlives the element.
84
+ */
85
+ clearAll() {
86
+ for (const id of this.ids) {
87
+ this.cancel(id);
88
+ }
89
+ this.ids.clear();
90
+ }
91
+ /** Number of timers currently tracked (pending). */
92
+ get size() {
93
+ return this.ids.size;
94
+ }
95
+ };
96
+ var SafeTimeout = class extends TimerRegistry {
97
+ /**
98
+ * Schedules `callback` after `delay` ms and returns the timer id.
99
+ *
100
+ * The id is removed from the registry automatically when the timeout fires,
101
+ * so {@link TimerRegistry.size | size} reflects only still-pending timers.
102
+ */
103
+ set(callback, delay) {
104
+ const id = this.schedule(() => {
105
+ this.ids.delete(id);
106
+ callback();
107
+ }, delay);
108
+ this.ids.add(id);
109
+ return id;
110
+ }
111
+ schedule(callback, delay) {
112
+ return window.setTimeout(callback, delay);
113
+ }
114
+ cancel(id) {
115
+ window.clearTimeout(id);
116
+ }
117
+ };
118
+
119
+ // src/utils/typeahead.ts
120
+ var TYPEAHEAD_RESET_MS = 500;
121
+ var Typeahead = class {
122
+ /** Timer registry for the pending idle reset; private so `reset()` is the only exit. */
123
+ #timers = new SafeTimeout();
124
+ /** Idle window before the query resets, in milliseconds. */
125
+ #resetMs;
126
+ /** The accumulated lowercase query, empty when idle. */
127
+ #query = "";
128
+ /** Id of the pending reset timer, `0` when none is scheduled. */
129
+ #timerId = 0;
130
+ /** @param options - Overrides for the idle window. */
131
+ constructor({ resetMs = TYPEAHEAD_RESET_MS } = {}) {
132
+ this.#resetMs = resetMs;
133
+ }
134
+ /** The query a search would currently run with; empty while idle. */
135
+ get query() {
136
+ return this.#query;
137
+ }
138
+ /**
139
+ * Folds `key` into the query, restarts the idle window, and returns the query to
140
+ * search with. A repeated character collapses the query to that one character.
141
+ */
142
+ push(key) {
143
+ const char = key.toLowerCase();
144
+ const repeated = this.#query.length > 0 && [...this.#query].every((c) => c === char);
145
+ this.#query = repeated ? char : this.#query + char;
146
+ this.#timers.clear(this.#timerId);
147
+ this.#timerId = this.#timers.set(() => this.reset(), this.#resetMs);
148
+ return this.#query;
149
+ }
150
+ /** Clears the query and cancels the pending idle reset. */
151
+ reset() {
152
+ this.#query = "";
153
+ this.#timers.clear(this.#timerId);
154
+ this.#timerId = 0;
155
+ }
156
+ };
157
+ function isTypeaheadKey(event) {
158
+ return event.key.length === 1 && event.key !== " " && !event.ctrlKey && !event.metaKey && !event.altKey && !event.isComposing;
159
+ }
160
+ function typeaheadLabel(element, fallbackText) {
161
+ const label = element.getAttribute("aria-label")?.trim();
162
+ if (label) return label.toLowerCase();
163
+ const text = fallbackText ? fallbackText() : element.textContent ?? "";
164
+ return text.trim().toLowerCase();
165
+ }
166
+ function findTypeaheadMatch(items, from, query, label = (item) => typeaheadLabel(item)) {
167
+ if (query === "") return -1;
168
+ const count = items.length;
169
+ for (let step = 1; step <= count; step += 1) {
170
+ const index = ((from + step) % count + count) % count;
171
+ const candidate = items[index];
172
+ if (candidate && label(candidate).startsWith(query)) return index;
173
+ }
174
+ return -1;
175
+ }
176
+
177
+ // src/controllers/tree_view_controller.ts
178
+ var NESTED_INTERACTIVE = 'input, textarea, select, button, a[href], [contenteditable]:not([contenteditable="false"])';
179
+ var TreeViewController = class extends Controller {
180
+ static targets = ["item", "group"];
181
+ static actions = ["onClick", "onKeydown", "toggle"];
182
+ static events = ["select", "toggle"];
183
+ #roving = new RovingTabindex(() => this.itemTargets);
184
+ #typeahead = new Typeahead();
185
+ #timers = new SafeTimeout();
186
+ #connected = false;
187
+ /**
188
+ * Item targets in DOM order as of the last connect / target change. A removed
189
+ * item is gone from `itemTargets` by the time Stimulus reports it, so this is
190
+ * the only record of where in the order the gap opened.
191
+ */
192
+ #order = [];
193
+ /** The `treeitem` that last took DOM focus inside this tree, if any. */
194
+ #focused = null;
195
+ /**
196
+ * Records which row owns DOM focus. `focusin` bubbles, so one listener covers
197
+ * rows and anything nested in them; the tree needs this because a removal is
198
+ * only reported *after* the fact, when the browser has already reset
199
+ * `document.activeElement`.
200
+ */
201
+ #onFocusIn = (event) => {
202
+ const target = event.target;
203
+ this.#focused = target?.closest('[role="treeitem"]') ?? null;
204
+ };
205
+ /**
206
+ * Forgets the tracked row when focus genuinely leaves the tree, so a later
207
+ * removal of that row cannot be mistaken for "focus died with it" and pull
208
+ * focus back in.
209
+ *
210
+ * A *removed* focused row must not clear the record — that is exactly the case
211
+ * {@link TreeViewController.itemTargetDisconnected} needs it for. The two are
212
+ * indistinguishable while the event is dispatching: Chromium fires `focusout`
213
+ * for a removed row *before* the detach lands — `isConnected` still reads
214
+ * `true` — with `document.activeElement` already on `<body>`, exactly like a
215
+ * plain `blur()`. Deciding on a microtask separates them — a row that is gone
216
+ * by then was removed. Focus moving between two rows needs no handling:
217
+ * `focusin` overwrites the record right after.
218
+ */
219
+ #onFocusOut = (event) => {
220
+ const target = event.target;
221
+ const row = target?.closest('[role="treeitem"]') ?? null;
222
+ if (!row || row !== this.#focused) return;
223
+ const next = event.relatedTarget;
224
+ if (next instanceof Node && this.element.contains(next)) return;
225
+ queueMicrotask(() => {
226
+ if (this.#focused === row && row.isConnected) this.#focused = null;
227
+ });
228
+ };
229
+ /**
230
+ * Reconciles authored expansion state and establishes the single tab stop.
231
+ * Idempotent, so a Turbo cache restore / morph re-runs it safely (re-adding
232
+ * the same listener reference is a no-op per the DOM spec).
233
+ */
234
+ connect() {
235
+ this.element.addEventListener("focusin", this.#onFocusIn);
236
+ this.element.addEventListener("focusout", this.#onFocusOut);
237
+ this.#seedFocused();
238
+ this.#reconcileExpansion();
239
+ this.#normalizeSelection();
240
+ this.#normalizeTabStop();
241
+ this.#order = [...this.itemTargets];
242
+ this.#connected = true;
243
+ }
244
+ /** Detaches the focus trackers, drops the typeahead buffer and its timer. */
245
+ disconnect() {
246
+ this.element.removeEventListener("focusin", this.#onFocusIn);
247
+ this.element.removeEventListener("focusout", this.#onFocusOut);
248
+ this.#connected = false;
249
+ this.#typeahead.reset();
250
+ this.#timers.clearAll();
251
+ this.#order = [];
252
+ this.#focused = null;
253
+ }
254
+ /**
255
+ * Seeds the focus record from the live document. A row can already hold focus
256
+ * when the controller connects (a Turbo restore, or `data-controller` added to
257
+ * a tree the user was already in), and the `focusin` listener alone would
258
+ * never learn about it.
259
+ */
260
+ #seedFocused() {
261
+ const active = this.element.ownerDocument.activeElement;
262
+ this.#focused = active instanceof HTMLElement && this.element.contains(active) ? active.closest('[role="treeitem"]') : null;
263
+ }
264
+ /**
265
+ * Re-normalizes the tab stop when a `treeitem` is added at runtime, so an
266
+ * appended item carrying `tabindex="0"` cannot turn the tree into two Tab
267
+ * stops. Skipped before `connect()`: Stimulus registers the initial targets
268
+ * first, and `connect()` owns the initial tab-stop policy.
269
+ */
270
+ itemTargetConnected() {
271
+ if (!this.#connected) return;
272
+ this.#normalizeSelection();
273
+ this.#normalizeTabStop();
274
+ this.#trackOrder();
275
+ }
276
+ /**
277
+ * Brings authored `aria-selected` to the shape the APG requires, without
278
+ * changing which item the author chose.
279
+ *
280
+ * Every item gets an explicit value — an absent `aria-selected` means "not
281
+ * selectable" in ARIA, so a forgotten attribute hides a selectable row — and a
282
+ * single-select tree keeps at most one `true`, first in DOM order. `connect()`
283
+ * reconciles authored expansion the same way. The scan is the `item` target
284
+ * set: a `role="treeitem"` without the target is outside the contract and is
285
+ * neither counted nor written.
286
+ */
287
+ #normalizeSelection() {
288
+ const items = this.itemTargets;
289
+ const selected = items.find((item) => item.getAttribute("aria-selected") === "true");
290
+ for (const item of items) {
291
+ item.setAttribute("aria-selected", item === selected ? "true" : "false");
292
+ }
293
+ }
294
+ /**
295
+ * Restores the roving invariants when a `treeitem` leaves at runtime — a Turbo
296
+ * Stream `remove`, a morph, or plain DOM surgery. The mirror of
297
+ * {@link TreeViewController.itemTargetConnected}: without it, removing the row
298
+ * that held `tabindex="0"` leaves every survivor at `-1`, i.e. a tree with
299
+ * **no** Tab stop, and drops DOM focus to `<body>` when the removed subtree
300
+ * held it.
301
+ *
302
+ * The replacement is the nearest surviving *visible* item in the pre-removal
303
+ * order — the one after the gap, else the one before it — which is the row
304
+ * `ArrowDown` would have reached. Removing a non-active item changes nothing.
305
+ * When no visible item survives, a tree with zero Tab stops is the correct end
306
+ * state.
307
+ *
308
+ * Focus is only *restored*, never *stolen*: DOM focus moves solely when it was
309
+ * inside the removed subtree and the document has nowhere left to put it.
310
+ */
311
+ itemTargetDisconnected(item) {
312
+ if (!this.#connected || !this.element.isConnected) return;
313
+ const stranded = this.#focusDiedWith(item);
314
+ if (stranded) this.#focused = null;
315
+ if (stranded || this.#roving.activeIndex === -1) {
316
+ const next = this.#neighborOf(item);
317
+ if (next) this.#roving.setActive(this.itemTargets.indexOf(next), { focus: stranded });
318
+ }
319
+ this.#order = this.#order.filter((tracked) => tracked !== item);
320
+ }
321
+ /**
322
+ * Folds newly connected targets into the DOM-order snapshot, keeping the slot
323
+ * of any item whose disconnect callback has not arrived yet — a morph that
324
+ * both adds and removes rows reports one batch as several callbacks, and
325
+ * {@link TreeViewController.#neighborOf} needs those slots.
326
+ */
327
+ #trackOrder() {
328
+ const live = this.itemTargets;
329
+ const pending = this.#order.filter((tracked) => !live.includes(tracked));
330
+ if (pending.length === 0) {
331
+ this.#order = live;
332
+ return;
333
+ }
334
+ const merged = [...live];
335
+ for (const tracked of pending) {
336
+ merged.splice(Math.min(this.#order.indexOf(tracked), merged.length), 0, tracked);
337
+ }
338
+ this.#order = merged;
339
+ }
340
+ /**
341
+ * Routes tree keyboard interaction. Because `treeitem`s nest, only the handler
342
+ * on the nearest item to the event target acts; the same keydown bubbling to an
343
+ * ancestor item's handler is ignored to avoid double moves / selections. Keys
344
+ * from a nested interactive control (or an IME composition) are left untouched.
345
+ */
346
+ onKeydown(event) {
347
+ if (event.defaultPrevented) return;
348
+ if (isReservedArrowChord(event)) return;
349
+ if (event.isComposing) return;
350
+ const item = this.#ownerItem(event);
351
+ if (!item) return;
352
+ switch (event.key) {
353
+ case "ArrowDown":
354
+ event.preventDefault();
355
+ this.#moveBy(item, 1);
356
+ break;
357
+ case "ArrowUp":
358
+ event.preventDefault();
359
+ this.#moveBy(item, -1);
360
+ break;
361
+ // Logical, not physical: APG describes these as "to the child level" and
362
+ // "to the parent level" — a spatial move — so they follow the writing
363
+ // direction, exactly as horizontal roving does. `isRtl()` is the shared
364
+ // detector; never introduce a second one.
365
+ //
366
+ // Direction comes from the tree, not from `item`: the tree is what lays
367
+ // the rows out, and a row may carry its own `dir` (an LTR path inside an
368
+ // RTL browser is ordinary bidi authoring). Probing the focused row instead
369
+ // would let one row's arrows mean the opposite of its sibling's.
370
+ case "ArrowRight":
371
+ event.preventDefault();
372
+ if (isRtl(this.element)) this.#collapseOrLeave(item);
373
+ else this.#expandOrEnter(item);
374
+ break;
375
+ case "ArrowLeft":
376
+ event.preventDefault();
377
+ if (isRtl(this.element)) this.#expandOrEnter(item);
378
+ else this.#collapseOrLeave(item);
379
+ break;
380
+ case "Home":
381
+ event.preventDefault();
382
+ this.#focusEdge(0);
383
+ break;
384
+ case "End":
385
+ event.preventDefault();
386
+ this.#focusEdge(-1);
387
+ break;
388
+ case "Enter":
389
+ case " ":
390
+ event.preventDefault();
391
+ this.#select(item);
392
+ break;
393
+ default:
394
+ if (isTypeaheadKey(event)) {
395
+ event.preventDefault();
396
+ this.#typeaheadTo(item, event.key);
397
+ }
398
+ break;
399
+ }
400
+ }
401
+ /** Selects the clicked item (nearest to the target only). */
402
+ onClick(event) {
403
+ const item = this.#ownerItem(event);
404
+ if (!item) return;
405
+ const group = this.#childGroup(item);
406
+ if (group?.contains(event.target)) return;
407
+ this.#focusItem(item);
408
+ this.#select(item);
409
+ }
410
+ /**
411
+ * Expands or collapses the `treeitem` nearest to the event target. Optional:
412
+ * wire it on a chevron control so pointer users can reach child nodes, which
413
+ * otherwise only `ArrowRight` / `ArrowLeft` can do. A leaf item is a no-op.
414
+ *
415
+ * The toggled row then takes focus and the tab stop, matching what the keyboard
416
+ * path guarantees. Without it, a browser that focuses buttons on mousedown leaves
417
+ * focus on the clicked chevron: arrows would stop working until focus returns to a
418
+ * row, and a decorative `aria-hidden` chevron would hold focus. The synchronous
419
+ * `stimeo--tree-view:toggle` event is observed before this final focus hand-off.
420
+ */
421
+ toggle(event) {
422
+ const item = event.target.closest('[role="treeitem"]');
423
+ if (!item || !this.element.contains(item)) return;
424
+ if (!this.#childGroup(item)) return;
425
+ this.#setExpanded(item, !this.#isExpanded(item));
426
+ this.#focusItem(item);
427
+ }
428
+ /**
429
+ * The item a raw event belongs to, or `null` when the tree must not act: the
430
+ * event came from a nested `treeitem` (it bubbled to an ancestor's handler) or
431
+ * from an interactive control inside the item.
432
+ */
433
+ #ownerItem(event) {
434
+ const item = event.currentTarget;
435
+ const target = event.target;
436
+ if (target.closest('[role="treeitem"]') !== item) return null;
437
+ const control = target.closest(NESTED_INTERACTIVE);
438
+ if (control && item.contains(control)) return null;
439
+ return item;
440
+ }
441
+ /**
442
+ * The surviving visible item that inherits `removed`'s position: the next one
443
+ * in the pre-removal order, else the previous one. Falls back to the first
444
+ * visible item when `removed` is not in that order — a target that arrived and
445
+ * left between two callbacks — and to `undefined` when nothing visible is left.
446
+ */
447
+ #neighborOf(removed) {
448
+ const visible = this.#visibleItems;
449
+ const index = this.#order.indexOf(removed);
450
+ for (let at = index + 1; at < this.#order.length; at += 1) {
451
+ const candidate = this.#order[at];
452
+ if (candidate && visible.includes(candidate)) return candidate;
453
+ }
454
+ for (let at = index - 1; at >= 0; at -= 1) {
455
+ const candidate = this.#order[at];
456
+ if (candidate && visible.includes(candidate)) return candidate;
457
+ }
458
+ return visible[0];
459
+ }
460
+ /**
461
+ * Whether DOM focus went down with `removed`: it sat on that row (or inside
462
+ * it) and the document now has nowhere to put focus. A browser falls back to
463
+ * `<body>`; a still-referenced but detached `activeElement` is the same
464
+ * condition seen from the other side. Focus that landed on a real element —
465
+ * inside this tree or anywhere else on the page — is left alone, so a removal
466
+ * can never steal it.
467
+ */
468
+ #focusDiedWith(removed) {
469
+ const focused = this.#focused;
470
+ if (!focused || focused !== removed && !removed.contains(focused)) return false;
471
+ const doc = this.element.ownerDocument;
472
+ const active = doc.activeElement;
473
+ if (active === null || active === doc.body || active === doc.documentElement) return true;
474
+ return !active.isConnected;
475
+ }
476
+ /** Moves focus to the next (`delta=1`) or previous visible item, if any. */
477
+ #moveBy(item, delta) {
478
+ const visible = this.#visibleItems;
479
+ const current = visible.indexOf(item);
480
+ if (current === -1) return;
481
+ const next = visible[current + delta];
482
+ if (next) this.#focusItem(next);
483
+ }
484
+ /**
485
+ * Expands a collapsed parent, else steps into the first child.
486
+ *
487
+ * Bound to `ArrowRight` under LTR and `ArrowLeft` under RTL — the caller picks;
488
+ * this method only knows "toward the child level".
489
+ */
490
+ #expandOrEnter(item) {
491
+ const group = this.#childGroup(item);
492
+ if (!group) return;
493
+ if (!this.#isExpanded(item)) {
494
+ this.#setExpanded(item, true);
495
+ return;
496
+ }
497
+ if (group.hidden) return;
498
+ const firstChild = Array.from(
499
+ group.querySelectorAll(':scope > [role="treeitem"]')
500
+ ).find((child) => !child.hidden);
501
+ if (firstChild) this.#focusItem(firstChild);
502
+ }
503
+ /** `ArrowLeft`: collapse an expanded parent, else step out to the parent item. */
504
+ #collapseOrLeave(item) {
505
+ if (this.#childGroup(item) && this.#isExpanded(item)) {
506
+ this.#setExpanded(item, false);
507
+ return;
508
+ }
509
+ const parent = this.#parentItem(item);
510
+ if (parent) this.#focusItem(parent);
511
+ }
512
+ /** Focuses the first (`0`) or last (`-1`) visible item. */
513
+ #focusEdge(index) {
514
+ const visible = this.#visibleItems;
515
+ const target = index < 0 ? visible[visible.length - 1] : visible[index];
516
+ if (target) this.#focusItem(target);
517
+ }
518
+ /**
519
+ * Whether the item is disabled — **its own attribute or an ancestor's**.
520
+ *
521
+ * ARIA is explicit that the state carries down: "The state of being disabled
522
+ * applies to the current element *and all focusable descendant elements* of the
523
+ * element on which the `aria-disabled` attribute is applied." A tree is the only
524
+ * pattern here whose items nest, so it is the only place the inheritance is
525
+ * observable — but the rule is the shared one.
526
+ *
527
+ * The walk stops at the controller element: an `aria-disabled` on the tree root
528
+ * would otherwise disable every row, and a disabled *tree* is the consumer's
529
+ * call to make with `inert`, not something this controller infers.
530
+ */
531
+ #isDisabled(item) {
532
+ let current = item;
533
+ while (current && current !== this.element) {
534
+ if (current.getAttribute("aria-disabled") === "true") return true;
535
+ current = current.parentElement;
536
+ }
537
+ return false;
538
+ }
539
+ /**
540
+ * Applies single selection and dispatches `select`. An `aria-disabled="true"`
541
+ * item stays focusable (APG keeps disabled nodes reachable) but is never
542
+ * activated, so consumers see no `select` for it.
543
+ */
544
+ #select(item) {
545
+ if (this.#isDisabled(item)) return;
546
+ for (const candidate of this.itemTargets) {
547
+ candidate.setAttribute("aria-selected", candidate === item ? "true" : "false");
548
+ }
549
+ this.dispatch("select", { detail: { item } });
550
+ }
551
+ /** Updates expansion, reconciles a collapsed subtree, then synchronously dispatches `toggle`. */
552
+ #setExpanded(item, expanded) {
553
+ const group = this.#childGroup(item);
554
+ if (!group) return;
555
+ item.setAttribute("aria-expanded", String(expanded));
556
+ group.hidden = !expanded;
557
+ if (!expanded) this.#escapeCollapsedSubtree(item, group);
558
+ this.dispatch("toggle", { detail: { item, expanded } });
559
+ }
560
+ /**
561
+ * Reconciles focus and the roving tab stop before a synchronous `toggle` event
562
+ * observes a newly collapsed group. If DOM focus remains inside the group,
563
+ * both move to the parent. Otherwise only a stranded tab stop moves, preserving
564
+ * DOM focus. A native chevron click takes this path when mousedown has already
565
+ * focused the button. {@link TreeViewController.toggle} completes its focus
566
+ * hand-off after this reconciliation and event dispatch.
567
+ */
568
+ #escapeCollapsedSubtree(item, group) {
569
+ const active = document.activeElement;
570
+ if (active instanceof HTMLElement && group.contains(active)) {
571
+ this.#focusItem(item);
572
+ return;
573
+ }
574
+ const activeIndex = this.#roving.activeIndex;
575
+ const tabbable = activeIndex === -1 ? void 0 : this.itemTargets[activeIndex];
576
+ if (!tabbable || !group.contains(tabbable)) return;
577
+ const index = this.itemTargets.indexOf(item);
578
+ if (index !== -1) this.#roving.setActive(index);
579
+ }
580
+ /** Makes `item` the single tab stop and moves DOM focus to it. */
581
+ #focusItem(item) {
582
+ const index = this.itemTargets.indexOf(item);
583
+ if (index !== -1) {
584
+ this.#roving.setActive(index, { focus: true });
585
+ return;
586
+ }
587
+ if (!canTakeFocus(item)) return;
588
+ item.tabIndex = -1;
589
+ item.focus();
590
+ }
591
+ /** Advances the typeahead query and focuses the next matching visible item. */
592
+ #typeaheadTo(item, char) {
593
+ const query = this.#typeahead.push(char);
594
+ const visible = this.#visibleItems;
595
+ const index = findTypeaheadMatch(
596
+ visible,
597
+ visible.indexOf(item),
598
+ query,
599
+ (candidate) => this.#label(candidate)
600
+ );
601
+ const match = visible[index];
602
+ if (match) this.#focusItem(match);
603
+ }
604
+ /**
605
+ * Aligns each parent's `aria-expanded` with its child `group`'s `hidden`.
606
+ * `aria-expanded` wins when authored (it is what assistive tech reads); a
607
+ * parent without it derives one from `hidden` (establish-only-when-absent, so
608
+ * a restored snapshot is never clobbered). Disagreeing markup would otherwise
609
+ * strand the only tab stop on an invisible item.
610
+ */
611
+ #reconcileExpansion() {
612
+ for (const item of this.itemTargets) {
613
+ const group = this.#childGroup(item);
614
+ if (!group) continue;
615
+ if (item.hasAttribute("aria-expanded")) {
616
+ group.hidden = !this.#isExpanded(item);
617
+ } else {
618
+ item.setAttribute("aria-expanded", String(!group.hidden));
619
+ }
620
+ }
621
+ }
622
+ /**
623
+ * Leaves exactly one item tabbable. An existing, still-visible tab stop wins
624
+ * (the DOM is the source of truth after a Turbo restore, so the user's roving
625
+ * position survives); otherwise the selected item takes it (APG: a single-select
626
+ * tree puts initial focus on its selected node), else the first visible item.
627
+ */
628
+ #normalizeTabStop() {
629
+ const items = this.itemTargets;
630
+ const visible = this.#visibleItems;
631
+ const activeIndex = this.#roving.activeIndex;
632
+ const active = activeIndex === -1 ? void 0 : items[activeIndex];
633
+ if (active && visible.includes(active)) {
634
+ this.#roving.setActive(activeIndex);
635
+ return;
636
+ }
637
+ const selected = visible.find((item) => item.getAttribute("aria-selected") === "true");
638
+ const next = selected ?? visible[0];
639
+ this.#roving.setActive(next ? items.indexOf(next) : -1);
640
+ }
641
+ /**
642
+ * The visible items: those with no `hidden` ancestor up to the tree, and not
643
+ * `hidden` themselves. A hidden row is out of the move set — it can hold
644
+ * neither DOM focus nor the only Tab stop — and neither can anything nested
645
+ * under one, which is why the walk tests every ancestor rather than only the
646
+ * collapsed `role="group"` containers.
647
+ */
648
+ get #visibleItems() {
649
+ return this.itemTargets.filter((item) => {
650
+ let node = item;
651
+ while (node && node !== this.element) {
652
+ if (node.hidden) return false;
653
+ node = node.parentElement;
654
+ }
655
+ return true;
656
+ });
657
+ }
658
+ /** The child `group` owned directly by `item`, or `null` for a leaf. */
659
+ #childGroup(item) {
660
+ return item.querySelector(':scope > [role="group"]');
661
+ }
662
+ /** The nearest ancestor `treeitem`, or `null` at the root level. */
663
+ #parentItem(item) {
664
+ return item.parentElement?.closest('[role="treeitem"]') ?? null;
665
+ }
666
+ /** Whether a parent item is currently expanded. */
667
+ #isExpanded(item) {
668
+ return item.getAttribute("aria-expanded") === "true";
669
+ }
670
+ /**
671
+ * `item`'s name for typeahead. The fallback is the item's *own* text: a parent
672
+ * row's nested child group is rendered inside it, and folding a whole subtree
673
+ * into one name would let "readme" match the folder containing it.
674
+ */
675
+ #label(item) {
676
+ return typeaheadLabel(item, () => {
677
+ let text = "";
678
+ for (const node of Array.from(item.childNodes)) {
679
+ if (node.nodeType === Node.ELEMENT_NODE && node.matches('[role="group"]')) {
680
+ continue;
681
+ }
682
+ text += node.textContent ?? "";
683
+ }
684
+ return text;
685
+ });
686
+ }
687
+ };
688
+
689
+ export { TreeViewController };
690
+ //# sourceMappingURL=tree_view_controller.js.map
691
+ //# sourceMappingURL=tree_view_controller.js.map