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
@@ -2,6 +2,64 @@ import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/persist_controller.ts
4
4
 
5
+ // src/utils/microtask_coalescer.ts
6
+ var MicrotaskCoalescer = class {
7
+ #run;
8
+ #queued = false;
9
+ #active = false;
10
+ #generation = 0;
11
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
12
+ constructor(run) {
13
+ this.#run = run;
14
+ }
15
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
16
+ activate() {
17
+ this.#active = true;
18
+ }
19
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
20
+ cancel() {
21
+ this.#active = false;
22
+ this.#queued = false;
23
+ this.#generation += 1;
24
+ }
25
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
26
+ schedule() {
27
+ if (!this.#active || this.#queued) return;
28
+ this.#queued = true;
29
+ const generation = this.#generation;
30
+ queueMicrotask(() => {
31
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
32
+ this.#queued = false;
33
+ this.#run();
34
+ });
35
+ }
36
+ };
37
+
38
+ // src/utils/safe_storage.ts
39
+ function readLocalStorage(key) {
40
+ try {
41
+ return { ok: true, value: window.localStorage.getItem(key) };
42
+ } catch (error) {
43
+ return { ok: false, error };
44
+ }
45
+ }
46
+ function writeLocalStorage(key, value) {
47
+ try {
48
+ window.localStorage.setItem(key, value);
49
+ return { ok: true, value: void 0 };
50
+ } catch (error) {
51
+ return { ok: false, error };
52
+ }
53
+ }
54
+ function removeLocalStorage(key) {
55
+ try {
56
+ window.localStorage.removeItem(key);
57
+ return { ok: true, value: void 0 };
58
+ } catch (error) {
59
+ return { ok: false, error };
60
+ }
61
+ }
62
+
5
63
  // src/utils/safe_timeout.ts
6
64
  var TimerRegistry = class {
7
65
  /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
@@ -71,170 +129,349 @@ function parseStringList(raw, fallback = []) {
71
129
 
72
130
  // src/controllers/persist_controller.ts
73
131
  var NON_VALUE_TYPES = /* @__PURE__ */ new Set(["file", "submit", "reset", "button", "image"]);
74
- var DEFAULT_EXCLUDE = ["password"];
132
+ var SENSITIVE_TYPES = /* @__PURE__ */ new Set(["password"]);
133
+ var DEFAULT_EXCLUDE = ["authenticity_token", "_method", "utf8"];
75
134
  var STORAGE_PREFIX = "stimeo--persist:";
76
135
  var OCCURRENCE_SEP = "\0";
136
+ var OWNERSHIP_ATTRIBUTE = "data-controller";
137
+ var DEFAULT_DEBOUNCE = 400;
138
+ var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
139
+ var isPersistValue = (value) => typeof value === "string" || typeof value === "boolean" || Array.isArray(value) && value.every((item) => typeof item === "string");
140
+ var parsePayload = (raw) => {
141
+ let parsed;
142
+ try {
143
+ parsed = JSON.parse(raw);
144
+ } catch {
145
+ return null;
146
+ }
147
+ if (!isRecord(parsed) || parsed.version !== 1 || !Array.isArray(parsed.fields)) return null;
148
+ const fields = [];
149
+ const keys = /* @__PURE__ */ new Set();
150
+ for (const candidate of parsed.fields) {
151
+ if (!isRecord(candidate)) return null;
152
+ const { key, value } = candidate;
153
+ if (typeof key !== "string" || key.length === 0 || keys.has(key)) return null;
154
+ if (!isPersistValue(value)) return null;
155
+ keys.add(key);
156
+ fields.push({ key, value });
157
+ }
158
+ return { version: 1, fields };
159
+ };
160
+ var isPersistField = (node) => node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement;
77
161
  var PersistController = class extends Controller {
78
162
  static targets = ["field"];
79
163
  static values = {
80
164
  key: { type: String, default: "" },
81
- debounce: { type: Number, default: 400 },
82
- // A JSON list read through `parseStringList` rather than Stimulus's `Array`
83
- // type: that reader throws out of the value observer before any callback
84
- // runs, so one malformed attribute would stop the controller connecting.
165
+ debounce: { type: Number, default: DEFAULT_DEBOUNCE },
166
+ // A malformed JSON list must fall back without aborting Stimulus connection.
85
167
  exclude: { type: String, default: "" },
86
168
  clearOn: { type: String, default: "" }
87
169
  };
88
170
  static actions = ["clear"];
89
- static events = ["restore", "save", "clear"];
171
+ static events = ["restore", "save", "clear", "error"];
90
172
  #timeouts = new SafeTimeout();
91
- #saveId = null;
92
- #onInput = () => {
173
+ #restoreDynamic = new MicrotaskCoalescer(() => this.#restoreDynamicFields());
174
+ #observer = new MutationObserver((records) => this.#onMutations(records));
175
+ #pendingSave = null;
176
+ #connected = false;
177
+ #logicalKey = null;
178
+ #payload = null;
179
+ #registeredClearOn = null;
180
+ #excluded = DEFAULT_EXCLUDE;
181
+ #knownFields = /* @__PURE__ */ new WeakSet();
182
+ #forcedRestore = /* @__PURE__ */ new Set();
183
+ #onInput = (event) => {
184
+ const field = event.target;
185
+ if (!isPersistField(field) || !this.#ownsField(field)) return;
186
+ if (this.hasFieldTarget && !this.fieldTargets.includes(field)) return;
187
+ if (this.#keyOf(field) === null || !this.#persistable(field, this.#excluded)) return;
93
188
  this.#scheduleSave();
94
189
  };
95
190
  #onClearEvent = () => {
96
191
  this.clear();
97
192
  };
98
193
  connect() {
99
- if (this.#storageKey === null) return;
100
- this.#restore();
194
+ if (this.#connected) return;
195
+ this.#connected = true;
196
+ this.#logicalKey = this.#resolveLogicalKey();
197
+ this.#excluded = parseStringList(this.excludeValue, DEFAULT_EXCLUDE);
198
+ this.#knownFields = /* @__PURE__ */ new WeakSet();
199
+ this.#forcedRestore.clear();
200
+ this.element.removeAttribute("data-persist-restored");
101
201
  this.element.addEventListener("input", this.#onInput);
102
202
  this.element.addEventListener("change", this.#onInput);
103
- if (this.clearOnValue.length > 0) {
104
- this.element.addEventListener(this.clearOnValue, this.#onClearEvent);
105
- }
203
+ this.#syncClearOn();
204
+ this.#restoreDynamic.activate();
205
+ this.#observer.observe(this.element, {
206
+ attributes: true,
207
+ attributeFilter: [
208
+ "checked",
209
+ "data-controller",
210
+ "data-stimeo--persist-target",
211
+ "id",
212
+ "multiple",
213
+ "name",
214
+ "selected",
215
+ "type",
216
+ "value"
217
+ ],
218
+ childList: true,
219
+ subtree: true
220
+ });
221
+ this.#loadActiveDraft();
106
222
  }
107
223
  disconnect() {
224
+ this.#flushPendingSave();
225
+ this.#connected = false;
108
226
  this.element.removeEventListener("input", this.#onInput);
109
227
  this.element.removeEventListener("change", this.#onInput);
110
- if (this.clearOnValue.length > 0) {
111
- this.element.removeEventListener(this.clearOnValue, this.#onClearEvent);
112
- }
113
- if (this.#saveId !== null) {
114
- this.#timeouts.clear(this.#saveId);
115
- this.#saveId = null;
116
- this.#write();
117
- }
228
+ this.#unbindClearOn();
229
+ this.#observer.disconnect();
230
+ this.#restoreDynamic.cancel();
231
+ this.#forcedRestore.clear();
118
232
  this.#timeouts.clearAll();
233
+ this.element.removeAttribute("data-persist-restored");
234
+ this.#payload = null;
119
235
  }
120
- /** Drops the saved draft and clears the restored marker. */
236
+ /** Drops the active draft after storage confirms the removal. */
121
237
  clear() {
122
- const key = this.#storageKey;
123
- if (key === null) return;
124
- if (this.#saveId !== null) {
125
- this.#timeouts.clear(this.#saveId);
126
- this.#saveId = null;
238
+ const logicalKey = this.#logicalKey ?? this.#resolveLogicalKey();
239
+ if (logicalKey === null) return;
240
+ this.#cancelPendingSave();
241
+ const result = removeLocalStorage(this.#storageKey(logicalKey));
242
+ if (!result.ok) {
243
+ this.#dispatchError(logicalKey, "remove", "unavailable");
244
+ return;
127
245
  }
128
- this.#removeItem(key);
246
+ this.#payload = null;
129
247
  this.element.removeAttribute("data-persist-restored");
130
- this.dispatch("clear", { detail: { key: this.#logicalKey } });
248
+ this.dispatch("clear", { detail: { key: logicalKey } });
249
+ }
250
+ /** Rebinds the external clear event when its Value changes. */
251
+ clearOnValueChanged() {
252
+ if (this.#connected) this.#syncClearOn();
253
+ }
254
+ /** Switches storage namespaces without writing old edits under the new key. */
255
+ keyValueChanged() {
256
+ if (this.#connected) this.#switchLogicalKey();
257
+ }
258
+ /** Reschedules a pending write against the current debounce delay. */
259
+ debounceValueChanged() {
260
+ if (this.#connected && this.#pendingSave !== null) this.#scheduleSave();
261
+ }
262
+ /** Restores every field the new exclusion list makes eligible. */
263
+ excludeValueChanged() {
264
+ const previous = this.#excluded;
265
+ this.#excluded = parseStringList(this.excludeValue, DEFAULT_EXCLUDE);
266
+ if (!this.#connected) return;
267
+ for (const field of this.#candidateFields()) {
268
+ if (this.#persistable(field, this.#excluded) && !this.#persistable(field, previous)) {
269
+ this.#forcedRestore.add(field);
270
+ }
271
+ }
272
+ this.#restoreDynamic.schedule();
131
273
  }
132
274
  /** Schedules a debounced save. */
133
275
  #scheduleSave() {
134
- if (this.#saveId !== null) this.#timeouts.clear(this.#saveId);
135
- this.#saveId = this.#timeouts.set(() => {
136
- this.#saveId = null;
137
- this.#save();
138
- }, this.debounceValue);
139
- }
140
- /** Writes the current values and emits `save`. */
141
- #save() {
142
- this.#write();
143
- this.dispatch("save", { detail: { key: this.#logicalKey } });
144
- }
145
- /** Serializes persistable fields and stores them under the storage key. */
146
- #write() {
147
- const key = this.#storageKey;
148
- if (key === null) return;
149
- const data = {};
150
- for (const { field, key: fieldKey } of this.#fieldEntries()) {
276
+ const logicalKey = this.#logicalKey;
277
+ if (logicalKey === null) return;
278
+ this.#cancelPendingSave();
279
+ const id = this.#timeouts.set(() => {
280
+ this.#pendingSave = null;
281
+ this.#save(logicalKey);
282
+ }, this.#debounceDelay);
283
+ this.#pendingSave = { id, logicalKey };
284
+ }
285
+ /** Cancels the currently pending save, if any. */
286
+ #cancelPendingSave() {
287
+ const pending = this.#pendingSave;
288
+ if (pending === null) return;
289
+ this.#timeouts.clear(pending.id);
290
+ this.#pendingSave = null;
291
+ }
292
+ /** Flushes a pending edit through the same success/error event path as a timer. */
293
+ #flushPendingSave() {
294
+ const pending = this.#pendingSave;
295
+ if (pending === null) return;
296
+ this.#cancelPendingSave();
297
+ this.#save(pending.logicalKey);
298
+ }
299
+ /** Writes the active payload and emits `save` only after storage succeeds. */
300
+ #save(logicalKey) {
301
+ const payload = this.#serialize();
302
+ const result = writeLocalStorage(this.#storageKey(logicalKey), JSON.stringify(payload));
303
+ if (!result.ok) {
304
+ this.#dispatchError(logicalKey, "write", "unavailable");
305
+ return false;
306
+ }
307
+ this.#payload = payload;
308
+ this.dispatch("save", { detail: { key: logicalKey } });
309
+ return true;
310
+ }
311
+ /** Serializes every persistable field into a versioned, typed payload. */
312
+ #serialize() {
313
+ const fields = [];
314
+ for (const { field, key } of this.#fieldEntries()) {
151
315
  if (field instanceof HTMLInputElement && field.type === "checkbox") {
152
- data[fieldKey] = field.checked;
316
+ fields.push({ key, value: field.checked });
153
317
  } else if (field instanceof HTMLInputElement && field.type === "radio") {
154
- if (field.checked) data[fieldKey] = field.value;
318
+ if (field.checked) fields.push({ key, value: field.value });
155
319
  } else if (field instanceof HTMLSelectElement && field.multiple) {
156
- data[fieldKey] = Array.from(field.selectedOptions).map((o) => o.value);
320
+ fields.push({
321
+ key,
322
+ value: Array.from(field.selectedOptions).map((option) => option.value)
323
+ });
157
324
  } else {
158
- data[fieldKey] = field.value;
325
+ fields.push({ key, value: field.value });
159
326
  }
160
327
  }
161
- this.#setItem(key, JSON.stringify(data));
162
- }
163
- /** Applies any saved values to the fields, without moving focus. */
164
- #restore() {
165
- const key = this.#storageKey;
166
- if (key === null) return;
167
- const raw = this.#getItem(key);
168
- if (raw === null) return;
169
- let data;
170
- try {
171
- data = JSON.parse(raw);
172
- } catch {
328
+ return { version: 1, fields };
329
+ }
330
+ /** Reads, validates, and restores the active namespace. */
331
+ #loadActiveDraft() {
332
+ this.element.removeAttribute("data-persist-restored");
333
+ this.#payload = null;
334
+ this.#knownFields = /* @__PURE__ */ new WeakSet();
335
+ this.#forcedRestore.clear();
336
+ const logicalKey = this.#logicalKey;
337
+ if (logicalKey === null) {
338
+ this.#markCurrentFieldsKnown();
339
+ return;
340
+ }
341
+ const result = readLocalStorage(this.#storageKey(logicalKey));
342
+ if (!result.ok) {
343
+ this.#dispatchError(logicalKey, "read", "unavailable");
344
+ this.#markCurrentFieldsKnown();
345
+ return;
346
+ }
347
+ if (result.value === null) {
348
+ this.#markCurrentFieldsKnown();
349
+ return;
350
+ }
351
+ const payload = parsePayload(result.value);
352
+ if (payload === null) {
353
+ const removal = removeLocalStorage(this.#storageKey(logicalKey));
354
+ this.#dispatchError(logicalKey, "read", "invalid-payload");
355
+ if (!removal.ok) this.#dispatchError(logicalKey, "remove", "unavailable");
356
+ this.#markCurrentFieldsKnown();
173
357
  return;
174
358
  }
175
- let restoredAny = false;
176
- for (const { field, key: fieldKey } of this.#fieldEntries()) {
177
- if (!Object.hasOwn(data, fieldKey)) continue;
178
- this.#applyValue(field, data[fieldKey]);
179
- restoredAny = true;
359
+ this.#payload = payload;
360
+ this.#restoreEntries(null);
361
+ }
362
+ /** Restores fields inserted or made eligible after the initial connection. */
363
+ #restoreDynamicFields() {
364
+ const selected = new Set(this.#forcedRestore);
365
+ this.#forcedRestore.clear();
366
+ for (const { field } of this.#fieldEntries()) {
367
+ if (!this.#knownFields.has(field)) selected.add(field);
368
+ }
369
+ this.#restoreEntries(selected);
370
+ }
371
+ /** Applies the active payload to all fields, or only the supplied candidates. */
372
+ #restoreEntries(selected) {
373
+ const entries = this.#fieldEntries();
374
+ for (const { field } of entries) this.#knownFields.add(field);
375
+ const payload = this.#payload;
376
+ if (payload === null) return;
377
+ const savedFields = new Map(payload.fields.map((field) => [field.key, field]));
378
+ const restoredKeys = /* @__PURE__ */ new Set();
379
+ const processedRadios = /* @__PURE__ */ new Set();
380
+ for (const entry of entries) {
381
+ if (selected !== null && !selected.has(entry.field)) continue;
382
+ const saved = savedFields.get(entry.key);
383
+ if (saved === void 0) continue;
384
+ const { value } = saved;
385
+ if (entry.field instanceof HTMLInputElement && entry.field.type === "radio") {
386
+ if (processedRadios.has(entry.key)) continue;
387
+ processedRadios.add(entry.key);
388
+ const radios = entries.filter(
389
+ ({ key, field }) => key === entry.key && field instanceof HTMLInputElement && field.type === "radio"
390
+ ).map(({ field }) => field);
391
+ if (typeof value !== "string" || !radios.some((radio) => radio.value === value)) continue;
392
+ for (const radio of radios) radio.checked = radio.value === value;
393
+ restoredKeys.add(entry.key);
394
+ continue;
395
+ }
396
+ if (this.#applyValue(entry.field, value)) restoredKeys.add(entry.key);
180
397
  }
181
- if (restoredAny) {
398
+ if (restoredKeys.size > 0) {
399
+ this.#observer.takeRecords();
182
400
  this.element.setAttribute("data-persist-restored", "true");
183
401
  this.dispatch("restore", { detail: { key: this.#logicalKey } });
184
402
  }
185
403
  }
186
- /** Sets a single field's value from a stored entry. */
404
+ /** Applies a type-compatible stored value without coercion. */
187
405
  #applyValue(field, value) {
188
406
  if (field instanceof HTMLInputElement && field.type === "checkbox") {
189
- field.checked = Boolean(value);
190
- } else if (field instanceof HTMLInputElement && field.type === "radio") {
191
- field.checked = field.value === value;
192
- } else if (field instanceof HTMLSelectElement && field.multiple) {
193
- const selected = new Set(Array.isArray(value) ? value.map(String) : []);
407
+ if (typeof value !== "boolean") return false;
408
+ field.checked = value;
409
+ return true;
410
+ }
411
+ if (field instanceof HTMLSelectElement && field.multiple) {
412
+ if (!Array.isArray(value)) return false;
413
+ const available = new Set(Array.from(field.options).map((option) => option.value));
414
+ const selected = value.filter((candidate) => available.has(candidate));
415
+ if (value.length > 0 && selected.length === 0) return false;
416
+ const selectedValues = new Set(selected);
194
417
  for (const option of Array.from(field.options)) {
195
- option.selected = selected.has(option.value);
418
+ option.selected = selectedValues.has(option.value);
196
419
  }
197
- } else {
198
- field.value = String(value);
420
+ return true;
421
+ }
422
+ if (typeof value !== "string") return false;
423
+ if (field instanceof HTMLSelectElement) {
424
+ const exists = Array.from(field.options).some((option) => option.value === value);
425
+ if (!exists) return false;
199
426
  }
427
+ field.value = value;
428
+ return true;
429
+ }
430
+ /** Every control this instance owns, before the exclusion list narrows them. */
431
+ #candidateFields() {
432
+ return this.hasFieldTarget ? this.fieldTargets.filter(isPersistField) : Array.from(this.element.querySelectorAll("input, textarea, select")).filter(
433
+ (field) => this.#ownsField(field)
434
+ );
200
435
  }
201
436
  /**
202
- * Persistable fields paired with a stable storage key. Uniquely-named fields key
203
- * by their name. Repeated same-name fields (e.g. a `tags[]` checkbox group or
204
- * array text inputs) are disambiguated by DOM-order occurrence — the first keeps
205
- * its plain `name`, later ones get a NUL-separated index suffix — so each is
206
- * stored and restored individually instead of the last one clobbering the rest.
207
- * Radios are the exception: a group intentionally shares one key (one value per
208
- * name).
437
+ * Pairs persistable fields with DOM-order occurrence keys. Every HTML radio
438
+ * group (same name and form owner) occupies one shared occurrence slot.
209
439
  */
210
440
  #fieldEntries() {
211
441
  const entries = [];
212
- const occurrence = /* @__PURE__ */ new Map();
213
- for (const field of this.#fields()) {
442
+ const eligible = [];
443
+ for (const field of this.#candidateFields()) {
214
444
  const name = this.#keyOf(field);
215
- if (name === null) continue;
445
+ if (name === null || !this.#persistable(field, this.#excluded)) continue;
446
+ eligible.push({ field, name });
447
+ }
448
+ const nextOccurrence = /* @__PURE__ */ new Map();
449
+ const radioSlots = /* @__PURE__ */ new Map();
450
+ const allocate = (name) => {
451
+ const occurrence = nextOccurrence.get(name) ?? 0;
452
+ nextOccurrence.set(name, occurrence + 1);
453
+ return occurrence === 0 ? name : `${name}${OCCURRENCE_SEP}${occurrence}`;
454
+ };
455
+ for (const { field, name } of eligible) {
216
456
  if (field instanceof HTMLInputElement && field.type === "radio") {
217
- entries.push({ field, key: name });
457
+ const groups = radioSlots.get(name) ?? /* @__PURE__ */ new Map();
458
+ radioSlots.set(name, groups);
459
+ let key = groups.get(field.form);
460
+ if (key === void 0) {
461
+ key = allocate(name);
462
+ groups.set(field.form, key);
463
+ }
464
+ entries.push({ field, key });
218
465
  continue;
219
466
  }
220
- const seen = occurrence.get(name) ?? 0;
221
- occurrence.set(name, seen + 1);
222
- entries.push({ field, key: seen === 0 ? name : `${name}${OCCURRENCE_SEP}${seen}` });
467
+ entries.push({ field, key: allocate(name) });
223
468
  }
224
469
  return entries;
225
470
  }
226
- /** The fields to persist: `field` targets, or the element's named controls. */
227
- #fields() {
228
- const candidates = this.hasFieldTarget ? this.fieldTargets : Array.from(this.element.querySelectorAll("input, textarea, select"));
229
- const excluded = parseStringList(this.excludeValue, DEFAULT_EXCLUDE);
230
- return candidates.filter((field) => this.#persistable(field, excluded));
231
- }
232
471
  /** Whether a field carries a restorable, non-excluded value. */
233
472
  #persistable(field, excluded) {
234
- if (this.#keyOf(field) === null) return false;
235
- const type = field instanceof HTMLInputElement ? field.type : "";
236
- if (NON_VALUE_TYPES.has(type)) return false;
237
- if (excluded.includes(type)) return false;
473
+ if (NON_VALUE_TYPES.has(field.type) || SENSITIVE_TYPES.has(field.type)) return false;
474
+ if (excluded.includes(field.type)) return false;
238
475
  if (field.name.length > 0 && excluded.includes(field.name)) return false;
239
476
  return true;
240
477
  }
@@ -242,35 +479,108 @@ var PersistController = class extends Controller {
242
479
  #keyOf(field) {
243
480
  return field.name || field.id || null;
244
481
  }
245
- /** The logical key (key Value or element id), or null when neither is set. */
246
- get #logicalKey() {
247
- const key = this.keyValue || this.element.id;
248
- return key.length > 0 ? key : null;
482
+ /** Whether this instance, rather than a nested Persist host, owns a field. */
483
+ #ownsField(field) {
484
+ return field.closest('[data-controller~="stimeo--persist"]') === this.element;
485
+ }
486
+ /** Marks all currently eligible fields without applying a payload. */
487
+ #markCurrentFieldsKnown() {
488
+ for (const { field } of this.#fieldEntries()) this.#knownFields.add(field);
489
+ }
490
+ /** Switches namespace after flushing the old namespace's pending edit. */
491
+ #switchLogicalKey() {
492
+ const next = this.#resolveLogicalKey();
493
+ if (next === this.#logicalKey) return;
494
+ this.#flushPendingSave();
495
+ this.#logicalKey = next;
496
+ this.#loadActiveDraft();
497
+ }
498
+ /** Synchronizes the exact event name owned by the `clearOn` Value. */
499
+ #syncClearOn() {
500
+ const next = this.#validEventName(this.clearOnValue);
501
+ if (next === this.#registeredClearOn) return;
502
+ this.#unbindClearOn();
503
+ if (next === null) return;
504
+ this.element.addEventListener(next, this.#onClearEvent);
505
+ this.#registeredClearOn = next;
506
+ }
507
+ /** Removes the event listener using the name that was actually registered. */
508
+ #unbindClearOn() {
509
+ if (this.#registeredClearOn === null) return;
510
+ this.element.removeEventListener(this.#registeredClearOn, this.#onClearEvent);
511
+ this.#registeredClearOn = null;
249
512
  }
250
- /** The prefixed localStorage key, or null when persistence is disabled. */
251
- get #storageKey() {
252
- const logical = this.#logicalKey;
253
- return logical === null ? null : `${STORAGE_PREFIX}${logical}`;
513
+ /** Returns a non-whitespace event type, or null when the hook is disabled. */
514
+ #validEventName(value) {
515
+ const name = value.trim();
516
+ return name.length > 0 && !/\s/.test(name) ? name : null;
254
517
  }
255
- #getItem(key) {
256
- try {
257
- return window.localStorage.getItem(key);
258
- } catch {
259
- return null;
518
+ /** Collects dynamic controls and select-option changes into one restore pass. */
519
+ #onMutations(records) {
520
+ let rootIdChanged = false;
521
+ for (const record of records) {
522
+ if (record.type === "attributes") {
523
+ if (record.target === this.element && record.attributeName === "id") {
524
+ rootIdChanged = true;
525
+ }
526
+ this.#collectAttributeCandidate(record.target, record.attributeName);
527
+ continue;
528
+ }
529
+ for (const node of record.addedNodes) this.#collectRestoreCandidate(node);
530
+ }
531
+ if (rootIdChanged && this.keyValue.length === 0) this.#switchLogicalKey();
532
+ this.#restoreDynamic.schedule();
533
+ }
534
+ /**
535
+ * Adds the controls one mutated attribute can affect. Only an ownership change on a
536
+ * descendant moves fields between Persist hosts, so that is the single case worth a
537
+ * subtree sweep; every other observed attribute describes one control, and sweeping
538
+ * from its container would re-apply the stored draft over edits still being debounced.
539
+ */
540
+ #collectAttributeCandidate(target, attributeName) {
541
+ if (attributeName === OWNERSHIP_ATTRIBUTE && target !== this.element) {
542
+ this.#collectRestoreCandidate(target);
543
+ return;
260
544
  }
545
+ this.#collectControlCandidate(target);
261
546
  }
262
- #setItem(key, value) {
263
- try {
264
- window.localStorage.setItem(key, value);
265
- } catch {
547
+ /** Adds a native control, or the select that owns a mutated option. */
548
+ #collectControlCandidate(node) {
549
+ if (isPersistField(node)) {
550
+ this.#forcedRestore.add(node);
551
+ return;
552
+ }
553
+ if (node instanceof HTMLOptionElement) {
554
+ const select = node.closest("select");
555
+ if (select) this.#forcedRestore.add(select);
266
556
  }
267
557
  }
268
- #removeItem(key) {
269
- try {
270
- window.localStorage.removeItem(key);
271
- } catch {
558
+ /** Adds every control inside an inserted or newly owned subtree. */
559
+ #collectRestoreCandidate(node) {
560
+ this.#collectControlCandidate(node);
561
+ if (!(node instanceof Element)) return;
562
+ for (const field of node.querySelectorAll("input, textarea, select")) {
563
+ this.#forcedRestore.add(field);
272
564
  }
273
565
  }
566
+ /** Resolves the logical key from the Value, falling back to the host id. */
567
+ #resolveLogicalKey() {
568
+ const key = this.keyValue || this.element.id;
569
+ return key.length > 0 ? key : null;
570
+ }
571
+ /** Namespaces one logical key within localStorage. */
572
+ #storageKey(logicalKey) {
573
+ return `${STORAGE_PREFIX}${logicalKey}`;
574
+ }
575
+ /** Normalizes invalid debounce Values to the documented default. */
576
+ get #debounceDelay() {
577
+ const value = this.debounceValue;
578
+ return Number.isFinite(value) && value >= 0 ? value : DEFAULT_DEBOUNCE;
579
+ }
580
+ /** Dispatches one observable storage failure without exposing browser errors. */
581
+ #dispatchError(key, operation, reason) {
582
+ this.dispatch("error", { detail: { key, operation, reason } });
583
+ }
274
584
  };
275
585
 
276
586
  export { PersistController };