stimeo-ui 0.6.0 → 0.8.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +124 -0
  3. data/dist/controllers/alert_dialog_controller.js +75 -4
  4. data/dist/controllers/auto_submit_controller.js +94 -16
  5. data/dist/controllers/avatar_controller.js +47 -5
  6. data/dist/controllers/carousel_controller.js +451 -100
  7. data/dist/controllers/character_counter_controller.js +338 -63
  8. data/dist/controllers/command_palette_controller.js +75 -4
  9. data/dist/controllers/conditional_fields_controller.js +345 -51
  10. data/dist/controllers/confirm_controller.js +75 -4
  11. data/dist/controllers/currency_input_controller.js +305 -74
  12. data/dist/controllers/dialog_controller.js +75 -4
  13. data/dist/controllers/direct_upload_controller.js +212 -46
  14. data/dist/controllers/dirty_form_controller.js +192 -29
  15. data/dist/controllers/dismissible_controller.js +83 -18
  16. data/dist/controllers/drawer_controller.js +75 -4
  17. data/dist/controllers/file_dropzone_controller.js +386 -63
  18. data/dist/controllers/flash_controller.js +3 -1
  19. data/dist/controllers/focus_controller.js +75 -4
  20. data/dist/controllers/form_field_controller.js +280 -62
  21. data/dist/controllers/form_validation_controller.js +208 -83
  22. data/dist/controllers/frame_loading_controller.js +2 -1
  23. data/dist/controllers/input_mask_controller.js +251 -76
  24. data/dist/controllers/nested_form_controller.js +450 -42
  25. data/dist/controllers/number_input_controller.js +47 -5
  26. data/dist/controllers/otp_controller.js +472 -114
  27. data/dist/controllers/overflow_menu_controller.js +2 -1
  28. data/dist/controllers/pagination_controller.js +2 -1
  29. data/dist/controllers/persist_controller.js +432 -122
  30. data/dist/controllers/popover_controller.js +77 -4
  31. data/dist/controllers/rating_controller.js +9 -5
  32. data/dist/controllers/scroll_area_controller.js +462 -162
  33. data/dist/controllers/separator_controller.js +354 -38
  34. data/dist/controllers/sidebar_controller.js +83 -10
  35. data/dist/controllers/spinner_controller.js +7 -5
  36. data/dist/controllers/submit_once_controller.js +402 -122
  37. data/dist/controllers/textarea_autosize_controller.js +131 -3
  38. data/dist/controllers/theme_controller.js +8 -6
  39. data/dist/controllers/tree_view_controller.js +2 -1
  40. data/dist/index.js +4293 -1447
  41. data/lib/stimeo/ui/version.rb +1 -1
  42. metadata +2 -2
@@ -8,82 +8,398 @@ 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/default_attribute.ts
12
- function setDefaultAttribute(element, name, value) {
13
- if (element.hasAttribute(name)) return false;
14
- element.setAttribute(name, value);
15
- return true;
11
+ // src/utils/attribute_lease.ts
12
+ var AttributeLease = class {
13
+ #attribute;
14
+ #records = /* @__PURE__ */ new Map();
15
+ /** @param attribute - The attribute whose temporary values this lease owns. */
16
+ constructor(attribute) {
17
+ this.#attribute = attribute;
18
+ }
19
+ /** Writes or removes the leased attribute while preserving its authored value. */
20
+ write(element, value) {
21
+ const existing = this.#records.get(element);
22
+ if (existing) {
23
+ existing.written = value;
24
+ } else {
25
+ this.#records.set(element, {
26
+ original: element.getAttribute(this.#attribute),
27
+ written: value
28
+ });
29
+ }
30
+ this.#reflect(element, value);
31
+ }
32
+ /** Returns one lease without overwriting a value subsequently authored by a consumer. */
33
+ return(element) {
34
+ const record = this.#records.get(element);
35
+ if (!record) return;
36
+ this.#records.delete(element);
37
+ const stillOwned = element.getAttribute(this.#attribute) === record.written;
38
+ if (stillOwned) this.#reflect(element, record.original);
39
+ }
40
+ /** Reflects only a real value transition, avoiding self-triggered mutation work. */
41
+ #reflect(element, value) {
42
+ if (element.getAttribute(this.#attribute) === value) return;
43
+ if (value === null) element.removeAttribute(this.#attribute);
44
+ else element.setAttribute(this.#attribute, value);
45
+ }
46
+ /** Returns every outstanding lease using the same ownership check as {@link return}. */
47
+ returnAll() {
48
+ for (const element of Array.from(this.#records.keys())) this.return(element);
49
+ }
50
+ };
51
+
52
+ // src/utils/before_cache_reset.ts
53
+ var BeforeCacheReset = class _BeforeCacheReset {
54
+ /** Every subscribed instance, iterated by the one shared document listener. */
55
+ static #subscribers = /* @__PURE__ */ new Set();
56
+ /** The shared listener; installed while at least one instance is subscribed. */
57
+ static #onBeforeCache = () => {
58
+ for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
59
+ };
60
+ #rewind;
61
+ /** @param rewind - the pass that returns this controller's state to its initial form. */
62
+ constructor(rewind) {
63
+ this.#rewind = rewind;
64
+ }
65
+ /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
66
+ activate() {
67
+ const first = _BeforeCacheReset.#subscribers.size === 0;
68
+ _BeforeCacheReset.#subscribers.add(this);
69
+ if (first) {
70
+ document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
71
+ }
72
+ }
73
+ /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
74
+ deactivate() {
75
+ _BeforeCacheReset.#subscribers.delete(this);
76
+ if (_BeforeCacheReset.#subscribers.size > 0) return;
77
+ document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
78
+ }
79
+ };
80
+
81
+ // src/utils/microtask_coalescer.ts
82
+ var MicrotaskCoalescer = class {
83
+ #run;
84
+ #queued = false;
85
+ #active = false;
86
+ #generation = 0;
87
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
88
+ constructor(run) {
89
+ this.#run = run;
90
+ }
91
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
92
+ activate() {
93
+ this.#active = true;
94
+ }
95
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
96
+ cancel() {
97
+ this.#active = false;
98
+ this.#queued = false;
99
+ this.#generation += 1;
100
+ }
101
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
102
+ schedule() {
103
+ if (!this.#active || this.#queued) return;
104
+ this.#queued = true;
105
+ const generation = this.#generation;
106
+ queueMicrotask(() => {
107
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
108
+ this.#queued = false;
109
+ this.#run();
110
+ });
111
+ }
112
+ };
113
+
114
+ // src/utils/stepped_value.ts
115
+ function effectiveStep(step) {
116
+ return Number.isFinite(step) && step > 0 ? step : 1;
117
+ }
118
+ function snapSteppedValue(raw, range) {
119
+ if (!(range.min <= range.max)) return finiteFallback(range.min, range.max);
120
+ const input = Number.isNaN(raw) ? finiteFallback(range.min, range.max) : raw;
121
+ const clamped = Math.min(range.max, Math.max(range.min, input));
122
+ const step = effectiveStep(range.step);
123
+ const base = stepBase(range);
124
+ const candidates = [];
125
+ if (Number.isFinite(range.min)) candidates.push(range.min);
126
+ if (Number.isFinite(range.max)) candidates.push(range.max);
127
+ const gridPosition = (clamped - base) / step;
128
+ if (Number.isFinite(gridPosition)) {
129
+ addGridCandidate(candidates, Math.floor(gridPosition), range, base, step);
130
+ addGridCandidate(candidates, Math.ceil(gridPosition), range, base, step);
131
+ }
132
+ if (candidates.length === 0) return clamped;
133
+ let nearest = candidates[0];
134
+ let nearestDistance = Math.abs(clamped - nearest);
135
+ for (const candidate of candidates.slice(1)) {
136
+ const distance = Math.abs(clamped - candidate);
137
+ if (distance < nearestDistance || nearlyEqual(distance, nearestDistance) && candidate > nearest) {
138
+ nearest = candidate;
139
+ nearestDistance = distance;
140
+ }
141
+ }
142
+ return nearest;
143
+ }
144
+ function stepSteppedValue(current, count, range) {
145
+ const value = snapSteppedValue(current, range);
146
+ const distance = Math.abs(Math.trunc(count));
147
+ if (!Number.isFinite(distance) || distance === 0) return value;
148
+ const direction = Math.sign(count);
149
+ const adjacent = adjacentSteppedValue(value, direction, range);
150
+ const raw = adjacent + direction * (distance - 1) * effectiveStep(range.step);
151
+ return snapSteppedValue(raw, range);
152
+ }
153
+ function adjacentSteppedValue(current, direction, range) {
154
+ const step = effectiveStep(range.step);
155
+ const base = stepBase(range);
156
+ const candidates = [];
157
+ if (direction > 0) {
158
+ const position2 = (current - base) / step;
159
+ if (Number.isFinite(position2)) {
160
+ let gridIndex = Math.floor(position2) + 1;
161
+ let candidate = cleanGridValue(base + gridIndex * step, base, step);
162
+ if (candidate < current || nearlyEqual(candidate, current)) {
163
+ gridIndex += 1;
164
+ candidate = cleanGridValue(base + gridIndex * step, base, step);
165
+ }
166
+ if (candidate > current && !nearlyEqual(candidate, current) && within(candidate, range.min, range.max)) {
167
+ candidates.push(clamp(candidate, range));
168
+ }
169
+ }
170
+ return clamp(Math.min(...candidates), range);
171
+ }
172
+ const position = (current - base) / step;
173
+ if (Number.isFinite(position)) {
174
+ let gridIndex = Math.ceil(position) - 1;
175
+ let candidate = cleanGridValue(base + gridIndex * step, base, step);
176
+ if (candidate > current || nearlyEqual(candidate, current)) {
177
+ gridIndex -= 1;
178
+ candidate = cleanGridValue(base + gridIndex * step, base, step);
179
+ }
180
+ if (candidate < current && !nearlyEqual(candidate, current) && within(candidate, range.min, range.max)) {
181
+ candidates.push(clamp(candidate, range));
182
+ }
183
+ }
184
+ return clamp(Math.max(...candidates), range);
185
+ }
186
+ function addGridCandidate(candidates, index, range, base, step) {
187
+ const candidate = cleanGridValue(base + index * step, base, step);
188
+ if (within(candidate, range.min, range.max)) candidates.push(clamp(candidate, range));
189
+ }
190
+ function stepBase(range) {
191
+ if (range.base !== void 0 && Number.isFinite(range.base)) return range.base;
192
+ return Number.isFinite(range.min) ? range.min : 0;
193
+ }
194
+ function cleanGridValue(value, base, step) {
195
+ const precision = Math.max(decimalPlaces(base), decimalPlaces(step));
196
+ return precision <= 100 ? Number(value.toFixed(precision)) : value;
197
+ }
198
+ function decimalPlaces(value) {
199
+ const [coefficient = "", exponentText] = Math.abs(value).toString().toLowerCase().split("e");
200
+ const fractionLength = coefficient.split(".")[1]?.length ?? 0;
201
+ const exponent = exponentText === void 0 ? 0 : Number(exponentText);
202
+ return Math.max(0, fractionLength - exponent);
203
+ }
204
+ function within(value, min, max) {
205
+ return (value > min || nearlyEqual(value, min)) && (value < max || nearlyEqual(value, max));
206
+ }
207
+ function clamp(value, range) {
208
+ return Math.min(range.max, Math.max(range.min, value));
209
+ }
210
+ function nearlyEqual(left, right) {
211
+ const scale = Math.max(Math.abs(left), Math.abs(right));
212
+ return Number.isFinite(left) && Number.isFinite(right) && Math.abs(left - right) <= Number.EPSILON * scale;
213
+ }
214
+ function finiteFallback(min, max) {
215
+ if (Number.isFinite(min)) return min;
216
+ if (Number.isFinite(max)) return max;
217
+ return 0;
16
218
  }
17
219
 
18
220
  // src/controllers/separator_controller.ts
221
+ var DEFAULT_MIN = 0;
222
+ var DEFAULT_MAX = 100;
19
223
  var SeparatorController = class extends Controller {
20
224
  static values = {
21
225
  orientation: { type: String, default: "horizontal" },
22
226
  focusable: { type: Boolean, default: false },
23
- step: { type: Number, default: 1 }
227
+ min: { type: Number, default: DEFAULT_MIN },
228
+ max: { type: Number, default: DEFAULT_MAX },
229
+ step: { type: Number, default: 1 },
230
+ value: { type: Number, default: DEFAULT_MIN }
24
231
  };
25
232
  static actions = ["onKeydown"];
26
233
  static events = ["change"];
234
+ #role = new AttributeLease("role");
235
+ #tabindex = new AttributeLease("tabindex");
236
+ #orientation = new AttributeLease("aria-orientation");
237
+ #minimum = new AttributeLease("aria-valuemin");
238
+ #maximum = new AttributeLease("aria-valuemax");
239
+ #current = new AttributeLease("aria-valuenow");
240
+ /** Collapses a morph that changes several render Values into one silent repaint. */
241
+ #repaint = new MicrotaskCoalescer(() => this.#render());
242
+ /** Restores authored semantics before Turbo snapshots the element. */
243
+ #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
244
+ /** Hydrates legacy ARIA input once, normalizes the current Value, and renders. */
27
245
  connect() {
28
- setDefaultAttribute(this.element, "role", "separator");
29
- setDefaultAttribute(this.element, "aria-orientation", this.orientationValue);
30
- if (this.focusableValue) {
31
- setDefaultAttribute(this.element, "tabindex", "0");
32
- setDefaultAttribute(this.element, "aria-valuemin", "0");
33
- setDefaultAttribute(this.element, "aria-valuemax", "100");
34
- setDefaultAttribute(this.element, "aria-valuenow", String(this.#clamp(this.#value)));
35
- }
246
+ this.#hydrateValues();
247
+ this.#repaint.activate();
248
+ this.#beforeCache.activate();
249
+ const value = this.#currentValue();
250
+ if (!Object.is(this.valueValue, value)) this.valueValue = value;
251
+ this.#render();
252
+ }
253
+ /** Cancels repaint work and returns every semantic attribute this instance controlled. */
254
+ disconnect() {
255
+ this.#repaint.cancel();
256
+ this.#beforeCache.deactivate();
257
+ this.#returnAttributes();
258
+ }
259
+ /** Silently repaints an orientation changed by application code or a Turbo morph. */
260
+ orientationValueChanged() {
261
+ this.#repaint.schedule();
262
+ }
263
+ /** Silently adds or removes the focusable range semantics at runtime. */
264
+ focusableValueChanged() {
265
+ this.#repaint.schedule();
266
+ }
267
+ /** Silently repaints a minimum changed by application code or a Turbo morph. */
268
+ minValueChanged() {
269
+ this.#repaint.schedule();
270
+ }
271
+ /** Silently repaints a maximum changed by application code or a Turbo morph. */
272
+ maxValueChanged() {
273
+ this.#repaint.schedule();
274
+ }
275
+ /** Silently repaints a step changed by application code or a Turbo morph. */
276
+ stepValueChanged() {
277
+ this.#repaint.schedule();
278
+ }
279
+ /** Silently repaints a current value changed by application code or a Turbo morph. */
280
+ valueValueChanged() {
281
+ this.#repaint.schedule();
36
282
  }
37
283
  /** Adjusts the value on arrow / Home / End keys (focusable variant only). */
38
284
  onKeydown(event) {
39
- if (isReservedArrowChord(event)) return;
40
- if (!this.focusableValue) return;
41
- const horizontal = this.element.getAttribute("aria-orientation") !== "vertical";
285
+ if (isReservedArrowChord(event) || !this.focusableValue) return;
286
+ const horizontal = this.#effectiveOrientation === "horizontal";
287
+ const range = this.#effectiveRange;
288
+ const current = snapSteppedValue(this.valueValue, range);
42
289
  let next = null;
43
290
  switch (event.key) {
44
291
  case "ArrowUp":
45
- if (horizontal) next = this.#value + this.stepValue;
292
+ if (horizontal) next = stepSteppedValue(current, 1, range);
46
293
  break;
47
294
  case "ArrowDown":
48
- if (horizontal) next = this.#value - this.stepValue;
295
+ if (horizontal) next = stepSteppedValue(current, -1, range);
49
296
  break;
50
297
  case "ArrowRight":
51
- if (!horizontal) next = this.#value + this.stepValue;
298
+ if (!horizontal) next = stepSteppedValue(current, 1, range);
52
299
  break;
53
300
  case "ArrowLeft":
54
- if (!horizontal) next = this.#value - this.stepValue;
301
+ if (!horizontal) next = stepSteppedValue(current, -1, range);
55
302
  break;
56
303
  case "Home":
57
- next = this.#min;
304
+ next = range.min;
58
305
  break;
59
306
  case "End":
60
- next = this.#max;
307
+ next = range.max;
61
308
  break;
62
309
  default:
63
310
  return;
64
311
  }
65
312
  if (next === null) return;
66
313
  event.preventDefault();
67
- const clamped = this.#clamp(next);
68
- if (clamped === this.#value) return;
69
- this.element.setAttribute("aria-valuenow", String(clamped));
70
- this.dispatch("change", { detail: { value: clamped } });
314
+ this.#commit(next, current);
315
+ }
316
+ /** Stores a normalized user value, renders immediately, and reports real changes. */
317
+ #commit(raw, previous) {
318
+ const value = snapSteppedValue(raw, this.#effectiveRange);
319
+ if (!Object.is(this.valueValue, value)) this.valueValue = value;
320
+ this.#render();
321
+ if (value !== previous) this.dispatch("change", { detail: { value } });
71
322
  }
72
- get #value() {
73
- return this.#numericAttr("aria-valuenow", 0);
323
+ /**
324
+ * Reflects live Values into the connected element without dispatching.
325
+ *
326
+ * @stimeoRenderRoot
327
+ */
328
+ #render() {
329
+ const range = this.#effectiveRange;
330
+ const value = snapSteppedValue(this.valueValue, range);
331
+ this.#role.write(this.element, "separator");
332
+ this.#orientation.write(this.element, this.#effectiveOrientation);
333
+ if (!this.focusableValue) {
334
+ this.#tabindex.write(this.element, null);
335
+ this.#minimum.write(this.element, null);
336
+ this.#maximum.write(this.element, null);
337
+ this.#current.write(this.element, null);
338
+ return;
339
+ }
340
+ this.#tabindex.write(this.element, "0");
341
+ this.#minimum.write(this.element, String(range.min));
342
+ this.#maximum.write(this.element, String(range.max));
343
+ this.#current.write(this.element, String(value));
344
+ }
345
+ /** Effective orientation; unknown strings fall back to the horizontal contract. */
346
+ get #effectiveOrientation() {
347
+ return this.orientationValue === "vertical" ? "vertical" : "horizontal";
348
+ }
349
+ /** Finite, ordered range shared by rendering and keyboard stepping. */
350
+ get #effectiveRange() {
351
+ const min = Number.isFinite(this.minValue) ? this.minValue : DEFAULT_MIN;
352
+ const candidateMax = Number.isFinite(this.maxValue) ? this.maxValue : DEFAULT_MAX;
353
+ return {
354
+ min,
355
+ max: Math.max(min, candidateMax),
356
+ step: this.stepValue,
357
+ base: min
358
+ };
359
+ }
360
+ /** Current normalized value derived from the live declarative inputs. */
361
+ #currentValue() {
362
+ return snapSteppedValue(this.valueValue, this.#effectiveRange);
363
+ }
364
+ /** Copies valid authored ARIA into Values only when no explicit Value exists. */
365
+ #hydrateValues() {
366
+ if (!this.#hasInput("orientation")) {
367
+ const orientation = this.element.getAttribute("aria-orientation");
368
+ if (orientation === "horizontal" || orientation === "vertical") {
369
+ this.orientationValue = orientation;
370
+ }
371
+ }
372
+ const min = this.#finiteAttribute("aria-valuemin");
373
+ if (!this.#hasInput("min") && min !== null) this.minValue = min;
374
+ const max = this.#finiteAttribute("aria-valuemax");
375
+ if (!this.#hasInput("max") && max !== null) this.maxValue = max;
376
+ const value = this.#finiteAttribute("aria-valuenow");
377
+ if (!this.#hasInput("value") && value !== null) this.valueValue = value;
74
378
  }
75
- get #min() {
76
- return this.#numericAttr("aria-valuemin", 0);
379
+ /** Whether the author supplied the namespaced Value instead of using its default. */
380
+ #hasInput(name) {
381
+ return this.element.hasAttribute(`data-${this.identifier}-${name}-value`);
77
382
  }
78
- get #max() {
79
- return this.#numericAttr("aria-valuemax", 100);
383
+ /** Strict finite-number parser for the one-time progressive-enhancement bridge. */
384
+ #finiteAttribute(name) {
385
+ const raw = this.element.getAttribute(name);
386
+ if (raw === null || raw.trim() === "") return null;
387
+ const value = Number(raw);
388
+ return Number.isFinite(value) ? value : null;
80
389
  }
81
- #clamp(value) {
82
- return Math.min(this.#max, Math.max(this.#min, value));
390
+ /** Returns attributes without overwriting values a consumer changed after render. */
391
+ #returnAttributes() {
392
+ this.#role.return(this.element);
393
+ this.#tabindex.return(this.element);
394
+ this.#orientation.return(this.element);
395
+ this.#minimum.return(this.element);
396
+ this.#maximum.return(this.element);
397
+ this.#current.return(this.element);
83
398
  }
84
- #numericAttr(name, fallback) {
85
- const parsed = Number.parseFloat(this.element.getAttribute(name) ?? "");
86
- return Number.isNaN(parsed) ? fallback : parsed;
399
+ /** Prevents a queued Value repaint from racing Turbo's restored snapshot. */
400
+ #rewindForCache() {
401
+ this.#repaint.cancel();
402
+ this.#returnAttributes();
87
403
  }
88
404
  };
89
405
 
@@ -115,8 +115,81 @@ var EscapeLayer = class _EscapeLayer {
115
115
  }
116
116
  };
117
117
 
118
+ // src/utils/focus_candidate.ts
119
+ function inheritsFieldsetDisabled(control) {
120
+ let fieldset = control.closest("fieldset[disabled]");
121
+ while (fieldset) {
122
+ const legend = Array.from(fieldset.children).find((child) => child.tagName === "LEGEND");
123
+ if (!legend?.contains(control)) return true;
124
+ fieldset = fieldset.parentElement?.closest("fieldset[disabled]") ?? null;
125
+ }
126
+ return false;
127
+ }
128
+ function canTakeFocus(element) {
129
+ if (element.closest("[hidden], [inert]")) return false;
130
+ if (element instanceof HTMLInputElement && element.type === "hidden") return false;
131
+ if (!("disabled" in element)) return true;
132
+ if (element.disabled) return false;
133
+ return !inheritsFieldsetDisabled(element);
134
+ }
135
+ var TAB_STOP_CANDIDATE_SELECTOR = [
136
+ "a[href]",
137
+ "area[href]",
138
+ "button",
139
+ "input",
140
+ "select",
141
+ "textarea",
142
+ "summary",
143
+ "iframe",
144
+ "audio[controls]",
145
+ "video[controls]",
146
+ "[tabindex]",
147
+ "[contenteditable]"
148
+ ].join(",");
149
+ function isRenderedForFocus(element) {
150
+ const check = element.checkVisibility;
151
+ return typeof check === "function" ? check.call(element, { visibilityProperty: true }) : true;
152
+ }
153
+ function authoredTabindex(element) {
154
+ const value = element.getAttribute("tabindex");
155
+ if (value === null || !/^[+-]?\d+$/.test(value.trim())) return null;
156
+ return Number(value);
157
+ }
158
+ function hasNativeTabStop(element) {
159
+ if (element instanceof HTMLAnchorElement || element instanceof HTMLAreaElement) {
160
+ return element.hasAttribute("href");
161
+ }
162
+ if (element instanceof HTMLButtonElement || element instanceof HTMLSelectElement || element instanceof HTMLTextAreaElement) {
163
+ return true;
164
+ }
165
+ if (element instanceof HTMLInputElement) return element.type !== "hidden";
166
+ if (element instanceof HTMLIFrameElement) return true;
167
+ if (element.tagName === "AUDIO" || element.tagName === "VIDEO") {
168
+ return element.hasAttribute("controls");
169
+ }
170
+ if (element instanceof HTMLElement && element.tagName === "SUMMARY") {
171
+ const details = element.parentElement;
172
+ return details instanceof HTMLDetailsElement && Array.from(details.children).find((child) => child.tagName === "SUMMARY") === element;
173
+ }
174
+ return false;
175
+ }
176
+ function hasEditableTabStop(element) {
177
+ const value = element.getAttribute("contenteditable")?.toLowerCase();
178
+ return value === "" || value === "true" || value === "plaintext-only";
179
+ }
180
+ function isTabStop(element) {
181
+ if (!canTakeFocus(element) || !isRenderedForFocus(element)) return false;
182
+ const tabindex = authoredTabindex(element);
183
+ if (tabindex !== null) return tabindex >= 0;
184
+ return hasNativeTabStop(element) || hasEditableTabStop(element);
185
+ }
186
+ function tabStopsWithin(root) {
187
+ return Array.from(root.querySelectorAll(TAB_STOP_CANDIDATE_SELECTOR)).filter(
188
+ isTabStop
189
+ );
190
+ }
191
+
118
192
  // src/utils/focus_trap.ts
119
- var FOCUSABLE = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
120
193
  var FocusTrap = class {
121
194
  /** The element focused before activation, restored on deactivation. */
122
195
  #previouslyFocused = null;
@@ -278,24 +351,24 @@ var FocusTrap = class {
278
351
  }
279
352
  /** Collects the container's currently focusable descendants in DOM order. */
280
353
  #focusableElements() {
281
- return Array.from(this.#getContainer().querySelectorAll(FOCUSABLE)).filter(
282
- (el) => !el.hidden
283
- );
354
+ return tabStopsWithin(this.#getContainer());
284
355
  }
285
356
  };
286
357
 
287
358
  // src/utils/safe_storage.ts
288
359
  function readLocalStorage(key) {
289
360
  try {
290
- return window.localStorage.getItem(key);
291
- } catch {
292
- return null;
361
+ return { ok: true, value: window.localStorage.getItem(key) };
362
+ } catch (error) {
363
+ return { ok: false, error };
293
364
  }
294
365
  }
295
366
  function writeLocalStorage(key, value) {
296
367
  try {
297
368
  window.localStorage.setItem(key, value);
298
- } catch {
369
+ return { ok: true, value: void 0 };
370
+ } catch (error) {
371
+ return { ok: false, error };
299
372
  }
300
373
  }
301
374
 
@@ -754,8 +827,8 @@ var SidebarController = class extends Controller {
754
827
  #restoreCollapsed() {
755
828
  const key = this.#storageKey;
756
829
  if (key) {
757
- const stored = readLocalStorage(key);
758
- if (stored !== null) return stored === "1";
830
+ const result = readLocalStorage(key);
831
+ if (result.ok && result.value !== null) return result.value === "1";
759
832
  }
760
833
  const domState = this.hasPanelTarget ? this.panelTarget.getAttribute("data-state") : null;
761
834
  if (domState === "collapsed") return true;
@@ -222,7 +222,7 @@ var SpinnerController = class extends Controller {
222
222
  timeout: { type: Number, default: 0 }
223
223
  };
224
224
  static actions = ["start", "stop"];
225
- static events = ["hide", "show", "timeout"];
225
+ static events = ["hide", "show", "timeout", "reconcile"];
226
226
  #timers = new SafeTimeout();
227
227
  #floor = new MinDurationFloor(this.#timers);
228
228
  #gate = new DetachGate();
@@ -326,16 +326,18 @@ var SpinnerController = class extends Controller {
326
326
  * Returns the loading state to idle for the snapshot Turbo is about to take,
327
327
  * so a page reached with the Back button is not restored mid-load with a
328
328
  * spinner nothing can stop. State only: `data-state`, the indicator's `hidden`,
329
- * and `aria-busy`. No `hide` is dispatched — the load was never observed to
330
- * finish, and a snapshot rewind is not a lifecycle event the consumer can act
331
- * on. The live page keeps its timers, so a navigation that never completes
332
- * leaves the running cycle intact.
329
+ * and `aria-busy`. No `hide` is dispatched — that would claim the load finished.
330
+ * The rewind reports itself as `reconcile` instead, so a consumer painting from
331
+ * `show` can drop it before the snapshot is taken. The live page keeps its
332
+ * timers, so a navigation that never completes leaves the running cycle intact.
333
333
  */
334
334
  #rewindForCache() {
335
+ const running = this.element.getAttribute("data-state") !== "idle";
335
336
  this.#cancelTimeout();
336
337
  this.#setBusy(false);
337
338
  if (this.hasIndicatorTarget) this.indicatorTarget.hidden = true;
338
339
  this.element.setAttribute("data-state", "idle");
340
+ if (running) this.dispatch("reconcile", { detail: {} });
339
341
  }
340
342
  /** Reflects busy state onto the controlled region (if present). */
341
343
  #setBusy(busy) {