stimeo-ui 0.15.0 → 0.17.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 (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +209 -0
  3. data/README.md +128 -1
  4. data/dist/cable/index.js +392 -417
  5. data/dist/controllers/accordion_controller.js +228 -28
  6. data/dist/controllers/alert_dialog_controller.js +862 -221
  7. data/dist/controllers/announcer_controller.js +135 -191
  8. data/dist/controllers/aspect_ratio_controller.js +0 -10
  9. data/dist/controllers/auto_submit_controller.js +295 -110
  10. data/dist/controllers/avatar_controller.js +172 -106
  11. data/dist/controllers/breadcrumb_controller.js +0 -129
  12. data/dist/controllers/bulk_select_controller.js +264 -53
  13. data/dist/controllers/calendar_controller.js +346 -180
  14. data/dist/controllers/carousel_controller.js +587 -300
  15. data/dist/controllers/character_counter_controller.js +271 -143
  16. data/dist/controllers/checkbox_controller.js +77 -75
  17. data/dist/controllers/clipboard_controller.js +187 -108
  18. data/dist/controllers/collapsible_controller.js +374 -108
  19. data/dist/controllers/color_picker_controller.js +183 -127
  20. data/dist/controllers/combobox_controller.js +426 -109
  21. data/dist/controllers/command_palette_controller.js +994 -350
  22. data/dist/controllers/conditional_fields_controller.js +212 -125
  23. data/dist/controllers/confirm_controller.js +939 -245
  24. data/dist/controllers/context_menu_controller.js +275 -127
  25. data/dist/controllers/count_up_controller.js +73 -21
  26. data/dist/controllers/countdown_controller.js +235 -97
  27. data/dist/controllers/currency_input_controller.js +241 -118
  28. data/dist/controllers/data_grid_controller.js +229 -135
  29. data/dist/controllers/date_range_picker_controller.js +359 -165
  30. data/dist/controllers/dialog_controller.js +887 -225
  31. data/dist/controllers/direct_upload_controller.js +80 -142
  32. data/dist/controllers/dirty_form_controller.js +26 -47
  33. data/dist/controllers/dismissible_controller.js +175 -20
  34. data/dist/controllers/drawer_controller.js +963 -336
  35. data/dist/controllers/dropdown_controller.js +342 -85
  36. data/dist/controllers/editable_controller.js +182 -58
  37. data/dist/controllers/empty_state_controller.js +67 -53
  38. data/dist/controllers/file_dropzone_controller.js +426 -229
  39. data/dist/controllers/filter_controller.js +78 -36
  40. data/dist/controllers/flash_controller.js +328 -211
  41. data/dist/controllers/focus_controller.js +654 -239
  42. data/dist/controllers/form_field_controller.js +153 -136
  43. data/dist/controllers/form_validation_controller.js +29 -96
  44. data/dist/controllers/frame_loading_controller.js +263 -198
  45. data/dist/controllers/highlight_controller.js +119 -70
  46. data/dist/controllers/hover_card_controller.js +335 -118
  47. data/dist/controllers/idle_controller.js +316 -65
  48. data/dist/controllers/input_mask_controller.js +116 -61
  49. data/dist/controllers/intersection_controller.js +147 -103
  50. data/dist/controllers/lazy_frame_controller.js +9 -56
  51. data/dist/controllers/listbox_controller.js +413 -153
  52. data/dist/controllers/local_time_controller.js +54 -64
  53. data/dist/controllers/masonry_controller.js +70 -95
  54. data/dist/controllers/menu_controller.js +254 -167
  55. data/dist/controllers/menubar_controller.js +206 -331
  56. data/dist/controllers/meter_controller.js +159 -71
  57. data/dist/controllers/multi_select_controller.js +797 -388
  58. data/dist/controllers/navigation_menu_controller.js +156 -231
  59. data/dist/controllers/nested_form_controller.js +373 -174
  60. data/dist/controllers/network_status_controller.js +241 -45
  61. data/dist/controllers/number_input_controller.js +387 -227
  62. data/dist/controllers/optimistic_controller.js +28 -75
  63. data/dist/controllers/otp_controller.js +344 -196
  64. data/dist/controllers/overflow_indicator_controller.js +167 -93
  65. data/dist/controllers/overflow_menu_controller.js +408 -277
  66. data/dist/controllers/pagination_controller.js +342 -120
  67. data/dist/controllers/password_reveal_controller.js +424 -99
  68. data/dist/controllers/password_strength_controller.js +99 -153
  69. data/dist/controllers/persist_controller.js +252 -96
  70. data/dist/controllers/pointer_drag_controller.js +263 -164
  71. data/dist/controllers/popover_controller.js +267 -94
  72. data/dist/controllers/portal_controller.js +209 -72
  73. data/dist/controllers/preview_guard_controller.js +243 -112
  74. data/dist/controllers/progress_controller.js +132 -59
  75. data/dist/controllers/radio_group_controller.js +138 -133
  76. data/dist/controllers/range_slider_controller.js +245 -112
  77. data/dist/controllers/rating_controller.js +429 -194
  78. data/dist/controllers/read_more_controller.js +185 -47
  79. data/dist/controllers/reading_progress_controller.js +147 -135
  80. data/dist/controllers/relative_time_controller.js +145 -110
  81. data/dist/controllers/{reset_before_cache_controller.js → reset_on_restore_controller.js} +97 -38
  82. data/dist/controllers/resizable_controller.js +294 -118
  83. data/dist/controllers/roving_controller.js +39 -84
  84. data/dist/controllers/scroll_area_controller.js +428 -221
  85. data/dist/controllers/scroll_restore_controller.js +0 -53
  86. data/dist/controllers/scroll_visibility_controller.js +395 -146
  87. data/dist/controllers/scrollspy_controller.js +181 -236
  88. data/dist/controllers/separator_controller.js +288 -132
  89. data/dist/controllers/sidebar_controller.js +1071 -375
  90. data/dist/controllers/skeleton_controller.js +248 -92
  91. data/dist/controllers/slider_controller.js +211 -96
  92. data/dist/controllers/smart_sticky_header_controller.js +163 -54
  93. data/dist/controllers/sortable_controller.js +13 -110
  94. data/dist/controllers/spinner_controller.js +277 -161
  95. data/dist/controllers/step_indicator_controller.js +107 -53
  96. data/dist/controllers/stepper_controller.js +171 -48
  97. data/dist/controllers/stick_to_bottom_controller.js +310 -116
  98. data/dist/controllers/sticky_observer_controller.js +214 -49
  99. data/dist/controllers/submit_once_controller.js +294 -192
  100. data/dist/controllers/switch_controller.js +107 -43
  101. data/dist/controllers/tabs_controller.js +50 -25
  102. data/dist/controllers/tags_input_controller.js +290 -149
  103. data/dist/controllers/textarea_autosize_controller.js +71 -58
  104. data/dist/controllers/theme_controller.js +148 -80
  105. data/dist/controllers/time_picker_controller.js +162 -81
  106. data/dist/controllers/toast_controller.js +365 -193
  107. data/dist/controllers/toggle_group_controller.js +194 -99
  108. data/dist/controllers/toolbar_controller.js +92 -90
  109. data/dist/controllers/tooltip_controller.js +185 -125
  110. data/dist/controllers/transition_controller.js +123 -115
  111. data/dist/controllers/tree_view_controller.js +248 -290
  112. data/dist/index.js +8649 -8572
  113. data/dist/positioning/index.js +148 -45
  114. data/lib/stimeo/ui/version.rb +1 -1
  115. metadata +3 -3
@@ -1,8 +1,5 @@
1
1
  import { Controller } from '@hotwired/stimulus';
2
2
 
3
- // src/controllers/auto_submit_controller.ts
4
-
5
- // src/utils/announce.ts
6
3
  function announce(message, options = {}) {
7
4
  const text = message.trim();
8
5
  if (text.length === 0) return;
@@ -13,143 +10,331 @@ function announce(message, options = {}) {
13
10
  );
14
11
  }
15
12
 
16
- // src/utils/before_cache_reset.ts
17
- var BeforeCacheReset = class _BeforeCacheReset {
18
- /** Every subscribed instance, iterated by the one shared document listener. */
19
- static #subscribers = /* @__PURE__ */ new Set();
20
- /** The shared listener; installed while at least one instance is subscribed. */
21
- static #onBeforeCache = () => {
22
- for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
23
- };
24
- #rewind;
25
- /** @param rewind - the pass that returns this controller's state to its initial form. */
26
- constructor(rewind) {
27
- this.#rewind = rewind;
28
- }
29
- /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
30
- activate() {
31
- const first = _BeforeCacheReset.#subscribers.size === 0;
32
- _BeforeCacheReset.#subscribers.add(this);
33
- if (first) {
34
- document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
13
+ function sharedRegistry(key, create) {
14
+ const symbol = Symbol.for(key);
15
+ const scope = globalThis;
16
+ const existing = scope[symbol];
17
+ if (existing !== void 0) return existing;
18
+ const registry = create();
19
+ Object.defineProperty(globalThis, symbol, { value: registry });
20
+ return registry;
21
+ }
22
+
23
+ function isAttributeValue(value) {
24
+ return value === null || typeof value === "string";
25
+ }
26
+ function readLeaseRecord(element, record) {
27
+ try {
28
+ const parsed = JSON.parse(element.getAttribute(record) ?? "null");
29
+ if (!Array.isArray(parsed)) return void 0;
30
+ const [original, written, ...rest] = parsed;
31
+ const linked = rest[0] !== false;
32
+ const beneath = linked ? rest : rest.slice(1);
33
+ if (!beneath.every((under) => typeof under === "string")) return void 0;
34
+ return isAttributeValue(original) && isAttributeValue(written) ? { original, written, linked, beneath } : void 0;
35
+ } catch {
36
+ return void 0;
37
+ }
38
+ }
39
+ var AttributeLease = class _AttributeLease {
40
+ static #registry = sharedRegistry(
41
+ "stimeo-ui.attribute-lease.registry.v1",
42
+ () => ({ stacks: /* @__PURE__ */ new WeakMap(), names: /* @__PURE__ */ new Map() })
43
+ );
44
+ #attribute;
45
+ #record;
46
+ #base;
47
+ #held = /* @__PURE__ */ new Set();
48
+ constructor(attribute, owner, options = {}) {
49
+ this.#attribute = attribute;
50
+ this.#record = `data-${owner}-${attribute}-lease`;
51
+ this.#base = options.base === true;
52
+ const names = _AttributeLease.#registry.names;
53
+ names.set(attribute, (names.get(attribute) ?? /* @__PURE__ */ new Set()).add(this.#record));
54
+ }
55
+ write(element, value) {
56
+ const stack = this.#stack(element);
57
+ const hold = this.#take(stack) ?? this.#acquire(element, stack);
58
+ this.#held.add(element);
59
+ const shows = !this.#covered(element, stack, hold);
60
+ hold.written = value;
61
+ this.#mark(element, stack);
62
+ if (shows) this.#reflect(element, value);
63
+ }
64
+ return(element) {
65
+ const held = this.#held.delete(element);
66
+ const known = _AttributeLease.#registry.stacks.get(element)?.has(this.#attribute);
67
+ const stack = held || known || element.hasAttribute(this.#record) ? this.#stack(element) : [];
68
+ const hold = this.#take(stack);
69
+ if (!hold) return false;
70
+ const carries = element.getAttribute(this.#attribute) === hold.written;
71
+ const owns = carries && !this.#covered(element, stack, hold);
72
+ const index = stack.indexOf(hold);
73
+ stack.splice(index, 1);
74
+ const above = stack[index];
75
+ if (above) above.linked &&= hold.linked;
76
+ this.#mark(element, stack);
77
+ if (!owns) return true;
78
+ const below = hold.linked ? stack[index - 1] : void 0;
79
+ this.#reflect(element, below ? below.written : hold.original);
80
+ return true;
81
+ }
82
+ returnAll() {
83
+ for (const element of Array.from(this.#held)) this.return(element);
84
+ }
85
+ #stack(element) {
86
+ const stacks = _AttributeLease.#registry.stacks;
87
+ const byAttribute = stacks.get(element) ?? /* @__PURE__ */ new Map();
88
+ stacks.set(element, byAttribute);
89
+ const stack = byAttribute.get(this.#attribute) ?? this.#seed(element);
90
+ byAttribute.set(this.#attribute, stack);
91
+ return stack;
92
+ }
93
+ #seed(element) {
94
+ const found = [];
95
+ for (const record of _AttributeLease.#registry.names.get(this.#attribute) ?? []) {
96
+ const read = readLeaseRecord(element, record);
97
+ if (read) found.push({ ...read, record });
35
98
  }
99
+ const depth = ({ beneath }) => beneath.filter((name) => found.some((other) => other.record === name)).length;
100
+ return found.sort((one, other) => depth(one) - depth(other)).map(({ record, written, original, linked }, index) => ({
101
+ lease: null,
102
+ record,
103
+ written,
104
+ original,
105
+ linked: linked && index > 0
106
+ }));
107
+ }
108
+ #take(stack) {
109
+ const hold = stack.find((held) => held.lease === this) ?? stack.find((held) => held.lease === null && held.record === this.#record);
110
+ if (hold) hold.lease = this;
111
+ return hold;
36
112
  }
37
- /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
38
- deactivate() {
39
- _BeforeCacheReset.#subscribers.delete(this);
40
- if (_BeforeCacheReset.#subscribers.size > 0) return;
41
- document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
113
+ #acquire(element, stack) {
114
+ const current = element.getAttribute(this.#attribute);
115
+ const top = stack.at(-1);
116
+ const bottom = stack[0];
117
+ const over = top?.written === current ? top : void 0;
118
+ const from = this.#base ? bottom : over;
119
+ const hold = {
120
+ lease: this,
121
+ record: this.#record,
122
+ written: current,
123
+ original: from ? from.original : current,
124
+ linked: !this.#base && over !== void 0
125
+ };
126
+ if (this.#base && bottom) {
127
+ bottom.linked = true;
128
+ stack.unshift(hold);
129
+ } else stack.push(hold);
130
+ return hold;
131
+ }
132
+ #covered(element, stack, hold) {
133
+ const current = element.getAttribute(this.#attribute);
134
+ return stack.slice(stack.indexOf(hold) + 1).some((above) => above.written === current);
135
+ }
136
+ #mark(element, stack) {
137
+ const records = /* @__PURE__ */ new Map([[this.#record, null]]);
138
+ stack.forEach((hold, index) => {
139
+ const names = stack.slice(0, index).map((under) => under.record);
140
+ const beneath = [...new Set(names.reverse())].filter((name) => name !== hold.record);
141
+ const marker = hold.linked || beneath.length === 0 ? [] : [false];
142
+ records.set(
143
+ hold.record,
144
+ hold.written === hold.original && beneath.length === 0 && index === stack.length - 1 ? null : JSON.stringify([hold.original, hold.written, ...marker, ...beneath])
145
+ );
146
+ });
147
+ for (const [name, recorded] of records) {
148
+ if (element.getAttribute(name) === recorded) continue;
149
+ if (recorded === null) element.removeAttribute(name);
150
+ else element.setAttribute(name, recorded);
151
+ }
152
+ }
153
+ #reflect(element, value) {
154
+ if (element.getAttribute(this.#attribute) === value) return;
155
+ if (value === null) element.removeAttribute(this.#attribute);
156
+ else element.setAttribute(this.#attribute, value);
42
157
  }
43
158
  };
44
159
 
45
- // src/utils/composition_tracker.ts
160
+ var COMPOSITION_INPUT_TYPES = /* @__PURE__ */ new Set([
161
+ "insertCompositionText",
162
+ "insertFromComposition",
163
+ "deleteCompositionText",
164
+ "deleteByComposition"
165
+ ]);
46
166
  var CompositionTracker = class {
47
167
  #observedTargets = /* @__PURE__ */ new Set();
48
168
  #activeTargets = /* @__PURE__ */ new Set();
49
169
  #onStart;
50
170
  #onEnd;
171
+ #confirmedTarget = null;
51
172
  constructor(options = {}) {
52
173
  this.#onStart = options.onStart;
53
174
  this.#onEnd = options.onEnd;
54
175
  }
55
- /** Starts lifecycle tracking for `target`; repeated calls are idempotent. */
56
176
  observe(target) {
57
177
  if (this.#observedTargets.has(target)) return;
58
178
  target.addEventListener("compositionstart", this.#handleStart);
59
179
  target.addEventListener("compositionend", this.#handleEnd);
180
+ target.addEventListener("keydown", this.#handleKeydown);
60
181
  this.#observedTargets.add(target);
61
182
  }
62
- /** Stops tracking one target and clears any active composition it owned. */
63
183
  unobserve(target) {
64
184
  if (!this.#observedTargets.delete(target)) return;
65
185
  target.removeEventListener("compositionstart", this.#handleStart);
66
186
  target.removeEventListener("compositionend", this.#handleEnd);
187
+ target.removeEventListener("keydown", this.#handleKeydown);
67
188
  this.#activeTargets.delete(target);
189
+ if (this.#confirmedTarget === target) this.#confirmedTarget = null;
68
190
  }
69
- /** Releases every listener and clears state so reconnect starts cleanly. */
70
191
  disconnect() {
71
192
  for (const target of this.#observedTargets) {
72
193
  target.removeEventListener("compositionstart", this.#handleStart);
73
194
  target.removeEventListener("compositionend", this.#handleEnd);
195
+ target.removeEventListener("keydown", this.#handleKeydown);
74
196
  }
75
197
  this.#observedTargets.clear();
76
198
  this.#activeTargets.clear();
199
+ this.#confirmedTarget = null;
77
200
  }
78
- /** True when lifecycle tracking or the current event reports composition. */
79
201
  isComposing(event) {
80
202
  return this.#activeTargets.size > 0 || event?.isComposing === true;
81
203
  }
204
+ consumesConfirmedInput(event) {
205
+ const confirmed = this.#confirmedTarget;
206
+ this.#confirmedTarget = null;
207
+ if (confirmed === null || confirmed !== event.target) return false;
208
+ const inputType = event.inputType;
209
+ return !inputType || COMPOSITION_INPUT_TYPES.has(inputType);
210
+ }
82
211
  #handleStart = (event) => {
212
+ this.#confirmedTarget = null;
83
213
  if (event.currentTarget) this.#activeTargets.add(event.currentTarget);
84
214
  this.#onStart?.(event);
85
215
  };
86
216
  #handleEnd = (event) => {
87
217
  if (event.currentTarget) this.#activeTargets.delete(event.currentTarget);
218
+ this.#confirmedTarget = event.target;
88
219
  this.#onEnd?.(event);
89
220
  };
221
+ #handleKeydown = () => {
222
+ this.#confirmedTarget = null;
223
+ };
224
+ };
225
+
226
+ var DetachGate = class _DetachGate {
227
+ #pending = false;
228
+ get pending() {
229
+ return this.#pending;
230
+ }
231
+ static isDetached(host) {
232
+ return !host.element.isConnected || !_DetachGate.#listed(host);
233
+ }
234
+ disconnected(host, teardown) {
235
+ const carried = _DetachGate.#carried(host);
236
+ if (!carried && _DetachGate.isDetached(host)) {
237
+ this.#pending = false;
238
+ teardown();
239
+ return;
240
+ }
241
+ this.#pending = true;
242
+ const probe = () => {
243
+ if (!this.#pending) return;
244
+ this.#pending = false;
245
+ teardown();
246
+ };
247
+ if (carried) setTimeout(probe, 0);
248
+ else queueMicrotask(probe);
249
+ }
250
+ static #carried(host) {
251
+ const element = host.element;
252
+ return !element.isConnected && element.closest("[data-turbo-permanent]") !== null && _DetachGate.#listed(host);
253
+ }
254
+ static #listed(host) {
255
+ const tokens = (host.element.getAttribute("data-controller") ?? "").split(/\s+/);
256
+ return tokens.includes(host.identifier);
257
+ }
258
+ cancel() {
259
+ this.#pending = false;
260
+ }
90
261
  };
91
262
 
92
- // src/utils/listener_set.ts
93
263
  var ListenerSet = class {
94
- /** The generation every `add` joins until the next `dispose()`. */
95
264
  #abort = new AbortController();
96
- /**
97
- * Attaches `handler` to the open generation, exactly as the caller spelled it.
98
- *
99
- * The set supplies the signal, so `options` carries everything else the DOM
100
- * accepts — `capture` included, which has to match at release time and no
101
- * longer has a second place to drift from.
102
- */
103
265
  add(target, type, handler, options) {
104
266
  target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
105
267
  }
106
- /**
107
- * Releases every listener of the open generation, synchronously, and opens the
108
- * next one. Idempotent, and safe before anything has been added.
109
- */
110
268
  dispose() {
111
269
  this.#abort.abort();
112
270
  this.#abort = new AbortController();
113
271
  }
114
272
  };
115
273
 
116
- // src/utils/safe_timeout.ts
274
+ var MAX_TIMER_DELAY_MS = 2147483647;
275
+
276
+ var NUMBER_BOUNDS = {
277
+ timer: { finite: true, min: 0, max: MAX_TIMER_DELAY_MS }};
278
+ function matchesNumberBounds(value, bounds) {
279
+ if (!Number.isFinite(value)) {
280
+ const direction = value === Infinity ? "positive" : value === -Infinity ? "negative" : null;
281
+ if (direction === null) return false;
282
+ if (bounds.allowInfinity !== "both" && bounds.allowInfinity !== direction) return false;
283
+ }
284
+ if (bounds.min !== void 0 && value < bounds.min) return false;
285
+ if (bounds.max !== void 0 && value > bounds.max) return false;
286
+ if (bounds.exclusiveMin !== void 0 && value <= bounds.exclusiveMin) return false;
287
+ if (bounds.integer && !Number.isInteger(value)) return false;
288
+ if (bounds.allowedValues !== void 0 && !bounds.allowedValues.includes(value)) return false;
289
+ return true;
290
+ }
291
+
292
+ function readNumber(raw, fallback, bounds) {
293
+ return matchesNumberBounds(raw, bounds) ? raw : fallback;
294
+ }
295
+
296
+ var NumberValueReader = class {
297
+ #lastRejected = /* @__PURE__ */ new Map();
298
+ read(owner, name, raw, fallback, bounds) {
299
+ const resolved = readNumber(raw, fallback, bounds);
300
+ if (matchesNumberBounds(raw, bounds)) {
301
+ this.#lastRejected.delete(name);
302
+ return resolved;
303
+ }
304
+ const attribute = `data-${owner.identifier}-${name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}-value`;
305
+ const literal = owner.element.getAttribute(attribute);
306
+ if (literal === null) {
307
+ this.#lastRejected.delete(name);
308
+ return resolved;
309
+ }
310
+ if (this.#lastRejected.get(name) !== literal) {
311
+ this.#lastRejected.set(name, literal);
312
+ console.warn(
313
+ `Stimeo UI: "${owner.identifier}" has an invalid number Value "${name}" declaration ${JSON.stringify(literal)}; using ${fallback}.`
314
+ );
315
+ }
316
+ return resolved;
317
+ }
318
+ };
319
+
117
320
  var TimerRegistry = class {
118
- /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
119
321
  ids = /* @__PURE__ */ new Set();
120
- /**
121
- * Cancels a single tracked timer.
122
- *
123
- * No-ops if the id is unknown (already cleared, fired, or never owned by this
124
- * registry), so callers can clear defensively without guarding.
125
- */
126
322
  clear(id) {
127
323
  if (this.ids.delete(id)) {
128
324
  this.cancel(id);
129
325
  }
130
326
  }
131
- /**
132
- * Cancels every tracked timer. Call this from a controller's `disconnect()`
133
- * to guarantee no timer outlives the element.
134
- */
135
327
  clearAll() {
136
328
  for (const id of this.ids) {
137
329
  this.cancel(id);
138
330
  }
139
331
  this.ids.clear();
140
332
  }
141
- /** Number of timers currently tracked (pending). */
142
333
  get size() {
143
334
  return this.ids.size;
144
335
  }
145
336
  };
146
337
  var SafeTimeout = class extends TimerRegistry {
147
- /**
148
- * Schedules `callback` after `delay` ms and returns the timer id.
149
- *
150
- * The id is removed from the registry automatically when the timeout fires,
151
- * so {@link TimerRegistry.size | size} reflects only still-pending timers.
152
- */
153
338
  set(callback, delay) {
154
339
  const id = this.schedule(() => {
155
340
  this.ids.delete(id);
@@ -166,8 +351,19 @@ var SafeTimeout = class extends TimerRegistry {
166
351
  }
167
352
  };
168
353
 
169
- // src/controllers/auto_submit_controller.ts
170
- var AutoSubmitController = class extends Controller {
354
+ var TransientHooks = class {
355
+ #attributes;
356
+ constructor(options) {
357
+ this.#attributes = options.attributes;
358
+ }
359
+ reset(element) {
360
+ for (const attribute of this.#attributes) element.removeAttribute(attribute);
361
+ }
362
+ };
363
+
364
+ var PENDING = new TransientHooks({ attributes: ["data-auto-submit-pending"] });
365
+ var AutoSubmitController = class _AutoSubmitController extends Controller {
366
+ #numbers = new NumberValueReader();
171
367
  static targets = ["form"];
172
368
  static values = {
173
369
  debounce: { type: Number, default: 300 },
@@ -175,61 +371,60 @@ var AutoSubmitController = class extends Controller {
175
371
  announce: { type: Boolean, default: false },
176
372
  message: { type: String, default: "" }
177
373
  };
374
+ static valueConstraints = {
375
+ debounce: NUMBER_BOUNDS.timer
376
+ };
178
377
  static actions = ["submit"];
179
378
  static events = ["submit", "done", "reconcile"];
180
- /** Debounce timer registry; one `clearAll()` in disconnect tears it down. */
181
379
  #timers = new SafeTimeout();
182
- /** Id of the pending debounce timer, so a new keystroke can reset it. */
183
380
  #pendingId = 0;
184
- /** Listeners that live with the bound form rather than with the connection. */
185
381
  #formListeners = new ListenerSet();
186
- /** The form the listeners are attached to; target callbacks rebind it. */
187
382
  #boundForm = null;
188
- /** Rewinds the transient state hooks just before Turbo snapshots the page. */
189
- #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
190
- /** Clears `aria-busy` and emits completion once Turbo finishes the submit. */
383
+ #busy = new AttributeLease("aria-busy", this.identifier);
384
+ #gate = new DetachGate();
191
385
  #onSubmitEnd = () => {
192
- this.#boundForm?.removeAttribute("aria-busy");
386
+ if (this.#boundForm) this.#busy.return(this.#boundForm);
193
387
  const message = this.messageValue;
194
388
  this.dispatch("done", { detail: { message: message || void 0 } });
195
389
  if (this.announceValue) announce(message);
196
390
  };
197
- /** Owns delegated IME lifecycle state and submits confirmed input text. */
198
391
  #composition = new CompositionTracker({
199
392
  onEnd: (event) => {
200
393
  if (this.#triggers("input")) this.#schedule(event.target ?? null);
201
394
  }
202
395
  });
203
396
  connect() {
204
- this.#beforeCache.activate();
205
- this.#bindForm(this.#resolveForm());
397
+ const moved = this.#gate.pending;
398
+ this.#gate.cancel();
399
+ const form = this.#resolveForm();
400
+ if (moved && form && form === this.#boundForm) {
401
+ this.#composition.observe(form);
402
+ return;
403
+ }
404
+ const stale = form?.hasAttribute("data-auto-submit-pending") === true;
405
+ if (form) PENDING.reset(form);
406
+ const busy = form !== null && this.#busy.return(form);
407
+ this.#bindForm(form);
408
+ if (stale || busy) this.dispatch("reconcile", { detail: {} });
206
409
  }
207
410
  disconnect() {
208
- this.#beforeCache.deactivate();
209
- this.#bindForm(null);
210
411
  this.#composition.disconnect();
211
- this.#timers.clearAll();
212
- this.#pendingId = 0;
412
+ this.#gate.disconnected(this, () => {
413
+ this.#bindForm(null);
414
+ this.#pendingId = 0;
415
+ });
213
416
  }
214
- /** Follows a `form` target added (or swapped in) at runtime. */
215
417
  formTargetConnected() {
216
418
  this.#bindForm(this.#resolveForm());
217
419
  }
218
- /** Follows a `form` target removed (or swapped out) at runtime. */
219
420
  formTargetDisconnected() {
220
421
  this.#bindForm(this.#resolveForm());
221
422
  }
222
- /**
223
- * Schedules a debounced submit. Wired to `input`/`change`; the `on` value is an
224
- * allowlist so a configured subset (e.g. only `change`) is honored even when both
225
- * are bound in markup. Coalesces rapid events into a single `requestSubmit`.
226
- */
227
423
  submit(event) {
228
424
  if (!this.#triggers(event.type)) return;
229
425
  if (event.type === "input" && this.#composition.isComposing(event)) return;
230
426
  this.#schedule(event.target ?? null);
231
427
  }
232
- /** Schedules (and coalesces) the debounced submit for the given trigger. */
233
428
  #schedule(trigger) {
234
429
  const form = this.#boundForm;
235
430
  if (!form) return;
@@ -240,23 +435,15 @@ var AutoSubmitController = class extends Controller {
240
435
  form.removeAttribute("data-auto-submit-pending");
241
436
  this.dispatch("submit", { detail: { trigger } });
242
437
  if (form.checkValidity()) {
243
- form.setAttribute("aria-busy", "true");
438
+ this.#busy.write(form, "true");
244
439
  }
245
440
  form.requestSubmit();
246
- }, this.debounceValue);
441
+ }, this.#safeDebounce);
247
442
  }
248
- /** Cancels the pending debounced submit, if any (`clear` no-ops on unknown ids). */
249
443
  #cancelPending() {
250
444
  this.#timers.clear(this.#pendingId);
251
445
  this.#pendingId = 0;
252
446
  }
253
- /**
254
- * Replaces the subscribed form symmetrically. The outgoing form loses the
255
- * `turbo:submit-end`/composition listeners, its pending hook, and any pending
256
- * debounced submit (which described the outgoing form). An in-flight `aria-busy`
257
- * is left for `turbo:submit-end` or the pre-cache rewind — removing it here
258
- * would wipe a legitimately in-progress submission.
259
- */
260
447
  #bindForm(form) {
261
448
  if (form === this.#boundForm) return;
262
449
  const previous = this.#boundForm;
@@ -271,25 +458,23 @@ var AutoSubmitController = class extends Controller {
271
458
  this.#formListeners.add(form, "turbo:submit-end", this.#onSubmitEnd);
272
459
  this.#composition.observe(form);
273
460
  }
274
- /** Resolves the form: the explicit `form` target, else a `<form>` root, else null. */
275
461
  #resolveForm() {
276
462
  if (this.hasFormTarget) return this.formTarget;
277
463
  return this.element instanceof HTMLFormElement ? this.element : null;
278
464
  }
279
- /** Returns the transient state hooks to their initial (absent) state. */
280
- #rewindForCache() {
281
- const inProgress = this.#pendingId !== 0 || this.#boundForm?.hasAttribute("aria-busy") === true;
282
- this.#cancelPending();
283
- this.#boundForm?.removeAttribute("data-auto-submit-pending");
284
- this.#boundForm?.removeAttribute("aria-busy");
285
- if (inProgress) this.dispatch("reconcile", { detail: {} });
286
- }
287
- /** Whether `type` is one of the whitespace-separated event types in `on`. */
288
465
  #triggers(type) {
289
466
  return this.onValue.split(/\s+/).filter(Boolean).includes(type);
290
467
  }
468
+ get #safeDebounce() {
469
+ return this.#numbers.read(
470
+ this,
471
+ "debounce",
472
+ this.debounceValue,
473
+ _AutoSubmitController.values.debounce.default,
474
+ _AutoSubmitController.valueConstraints.debounce
475
+ );
476
+ }
291
477
  };
292
478
 
293
479
  export { AutoSubmitController };
294
- //# sourceMappingURL=auto_submit_controller.js.map
295
480
  //# sourceMappingURL=auto_submit_controller.js.map