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