stimeo-ui 0.5.0 → 0.6.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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +116 -0
  3. data/dist/controllers/aspect_ratio_controller.js +19 -11
  4. data/dist/controllers/avatar_controller.js +195 -40
  5. data/dist/controllers/carousel_controller.js +85 -9
  6. data/dist/controllers/checkbox_controller.js +136 -25
  7. data/dist/controllers/color_picker_controller.js +35 -9
  8. data/dist/controllers/date_range_picker_controller.js +157 -30
  9. data/dist/controllers/file_dropzone_controller.js +26 -3
  10. data/dist/controllers/idle_controller.js +27 -5
  11. data/dist/controllers/menubar_controller.js +5 -3
  12. data/dist/controllers/multi_select_controller.js +460 -151
  13. data/dist/controllers/number_input_controller.js +275 -51
  14. data/dist/controllers/overflow_menu_controller.js +4 -0
  15. data/dist/controllers/pagination_controller.js +33 -0
  16. data/dist/controllers/password_strength_controller.js +20 -2
  17. data/dist/controllers/persist_controller.js +24 -5
  18. data/dist/controllers/radio_group_controller.js +540 -56
  19. data/dist/controllers/rating_controller.js +272 -89
  20. data/dist/controllers/resizable_controller.js +33 -0
  21. data/dist/controllers/roving_controller.js +60 -5
  22. data/dist/controllers/scroll_area_controller.js +154 -22
  23. data/dist/controllers/scroll_visibility_controller.js +33 -0
  24. data/dist/controllers/tags_input_controller.js +356 -120
  25. data/dist/controllers/time_picker_controller.js +296 -107
  26. data/dist/controllers/toggle_group_controller.js +378 -55
  27. data/dist/controllers/toolbar_controller.js +5 -3
  28. data/dist/controllers/tree_view_controller.js +5 -3
  29. data/dist/index.js +2637 -781
  30. data/lib/stimeo/ui/version.rb +1 -1
  31. metadata +2 -2
@@ -13,6 +13,72 @@ function isReservedArrowChord(event, allow = []) {
13
13
  return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
14
14
  }
15
15
 
16
+ // src/utils/attribute_lease.ts
17
+ var AttributeLease = class {
18
+ #attribute;
19
+ #records = /* @__PURE__ */ new Map();
20
+ /** @param attribute - The attribute whose temporary values this lease owns. */
21
+ constructor(attribute) {
22
+ this.#attribute = attribute;
23
+ }
24
+ /** Writes or removes the leased attribute while preserving its authored value. */
25
+ write(element, value) {
26
+ const existing = this.#records.get(element);
27
+ if (existing) {
28
+ existing.written = value;
29
+ } else {
30
+ this.#records.set(element, {
31
+ original: element.getAttribute(this.#attribute),
32
+ written: value
33
+ });
34
+ }
35
+ if (value === null) element.removeAttribute(this.#attribute);
36
+ else element.setAttribute(this.#attribute, value);
37
+ }
38
+ /** Returns one lease without overwriting a value subsequently authored by a consumer. */
39
+ return(element) {
40
+ const record = this.#records.get(element);
41
+ if (!record) return;
42
+ this.#records.delete(element);
43
+ if (element.getAttribute(this.#attribute) !== record.written) return;
44
+ if (record.original === null) element.removeAttribute(this.#attribute);
45
+ else element.setAttribute(this.#attribute, record.original);
46
+ }
47
+ /** Returns every outstanding lease using the same ownership check as {@link return}. */
48
+ returnAll() {
49
+ for (const element of Array.from(this.#records.keys())) this.return(element);
50
+ }
51
+ };
52
+
53
+ // src/utils/before_cache_reset.ts
54
+ var BeforeCacheReset = class _BeforeCacheReset {
55
+ /** Every subscribed instance, iterated by the one shared document listener. */
56
+ static #subscribers = /* @__PURE__ */ new Set();
57
+ /** The shared listener; installed while at least one instance is subscribed. */
58
+ static #onBeforeCache = () => {
59
+ for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
60
+ };
61
+ #rewind;
62
+ /** @param rewind - the pass that returns this controller's state to its initial form. */
63
+ constructor(rewind) {
64
+ this.#rewind = rewind;
65
+ }
66
+ /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
67
+ activate() {
68
+ const first = _BeforeCacheReset.#subscribers.size === 0;
69
+ _BeforeCacheReset.#subscribers.add(this);
70
+ if (first) {
71
+ document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
72
+ }
73
+ }
74
+ /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
75
+ deactivate() {
76
+ _BeforeCacheReset.#subscribers.delete(this);
77
+ if (_BeforeCacheReset.#subscribers.size > 0) return;
78
+ document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
79
+ }
80
+ };
81
+
16
82
  // src/utils/microtask_coalescer.ts
17
83
  var MicrotaskCoalescer = class {
18
84
  #run;
@@ -68,10 +134,12 @@ var RovingTabindex = class {
68
134
  * "nothing is currently tabbable".
69
135
  *
70
136
  * @param index - Position of the item to make tabbable.
71
- * @param options - Pass `{ focus: true }` to also move DOM focus to that item.
137
+ * @param options - Pass `{ focus: true }` to also move DOM focus to that item,
138
+ * and `items` to reuse an event-scoped collection snapshot.
72
139
  */
73
- setActive(index, { focus = false } = {}) {
74
- const items = this.#getItems();
140
+ setActive(index, options = {}) {
141
+ const { focus = false } = options;
142
+ const items = options.items ?? this.#getItems();
75
143
  items.forEach((item, i) => {
76
144
  item.tabIndex = i === index ? 0 : -1;
77
145
  });
@@ -79,167 +147,282 @@ var RovingTabindex = class {
79
147
  }
80
148
  };
81
149
 
150
+ // src/utils/tabindex_loan.ts
151
+ var TabindexLoan = class {
152
+ #value;
153
+ #lent = /* @__PURE__ */ new Set();
154
+ /** Returns live loans before Turbo can copy them into its page snapshot. */
155
+ #beforeCache = new BeforeCacheReset(() => this.returnAll());
156
+ /**
157
+ * @param value - the `tabindex` to lend. `"-1"` (the default) is
158
+ * programmatically focusable but not a Tab stop; `"0"` is a real Tab stop,
159
+ * which a scroll region with no focusable content of its own needs.
160
+ */
161
+ constructor(value = "-1") {
162
+ this.#value = value;
163
+ }
164
+ /** Lends `element` the value; no-ops when it already carries a `tabindex`. */
165
+ lend(element) {
166
+ if (element.hasAttribute("tabindex")) return;
167
+ element.setAttribute("tabindex", this.#value);
168
+ this.#lent.add(element);
169
+ this.#beforeCache.activate();
170
+ }
171
+ /** Takes back every loan whose value is still the one that was lent. */
172
+ returnAll() {
173
+ for (const element of this.#lent) {
174
+ if (element.getAttribute("tabindex") === this.#value) element.removeAttribute("tabindex");
175
+ }
176
+ this.#lent.clear();
177
+ this.#beforeCache.deactivate();
178
+ }
179
+ };
180
+
82
181
  // src/controllers/rating_controller.ts
83
182
  var RatingController = class extends Controller {
84
183
  static targets = ["symbol", "field"];
85
184
  static values = {
86
185
  value: { type: Number, default: 0 },
87
- max: { type: Number, default: 5 },
88
186
  clearable: { type: Boolean, default: true },
89
187
  readonly: { type: Boolean, default: false }
90
188
  };
91
189
  static actions = ["endPreview", "onKeydown", "preview", "select"];
92
- static events = ["change"];
190
+ static events = ["change", "reconcile"];
93
191
  #roving = new RovingTabindex(() => this.symbolTargets);
94
- /** Reflects the initial value, or switches to the non-interactive readonly view. */
95
- /**
96
- * Collapses a morph that swaps render inputs into one repaint, and refuses the
97
- * pass Stimulus delivers before `connect()`.
98
- */
99
- #repaint = new MicrotaskCoalescer(() => {
100
- if (this.readonlyValue) {
101
- this.#applyReadonly();
102
- return;
103
- }
104
- this.#apply(this.#clamp(this.valueValue), { focus: false });
105
- });
192
+ #rootRole = new AttributeLease("role");
193
+ #symbolRole = new AttributeLease("role");
194
+ #symbolAriaHidden = new AttributeLease("aria-hidden");
195
+ #rootTabindex = new TabindexLoan();
196
+ #repaint = new MicrotaskCoalescer(() => this.#reconcileScale());
197
+ #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
198
+ #connected = false;
199
+ #rescuedFocus = false;
200
+ /** Reflects declarative state without announcing an initial user change. */
106
201
  connect() {
107
202
  this.#repaint.activate();
108
- if (this.readonlyValue) {
109
- this.#applyReadonly();
110
- return;
111
- }
112
- this.#apply(this.#clamp(this.valueValue), { focus: false });
203
+ this.#beforeCache.activate();
204
+ this.#apply(this.#normalize(this.valueValue), { focus: false });
205
+ this.#connected = true;
113
206
  }
114
- /** Closes the window in which a queued repaint may still run. */
207
+ /** Drops a queued reconciliation and hands every borrowed attribute back. */
115
208
  disconnect() {
209
+ this.#connected = false;
116
210
  this.#repaint.cancel();
211
+ this.#beforeCache.deactivate();
212
+ this.#releaseReadonly();
213
+ }
214
+ /** Removes a runtime-added symbol's authored Tab stop before the batch repaint. */
215
+ symbolTargetConnected(symbol) {
216
+ if (this.#connected === false) return;
217
+ symbol.tabIndex = -1;
218
+ this.#repaint.schedule();
219
+ }
220
+ /** Releases readonly ownership and reconciles the remaining DOM-ordered scale. */
221
+ symbolTargetDisconnected(symbol) {
222
+ this.#symbolRole.return(symbol);
223
+ this.#symbolAriaHidden.return(symbol);
224
+ this.#repaint.schedule();
225
+ }
226
+ /** Reflects into a hidden field added or replaced after connection. */
227
+ fieldTargetConnected() {
228
+ this.#repaint.schedule();
117
229
  }
118
- /** Repaints when application code (or a Turbo morph) changes `value` at runtime. */
230
+ /** Reconciles after a hidden field is removed or replaced. */
231
+ fieldTargetDisconnected() {
232
+ this.#repaint.schedule();
233
+ }
234
+ /** Repaints when application code or a Turbo morph changes `value`. */
119
235
  valueValueChanged() {
120
236
  this.#repaint.schedule();
121
237
  }
122
- /** Selects (or clears) the clicked symbol. Bound via `data-action` (click). */
238
+ /** Repaints when application code changes whether value 0 is permitted. */
239
+ clearableValueChanged() {
240
+ this.#repaint.schedule();
241
+ }
242
+ /** Repaints when application code enters or leaves the readonly snapshot. */
243
+ readonlyValueChanged() {
244
+ this.#repaint.schedule();
245
+ }
246
+ /** Selects or clears the clicked symbol. Bound via `data-action` (click). */
123
247
  select(event) {
124
248
  if (this.readonlyValue) return;
125
- const value = this.#clamp(this.#symbolValue(event.currentTarget));
126
- if (this.clearableValue && value === this.valueValue) {
127
- this.#render(0, { focus: true });
128
- } else {
129
- this.#render(value, { focus: false });
130
- }
249
+ const ordinal = this.#symbolOrdinal(event.currentTarget);
250
+ if (ordinal === null) return;
251
+ const current = this.#normalize(this.valueValue);
252
+ this.#commit(this.clearableValue && ordinal === current ? 0 : ordinal, {
253
+ focus: ordinal === current
254
+ });
131
255
  }
132
- /** Previews a fill range on hover/focus. Bound via `data-action` (mouseenter/focus). */
256
+ /** Previews a fill range on hover or focus without committing it. */
133
257
  preview(event) {
134
258
  if (this.readonlyValue) return;
135
- this.#setFillRange(this.#clamp(this.#symbolValue(event.currentTarget)));
259
+ const ordinal = this.#symbolOrdinal(event.currentTarget);
260
+ if (ordinal !== null) this.#setFillRange(ordinal);
136
261
  }
137
- /** Restores the fill range to the selected value. Bound via `data-action` (mouseleave/blur). */
262
+ /** Restores the fill range after hover or focus leaves a symbol. */
138
263
  endPreview() {
139
264
  if (this.readonlyValue) return;
140
- this.#setFillRange(this.valueValue);
265
+ this.#setFillRange(this.#normalize(this.valueValue));
141
266
  }
142
- /** Arrow/Home/End/Space keyboard control, clamped (no wrap). */
267
+ /** Arrow/Home/End/Space/Delete keyboard control, clamped without wrapping. */
143
268
  onKeydown(event) {
144
- if (event.defaultPrevented) return;
145
- if (isReservedArrowChord(event)) return;
146
- if (this.readonlyValue) return;
269
+ if (event.defaultPrevented || isReservedArrowChord(event) || this.readonlyValue) return;
270
+ const current = this.#normalize(this.valueValue);
147
271
  let next = null;
148
272
  const rtl = isRtl(this.element);
149
273
  switch (event.key) {
150
274
  case "ArrowRight":
151
275
  case "ArrowUp":
152
- next = this.valueValue + (event.key === "ArrowRight" && rtl ? -1 : 1);
276
+ next = current + (event.key === "ArrowRight" && rtl ? -1 : 1);
153
277
  break;
154
278
  case "ArrowLeft":
155
279
  case "ArrowDown":
156
- next = this.valueValue - (event.key === "ArrowLeft" && rtl ? -1 : 1);
280
+ next = current - (event.key === "ArrowLeft" && rtl ? -1 : 1);
157
281
  break;
158
282
  case "Home":
159
283
  next = this.#minValue;
160
284
  break;
161
285
  case "End":
162
- next = this.maxValue;
286
+ next = this.symbolTargets.length;
163
287
  break;
164
288
  case " ":
165
289
  case "Enter":
166
- next = this.#symbolValue(event.currentTarget);
290
+ next = this.#symbolOrdinal(event.currentTarget);
291
+ break;
292
+ case "Delete":
293
+ case "Backspace":
294
+ if (!this.clearableValue) return;
295
+ next = 0;
167
296
  break;
168
297
  default:
169
298
  return;
170
299
  }
300
+ if (next === null) return;
171
301
  event.preventDefault();
172
- this.#render(this.#clamp(next), { focus: true });
302
+ this.#commit(next, { focus: true });
173
303
  }
174
304
  /**
175
- * Applies `value` (already clamped) everywhere, then dispatches `change`.
176
- * Use for user-driven changes; on connect call `#apply` directly so
177
- * initialization mirrors state without emitting an event.
305
+ * Repaints one settled target/Value mutation batch and reports only a value
306
+ * this controller had to normalize.
178
307
  *
179
308
  * @stimeoRenderRoot
180
309
  */
181
- #render(value, { focus }) {
310
+ #reconcileScale() {
311
+ const requested = this.valueValue;
312
+ const value = this.#normalize(requested);
313
+ this.#apply(value, { focus: false });
314
+ if (!Object.is(value, requested)) {
315
+ this.dispatch("reconcile", { detail: { value } });
316
+ }
317
+ }
318
+ /** Applies one user operation and emits only when its committed value changes. */
319
+ #commit(raw, { focus }) {
320
+ const previous = this.#normalize(this.valueValue);
321
+ const value = this.#normalize(raw);
182
322
  this.#apply(value, { focus });
183
- this.dispatch("change", { detail: { value } });
323
+ if (value !== previous) this.dispatch("change", { detail: { value } });
184
324
  }
185
- /**
186
- * Stores `value`, syncs `aria-checked` and the roving Tab stop, the hidden
187
- * field, and the fill range — without dispatching `change`. Idempotent and
188
- * safe on connect (and across Turbo morphing).
189
- */
325
+ /** Synchronizes value, ARIA, roving focus, form state, and the visual fill hook. */
190
326
  #apply(value, { focus }) {
191
- this.valueValue = value;
192
- this.symbolTargets.forEach((symbol) => {
193
- symbol.setAttribute(
194
- "aria-checked",
195
- value > 0 && this.#symbolValue(symbol) === value ? "true" : "false"
196
- );
327
+ if (!Object.is(this.valueValue, value)) this.valueValue = value;
328
+ this.symbolTargets.forEach((symbol, index) => {
329
+ symbol.setAttribute("aria-checked", value > 0 && index + 1 === value ? "true" : "false");
197
330
  });
198
- this.#roving.setActive(value > 0 ? value - 1 : 0, { focus });
331
+ if (this.readonlyValue) {
332
+ this.#applyReadonly();
333
+ } else {
334
+ const returning = this.#releaseReadonly();
335
+ this.#roving.setActive(value > 0 ? value - 1 : 0, { focus: focus || returning });
336
+ }
199
337
  if (this.hasFieldTarget) this.fieldTarget.value = String(value);
200
338
  this.#setFillRange(value);
201
339
  }
202
- /** Marks symbols up to `range` with `data-rating-hover` (the consumer's fill hook). */
340
+ /** Marks the first `range` symbols with the consumer-owned fill hook. */
203
341
  #setFillRange(range) {
204
- for (const symbol of this.symbolTargets) {
205
- if (this.#symbolValue(symbol) <= range && range > 0) {
206
- symbol.setAttribute("data-rating-hover", "");
207
- } else {
208
- symbol.removeAttribute("data-rating-hover");
209
- }
210
- }
342
+ this.symbolTargets.forEach((symbol, index) => {
343
+ symbol.toggleAttribute("data-rating-hover", range > 0 && index < range);
344
+ });
211
345
  }
212
- /** Turns the group into a non-interactive `role="img"` snapshot of the value. */
346
+ /**
347
+ * Temporarily turns the radiogroup into a non-interactive image snapshot.
348
+ *
349
+ * Each lease is returned before it is taken again, so a value the consumer
350
+ * wrote while readonly becomes the value the lease restores on release. The
351
+ * return is a no-op on an attribute still holding this controller's own
352
+ * write, which is the ordinary case.
353
+ */
213
354
  #applyReadonly() {
214
- const value = this.#clamp(this.valueValue);
215
- this.valueValue = value;
216
- this.element.setAttribute("role", "img");
355
+ this.#rescueFocus();
356
+ this.#rootRole.return(this.element);
357
+ this.#rootRole.write(this.element, "img");
217
358
  for (const symbol of this.symbolTargets) {
218
- symbol.removeAttribute("role");
219
- symbol.setAttribute("aria-hidden", "true");
220
- symbol.tabIndex = -1;
359
+ this.#symbolRole.return(symbol);
360
+ this.#symbolRole.write(symbol, null);
361
+ this.#symbolAriaHidden.return(symbol);
362
+ this.#symbolAriaHidden.write(symbol, "true");
221
363
  }
222
- if (this.hasFieldTarget) this.fieldTarget.value = String(value);
223
- this.#setFillRange(value);
364
+ this.#roving.setActive(-1);
365
+ }
366
+ /**
367
+ * Hands every readonly borrowing back before Turbo clones the page.
368
+ *
369
+ * The snapshot is taken while the controller is still connected, so an
370
+ * element left as `role="img"` with hidden symbols is what a restored page
371
+ * connects against — and that markup would be read as the authored one,
372
+ * leaving no way back to the radiogroup. Rewinding first keeps the cached
373
+ * copy identical to what the consumer wrote.
374
+ */
375
+ #rewindForCache() {
376
+ this.#releaseReadonly();
377
+ const value = this.#normalize(this.valueValue);
378
+ this.#roving.setActive(value > 0 ? value - 1 : 0);
224
379
  }
225
380
  /**
226
- * Clamps `value` to `[min, max]` (min is 0 when clearable, else 1). The upper
227
- * bound is also capped at the number of symbols so the roving Tab stop
228
- * (`value - 1`) always maps to a real symbol — even if the consumer's `max`
229
- * value and rendered symbol count disagree, the tabbable item is never lost.
381
+ * Lands focus on the root before the symbols leave the accessibility tree.
382
+ *
383
+ * A symbol holding focus when readonly begins would keep it while losing its
384
+ * role and gaining `aria-hidden`, stranding the user on a node no longer in
385
+ * the tree. The root is the one element that survives the transition named:
386
+ * it carries the consumer's accessible name under `role="img"`.
230
387
  */
231
- #clamp(value) {
232
- const max = Math.min(this.maxValue, this.symbolTargets.length);
233
- return Math.min(max, Math.max(this.#minValue, value));
388
+ #rescueFocus() {
389
+ const active = document.activeElement;
390
+ if (!(active instanceof HTMLElement) || active === this.element) return;
391
+ if (!this.element.contains(active)) return;
392
+ this.#rootTabindex.lend(this.element);
393
+ this.element.focus();
394
+ this.#rescuedFocus = true;
395
+ }
396
+ /**
397
+ * Restores authored roles and visibility after leaving readonly mode.
398
+ *
399
+ * @returns whether focus is standing on the root because {@link #rescueFocus}
400
+ * put it there, and therefore belongs back on the Tab stop.
401
+ */
402
+ #releaseReadonly() {
403
+ const returning = this.#rescuedFocus && document.activeElement === this.element;
404
+ this.#rescuedFocus = false;
405
+ this.#rootRole.return(this.element);
406
+ this.#symbolRole.returnAll();
407
+ this.#symbolAriaHidden.returnAll();
408
+ this.#rootTabindex.returnAll();
409
+ return returning;
410
+ }
411
+ /** Normalizes a raw value to an integer ordinal in the live DOM range. */
412
+ #normalize(raw) {
413
+ const maximum = this.symbolTargets.length;
414
+ const ordinal = Number.isFinite(raw) ? Math.round(raw) : this.#minValue;
415
+ return Math.min(maximum, Math.max(this.#minValue, ordinal));
234
416
  }
235
417
  /** Lowest selectable value: 0 when clearable, otherwise 1. */
236
418
  get #minValue() {
237
419
  return this.clearableValue ? 0 : 1;
238
420
  }
239
- /** A symbol's ordinal value (`data-rating-value`, defaulting to its position). */
240
- #symbolValue(symbol) {
241
- const raw = Number(symbol.getAttribute("data-rating-value"));
242
- return Number.isFinite(raw) && raw > 0 ? raw : this.symbolTargets.indexOf(symbol) + 1;
421
+ /** Returns a target's 1-based position, or null when the action host is invalid. */
422
+ #symbolOrdinal(target) {
423
+ const targets = this.symbolTargets;
424
+ const index = targets.indexOf(target);
425
+ return index < 0 ? null : index + 1;
243
426
  }
244
427
  };
245
428
 
@@ -8,10 +8,41 @@ function isReservedArrowChord(event, allow = []) {
8
8
  return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
9
9
  }
10
10
 
11
+ // src/utils/before_cache_reset.ts
12
+ var BeforeCacheReset = class _BeforeCacheReset {
13
+ /** Every subscribed instance, iterated by the one shared document listener. */
14
+ static #subscribers = /* @__PURE__ */ new Set();
15
+ /** The shared listener; installed while at least one instance is subscribed. */
16
+ static #onBeforeCache = () => {
17
+ for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
18
+ };
19
+ #rewind;
20
+ /** @param rewind - the pass that returns this controller's state to its initial form. */
21
+ constructor(rewind) {
22
+ this.#rewind = rewind;
23
+ }
24
+ /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
25
+ activate() {
26
+ const first = _BeforeCacheReset.#subscribers.size === 0;
27
+ _BeforeCacheReset.#subscribers.add(this);
28
+ if (first) {
29
+ document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
30
+ }
31
+ }
32
+ /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
33
+ deactivate() {
34
+ _BeforeCacheReset.#subscribers.delete(this);
35
+ if (_BeforeCacheReset.#subscribers.size > 0) return;
36
+ document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
37
+ }
38
+ };
39
+
11
40
  // src/utils/tabindex_loan.ts
12
41
  var TabindexLoan = class {
13
42
  #value;
14
43
  #lent = /* @__PURE__ */ new Set();
44
+ /** Returns live loans before Turbo can copy them into its page snapshot. */
45
+ #beforeCache = new BeforeCacheReset(() => this.returnAll());
15
46
  /**
16
47
  * @param value - the `tabindex` to lend. `"-1"` (the default) is
17
48
  * programmatically focusable but not a Tab stop; `"0"` is a real Tab stop,
@@ -25,6 +56,7 @@ var TabindexLoan = class {
25
56
  if (element.hasAttribute("tabindex")) return;
26
57
  element.setAttribute("tabindex", this.#value);
27
58
  this.#lent.add(element);
59
+ this.#beforeCache.activate();
28
60
  }
29
61
  /** Takes back every loan whose value is still the one that was lent. */
30
62
  returnAll() {
@@ -32,6 +64,7 @@ var TabindexLoan = class {
32
64
  if (element.getAttribute("tabindex") === this.#value) element.removeAttribute("tabindex");
33
65
  }
34
66
  this.#lent.clear();
67
+ this.#beforeCache.deactivate();
35
68
  }
36
69
  };
37
70
 
@@ -13,6 +13,39 @@ function isReservedArrowChord(event, allow = []) {
13
13
  return event.altKey && !allow.includes("alt") || event.ctrlKey && !allow.includes("ctrl") || event.metaKey && !allow.includes("meta") || event.shiftKey && !allow.includes("shift");
14
14
  }
15
15
 
16
+ // src/utils/microtask_coalescer.ts
17
+ var MicrotaskCoalescer = class {
18
+ #run;
19
+ #queued = false;
20
+ #active = false;
21
+ #generation = 0;
22
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
23
+ constructor(run) {
24
+ this.#run = run;
25
+ }
26
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
27
+ activate() {
28
+ this.#active = true;
29
+ }
30
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
31
+ cancel() {
32
+ this.#active = false;
33
+ this.#queued = false;
34
+ this.#generation += 1;
35
+ }
36
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
37
+ schedule() {
38
+ if (!this.#active || this.#queued) return;
39
+ this.#queued = true;
40
+ const generation = this.#generation;
41
+ queueMicrotask(() => {
42
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
43
+ this.#queued = false;
44
+ this.#run();
45
+ });
46
+ }
47
+ };
48
+
16
49
  // src/utils/roving_tabindex.ts
17
50
  var RovingTabindex = class {
18
51
  /** Returns the current ordered item elements; called on every operation. */
@@ -35,10 +68,12 @@ var RovingTabindex = class {
35
68
  * "nothing is currently tabbable".
36
69
  *
37
70
  * @param index - Position of the item to make tabbable.
38
- * @param options - Pass `{ focus: true }` to also move DOM focus to that item.
71
+ * @param options - Pass `{ focus: true }` to also move DOM focus to that item,
72
+ * and `items` to reuse an event-scoped collection snapshot.
39
73
  */
40
- setActive(index, { focus = false } = {}) {
41
- const items = this.#getItems();
74
+ setActive(index, options = {}) {
75
+ const { focus = false } = options;
76
+ const items = options.items ?? this.#getItems();
42
77
  items.forEach((item, i) => {
43
78
  item.tabIndex = i === index ? 0 : -1;
44
79
  });
@@ -62,16 +97,31 @@ var RovingController = class extends Controller {
62
97
  };
63
98
  static events = ["change"];
64
99
  #roving = new RovingTabindex(() => this.itemTargets);
100
+ #reconcile = new MicrotaskCoalescer(() => this.#ensureTabStop());
101
+ #connected = false;
65
102
  connect() {
66
- const active = this.#roving.activeIndex;
67
- this.#roving.setActive(active === -1 ? 0 : active);
103
+ this.#ensureTabStop();
68
104
  this.element.addEventListener("keydown", this.#onKeydown);
69
105
  this.element.addEventListener("focusin", this.#onFocusin);
106
+ this.#connected = true;
107
+ this.#reconcile.activate();
70
108
  }
71
109
  disconnect() {
110
+ this.#connected = false;
111
+ this.#reconcile.cancel();
72
112
  this.element.removeEventListener("keydown", this.#onKeydown);
73
113
  this.element.removeEventListener("focusin", this.#onFocusin);
74
114
  }
115
+ /** Drops a runtime-added item from the Tab sequence before batch reconciliation. */
116
+ itemTargetConnected(item) {
117
+ if (!this.#connected) return;
118
+ item.tabIndex = -1;
119
+ this.#reconcile.schedule();
120
+ }
121
+ /** Re-establishes the single Tab stop after an item leaves the target set. */
122
+ itemTargetDisconnected() {
123
+ this.#reconcile.schedule();
124
+ }
75
125
  /** Arrow keys move focus + the tab stop; Home/End jump to the ends. */
76
126
  #onKeydown = (event) => {
77
127
  if (event.defaultPrevented) return;
@@ -125,6 +175,11 @@ var RovingController = class extends Controller {
125
175
  this.dispatch("change", { detail: { index, item: this.itemTargets[index] } });
126
176
  }
127
177
  }
178
+ /** Keeps the first existing Tab stop, falling back to the first live item. */
179
+ #ensureTabStop() {
180
+ const active = this.#roving.activeIndex;
181
+ this.#roving.setActive(active === -1 ? 0 : active);
182
+ }
128
183
  };
129
184
 
130
185
  export { RovingController };