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 (184) hide show
  1. package/CHANGELOG.md +104 -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.js +10 -0
  5. package/dist/controllers/accordion_controller.js.map +1 -1
  6. package/dist/controllers/alert_dialog_controller.js.map +1 -1
  7. package/dist/controllers/breadcrumb_controller.d.ts +97 -10
  8. package/dist/controllers/breadcrumb_controller.js +225 -13
  9. package/dist/controllers/breadcrumb_controller.js.map +1 -1
  10. package/dist/controllers/calendar_controller.d.ts +11 -1
  11. package/dist/controllers/calendar_controller.js +89 -22
  12. package/dist/controllers/calendar_controller.js.map +1 -1
  13. package/dist/controllers/carousel_controller.d.ts +16 -1
  14. package/dist/controllers/carousel_controller.js +47 -6
  15. package/dist/controllers/carousel_controller.js.map +1 -1
  16. package/dist/controllers/collapsible_controller.js +2 -2
  17. package/dist/controllers/collapsible_controller.js.map +1 -1
  18. package/dist/controllers/color_picker_controller.d.ts +12 -1
  19. package/dist/controllers/color_picker_controller.js +46 -7
  20. package/dist/controllers/color_picker_controller.js.map +1 -1
  21. package/dist/controllers/combobox_controller.d.ts +14 -1
  22. package/dist/controllers/combobox_controller.js +162 -23
  23. package/dist/controllers/combobox_controller.js.map +1 -1
  24. package/dist/controllers/command_palette_controller.d.ts +2 -0
  25. package/dist/controllers/command_palette_controller.js +194 -17
  26. package/dist/controllers/command_palette_controller.js.map +1 -1
  27. package/dist/controllers/confirm_controller.js.map +1 -1
  28. package/dist/controllers/context_menu_controller.d.ts +7 -6
  29. package/dist/controllers/context_menu_controller.js +32 -10
  30. package/dist/controllers/context_menu_controller.js.map +1 -1
  31. package/dist/controllers/count_up_controller.js.map +1 -1
  32. package/dist/controllers/data_grid_controller.d.ts +19 -1
  33. package/dist/controllers/data_grid_controller.js +82 -4
  34. package/dist/controllers/data_grid_controller.js.map +1 -1
  35. package/dist/controllers/date_range_picker_controller.d.ts +1 -0
  36. package/dist/controllers/date_range_picker_controller.js +27 -3
  37. package/dist/controllers/date_range_picker_controller.js.map +1 -1
  38. package/dist/controllers/dialog_controller.js.map +1 -1
  39. package/dist/controllers/dismissible_controller.js.map +1 -1
  40. package/dist/controllers/drawer_controller.js.map +1 -1
  41. package/dist/controllers/dropdown_controller.d.ts +1 -2
  42. package/dist/controllers/dropdown_controller.js.map +1 -1
  43. package/dist/controllers/editable_controller.js +1 -0
  44. package/dist/controllers/editable_controller.js.map +1 -1
  45. package/dist/controllers/focus_controller.d.ts +1 -1
  46. package/dist/controllers/focus_controller.js.map +1 -1
  47. package/dist/controllers/form_field_controller.js.map +1 -1
  48. package/dist/controllers/form_validation_controller.js +1 -1
  49. package/dist/controllers/form_validation_controller.js.map +1 -1
  50. package/dist/controllers/highlight_controller.js.map +1 -1
  51. package/dist/controllers/hover_card_controller.d.ts +1 -1
  52. package/dist/controllers/hover_card_controller.js.map +1 -1
  53. package/dist/controllers/intersection_controller.js +36 -11
  54. package/dist/controllers/intersection_controller.js.map +1 -1
  55. package/dist/controllers/lazy_frame_controller.js +31 -10
  56. package/dist/controllers/lazy_frame_controller.js.map +1 -1
  57. package/dist/controllers/listbox_controller.d.ts +9 -2
  58. package/dist/controllers/listbox_controller.js +257 -53
  59. package/dist/controllers/listbox_controller.js.map +1 -1
  60. package/dist/controllers/local_time_controller.js +2 -2
  61. package/dist/controllers/local_time_controller.js.map +1 -1
  62. package/dist/controllers/menu_controller.d.ts +29 -11
  63. package/dist/controllers/menu_controller.js +104 -17
  64. package/dist/controllers/menu_controller.js.map +1 -1
  65. package/dist/controllers/menubar_controller.d.ts +91 -11
  66. package/dist/controllers/menubar_controller.js +415 -63
  67. package/dist/controllers/menubar_controller.js.map +1 -1
  68. package/dist/controllers/meter_controller.js.map +1 -1
  69. package/dist/controllers/multi_select_controller.d.ts +31 -5
  70. package/dist/controllers/multi_select_controller.js +312 -29
  71. package/dist/controllers/multi_select_controller.js.map +1 -1
  72. package/dist/controllers/navigation_menu_controller.d.ts +65 -18
  73. package/dist/controllers/navigation_menu_controller.js +154 -27
  74. package/dist/controllers/navigation_menu_controller.js.map +1 -1
  75. package/dist/controllers/nested_form_controller.d.ts +4 -5
  76. package/dist/controllers/nested_form_controller.js.map +1 -1
  77. package/dist/controllers/number_input_controller.d.ts +1 -1
  78. package/dist/controllers/number_input_controller.js +7 -0
  79. package/dist/controllers/number_input_controller.js.map +1 -1
  80. package/dist/controllers/otp_controller.js +18 -1
  81. package/dist/controllers/otp_controller.js.map +1 -1
  82. package/dist/controllers/overflow_indicator_controller.d.ts +3 -2
  83. package/dist/controllers/overflow_indicator_controller.js +81 -13
  84. package/dist/controllers/overflow_indicator_controller.js.map +1 -1
  85. package/dist/controllers/overflow_menu_controller.d.ts +78 -19
  86. package/dist/controllers/overflow_menu_controller.js +381 -57
  87. package/dist/controllers/overflow_menu_controller.js.map +1 -1
  88. package/dist/controllers/pagination_controller.d.ts +36 -5
  89. package/dist/controllers/pagination_controller.js +163 -32
  90. package/dist/controllers/pagination_controller.js.map +1 -1
  91. package/dist/controllers/persist_controller.js +6 -6
  92. package/dist/controllers/persist_controller.js.map +1 -1
  93. package/dist/controllers/pointer_drag_controller.d.ts +2 -3
  94. package/dist/controllers/pointer_drag_controller.js +9 -1
  95. package/dist/controllers/pointer_drag_controller.js.map +1 -1
  96. package/dist/controllers/popover_controller.d.ts +1 -2
  97. package/dist/controllers/popover_controller.js +2 -2
  98. package/dist/controllers/popover_controller.js.map +1 -1
  99. package/dist/controllers/portal_controller.d.ts +4 -4
  100. package/dist/controllers/portal_controller.js.map +1 -1
  101. package/dist/controllers/progress_controller.js.map +1 -1
  102. package/dist/controllers/radio_group_controller.js +22 -3
  103. package/dist/controllers/radio_group_controller.js.map +1 -1
  104. package/dist/controllers/range_slider_controller.d.ts +14 -4
  105. package/dist/controllers/range_slider_controller.js +32 -6
  106. package/dist/controllers/range_slider_controller.js.map +1 -1
  107. package/dist/controllers/rating_controller.js +16 -2
  108. package/dist/controllers/rating_controller.js.map +1 -1
  109. package/dist/controllers/read_more_controller.js +63 -19
  110. package/dist/controllers/read_more_controller.js.map +1 -1
  111. package/dist/controllers/reading_progress_controller.js.map +1 -1
  112. package/dist/controllers/resizable_controller.d.ts +3 -1
  113. package/dist/controllers/resizable_controller.js +65 -1
  114. package/dist/controllers/resizable_controller.js.map +1 -1
  115. package/dist/controllers/roving_controller.js +17 -2
  116. package/dist/controllers/roving_controller.js.map +1 -1
  117. package/dist/controllers/scroll_area_controller.js +86 -12
  118. package/dist/controllers/scroll_area_controller.js.map +1 -1
  119. package/dist/controllers/scroll_restore_controller.js +1 -1
  120. package/dist/controllers/scroll_restore_controller.js.map +1 -1
  121. package/dist/controllers/scroll_visibility_controller.d.ts +6 -3
  122. package/dist/controllers/scroll_visibility_controller.js +33 -3
  123. package/dist/controllers/scroll_visibility_controller.js.map +1 -1
  124. package/dist/controllers/scrollspy_controller.d.ts +64 -15
  125. package/dist/controllers/scrollspy_controller.js +346 -73
  126. package/dist/controllers/scrollspy_controller.js.map +1 -1
  127. package/dist/controllers/separator_controller.d.ts +20 -5
  128. package/dist/controllers/separator_controller.js +9 -0
  129. package/dist/controllers/separator_controller.js.map +1 -1
  130. package/dist/controllers/sidebar_controller.d.ts +1 -1
  131. package/dist/controllers/sidebar_controller.js.map +1 -1
  132. package/dist/controllers/skeleton_controller.js +1 -1
  133. package/dist/controllers/skeleton_controller.js.map +1 -1
  134. package/dist/controllers/slider_controller.d.ts +11 -1
  135. package/dist/controllers/slider_controller.js +32 -6
  136. package/dist/controllers/slider_controller.js.map +1 -1
  137. package/dist/controllers/sortable_controller.d.ts +3 -1
  138. package/dist/controllers/sortable_controller.js +34 -3
  139. package/dist/controllers/sortable_controller.js.map +1 -1
  140. package/dist/controllers/spinner_controller.js +1 -1
  141. package/dist/controllers/spinner_controller.js.map +1 -1
  142. package/dist/controllers/stick_to_bottom_controller.d.ts +5 -6
  143. package/dist/controllers/stick_to_bottom_controller.js +2 -1
  144. package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
  145. package/dist/controllers/sticky_observer_controller.js +32 -11
  146. package/dist/controllers/sticky_observer_controller.js.map +1 -1
  147. package/dist/controllers/submit_once_controller.d.ts +2 -2
  148. package/dist/controllers/submit_once_controller.js.map +1 -1
  149. package/dist/controllers/switch_controller.js +1 -0
  150. package/dist/controllers/switch_controller.js.map +1 -1
  151. package/dist/controllers/tabs_controller.d.ts +11 -3
  152. package/dist/controllers/tabs_controller.js +26 -3
  153. package/dist/controllers/tabs_controller.js.map +1 -1
  154. package/dist/controllers/tags_input_controller.js +22 -2
  155. package/dist/controllers/tags_input_controller.js.map +1 -1
  156. package/dist/controllers/theme_controller.d.ts +1 -1
  157. package/dist/controllers/theme_controller.js +22 -3
  158. package/dist/controllers/theme_controller.js.map +1 -1
  159. package/dist/controllers/time_picker_controller.js +20 -1
  160. package/dist/controllers/time_picker_controller.js.map +1 -1
  161. package/dist/controllers/toast_controller.d.ts +4 -5
  162. package/dist/controllers/toast_controller.js +4 -5
  163. package/dist/controllers/toast_controller.js.map +1 -1
  164. package/dist/controllers/toggle_group_controller.js +23 -2
  165. package/dist/controllers/toggle_group_controller.js.map +1 -1
  166. package/dist/controllers/toolbar_controller.d.ts +53 -13
  167. package/dist/controllers/toolbar_controller.js +230 -31
  168. package/dist/controllers/toolbar_controller.js.map +1 -1
  169. package/dist/controllers/tooltip_controller.d.ts +1 -1
  170. package/dist/controllers/tooltip_controller.js.map +1 -1
  171. package/dist/controllers/transition_controller.js.map +1 -1
  172. package/dist/controllers/tree_view_controller.d.ts +87 -6
  173. package/dist/controllers/tree_view_controller.js +467 -51
  174. package/dist/controllers/tree_view_controller.js.map +1 -1
  175. package/dist/index.js +3514 -689
  176. package/dist/index.js.map +1 -1
  177. package/dist/inspector/cli.d.ts +375 -39
  178. package/dist/inspector/cli.js +228 -14
  179. package/dist/inspector/cli.js.map +1 -1
  180. package/dist/inspector/cli_bin.js +419 -48
  181. package/dist/inspector/cli_bin.js.map +1 -1
  182. package/dist/inspector/examples.json +24 -24
  183. package/dist/inspector/manifest.json +1040 -125
  184. package/package.json +5 -6
@@ -2,6 +2,34 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/tree_view_controller.ts
4
4
 
5
+ // src/utils/logical_scroll.ts
6
+ function isRtl(element) {
7
+ return window.getComputedStyle(element).direction === "rtl";
8
+ }
9
+
10
+ // src/utils/arrow_step.ts
11
+ function isReservedArrowChord(event, allow = []) {
12
+ if (!event.key.startsWith("Arrow")) return false;
13
+ return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
14
+ }
15
+
16
+ // src/utils/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
+
5
33
  // src/utils/roving_tabindex.ts
6
34
  var RovingTabindex = class {
7
35
  /** Returns the current ordered item elements; called on every operation. */
@@ -88,33 +116,239 @@ var SafeTimeout = class extends TimerRegistry {
88
116
  }
89
117
  };
90
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
+
91
177
  // src/controllers/tree_view_controller.ts
92
- var TYPEAHEAD_TIMEOUT = 500;
178
+ var NESTED_INTERACTIVE = 'input, textarea, select, button, a[href], [contenteditable]:not([contenteditable="false"])';
93
179
  var TreeViewController = class extends Controller {
94
180
  static targets = ["item", "group"];
95
- static actions = ["onClick", "onKeydown"];
181
+ static actions = ["onClick", "onKeydown", "toggle"];
96
182
  static events = ["select", "toggle"];
97
183
  #roving = new RovingTabindex(() => this.itemTargets);
98
- #typeahead = "";
99
- #typeaheadTimer = 0;
184
+ #typeahead = new Typeahead();
100
185
  #timers = new SafeTimeout();
101
- /** Establishes the single tab stop (keeps an existing one, else the first). */
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
+ */
102
234
  connect() {
103
- const active = this.#roving.activeIndex;
104
- this.#roving.setActive(active === -1 ? 0 : active);
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;
105
243
  }
106
- /** Clears the pending typeahead-reset timer. */
244
+ /** Detaches the focus trackers, drops the typeahead buffer and its timer. */
107
245
  disconnect() {
246
+ this.element.removeEventListener("focusin", this.#onFocusIn);
247
+ this.element.removeEventListener("focusout", this.#onFocusOut);
248
+ this.#connected = false;
249
+ this.#typeahead.reset();
108
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;
109
339
  }
110
340
  /**
111
341
  * Routes tree keyboard interaction. Because `treeitem`s nest, only the handler
112
342
  * 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.
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.
114
345
  */
115
346
  onKeydown(event) {
116
- const item = event.currentTarget;
117
- if (event.target.closest('[role="treeitem"]') !== item) return;
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;
118
352
  switch (event.key) {
119
353
  case "ArrowDown":
120
354
  event.preventDefault();
@@ -124,13 +358,24 @@ var TreeViewController = class extends Controller {
124
358
  event.preventDefault();
125
359
  this.#moveBy(item, -1);
126
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.
127
370
  case "ArrowRight":
128
371
  event.preventDefault();
129
- this.#expandOrEnter(item);
372
+ if (isRtl(this.element)) this.#collapseOrLeave(item);
373
+ else this.#expandOrEnter(item);
130
374
  break;
131
375
  case "ArrowLeft":
132
376
  event.preventDefault();
133
- this.#collapseOrLeave(item);
377
+ if (isRtl(this.element)) this.#expandOrEnter(item);
378
+ else this.#collapseOrLeave(item);
134
379
  break;
135
380
  case "Home":
136
381
  event.preventDefault();
@@ -146,7 +391,7 @@ var TreeViewController = class extends Controller {
146
391
  this.#select(item);
147
392
  break;
148
393
  default:
149
- if (this.#isPrintable(event)) {
394
+ if (isTypeaheadKey(event)) {
150
395
  event.preventDefault();
151
396
  this.#typeaheadTo(item, event.key);
152
397
  }
@@ -155,28 +400,105 @@ var TreeViewController = class extends Controller {
155
400
  }
156
401
  /** Selects the clicked item (nearest to the target only). */
157
402
  onClick(event) {
158
- const item = event.currentTarget;
159
- if (event.target.closest('[role="treeitem"]') !== item) return;
403
+ const item = this.#ownerItem(event);
404
+ if (!item) return;
405
+ const group = this.#childGroup(item);
406
+ if (group?.contains(event.target)) return;
160
407
  this.#focusItem(item);
161
408
  this.#select(item);
162
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
+ }
163
476
  /** Moves focus to the next (`delta=1`) or previous visible item, if any. */
164
477
  #moveBy(item, delta) {
165
478
  const visible = this.#visibleItems;
166
479
  const current = visible.indexOf(item);
480
+ if (current === -1) return;
167
481
  const next = visible[current + delta];
168
482
  if (next) this.#focusItem(next);
169
483
  }
170
- /** `ArrowRight`: expand a collapsed parent, else step into the first child. */
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
+ */
171
490
  #expandOrEnter(item) {
172
491
  const group = this.#childGroup(item);
173
492
  if (!group) return;
174
- if (this.#isExpanded(item)) {
175
- const firstChild = group.querySelector(':scope > [role="treeitem"]');
176
- if (firstChild) this.#focusItem(firstChild);
177
- } else {
493
+ if (!this.#isExpanded(item)) {
178
494
  this.#setExpanded(item, true);
495
+ return;
179
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);
180
502
  }
181
503
  /** `ArrowLeft`: collapse an expanded parent, else step out to the parent item. */
182
504
  #collapseOrLeave(item) {
@@ -193,49 +515,141 @@ var TreeViewController = class extends Controller {
193
515
  const target = index < 0 ? visible[visible.length - 1] : visible[index];
194
516
  if (target) this.#focusItem(target);
195
517
  }
196
- /** Applies single selection and dispatches `select`. */
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
+ */
197
544
  #select(item) {
545
+ if (this.#isDisabled(item)) return;
198
546
  for (const candidate of this.itemTargets) {
199
547
  candidate.setAttribute("aria-selected", candidate === item ? "true" : "false");
200
548
  }
201
549
  this.dispatch("select", { detail: { item } });
202
550
  }
203
- /** Toggles `aria-expanded` + the child group's `hidden`, dispatching `toggle`. */
551
+ /** Updates expansion, reconciles a collapsed subtree, then synchronously dispatches `toggle`. */
204
552
  #setExpanded(item, expanded) {
205
553
  const group = this.#childGroup(item);
206
554
  if (!group) return;
207
555
  item.setAttribute("aria-expanded", String(expanded));
208
556
  group.hidden = !expanded;
557
+ if (!expanded) this.#escapeCollapsedSubtree(item, group);
209
558
  this.dispatch("toggle", { detail: { item, expanded } });
210
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
+ }
211
580
  /** Makes `item` the single tab stop and moves DOM focus to it. */
212
581
  #focusItem(item) {
213
582
  const index = this.itemTargets.indexOf(item);
214
- if (index !== -1) this.#roving.setActive(index, { focus: true });
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();
215
590
  }
216
- /** Advances the typeahead buffer and focuses the next matching visible item. */
591
+ /** Advances the typeahead query and focuses the next matching visible item. */
217
592
  #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);
593
+ const query = this.#typeahead.push(char);
223
594
  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;
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));
230
619
  }
231
620
  }
232
621
  }
233
- /** The visible items: those with no collapsed (`hidden`) ancestor group. */
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
+ */
234
648
  get #visibleItems() {
235
649
  return this.itemTargets.filter((item) => {
236
- let node = item.parentElement;
650
+ let node = item;
237
651
  while (node && node !== this.element) {
238
- if (node.matches('[role="group"]') && node.hidden) return false;
652
+ if (node.hidden) return false;
239
653
  node = node.parentElement;
240
654
  }
241
655
  return true;
@@ -253,20 +667,22 @@ var TreeViewController = class extends Controller {
253
667
  #isExpanded(item) {
254
668
  return item.getAttribute("aria-expanded") === "true";
255
669
  }
256
- /** `item`'s own label text (excluding any nested child group), lowercased. */
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
+ */
257
675
  #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;
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 ?? "";
262
683
  }
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;
684
+ return text;
685
+ });
270
686
  }
271
687
  };
272
688