stimeo-ui 0.8.0 → 0.9.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.
@@ -55,33 +55,64 @@ var EditableController = class extends Controller {
55
55
  static values = {
56
56
  submitOnBlur: { type: Boolean, default: true }
57
57
  };
58
- static actions = ["edit", "onBlur", "onDisplayKeydown", "onKeydown"];
58
+ static actions = ["cancel", "edit", "onDisplayKeydown", "onKeydown", "revert", "save"];
59
59
  static events = ["cancel", "change"];
60
60
  /** The value captured when edit mode began, used to detect real changes. */
61
61
  #previousValue = "";
62
+ /**
63
+ * The value the last save replaced, or `null` when there is nothing to undo.
64
+ * Cleared on connect: a value from before a page restore is unrecoverable, so
65
+ * `revert()` must not resurrect one.
66
+ */
67
+ #revertValue = null;
62
68
  /**
63
69
  * Owns IME lifecycle state for the edit surface, so a keydown that belongs to
64
70
  * a composition (cancel or confirm) is never treated as an edit command.
65
71
  */
66
72
  #composition = new CompositionTracker();
73
+ /**
74
+ * Watches focus leaving the editor from wherever it currently sits.
75
+ *
76
+ * `focusout` bubbles where `blur` does not, so one listener on the root sees
77
+ * every departure — including one from a Save or Cancel button the consumer
78
+ * placed beside the input. Binding the input alone would make the promise
79
+ * "saves wherever focus moved" true only for focus that leaves the input
80
+ * itself, and tabbing straight past an inner button would strand the editor
81
+ * open.
82
+ */
83
+ #onFocusOut = (event) => {
84
+ const from = event.target;
85
+ if (this.hasDisplayTarget && from instanceof Node && this.displayTarget.contains(from)) return;
86
+ const next = event.relatedTarget;
87
+ if (next instanceof Node && this.element.contains(next)) return;
88
+ if (this.submitOnBlurValue) this.#commit(false);
89
+ };
67
90
  /** Establishes the initial display mode (display shown, input hidden). */
68
91
  connect() {
69
92
  if (this.hasInputTarget) this.#composition.observe(this.inputTarget);
93
+ this.element.addEventListener("focusout", this.#onFocusOut);
94
+ this.#revertValue = null;
70
95
  this.#setMode("display");
71
96
  }
72
- /** Releases the composition listeners so nothing outlives the element. */
97
+ /** Releases the composition and focus listeners so nothing outlives the element. */
73
98
  disconnect() {
74
99
  this.#composition.disconnect();
100
+ this.element.removeEventListener("focusout", this.#onFocusOut);
75
101
  }
76
102
  /** Tracks an input added initially or after connect (e.g. a Turbo swap). */
77
103
  inputTargetConnected(input) {
78
104
  this.#composition.observe(input);
105
+ this.#applyMode();
79
106
  }
80
107
  /** Removes composition listeners when the active input is replaced or removed. */
81
108
  inputTargetDisconnected(input) {
82
109
  this.#composition.unobserve(input);
83
110
  }
84
- /** Enters edit mode: seeds the input from the display text, focuses, selects. */
111
+ /** Re-hides or re-shows a display element that arrived after the mode was set. */
112
+ displayTargetConnected() {
113
+ this.#applyMode();
114
+ }
115
+ /** Enters edit mode: seeds the input from the declared value, focuses, selects. */
85
116
  edit() {
86
117
  if (this.#isEditing || !this.hasInputTarget || !this.hasDisplayTarget) return;
87
118
  this.#previousValue = this.#currentValue;
@@ -90,6 +121,27 @@ var EditableController = class extends Controller {
90
121
  this.inputTarget.focus();
91
122
  this.inputTarget.select();
92
123
  }
124
+ /** Commits the edit and returns focus to the display element. */
125
+ save() {
126
+ this.#commit(true);
127
+ }
128
+ /** Discards edits, returns to display mode, and dispatches `cancel`. */
129
+ cancel() {
130
+ if (!this.#isEditing) return;
131
+ this.#setMode("display");
132
+ if (this.hasDisplayTarget) this.displayTarget.focus();
133
+ this.dispatch("cancel", { detail: {} });
134
+ }
135
+ /**
136
+ * Puts back the value the last save replaced — for a consumer whose server
137
+ * rejected it. Silent by design: a `change` here would re-enter the same
138
+ * handler that asked for the undo. One save, one undo.
139
+ */
140
+ revert() {
141
+ if (this.#revertValue === null || this.#isEditing) return;
142
+ this.#writeValue(this.#revertValue);
143
+ this.#revertValue = null;
144
+ }
93
145
  /** Adds `F2` as an editing entry point alongside the button's native activation. */
94
146
  onDisplayKeydown(event) {
95
147
  if (event.key === "F2") {
@@ -103,56 +155,57 @@ var EditableController = class extends Controller {
103
155
  if (event.key === "Escape") {
104
156
  if (event.defaultPrevented) return;
105
157
  event.preventDefault();
106
- this.#cancel();
158
+ this.cancel();
107
159
  return;
108
160
  }
109
161
  if (event.key === "Enter") {
110
162
  if (event.defaultPrevented) return;
111
163
  if (this.#isMultiline && !(event.ctrlKey || event.metaKey)) return;
112
164
  event.preventDefault();
113
- this.#save(true);
165
+ this.#commit(true);
114
166
  }
115
167
  }
116
- /** Saves on blur when `submitOnBlur` is set; otherwise keeps editing. */
117
- onBlur() {
118
- if (!this.#isEditing) return;
119
- if (this.submitOnBlurValue) this.#save(false);
120
- }
121
168
  /**
122
- * Returns to display mode, reflecting the input into the display text and
123
- * dispatching `change` when the value differs from where editing began.
169
+ * Returns to display mode, storing the input's value and dispatching `change`
170
+ * when it differs from where editing began.
124
171
  *
125
172
  * @param restoreFocus - Move focus back to the display element (explicit
126
- * keyboard commit) rather than honoring the user's new focus target (blur).
173
+ * commit) rather than honoring the user's new focus target (blur).
127
174
  */
128
- #save(restoreFocus) {
129
- if (!this.#isEditing) return;
130
- const value = this.inputTarget.value;
175
+ #commit(restoreFocus) {
176
+ if (!this.#isEditing || !this.hasInputTarget) return;
177
+ const value = this.inputTarget.value.trim();
131
178
  const previous = this.#previousValue;
132
- this.displayTarget.textContent = value;
179
+ this.#writeValue(value);
133
180
  this.#setMode("display");
134
- if (restoreFocus) this.displayTarget.focus();
181
+ if (restoreFocus && this.hasDisplayTarget) this.displayTarget.focus();
135
182
  if (value !== previous) {
183
+ this.#revertValue = previous;
136
184
  this.dispatch("change", { detail: { value, previous } });
137
185
  }
138
186
  }
139
- /** Discards edits, returns to display mode, and dispatches `cancel`. */
140
- #cancel() {
141
- if (!this.#isEditing) return;
142
- this.#setMode("display");
143
- this.displayTarget.focus();
144
- this.dispatch("cancel", { detail: {} });
187
+ /** Stores `value` wherever the display element declares it. */
188
+ #writeValue(value) {
189
+ if (!this.hasDisplayTarget) return;
190
+ const display = this.displayTarget;
191
+ if (display.hasAttribute("data-value")) display.dataset.value = value;
192
+ else display.textContent = value;
145
193
  }
146
- /** Toggles the `data-mode` flag and the `hidden` state of both elements. */
147
- #setMode(mode) {
148
- this.element.dataset.mode = mode;
149
- const editing = mode === "editing";
194
+ /** Derives both elements' visibility from the mode currently in the DOM. */
195
+ #applyMode() {
196
+ const editing = this.#isEditing;
150
197
  if (this.hasDisplayTarget) this.displayTarget.hidden = editing;
151
198
  if (this.hasInputTarget) this.inputTarget.hidden = !editing;
152
199
  }
153
- /** Current display text, trimmed — the value shown when not editing. */
200
+ /** Records the mode, then brings both elements in line with it. */
201
+ #setMode(mode) {
202
+ this.element.dataset.mode = mode;
203
+ this.#applyMode();
204
+ }
205
+ /** The value the display element declares, trimmed. */
154
206
  get #currentValue() {
155
- return (this.displayTarget.textContent ?? "").trim();
207
+ const display = this.displayTarget;
208
+ return (display.dataset.value ?? display.textContent ?? "").trim();
156
209
  }
157
210
  /** Whether the editing control is a multi-line `<textarea>`. */
158
211
  get #isMultiline() {
@@ -9,15 +9,46 @@ var FilterController = class extends Controller {
9
9
  #onChange = () => {
10
10
  this.apply();
11
11
  };
12
+ /**
13
+ * Gates the declaration callback to the connected window.
14
+ *
15
+ * Stimulus delivers a Value callback ahead of `connect()` and again for every
16
+ * runtime change; without the gate, merely connecting would emit an evaluation
17
+ * — and its `change` — before `connect()` runs its own.
18
+ */
19
+ #connected = false;
12
20
  connect() {
13
- this.apply();
21
+ this.#evaluate();
14
22
  this.element.addEventListener("change", this.#onChange);
23
+ this.#connected = true;
15
24
  }
16
25
  disconnect() {
26
+ this.#connected = false;
17
27
  this.element.removeEventListener("change", this.#onChange);
18
28
  }
29
+ /**
30
+ * Re-evaluates when the match declaration changes at runtime.
31
+ *
32
+ * The declaration decides which items are shown, so an element kept across a
33
+ * morph — where `connect()` does not run again — still has to follow it instead
34
+ * of waiting for the next control interaction. The evaluation is the ordinary
35
+ * one, `change` included: a declaration swap is an evaluation like any other.
36
+ */
37
+ matchValueChanged() {
38
+ if (!this.#connected) return;
39
+ this.#evaluate();
40
+ }
19
41
  /** Re-derives every item's visibility from the active tokens and syncs groups/empty. */
20
42
  apply() {
43
+ this.#evaluate();
44
+ }
45
+ /**
46
+ * The evaluation itself: every item's visibility from the active tokens, then
47
+ * the groups and the empty element, then the `change` event.
48
+ *
49
+ * @stimeoRenderRoot
50
+ */
51
+ #evaluate() {
21
52
  const active = this.#activeTokens();
22
53
  let visibleCount = 0;
23
54
  for (const item of this.itemTargets) {
@@ -58,16 +58,72 @@ var LayoutObserver = class {
58
58
  }
59
59
  };
60
60
 
61
+ // src/utils/microtask_coalescer.ts
62
+ var MicrotaskCoalescer = class {
63
+ #run;
64
+ #queued = false;
65
+ #active = false;
66
+ #generation = 0;
67
+ /** @param run - the single reconciliation pass, invoked at most once per batch. */
68
+ constructor(run) {
69
+ this.#run = run;
70
+ }
71
+ /** Opens the window in which {@link schedule} is honoured; call from `connect()`. */
72
+ activate() {
73
+ this.#active = true;
74
+ }
75
+ /** Closes the window and drops any pending pass; call from `disconnect()`. */
76
+ cancel() {
77
+ this.#active = false;
78
+ this.#queued = false;
79
+ this.#generation += 1;
80
+ }
81
+ /** Requests one pass after the batch settles. Idempotent; inert outside the window. */
82
+ schedule() {
83
+ if (!this.#active || this.#queued) return;
84
+ this.#queued = true;
85
+ const generation = this.#generation;
86
+ queueMicrotask(() => {
87
+ if (generation !== this.#generation || !this.#queued || !this.#active) return;
88
+ this.#queued = false;
89
+ this.#run();
90
+ });
91
+ }
92
+ };
93
+
61
94
  // src/controllers/masonry_controller.ts
62
95
  var COLUMNS_PROPERTY = "--stimeo--masonry-columns";
96
+ var DEFAULT_MIN_COLUMN_WIDTH = 240;
97
+ var DEFAULT_GAP = 16;
98
+ function usableNumber(value, fallback) {
99
+ return Number.isFinite(value) ? value : fallback;
100
+ }
63
101
  var MasonryController = class extends Controller {
64
102
  static targets = ["item"];
65
103
  static values = {
66
- minColumnWidth: { type: Number, default: 240 },
67
- gap: { type: Number, default: 16 }
104
+ minColumnWidth: { type: Number, default: DEFAULT_MIN_COLUMN_WIDTH },
105
+ gap: { type: Number, default: DEFAULT_GAP }
68
106
  };
69
107
  static events = ["layout"];
70
- #layout = new LayoutObserver(() => this.#relayout());
108
+ /**
109
+ * The declared numbers after validation, so the layout path never sees a value
110
+ * it cannot compute with. Both are resolved once per declaration change rather
111
+ * than on every pass.
112
+ */
113
+ #minColumnWidth = DEFAULT_MIN_COLUMN_WIDTH;
114
+ #gap = DEFAULT_GAP;
115
+ /**
116
+ * Collapses every re-layout trigger of one DOM mutation into a single pass, and
117
+ * refuses to run before `connect()` or after `disconnect()`.
118
+ *
119
+ * The triggers arrive in bursts — a resize stream, a morph that syncs several
120
+ * attributes, a batch of rows — and each pass measures every item, so folding
121
+ * them keeps the work proportional to the batch rather than to the events in it.
122
+ */
123
+ #reconcile = new MicrotaskCoalescer(() => this.#relayout());
124
+ /** Items that left the target set and still carry the column hook. */
125
+ #released = /* @__PURE__ */ new Set();
126
+ #layout = new LayoutObserver(() => this.#reconcile.schedule());
71
127
  #mutationObserver = null;
72
128
  /** Last published column count, so `layout` fires only on real changes. */
73
129
  #lastColumns = 0;
@@ -77,20 +133,49 @@ var MasonryController = class extends Controller {
77
133
  * first pass ran before they settled; `load` does not bubble, so this is bound in
78
134
  * the capture phase to catch every descendant.
79
135
  */
80
- #onLoad = () => this.#relayout();
136
+ #onLoad = () => this.#reconcile.schedule();
137
+ /** Resolves the declared column width once, falling back when it is unreadable. */
138
+ minColumnWidthValueChanged() {
139
+ this.#minColumnWidth = usableNumber(this.minColumnWidthValue, DEFAULT_MIN_COLUMN_WIDTH);
140
+ this.#reconcile.schedule();
141
+ }
142
+ /** Resolves the declared gap once, falling back when it is unreadable. */
143
+ gapValueChanged() {
144
+ this.#gap = usableNumber(this.gapValue, DEFAULT_GAP);
145
+ this.#reconcile.schedule();
146
+ }
147
+ /** Packs an element that became an item without moving in the DOM. */
148
+ itemTargetConnected() {
149
+ this.#reconcile.schedule();
150
+ }
151
+ /**
152
+ * Queues the column hook of an element that stopped being an item for removal.
153
+ *
154
+ * The removal is queued rather than immediate because teardown reports every
155
+ * target as disconnected: doing it here would strip the whole grid just before
156
+ * a Turbo snapshot is taken. {@link MicrotaskCoalescer.cancel} drops the queue
157
+ * with the pass, so only a genuine target change reaches it.
158
+ */
159
+ itemTargetDisconnected(item) {
160
+ this.#released.add(item);
161
+ this.#reconcile.schedule();
162
+ }
81
163
  /** Observes size/content changes and performs the first layout pass. */
82
164
  connect() {
83
165
  this.#layout.observe(this.element);
84
166
  this.#layout.observeViewport();
85
167
  if (typeof MutationObserver !== "undefined") {
86
- this.#mutationObserver = new MutationObserver(() => this.#relayout());
168
+ this.#mutationObserver = new MutationObserver(() => this.#reconcile.schedule());
87
169
  this.#mutationObserver.observe(this.element, { childList: true, subtree: true });
88
170
  }
89
171
  this.element.addEventListener("load", this.#onLoad, true);
90
172
  this.#relayout();
173
+ this.#reconcile.activate();
91
174
  }
92
175
  /** Releases both observers and the load listener so nothing fires after detach. */
93
176
  disconnect() {
177
+ this.#reconcile.cancel();
178
+ this.#released.clear();
94
179
  this.#layout.disconnect();
95
180
  this.#mutationObserver?.disconnect();
96
181
  this.#mutationObserver = null;
@@ -99,26 +184,53 @@ var MasonryController = class extends Controller {
99
184
  }
100
185
  /**
101
186
  * Recomputes the column count and assigns every item to the shortest column.
102
- * Runs automatically on connect, on resize, on item add/remove, and when a
103
- * descendant resource loads (private — there is no public action; the observers
104
- * and the capture-phase `load` listener drive it). Items are walked in DOM
105
- * order; each lands in the column with the least accumulated height, which
106
- * keeps the packing balanced without reordering the DOM.
187
+ * Runs automatically on connect, on resize, on item add/remove, when a declared
188
+ * number changes, and when a descendant resource loads (private — there is no
189
+ * public action; the observers, the target callbacks and the capture-phase
190
+ * `load` listener drive it). Items are walked in DOM order; each lands in the
191
+ * column with the least accumulated height, which keeps the packing balanced
192
+ * without reordering the DOM.
193
+ *
194
+ * Every box is measured before anything is written. Interleaving the two would
195
+ * make a consumer's `data-column` rule invalidate style once per item, and the
196
+ * next measurement then has to settle layout again — once per item instead of
197
+ * once per pass. The assignment is independent of the measurement because the
198
+ * columns are uniform in width, so the order of the two passes does not change
199
+ * the result.
200
+ *
201
+ * @stimeoRenderRoot
107
202
  */
108
203
  #relayout() {
109
204
  const items = this.itemTargets;
110
205
  const columns = this.#columnCount();
206
+ const boxes = items.map((item) => item.getBoundingClientRect().height);
207
+ let changed = false;
208
+ if (this.#released.size > 0) {
209
+ const owned = new Set(items);
210
+ for (const released of this.#released) {
211
+ if (owned.has(released)) continue;
212
+ if (released.hasAttribute("data-column")) {
213
+ released.removeAttribute("data-column");
214
+ changed = true;
215
+ }
216
+ }
217
+ this.#released.clear();
218
+ }
111
219
  const heights = new Array(columns).fill(0);
112
- for (const item of items) {
220
+ items.forEach((item, index) => {
113
221
  let shortest = 0;
114
222
  for (let col = 1; col < columns; col++) {
115
223
  if ((heights[col] ?? 0) < (heights[shortest] ?? 0)) shortest = col;
116
224
  }
117
- item.setAttribute("data-column", String(shortest));
118
- heights[shortest] = (heights[shortest] ?? 0) + item.getBoundingClientRect().height + this.gapValue;
119
- }
225
+ const assigned = String(shortest);
226
+ if (item.getAttribute("data-column") !== assigned) {
227
+ item.setAttribute("data-column", assigned);
228
+ changed = true;
229
+ }
230
+ heights[shortest] = (heights[shortest] ?? 0) + (boxes[index] ?? 0) + this.#gap;
231
+ });
120
232
  this.element.style.setProperty(COLUMNS_PROPERTY, String(columns));
121
- if (columns !== this.#lastColumns) {
233
+ if (columns !== this.#lastColumns || changed) {
122
234
  this.#lastColumns = columns;
123
235
  this.dispatch("layout", { detail: { columns } });
124
236
  }
@@ -131,9 +243,9 @@ var MasonryController = class extends Controller {
131
243
  */
132
244
  #columnCount() {
133
245
  const width = this.element.getBoundingClientRect().width;
134
- const denominator = this.minColumnWidthValue + this.gapValue;
246
+ const denominator = this.#minColumnWidth + this.#gap;
135
247
  if (width <= 0 || denominator <= 0) return 1;
136
- return Math.max(1, Math.floor((width + this.gapValue) / denominator));
248
+ return Math.max(1, Math.floor((width + this.#gap) / denominator));
137
249
  }
138
250
  };
139
251
 
@@ -211,6 +211,9 @@ function compilePattern(source) {
211
211
  function hasModifier(event) {
212
212
  return event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
213
213
  }
214
+ function statesDiffer(left, right) {
215
+ return left.value !== right.value || left.state !== right.state;
216
+ }
214
217
  var OtpController = class extends Controller {
215
218
  static targets = ["field", "value", "error"];
216
219
  static values = {
@@ -222,8 +225,8 @@ var OtpController = class extends Controller {
222
225
  #pattern = new RegExp(`^${DEFAULT_PATTERN}$`);
223
226
  /** Source of {@link #pattern}, reported in `invalid` so consumers can word it. */
224
227
  #patternSource = DEFAULT_PATTERN;
225
- /** Combined value carried by the last dispatch; keeps a no-op sync silent. */
226
- #lastValue = null;
228
+ /** Public state carried by the last dispatch; keeps a no-op sync silent. */
229
+ #published = null;
227
230
  /** Field whose confirming `input` after `compositionend` is already handled. */
228
231
  #confirmedField = null;
229
232
  /** True between connect and disconnect, so pre-connect Value changes stay silent. */
@@ -259,7 +262,7 @@ var OtpController = class extends Controller {
259
262
  this.#beforeCache.activate();
260
263
  this.#reconcile.activate();
261
264
  this.#adopt();
262
- this.#lastValue = this.#sync();
265
+ this.#sync();
263
266
  }
264
267
  disconnect() {
265
268
  this.#connected = false;
@@ -451,19 +454,23 @@ var OtpController = class extends Controller {
451
454
  this.#markFilled(field, field.value);
452
455
  }
453
456
  /**
454
- * Absorbs a batch of field additions or removals as one value transition.
457
+ * Absorbs a batch of field additions or removals as one state transition.
455
458
  *
456
- * The page, not the user, moved the value here, so it is reported as
459
+ * The page, not the user, moved the state here, so it is reported as
457
460
  * `reconcile`: automation listening for `change` must not read a re-render as
458
461
  * an edit, and a passcode that happens to end up full must not fire the
459
462
  * `complete` that submits it.
463
+ *
464
+ * Completeness moves on its own when the field count changes: dropping a
465
+ * trailing empty field completes a passcode whose combined value never moved,
466
+ * and adding one un-completes it. Comparing the whole derived state, not the
467
+ * string it contains, is what makes those transitions reportable.
460
468
  */
461
469
  #reconcileFields() {
462
- const previous = this.#lastValue;
463
- const combined = this.#sync();
464
- if (combined === previous) return;
465
- this.#lastValue = combined;
466
- this.dispatch("reconcile", { detail: { value: combined } });
470
+ const previous = this.#published;
471
+ const current = this.#sync();
472
+ if (previous && !statesDiffer(previous, current)) return;
473
+ this.dispatch("reconcile", { detail: { value: current.value } });
467
474
  }
468
475
  /**
469
476
  * Validates the text an entry point received and distributes what it accepts.
@@ -558,23 +565,29 @@ var OtpController = class extends Controller {
558
565
  const fields = this.fieldTargets;
559
566
  return fields.length > 0 && fields.every((field) => field.value.length > 0);
560
567
  }
561
- /** Mirrors the combined value into the form and the root's readable state. */
568
+ /**
569
+ * Mirrors the combined value into the form and the root's readable state, and
570
+ * records what was published so the next pass can compare against it.
571
+ */
562
572
  #sync() {
563
573
  const combined = this.#combinedValue();
564
574
  if (this.hasValueTarget) {
565
575
  this.valueTarget.value = combined;
566
576
  }
567
- this.#state.write(this.element, this.#stateName(combined));
568
- return combined;
577
+ const state = this.#stateName(combined);
578
+ this.#state.write(this.element, state);
579
+ const published = { value: combined, state };
580
+ this.#published = published;
581
+ return published;
569
582
  }
570
583
  #stateName(combined) {
571
584
  if (combined.length === 0) return "empty";
572
585
  return this.#isComplete() ? "complete" : "partial";
573
586
  }
574
587
  #syncAndDispatch() {
575
- const combined = this.#sync();
576
- if (combined === this.#lastValue) return;
577
- this.#lastValue = combined;
588
+ const previous = this.#published;
589
+ const { value: combined } = this.#sync();
590
+ if (previous?.value === combined) return;
578
591
  this.dispatch("change", { detail: { value: combined } });
579
592
  if (this.#isComplete()) {
580
593
  this.dispatch("complete", { detail: { value: combined } });
@@ -1,6 +1,26 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
3
  // src/controllers/reset_before_cache_controller.ts
4
+ var STATELESS_INPUT_TYPES = /* @__PURE__ */ new Set(["hidden", "submit", "reset", "button", "image"]);
5
+ function restoreField(element) {
6
+ if (element instanceof HTMLTextAreaElement) {
7
+ element.value = element.defaultValue;
8
+ return;
9
+ }
10
+ if (element instanceof HTMLSelectElement) {
11
+ for (const option of element.options) option.selected = option.hasAttribute("selected");
12
+ return;
13
+ }
14
+ if (element instanceof HTMLInputElement) {
15
+ if (element.type === "checkbox" || element.type === "radio") {
16
+ element.checked = element.defaultChecked;
17
+ } else if (element.type === "file") {
18
+ element.value = "";
19
+ } else if (!STATELESS_INPUT_TYPES.has(element.type)) {
20
+ element.value = element.defaultValue;
21
+ }
22
+ }
23
+ }
4
24
  var ResetBeforeCacheController = class extends Controller {
5
25
  static values = {
6
26
  scope: { type: String, default: "" },
@@ -8,8 +28,32 @@ var ResetBeforeCacheController = class extends Controller {
8
28
  };
9
29
  static actions = ["reset"];
10
30
  static events = ["reset", "request"];
31
+ /** The `scope` declaration after validation; empty when it cannot be parsed. */
32
+ #scopeSelector = "";
11
33
  /** Runs the reset just before Turbo caches the snapshot. */
12
34
  #onBeforeCache = () => this.reset();
35
+ /**
36
+ * Validates the scope declaration once, keeping only a selector the engine can
37
+ * read.
38
+ *
39
+ * A selector reads back as an ordinary string, so a malformed one survives
40
+ * until it is handed to the DOM — and this part runs from a single listener
41
+ * whose whole job is to keep a cached page from freezing. Falling back to the
42
+ * default keeps that job running with a visible, findable result instead of
43
+ * silently taking the sweep down.
44
+ */
45
+ scopeValueChanged() {
46
+ const selector = this.scopeValue;
47
+ if (selector.length > 0) {
48
+ try {
49
+ this.element.matches(selector);
50
+ this.#scopeSelector = selector;
51
+ return;
52
+ } catch {
53
+ }
54
+ }
55
+ this.#scopeSelector = "";
56
+ }
13
57
  connect() {
14
58
  document.addEventListener("turbo:before-cache", this.#onBeforeCache);
15
59
  }
@@ -20,6 +64,10 @@ var ResetBeforeCacheController = class extends Controller {
20
64
  * Resets transient UI within scope to its initial state. Asks controllers to
21
65
  * close (via `request`) first, then applies the declarative `data-reset-*` cleanup,
22
66
  * and finally emits `reset`. Safe to call any number of times (idempotent).
67
+ *
68
+ * `dispatchReset` decides only whether the `request` ask goes out; the cleanup
69
+ * and the closing `reset` run either way. Both the listener and this action
70
+ * reach the same sweep, so `reset` is emitted for a manual call too.
23
71
  */
24
72
  reset() {
25
73
  const root = this.#scopeRoot();
@@ -38,9 +86,7 @@ var ResetBeforeCacheController = class extends Controller {
38
86
  if (element instanceof HTMLFormElement) element.reset();
39
87
  }
40
88
  for (const element of root.querySelectorAll("[data-reset-value]")) {
41
- if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement) {
42
- element.value = "";
43
- }
89
+ restoreField(element);
44
90
  }
45
91
  for (const element of root.querySelectorAll("[data-reset-hidden]")) {
46
92
  element.hidden = true;
@@ -52,8 +98,8 @@ var ResetBeforeCacheController = class extends Controller {
52
98
  }
53
99
  /** The scan root: a `scope` descendant when set, else the controller element. */
54
100
  #scopeRoot() {
55
- if (!this.scopeValue) return this.element;
56
- return this.element.querySelector(this.scopeValue) ?? this.element;
101
+ if (!this.#scopeSelector) return this.element;
102
+ return this.element.querySelector(this.#scopeSelector) ?? this.element;
57
103
  }
58
104
  };
59
105