stimeo-ui 0.6.0 → 0.7.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 +62 -0
  3. data/dist/controllers/alert_dialog_controller.js +75 -4
  4. data/dist/controllers/avatar_controller.js +47 -5
  5. data/dist/controllers/character_counter_controller.js +338 -63
  6. data/dist/controllers/command_palette_controller.js +75 -4
  7. data/dist/controllers/conditional_fields_controller.js +345 -51
  8. data/dist/controllers/confirm_controller.js +75 -4
  9. data/dist/controllers/dialog_controller.js +75 -4
  10. data/dist/controllers/direct_upload_controller.js +201 -45
  11. data/dist/controllers/dirty_form_controller.js +192 -29
  12. data/dist/controllers/dismissible_controller.js +83 -18
  13. data/dist/controllers/drawer_controller.js +75 -4
  14. data/dist/controllers/focus_controller.js +75 -4
  15. data/dist/controllers/form_field_controller.js +280 -62
  16. data/dist/controllers/form_validation_controller.js +208 -83
  17. data/dist/controllers/number_input_controller.js +47 -5
  18. data/dist/controllers/overflow_menu_controller.js +2 -1
  19. data/dist/controllers/pagination_controller.js +2 -1
  20. data/dist/controllers/persist_controller.js +432 -122
  21. data/dist/controllers/popover_controller.js +77 -4
  22. data/dist/controllers/rating_controller.js +9 -5
  23. data/dist/controllers/scroll_area_controller.js +462 -162
  24. data/dist/controllers/separator_controller.js +354 -38
  25. data/dist/controllers/sidebar_controller.js +83 -10
  26. data/dist/controllers/submit_once_controller.js +399 -121
  27. data/dist/controllers/theme_controller.js +8 -6
  28. data/dist/controllers/tree_view_controller.js +2 -1
  29. data/dist/index.js +2387 -861
  30. data/lib/stimeo/ui/version.rb +1 -1
  31. metadata +2 -2
@@ -9,8 +9,80 @@ function setDefaultAttribute(element, name, value) {
9
9
  return true;
10
10
  }
11
11
 
12
- // src/utils/focus_trap.ts
13
- var FOCUSABLE = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
12
+ // src/utils/focus_candidate.ts
13
+ function inheritsFieldsetDisabled(control) {
14
+ let fieldset = control.closest("fieldset[disabled]");
15
+ while (fieldset) {
16
+ const legend = Array.from(fieldset.children).find((child) => child.tagName === "LEGEND");
17
+ if (!legend?.contains(control)) return true;
18
+ fieldset = fieldset.parentElement?.closest("fieldset[disabled]") ?? null;
19
+ }
20
+ return false;
21
+ }
22
+ function canTakeFocus(element) {
23
+ if (element.closest("[hidden], [inert]")) return false;
24
+ if (element instanceof HTMLInputElement && element.type === "hidden") return false;
25
+ if (!("disabled" in element)) return true;
26
+ if (element.disabled) return false;
27
+ return !inheritsFieldsetDisabled(element);
28
+ }
29
+ var TAB_STOP_CANDIDATE_SELECTOR = [
30
+ "a[href]",
31
+ "area[href]",
32
+ "button",
33
+ "input",
34
+ "select",
35
+ "textarea",
36
+ "summary",
37
+ "iframe",
38
+ "audio[controls]",
39
+ "video[controls]",
40
+ "[tabindex]",
41
+ "[contenteditable]"
42
+ ].join(",");
43
+ function isRenderedForFocus(element) {
44
+ const check = element.checkVisibility;
45
+ return typeof check === "function" ? check.call(element, { visibilityProperty: true }) : true;
46
+ }
47
+ function authoredTabindex(element) {
48
+ const value = element.getAttribute("tabindex");
49
+ if (value === null || !/^[+-]?\d+$/.test(value.trim())) return null;
50
+ return Number(value);
51
+ }
52
+ function hasNativeTabStop(element) {
53
+ if (element instanceof HTMLAnchorElement || element instanceof HTMLAreaElement) {
54
+ return element.hasAttribute("href");
55
+ }
56
+ if (element instanceof HTMLButtonElement || element instanceof HTMLSelectElement || element instanceof HTMLTextAreaElement) {
57
+ return true;
58
+ }
59
+ if (element instanceof HTMLInputElement) return element.type !== "hidden";
60
+ if (element instanceof HTMLIFrameElement) return true;
61
+ if (element.tagName === "AUDIO" || element.tagName === "VIDEO") {
62
+ return element.hasAttribute("controls");
63
+ }
64
+ if (element instanceof HTMLElement && element.tagName === "SUMMARY") {
65
+ const details = element.parentElement;
66
+ return details instanceof HTMLDetailsElement && Array.from(details.children).find((child) => child.tagName === "SUMMARY") === element;
67
+ }
68
+ return false;
69
+ }
70
+ function hasEditableTabStop(element) {
71
+ const value = element.getAttribute("contenteditable")?.toLowerCase();
72
+ return value === "" || value === "true" || value === "plaintext-only";
73
+ }
74
+ function isTabStop(element) {
75
+ if (!canTakeFocus(element) || !isRenderedForFocus(element)) return false;
76
+ const tabindex = authoredTabindex(element);
77
+ if (tabindex !== null) return tabindex >= 0;
78
+ return hasNativeTabStop(element) || hasEditableTabStop(element);
79
+ }
80
+ function firstTabStop(root) {
81
+ for (const candidate of root.querySelectorAll(TAB_STOP_CANDIDATE_SELECTOR)) {
82
+ if (isTabStop(candidate)) return candidate;
83
+ }
84
+ return null;
85
+ }
14
86
 
15
87
  // src/controllers/form_validation_controller.ts
16
88
  var CONSTRAINT_MESSAGE_KEYS = [
@@ -24,10 +96,12 @@ var CONSTRAINT_MESSAGE_KEYS = [
24
96
  ["stepMismatch", "step-mismatch"],
25
97
  ["badInput", "bad-input"]
26
98
  ];
27
- var MESSAGE_ATTR_PREFIX = "data-stimeo--form-field-message-";
28
- var MESSAGE_ATTR_GENERIC = "data-stimeo--form-field-message";
29
- var DISALLOW_ATTR = "data-stimeo--form-field-disallow";
99
+ var MESSAGE_ATTR_PREFIX = "data-stimeo--form-validation-message-";
100
+ var MESSAGE_ATTR_GENERIC = "data-stimeo--form-validation-message";
101
+ var DISALLOW_ATTR = "data-stimeo--form-validation-disallow";
102
+ var DISALLOW_WHITESPACE_MESSAGE = `${MESSAGE_ATTR_PREFIX}whitespace`;
30
103
  var DISALLOW_WHITESPACE_DEFAULT = "Please enter a value that is not only whitespace.";
104
+ var FORM_FIELD_INVALID_ATTR = "data-stimeo--form-field-invalid";
31
105
  var FormValidationController = class _FormValidationController extends Controller {
32
106
  static outlets = ["stimeo--form-field"];
33
107
  static values = {
@@ -40,14 +114,21 @@ var FormValidationController = class _FormValidationController extends Controlle
40
114
  static events = ["valid", "invalid"];
41
115
  /** Marker recording that we added `novalidate`, so we only remove our own. */
42
116
  static #NOVALIDATE_MARKER = "data-stimeo--form-validation-novalidate";
43
- /** Controls already interacted with — the gate for blur / input (re)validation. */
44
- #touched = /* @__PURE__ */ new WeakSet();
117
+ /** Object-backed groups already interacted with — the input revalidation gate. */
118
+ #touchedGroups = /* @__PURE__ */ new WeakSet();
119
+ /** String-backed fallback radio groups already interacted with. */
120
+ #touchedRadioGroups = /* @__PURE__ */ new Set();
45
121
  /**
46
- * Controls whose `customError` *we* set via the `disallow` rule. Tracked so we
47
- * only ever clear our own custom validity — a consumer's `setCustomValidity` on
48
- * the same control survives once our rule passes (don't-clobber-authored-state).
122
+ * Last message this controller wrote through `setCustomValidity`, kept as a
123
+ * two-layer ownership ledger: a value is cleared only while the live message
124
+ * still equals the recorded write. Iterable so disconnect can release every
125
+ * surviving loan without touching a consumer's later custom error.
49
126
  */
50
- #ownedCustomError = /* @__PURE__ */ new WeakSet();
127
+ #ownedCustomErrors = /* @__PURE__ */ new Map();
128
+ /** Last invalid message routed to each field, suppressing duplicate reports. */
129
+ #reportedErrors = /* @__PURE__ */ new WeakMap();
130
+ /** Exact document that owns the delegated listeners for this connection. */
131
+ #listenerDocument = null;
51
132
  #onSubmit = (event) => {
52
133
  if (event.target !== this.element) return;
53
134
  const invalid = this.#validateAll();
@@ -63,43 +144,51 @@ var FormValidationController = class _FormValidationController extends Controlle
63
144
  };
64
145
  #onFocusOut = (event) => {
65
146
  if (!this.validateOnBlurValue) return;
66
- const control = this.#controlFrom(event.target);
67
- if (!control) return;
68
- const field = this.#fieldFor(control);
147
+ const snapshot = this.#snapshot();
148
+ const group = snapshot.groupByControl.get(this.#controlFrom(event.target));
149
+ if (!group) return;
69
150
  const related = event.relatedTarget;
70
- if (field && related instanceof Node && field.element.contains(related)) return;
71
- this.#touched.add(control);
72
- this.#validateControl(control);
151
+ if (group.field && related instanceof Node && group.field.element.contains(related)) return;
152
+ if (snapshot.groupByControl.get(this.#controlFrom(related))?.key === group.key) return;
153
+ this.#markTouched(group.key);
154
+ this.#applyGroup(group);
73
155
  };
74
156
  #onInput = (event) => {
75
157
  if (!this.revalidateOnInputValue) return;
76
- const control = this.#controlFrom(event.target);
77
- if (!control || !this.#touched.has(control)) return;
78
- this.#validateControl(control);
158
+ const group = this.#snapshot().groupByControl.get(this.#controlFrom(event.target));
159
+ if (!group || !this.#isTouched(group.key)) return;
160
+ this.#applyGroup(group);
79
161
  };
80
162
  #onChange = (event) => {
81
163
  if (!this.validateOnChangeValue) return;
82
- const control = this.#controlFrom(event.target);
83
- if (!control) return;
84
- this.#touched.add(control);
85
- this.#validateControl(control);
164
+ const group = this.#snapshot().groupByControl.get(this.#controlFrom(event.target));
165
+ if (!group) return;
166
+ this.#markTouched(group.key);
167
+ this.#applyGroup(group);
86
168
  };
87
169
  /** Suppresses native bubbles and binds the submit / blur / input listeners. */
88
170
  connect() {
89
171
  if (setDefaultAttribute(this.element, "novalidate", "")) {
90
172
  this.element.setAttribute(_FormValidationController.#NOVALIDATE_MARKER, "");
91
173
  }
92
- document.addEventListener("submit", this.#onSubmit, true);
93
- this.element.addEventListener("focusout", this.#onFocusOut);
94
- this.element.addEventListener("input", this.#onInput);
95
- this.element.addEventListener("change", this.#onChange);
174
+ this.#listenerDocument = this.element.ownerDocument;
175
+ this.#listenerDocument.addEventListener("submit", this.#onSubmit, true);
176
+ this.#listenerDocument.addEventListener("focusout", this.#onFocusOut);
177
+ this.#listenerDocument.addEventListener("input", this.#onInput);
178
+ this.#listenerDocument.addEventListener("change", this.#onChange);
96
179
  }
97
180
  /** Tears down listeners and restores `novalidate` if we added it. */
98
181
  disconnect() {
99
- document.removeEventListener("submit", this.#onSubmit, true);
100
- this.element.removeEventListener("focusout", this.#onFocusOut);
101
- this.element.removeEventListener("input", this.#onInput);
102
- this.element.removeEventListener("change", this.#onChange);
182
+ this.#listenerDocument?.removeEventListener("submit", this.#onSubmit, true);
183
+ this.#listenerDocument?.removeEventListener("focusout", this.#onFocusOut);
184
+ this.#listenerDocument?.removeEventListener("input", this.#onInput);
185
+ this.#listenerDocument?.removeEventListener("change", this.#onChange);
186
+ this.#listenerDocument = null;
187
+ for (const [control, message] of this.#ownedCustomErrors) {
188
+ if (control.validationMessage === message) control.setCustomValidity("");
189
+ }
190
+ this.#ownedCustomErrors.clear();
191
+ this.#touchedRadioGroups.clear();
103
192
  if (this.element.hasAttribute(_FormValidationController.#NOVALIDATE_MARKER)) {
104
193
  this.element.removeAttribute("novalidate");
105
194
  this.element.removeAttribute(_FormValidationController.#NOVALIDATE_MARKER);
@@ -107,8 +196,8 @@ var FormValidationController = class _FormValidationController extends Controlle
107
196
  }
108
197
  /**
109
198
  * Validates every control now, rendering or clearing each field's message, and
110
- * returns whether the whole form is valid. Marks every control touched so a
111
- * later input re-validates it. Bound via `data-action`
199
+ * returns whether the whole form is valid. Marks every field/group touched so
200
+ * a later input from any sibling re-validates it. Bound via `data-action`
112
201
  * (`#validate`) or callable directly (e.g. before a programmatic submit).
113
202
  */
114
203
  validate() {
@@ -122,33 +211,42 @@ var FormValidationController = class _FormValidationController extends Controlle
122
211
  * Each group's first invalid control supplies the message and the focus target.
123
212
  */
124
213
  #validateAll() {
214
+ const invalid = [];
215
+ for (const group of this.#snapshot().groups.values()) {
216
+ this.#markTouched(group.key);
217
+ const firstInvalid = this.#applyGroup(group);
218
+ if (firstInvalid) invalid.push(firstInvalid);
219
+ }
220
+ return invalid;
221
+ }
222
+ /** Builds the current field table in one pass over form controls and DOM depth. */
223
+ #snapshot() {
224
+ const fields = this.#fieldOutlets();
125
225
  const groups = /* @__PURE__ */ new Map();
226
+ const groupByControl = /* @__PURE__ */ new Map();
126
227
  for (const control of this.#controls) {
127
- this.#touched.add(control);
128
- const field = this.#fieldFor(control);
228
+ const field = this.#fieldFor(control, fields);
129
229
  const key = this.#keyFor(control, field);
130
- const group = groups.get(key);
131
- if (group) {
132
- group.controls.push(control);
133
- } else {
134
- groups.set(key, { field, controls: [control] });
230
+ let group = groups.get(key);
231
+ if (!group) {
232
+ group = { key, field, controls: [] };
233
+ groups.set(key, group);
135
234
  }
235
+ group.controls.push(control);
236
+ groupByControl.set(control, group);
136
237
  }
137
- const invalid = [];
138
- for (const group of groups.values()) {
139
- const firstInvalid = this.#applyGroup(group);
140
- if (firstInvalid) invalid.push(firstInvalid);
238
+ return { groups, groupByControl };
239
+ }
240
+ /** Records that a whole field/group, rather than one sibling control, was visited. */
241
+ #markTouched(key) {
242
+ if (typeof key === "string") {
243
+ this.#touchedRadioGroups.add(key);
244
+ } else {
245
+ this.#touchedGroups.add(key);
141
246
  }
142
- return invalid;
143
247
  }
144
- /** Re-validates the whole field a single control belongs to (or that control). */
145
- #validateControl(control) {
146
- const field = this.#fieldFor(control);
147
- const key = this.#keyFor(control, field);
148
- const controls = this.#controls.filter(
149
- (other) => this.#keyFor(other, this.#fieldFor(other)) === key
150
- );
151
- this.#applyGroup({ field, controls });
248
+ #isTouched(key) {
249
+ return typeof key === "string" ? this.#touchedRadioGroups.has(key) : this.#touchedGroups.has(key);
152
250
  }
153
251
  /**
154
252
  * Runs native constraint validation across a field's controls and routes the
@@ -162,51 +260,63 @@ var FormValidationController = class _FormValidationController extends Controlle
162
260
  const firstInvalid = group.controls.find((control) => !control.checkValidity()) ?? null;
163
261
  if (group.field) {
164
262
  if (firstInvalid) {
165
- group.field.setError(this.#messageFor(firstInvalid));
166
- } else {
263
+ const message = this.#messageFor(firstInvalid);
264
+ const alreadyReported = group.field.element.hasAttribute(FORM_FIELD_INVALID_ATTR) && this.#reportedErrors.get(group.field) === message;
265
+ if (!alreadyReported) group.field.setError(message, { focus: false });
266
+ this.#reportedErrors.set(group.field, message);
267
+ } else if (group.field.element.hasAttribute(FORM_FIELD_INVALID_ATTR) || this.#reportedErrors.has(group.field)) {
167
268
  group.field.clearError();
269
+ this.#reportedErrors.delete(group.field);
168
270
  }
169
271
  }
170
272
  return firstInvalid;
171
273
  }
172
274
  /**
173
275
  * Resolves the message to show for an invalid control: a per-constraint
174
- * override (`data-stimeo--form-field-message-<constraint>`) for the first failing
175
- * `ValidityState` flag, then a generic `data-stimeo--form-field-message`
276
+ * override (`data-stimeo--form-validation-message-<constraint>`) for the first failing
277
+ * `ValidityState` flag, then a generic `data-stimeo--form-validation-message`
176
278
  * override, then the browser's native `validationMessage`. Authoring an override
177
279
  * gives controlled, localizable, theme-able wording with **no consumer JS** —
178
280
  * and sidesteps headless browsers that return an empty native message.
179
281
  */
180
282
  #messageFor(control) {
283
+ if (control.validity.customError) return control.validationMessage;
181
284
  for (const [flag, key] of CONSTRAINT_MESSAGE_KEYS) {
182
285
  if (control.validity[flag]) {
183
- return control.getAttribute(`${MESSAGE_ATTR_PREFIX}${key}`) ?? control.getAttribute(MESSAGE_ATTR_GENERIC) ?? control.validationMessage;
286
+ return this.#authoredMessage(control, `${MESSAGE_ATTR_PREFIX}${key}`) ?? this.#authoredMessage(control, MESSAGE_ATTR_GENERIC) ?? control.validationMessage;
184
287
  }
185
288
  }
186
- return control.validationMessage || control.getAttribute(MESSAGE_ATTR_GENERIC) || "";
289
+ return control.validationMessage || this.#authoredMessage(control, MESSAGE_ATTR_GENERIC) || "";
187
290
  }
188
291
  /**
189
292
  * Applies (or clears) a declarative custom constraint via `setCustomValidity`,
190
- * for controls that opt in with `data-stimeo--form-field-disallow`. The supported
293
+ * for controls that opt in with `data-stimeo--form-validation-disallow`. The supported
191
294
  * rule is `"whitespace"` — a value that is non-empty but blank
192
295
  * after trimming (which slips past `required` / `minlength`); its message follows
193
- * the per-constraint (`value-missing`) → generic → default chain.
296
+ * the whitespace-specific → generic → default chain.
194
297
  *
195
- * Don't-clobber-authored-state: an unknown/absent rule is never touched, and a
196
- * custom error is only cleared when *we* set it (tracked in {@link #ownedCustomError}),
197
- * so a consumer's own `setCustomValidity` on the same control survives.
298
+ * Don't-clobber-authored-state: an unknown/absent rule is never written, an
299
+ * existing consumer custom error wins, and a controller error is only cleared
300
+ * while the live message still equals the recorded controller write.
198
301
  */
199
302
  #syncCustomValidity(control) {
303
+ const recorded = this.#ownedCustomErrors.get(control);
304
+ const ownsCurrent = recorded !== void 0 && control.validationMessage === recorded;
200
305
  const violates = control.getAttribute(DISALLOW_ATTR) === "whitespace" && control.value.length > 0 && control.value.trim() === "";
201
- if (violates) {
202
- control.setCustomValidity(
203
- control.getAttribute(`${MESSAGE_ATTR_PREFIX}value-missing`) ?? control.getAttribute(MESSAGE_ATTR_GENERIC) ?? DISALLOW_WHITESPACE_DEFAULT
204
- );
205
- this.#ownedCustomError.add(control);
206
- } else if (this.#ownedCustomError.has(control)) {
207
- this.#ownedCustomError.delete(control);
208
- control.setCustomValidity("");
306
+ if (!violates) {
307
+ if (ownsCurrent) control.setCustomValidity("");
308
+ this.#ownedCustomErrors.delete(control);
309
+ return;
209
310
  }
311
+ if (control.validity.customError && !ownsCurrent) return;
312
+ const message = this.#authoredMessage(control, DISALLOW_WHITESPACE_MESSAGE) ?? this.#authoredMessage(control, MESSAGE_ATTR_GENERIC) ?? DISALLOW_WHITESPACE_DEFAULT;
313
+ if (!ownsCurrent || recorded !== message) control.setCustomValidity(message);
314
+ this.#ownedCustomErrors.set(control, message);
315
+ }
316
+ /** Returns a non-blank authored message, otherwise falls through to a fallback. */
317
+ #authoredMessage(control, attribute) {
318
+ const message = control.getAttribute(attribute);
319
+ return message && message.trim().length > 0 ? message : null;
210
320
  }
211
321
  /**
212
322
  * A grouping key that collects controls belonging to the same field: the owning
@@ -230,18 +340,32 @@ var FormValidationController = class _FormValidationController extends Controlle
230
340
  * is deterministic and CSS-independent.
231
341
  */
232
342
  #focusTargetFor(control) {
233
- if (!control.hidden) return control;
234
- const field = this.#fieldFor(control);
343
+ if (canTakeFocus(control)) return control;
344
+ const field = this.#fieldFor(control, this.#fieldOutlets());
235
345
  if (!field?.hasControlTarget) return null;
236
346
  const root = field.controlTarget;
237
- if (root.matches(FOCUSABLE)) return root;
238
- return root.querySelector(FOCUSABLE);
347
+ if (isTabStop(root)) return root;
348
+ return firstTabStop(root);
239
349
  }
240
- /** The `stimeo--form-field` outlet whose element contains `control`, if any. */
241
- #fieldFor(control) {
350
+ /** Pairs each live outlet element with its controller once per operation. */
351
+ #fieldOutlets() {
352
+ const fields = /* @__PURE__ */ new Map();
242
353
  const elements = this.stimeoFormFieldOutletElements;
354
+ const outlets = this.stimeoFormFieldOutlets;
243
355
  for (let index = 0; index < elements.length; index++) {
244
- if (elements[index]?.contains(control)) return this.stimeoFormFieldOutlets[index];
356
+ const element = elements[index];
357
+ const outlet = outlets[index];
358
+ if (element && outlet) fields.set(element, outlet);
359
+ }
360
+ return fields;
361
+ }
362
+ /** The nearest configured field ancestor of `control`, if any. */
363
+ #fieldFor(control, fields) {
364
+ let ancestor = control;
365
+ while (ancestor) {
366
+ const field = fields.get(ancestor);
367
+ if (field) return field;
368
+ ancestor = ancestor.parentElement;
245
369
  }
246
370
  return void 0;
247
371
  }
@@ -255,11 +379,12 @@ var FormValidationController = class _FormValidationController extends Controlle
255
379
  }
256
380
  /** Narrows an event target to a validatable control. */
257
381
  #controlFrom(target) {
258
- return target instanceof Element && this.#isValidatable(target) ? target : null;
382
+ return target instanceof Element && this.#isValidatable(target) && target.form === this.element ? target : null;
259
383
  }
260
384
  #isValidatable(element) {
261
- return (element instanceof HTMLInputElement || element instanceof HTMLSelectElement || element instanceof HTMLTextAreaElement) && // `willValidate` already excludes disabled, read-only, hidden, and button
262
- // controls — the exact set barred from constraint validation.
385
+ return (element instanceof HTMLInputElement || element instanceof HTMLSelectElement || element instanceof HTMLTextAreaElement) && // `willValidate` already excludes disabled, read-only, input[type=hidden],
386
+ // and button-type controls. The `hidden` attribute intentionally does not
387
+ // bar a text mirror from constraint validation.
263
388
  element.willValidate;
264
389
  }
265
390
  };
@@ -27,17 +27,21 @@ var AttributeLease = class {
27
27
  written: value
28
28
  });
29
29
  }
30
- if (value === null) element.removeAttribute(this.#attribute);
31
- else element.setAttribute(this.#attribute, value);
30
+ this.#reflect(element, value);
32
31
  }
33
32
  /** Returns one lease without overwriting a value subsequently authored by a consumer. */
34
33
  return(element) {
35
34
  const record = this.#records.get(element);
36
35
  if (!record) return;
37
36
  this.#records.delete(element);
38
- if (element.getAttribute(this.#attribute) !== record.written) return;
39
- if (record.original === null) element.removeAttribute(this.#attribute);
40
- else element.setAttribute(this.#attribute, record.original);
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);
41
45
  }
42
46
  /** Returns every outstanding lease using the same ownership check as {@link return}. */
43
47
  returnAll() {
@@ -45,6 +49,35 @@ var AttributeLease = class {
45
49
  }
46
50
  };
47
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
+
48
81
  // src/utils/composition_tracker.ts
49
82
  var CompositionTracker = class {
50
83
  #observedTargets = /* @__PURE__ */ new Set();
@@ -328,6 +361,7 @@ var NumberInputController = class _NumberInputController extends Controller {
328
361
  /** Tracks IME lifecycle on the current input, including confirming keys without a signal. */
329
362
  #composition = new CompositionTracker();
330
363
  /** Restores authored custom-spinbutton ARIA when a target leaves or the controller stops. */
364
+ #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
331
365
  #ariaValueNow = new AttributeLease("aria-valuenow");
332
366
  #ariaValueMin = new AttributeLease("aria-valuemin");
333
367
  #ariaValueMax = new AttributeLease("aria-valuemax");
@@ -351,6 +385,7 @@ var NumberInputController = class _NumberInputController extends Controller {
351
385
  /** Normalizes any initial value and wires the focus/hold pointer guards. */
352
386
  connect() {
353
387
  this.#repaint.activate();
388
+ this.#beforeCache.activate();
354
389
  this.#globalGuards = new AbortController();
355
390
  const { signal } = this.#globalGuards;
356
391
  for (const button of this.incrementTargets) this.#wireButton(button, 1);
@@ -364,6 +399,7 @@ var NumberInputController = class _NumberInputController extends Controller {
364
399
  disconnect() {
365
400
  this.#repaint.cancel();
366
401
  this.#composition.disconnect();
402
+ this.#beforeCache.deactivate();
367
403
  this.#globalGuards?.abort();
368
404
  this.#globalGuards = null;
369
405
  const buttons = /* @__PURE__ */ new Set([
@@ -697,6 +733,12 @@ var NumberInputController = class _NumberInputController extends Controller {
697
733
  get #steppedRange() {
698
734
  return { min: this.minValue, max: this.maxValue, step: this.stepValue };
699
735
  }
736
+ /** Returns borrowed range ARIA before Turbo snapshots the page. */
737
+ #rewindForCache() {
738
+ this.#ariaValueNow.returnAll();
739
+ this.#ariaValueMin.returnAll();
740
+ this.#ariaValueMax.returnAll();
741
+ }
700
742
  };
701
743
 
702
744
  export { NumberInputController };
@@ -42,7 +42,8 @@ function inheritsFieldsetDisabled(control) {
42
42
  return false;
43
43
  }
44
44
  function canTakeFocus(element) {
45
- if (element.closest("[hidden]")) return false;
45
+ if (element.closest("[hidden], [inert]")) return false;
46
+ if (element instanceof HTMLInputElement && element.type === "hidden") return false;
46
47
  if (!("disabled" in element)) return true;
47
48
  if (element.disabled) return false;
48
49
  return !inheritsFieldsetDisabled(element);
@@ -13,7 +13,8 @@ function inheritsFieldsetDisabled(control) {
13
13
  return false;
14
14
  }
15
15
  function canTakeFocus(element) {
16
- if (element.closest("[hidden]")) return false;
16
+ if (element.closest("[hidden], [inert]")) return false;
17
+ if (element instanceof HTMLInputElement && element.type === "hidden") return false;
17
18
  if (!("disabled" in element)) return true;
18
19
  if (element.disabled) return false;
19
20
  return !inheritsFieldsetDisabled(element);