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/submit_once_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,192 +10,265 @@ function announce(message, options = {}) {
13
10
  );
14
11
  }
15
12
 
16
- // src/utils/attribute_lease.ts
17
- var AttributeLease = class {
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
+ );
18
44
  #attribute;
19
- #records = /* @__PURE__ */ new Map();
20
- /** @param attribute - The attribute whose temporary values this lease owns. */
21
- constructor(attribute) {
45
+ #record;
46
+ #base;
47
+ #held = /* @__PURE__ */ new Set();
48
+ constructor(attribute, owner, options = {}) {
22
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));
23
54
  }
24
- /** Writes or removes the leased attribute while preserving its authored value. */
25
55
  write(element, value) {
26
- const existing = this.#records.get(element);
27
- if (existing) {
28
- existing.written = value;
29
- } else {
30
- this.#records.set(element, {
31
- original: element.getAttribute(this.#attribute),
32
- written: value
33
- });
34
- }
35
- this.#reflect(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);
36
63
  }
37
- /** Returns one lease without overwriting a value subsequently authored by a consumer. */
38
64
  return(element) {
39
- const record = this.#records.get(element);
40
- if (!record) return;
41
- this.#records.delete(element);
42
- const stillOwned = element.getAttribute(this.#attribute) === record.written;
43
- if (stillOwned) this.#reflect(element, record.original);
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 });
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;
112
+ }
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
+ }
44
152
  }
45
- /** Reflects only a real value transition, avoiding self-triggered mutation work. */
46
153
  #reflect(element, value) {
47
154
  if (element.getAttribute(this.#attribute) === value) return;
48
155
  if (value === null) element.removeAttribute(this.#attribute);
49
156
  else element.setAttribute(this.#attribute, value);
50
157
  }
51
- /** Returns every outstanding lease using the same ownership check as {@link return}. */
52
- returnAll() {
53
- for (const element of Array.from(this.#records.keys())) this.return(element);
54
- }
55
- };
56
-
57
- // src/utils/before_cache_reset.ts
58
- var BeforeCacheReset = class _BeforeCacheReset {
59
- /** Every subscribed instance, iterated by the one shared document listener. */
60
- static #subscribers = /* @__PURE__ */ new Set();
61
- /** The shared listener; installed while at least one instance is subscribed. */
62
- static #onBeforeCache = () => {
63
- for (const subscriber of _BeforeCacheReset.#subscribers) subscriber.#rewind();
64
- };
65
- #rewind;
66
- /** @param rewind - the pass that returns this controller's state to its initial form. */
67
- constructor(rewind) {
68
- this.#rewind = rewind;
69
- }
70
- /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */
71
- activate() {
72
- const first = _BeforeCacheReset.#subscribers.size === 0;
73
- _BeforeCacheReset.#subscribers.add(this);
74
- if (first) {
75
- document.addEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
76
- }
77
- }
78
- /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */
79
- deactivate() {
80
- _BeforeCacheReset.#subscribers.delete(this);
81
- if (_BeforeCacheReset.#subscribers.size > 0) return;
82
- document.removeEventListener("turbo:before-cache", _BeforeCacheReset.#onBeforeCache);
83
- }
84
158
  };
85
159
 
86
- // src/utils/detach_gate.ts
87
160
  var DetachGate = class _DetachGate {
88
- /** Set while a probe is queued, waiting for a reconnect to cancel it. */
89
161
  #pending = false;
90
- /**
91
- * True while a probe is queued — the last disconnect was ambiguous and no
92
- * reconnect has cancelled it yet. Read it from `connect()` to tell the
93
- * reconnect half of an in-page move from a first connect: a controller whose
94
- * initialisation restarts a measurement (a min-duration floor, an elapsed
95
- * counter) must skip it for the move, where nothing actually restarted.
96
- */
97
162
  get pending() {
98
163
  return this.#pending;
99
164
  }
100
- /**
101
- * True when the disconnect is definitely a real detach — the element left
102
- * the document, or `data-controller` no longer lists the identifier. False
103
- * means ambiguous (in-page move or observed-root exit), NOT "alive".
104
- */
105
165
  static isDetached(host) {
106
- if (!host.element.isConnected) return true;
107
- const tokens = (host.element.getAttribute("data-controller") ?? "").split(/\s+/);
108
- return !tokens.includes(host.identifier);
109
- }
110
- /**
111
- * Call from `disconnect()`: runs `teardown` synchronously on a definite
112
- * detach (fast path), otherwise defers it one microtask — a reconnect
113
- * ({@link cancel} from `connect()`) keeps the state, no reconnect runs it.
114
- * One microtask is the whole probe window: Stimulus reconnects a moved
115
- * element within the same mutation batch, before the checkpoint drains.
116
- */
166
+ return !host.element.isConnected || !_DetachGate.#listed(host);
167
+ }
117
168
  disconnected(host, teardown) {
118
- if (_DetachGate.isDetached(host)) {
169
+ const carried = _DetachGate.#carried(host);
170
+ if (!carried && _DetachGate.isDetached(host)) {
119
171
  this.#pending = false;
120
172
  teardown();
121
173
  return;
122
174
  }
123
175
  this.#pending = true;
124
- queueMicrotask(() => {
176
+ const probe = () => {
125
177
  if (!this.#pending) return;
126
178
  this.#pending = false;
127
179
  teardown();
128
- });
180
+ };
181
+ if (carried) setTimeout(probe, 0);
182
+ else queueMicrotask(probe);
183
+ }
184
+ static #carried(host) {
185
+ const element = host.element;
186
+ return !element.isConnected && element.closest("[data-turbo-permanent]") !== null && _DetachGate.#listed(host);
187
+ }
188
+ static #listed(host) {
189
+ const tokens = (host.element.getAttribute("data-controller") ?? "").split(/\s+/);
190
+ return tokens.includes(host.identifier);
129
191
  }
130
- /**
131
- * Disarms a pending probe. Call from `connect()` (the reconnect that proves
132
- * an in-page move) and from the head of any teardown path not routed through
133
- * {@link disconnected} (disabled-toggle, Escape), so an orphaned probe can
134
- * never run the teardown a second time.
135
- */
136
192
  cancel() {
137
193
  this.#pending = false;
138
194
  }
139
195
  };
140
196
 
141
- // src/utils/listener_set.ts
142
197
  var ListenerSet = class {
143
- /** The generation every `add` joins until the next `dispose()`. */
144
198
  #abort = new AbortController();
145
- /**
146
- * Attaches `handler` to the open generation, exactly as the caller spelled it.
147
- *
148
- * The set supplies the signal, so `options` carries everything else the DOM
149
- * accepts — `capture` included, which has to match at release time and no
150
- * longer has a second place to drift from.
151
- */
152
199
  add(target, type, handler, options) {
153
200
  target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
154
201
  }
155
- /**
156
- * Releases every listener of the open generation, synchronously, and opens the
157
- * next one. Idempotent, and safe before anything has been added.
158
- */
159
202
  dispose() {
160
203
  this.#abort.abort();
161
204
  this.#abort = new AbortController();
162
205
  }
163
206
  };
164
207
 
165
- // src/utils/safe_timeout.ts
208
+ var MAX_TIMER_DELAY_MS = 2147483647;
209
+
210
+ var NUMBER_BOUNDS = {
211
+ timer: { finite: true, min: 0, max: MAX_TIMER_DELAY_MS }};
212
+ function matchesNumberBounds(value, bounds) {
213
+ if (!Number.isFinite(value)) {
214
+ const direction = value === Infinity ? "positive" : value === -Infinity ? "negative" : null;
215
+ if (direction === null) return false;
216
+ if (bounds.allowInfinity !== "both" && bounds.allowInfinity !== direction) return false;
217
+ }
218
+ if (bounds.min !== void 0 && value < bounds.min) return false;
219
+ if (bounds.max !== void 0 && value > bounds.max) return false;
220
+ if (bounds.exclusiveMin !== void 0 && value <= bounds.exclusiveMin) return false;
221
+ if (bounds.integer && !Number.isInteger(value)) return false;
222
+ if (bounds.allowedValues !== void 0 && !bounds.allowedValues.includes(value)) return false;
223
+ return true;
224
+ }
225
+
226
+ function readNumber(raw, fallback, bounds) {
227
+ return matchesNumberBounds(raw, bounds) ? raw : fallback;
228
+ }
229
+
230
+ var NumberValueReader = class {
231
+ #lastRejected = /* @__PURE__ */ new Map();
232
+ read(owner, name, raw, fallback, bounds) {
233
+ const resolved = readNumber(raw, fallback, bounds);
234
+ if (matchesNumberBounds(raw, bounds)) {
235
+ this.#lastRejected.delete(name);
236
+ return resolved;
237
+ }
238
+ const attribute = `data-${owner.identifier}-${name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}-value`;
239
+ const literal = owner.element.getAttribute(attribute);
240
+ if (literal === null) {
241
+ this.#lastRejected.delete(name);
242
+ return resolved;
243
+ }
244
+ if (this.#lastRejected.get(name) !== literal) {
245
+ this.#lastRejected.set(name, literal);
246
+ console.warn(
247
+ `Stimeo UI: "${owner.identifier}" has an invalid number Value "${name}" declaration ${JSON.stringify(literal)}; using ${fallback}.`
248
+ );
249
+ }
250
+ return resolved;
251
+ }
252
+ };
253
+
166
254
  var TimerRegistry = class {
167
- /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
168
255
  ids = /* @__PURE__ */ new Set();
169
- /**
170
- * Cancels a single tracked timer.
171
- *
172
- * No-ops if the id is unknown (already cleared, fired, or never owned by this
173
- * registry), so callers can clear defensively without guarding.
174
- */
175
256
  clear(id) {
176
257
  if (this.ids.delete(id)) {
177
258
  this.cancel(id);
178
259
  }
179
260
  }
180
- /**
181
- * Cancels every tracked timer. Call this from a controller's `disconnect()`
182
- * to guarantee no timer outlives the element.
183
- */
184
261
  clearAll() {
185
262
  for (const id of this.ids) {
186
263
  this.cancel(id);
187
264
  }
188
265
  this.ids.clear();
189
266
  }
190
- /** Number of timers currently tracked (pending). */
191
267
  get size() {
192
268
  return this.ids.size;
193
269
  }
194
270
  };
195
271
  var SafeTimeout = class extends TimerRegistry {
196
- /**
197
- * Schedules `callback` after `delay` ms and returns the timer id.
198
- *
199
- * The id is removed from the registry automatically when the timeout fires,
200
- * so {@link TimerRegistry.size | size} reflects only still-pending timers.
201
- */
202
272
  set(callback, delay) {
203
273
  const id = this.schedule(() => {
204
274
  this.ids.delete(id);
@@ -215,9 +285,11 @@ var SafeTimeout = class extends TimerRegistry {
215
285
  }
216
286
  };
217
287
 
218
- // src/controllers/submit_once_controller.ts
219
288
  var BUTTON_BUSY_LABEL = "data-submit-once-busy-label";
220
- var SubmitOnceController = class extends Controller {
289
+ var LABEL_RECORD = "label";
290
+ var SUBMITTER_MARK = "submitter";
291
+ var SubmitOnceController = class _SubmitOnceController extends Controller {
292
+ #numbers = new NumberValueReader();
221
293
  static targets = ["submit", "idle", "busy"];
222
294
  static values = {
223
295
  announceText: { type: String, default: "" },
@@ -226,26 +298,27 @@ var SubmitOnceController = class extends Controller {
226
298
  timeout: { type: Number, default: 0 },
227
299
  restoreFocus: { type: Boolean, default: false }
228
300
  };
301
+ static valueConstraints = {
302
+ timeout: NUMBER_BOUNDS.timer
303
+ };
229
304
  static actions = ["cancel", "finish", "start"];
230
305
  static events = ["start", "end", "reconcile"];
231
306
  #timers = new SafeTimeout();
232
307
  #gate = new DetachGate();
233
308
  #listeners = new ListenerSet();
234
- #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
235
309
  #sessions = /* @__PURE__ */ new Map();
236
- #disabled = new AttributeLease("disabled");
237
- #controlBusy = new AttributeLease("aria-busy");
238
- #formBusy = new AttributeLease("aria-busy");
239
- #submitting = new AttributeLease("data-submitting");
240
- #ariaLabel = new AttributeLease("aria-label");
241
- #hidden = new AttributeLease("hidden");
310
+ #disabled = new AttributeLease("disabled", this.identifier);
311
+ #controlBusy = new AttributeLease("aria-busy", this.identifier);
312
+ #formBusy = new AttributeLease("aria-busy", this.identifier);
313
+ #submitting = new AttributeLease("data-submitting", this.identifier);
314
+ #ariaLabel = new AttributeLease("aria-label", this.identifier);
315
+ #hidden = new AttributeLease("hidden", this.identifier);
242
316
  #onSubmitStart = (event) => {
243
317
  this.start(event);
244
318
  };
245
319
  #onSubmitEnd = (event) => {
246
320
  this.#complete(this.#eventForm(event), "turbo", this.#eventSuccess(event));
247
321
  };
248
- /** Blocks a later native submit while one for the same form is still active. */
249
322
  #onNativeSubmit = (event) => {
250
323
  const session = this.#sessionFor(event.target);
251
324
  if (!session) return;
@@ -254,24 +327,80 @@ var SubmitOnceController = class extends Controller {
254
327
  };
255
328
  connect() {
256
329
  this.#gate.cancel();
257
- this.#beforeCache.activate();
258
330
  this.#listeners.add(this.element, "submit", this.#onNativeSubmit, { capture: true });
259
331
  this.#listeners.add(this.element, "turbo:submit-start", this.#onSubmitStart);
260
332
  this.#listeners.add(this.element, "turbo:submit-end", this.#onSubmitEnd);
333
+ if (this.#sessions.size === 0) this.#releaseInherited();
261
334
  }
262
335
  disconnect() {
263
336
  this.#listeners.dispose();
264
- this.#beforeCache.deactivate();
265
337
  this.#gate.disconnected(this, () => this.#teardown());
266
338
  }
267
- /**
268
- * Begins the submission represented by `event`.
269
- *
270
- * Native `submit` events are intended for non-Turbo async forms. Turbo calls
271
- * this path through its auto-subscribed `turbo:submit-start` event. An event
272
- * whose default an earlier listener already cancelled carries no submission,
273
- * so it starts nothing.
274
- */
339
+ get #labelRecord() {
340
+ return `data-${this.identifier}-${LABEL_RECORD}`;
341
+ }
342
+ get #submitterMark() {
343
+ return `data-${this.identifier}-${SUBMITTER_MARK}`;
344
+ }
345
+ #releaseInherited() {
346
+ const forms = this.#ownedForms();
347
+ const controls = new Set(
348
+ this.element.querySelectorAll(`[${this.#labelRecord}]`)
349
+ );
350
+ for (const form of forms) {
351
+ for (const control of this.#submitControls(form)) {
352
+ if (control.hasAttribute(this.#labelRecord)) controls.add(control);
353
+ }
354
+ }
355
+ for (const control of controls) {
356
+ if (control.form && !this.#ownsForm(control.form)) continue;
357
+ this.#restoreLabel(control);
358
+ }
359
+ const copied = forms.filter((form) => this.#returnCopied(form));
360
+ if (copied.length > 0) this.dispatch("reconcile", { detail: { forms: copied } });
361
+ }
362
+ #returnCopied(form) {
363
+ let copied = this.#submitting.return(form);
364
+ this.#formBusy.return(form);
365
+ const parts = [...this.idleTargets, ...this.busyTargets];
366
+ for (const control of this.#submitControls(form)) {
367
+ this.#disabled.return(control);
368
+ this.#controlBusy.return(control);
369
+ if (control instanceof HTMLButtonElement) this.#ariaLabel.return(control);
370
+ for (const part of parts) if (control.contains(part)) this.#hidden.return(part);
371
+ if (!control.hasAttribute(this.#submitterMark)) continue;
372
+ control.removeAttribute(this.#submitterMark);
373
+ control.disabled = false;
374
+ if (control.getAttribute("aria-disabled") === "true")
375
+ control.removeAttribute("aria-disabled");
376
+ copied = true;
377
+ }
378
+ if (copied && form.getAttribute("aria-busy") === "true") form.removeAttribute("aria-busy");
379
+ return copied;
380
+ }
381
+ #restoreLabel(control) {
382
+ const raw = control.getAttribute(this.#labelRecord);
383
+ control.removeAttribute(this.#labelRecord);
384
+ let record;
385
+ try {
386
+ record = JSON.parse(raw ?? "");
387
+ } catch {
388
+ return;
389
+ }
390
+ if (!Array.isArray(record) || record.length !== 2) return;
391
+ const [original, written] = record;
392
+ if (typeof original !== "string" || typeof written !== "string") return;
393
+ if (control instanceof HTMLInputElement) {
394
+ if (control.value === written) control.value = original;
395
+ } else if (control.childElementCount === 0 && control.textContent === written) {
396
+ control.textContent = original;
397
+ }
398
+ }
399
+ #ownedForms() {
400
+ const forms = Array.from(this.element.querySelectorAll("form"));
401
+ if (this.element instanceof HTMLFormElement) forms.unshift(this.element);
402
+ return forms.filter((form) => this.#ownsForm(form));
403
+ }
275
404
  start(event) {
276
405
  if (event.defaultPrevented) return;
277
406
  const form = this.#eventForm(event);
@@ -289,49 +418,33 @@ var SubmitOnceController = class extends Controller {
289
418
  busyPart: null
290
419
  };
291
420
  this.#sessions.set(form, session);
421
+ submitter?.setAttribute(this.#submitterMark, "");
292
422
  this.#formBusy.write(form, "true");
293
423
  this.#submitting.write(form, "true");
294
424
  this.#syncControls(session, controls);
295
425
  this.#enterLabel(session);
296
426
  this.dispatch("start", { detail: { form, submitter } });
297
- if (this.timeoutValue > 0) {
427
+ if (this.#safeTimeout > 0) {
298
428
  session.timeoutId = this.#timers.set(
299
429
  () => this.#complete(form, "timeout", false),
300
- this.timeoutValue
430
+ this.#safeTimeout
301
431
  );
302
432
  }
303
433
  announce(this.announceTextValue);
304
434
  }
305
- /**
306
- * Completes a non-Turbo async submission for the form associated with
307
- * `event`. When called directly with no event, it completes the controller's
308
- * sole active form (or the controller element itself when it is a form).
309
- */
310
435
  finish(event) {
311
436
  const form = event ? this.#eventForm(event) : this.#defaultForm();
312
437
  this.#complete(form, "manual", event ? this.#eventSuccess(event) : void 0);
313
438
  }
314
- /**
315
- * Abandons the submission for the form associated with `event` because it
316
- * never reached the network — a veto from validation, or a request the
317
- * consumer gave up before sending. With no event it targets the controller's
318
- * sole active form, exactly like {@link finish}.
319
- *
320
- * The distinction from `finish` is observable: a request that ran and failed
321
- * is `finish` with `success: false` and still announces completion, while a
322
- * submission that never happened announces nothing.
323
- */
324
439
  cancel(event) {
325
440
  const form = event ? this.#eventForm(event) : this.#defaultForm();
326
441
  this.#complete(form, "canceled", false);
327
442
  }
328
- /** Disables a newly connected explicit submit target during its form's session. */
329
443
  submitTargetConnected(target) {
330
444
  const session = this.#sessionFor(target.form);
331
445
  if (!session || !this.#isNativeSubmitControl(target)) return;
332
446
  this.#enterControl(session, target);
333
447
  }
334
- /** Ends one session, restores owned state, and reports the observable completion. */
335
448
  #complete(form, reason, success) {
336
449
  const session = this.#sessionFor(form);
337
450
  if (!session) return;
@@ -342,18 +455,15 @@ var SubmitOnceController = class extends Controller {
342
455
  this.#restoreFocus(session);
343
456
  if (reason !== "canceled") announce(this.announceReadyTextValue);
344
457
  }
345
- /** Applies the busy state to every current native/explicit control exactly once. */
346
458
  #syncControls(session, controls = this.#submitControls(session.form)) {
347
459
  for (const control of controls) this.#enterControl(session, control);
348
460
  }
349
- /** Temporarily disables one enabled submit control while retaining authored state. */
350
461
  #enterControl(session, control) {
351
462
  if (session.controls.has(control) || control.matches(":disabled")) return;
352
463
  session.controls.add(control);
353
464
  this.#disabled.write(control, "");
354
465
  this.#controlBusy.write(control, "true");
355
466
  }
356
- /** Swaps only the triggering control's safe label channel. */
357
467
  #enterLabel(session) {
358
468
  const control = session.submitter;
359
469
  if (!control) return;
@@ -375,6 +485,7 @@ var SubmitOnceController = class extends Controller {
375
485
  original: control.value,
376
486
  written: label
377
487
  };
488
+ control.setAttribute(this.#labelRecord, JSON.stringify([control.value, label]));
378
489
  control.value = label;
379
490
  return;
380
491
  }
@@ -389,24 +500,27 @@ var SubmitOnceController = class extends Controller {
389
500
  original: control.textContent ?? "",
390
501
  written: label
391
502
  };
503
+ control.setAttribute(this.#labelRecord, JSON.stringify([control.textContent ?? "", label]));
392
504
  control.textContent = label;
393
505
  }
394
- /** Returns labels, controls, and form hooks, respecting intervening consumer writes. */
395
- #returnSessionState(session) {
506
+ #returnSessionState(session, unfinished = false) {
396
507
  if (session.timeoutId !== null) {
397
508
  this.#timers.clear(session.timeoutId);
398
509
  session.timeoutId = null;
399
510
  }
400
511
  if (session.labelWrite?.channel === "value") {
401
512
  const { control, original, written } = session.labelWrite;
513
+ control.removeAttribute(this.#labelRecord);
402
514
  if (control.value === written) control.value = original;
403
515
  } else if (session.labelWrite?.channel === "text") {
404
516
  const { control, original, written } = session.labelWrite;
517
+ control.removeAttribute(this.#labelRecord);
405
518
  if (control.childElementCount === 0 && control.textContent === written) {
406
519
  control.textContent = original;
407
520
  }
408
521
  }
409
522
  session.labelWrite = null;
523
+ if (!unfinished) session.submitter?.removeAttribute(this.#submitterMark);
410
524
  if (session.submitter instanceof HTMLButtonElement) {
411
525
  this.#ariaLabel.return(session.submitter);
412
526
  }
@@ -418,11 +532,9 @@ var SubmitOnceController = class extends Controller {
418
532
  this.#controlBusy.return(control);
419
533
  this.#disabled.return(control);
420
534
  }
421
- session.controls.clear();
422
535
  this.#submitting.return(session.form);
423
536
  this.#formBusy.return(session.form);
424
537
  }
425
- /** Restores focus only when disabling the focused submitter actually stranded it. */
426
538
  #restoreFocus(session) {
427
539
  const submitter = session.submitter;
428
540
  if (!this.restoreFocusValue || !session.submitterHadFocus || !submitter?.isConnected) return;
@@ -431,27 +543,18 @@ var SubmitOnceController = class extends Controller {
431
543
  submitter.focus();
432
544
  }
433
545
  }
434
- /** Silently abandons every session before caching or on a real detach. */
435
546
  #abandonSessions() {
436
547
  this.#timers.clearAll();
548
+ const departed = !this.element.isConnected;
437
549
  for (const session of this.#sessions.values()) {
438
550
  session.timeoutId = null;
439
- this.#returnSessionState(session);
551
+ this.#returnSessionState(session, departed);
440
552
  }
441
553
  this.#sessions.clear();
442
554
  }
443
- /** Keeps Turbo's cached snapshot idle, without events, announcements, or focus moves. */
444
- #rewindForCache() {
445
- const forms = [...this.#sessions.keys()];
446
- this.#abandonSessions();
447
- if (forms.length > 0) this.dispatch("reconcile", { detail: { forms } });
448
- }
449
- /** Tears down a true detach; an in-page move is canceled by the next `connect()`. */
450
555
  #teardown() {
451
- this.#gate.cancel();
452
556
  this.#abandonSessions();
453
557
  }
454
- /** All native controls associated with `form`, plus valid explicit targets. */
455
558
  #submitControls(form) {
456
559
  const controls = /* @__PURE__ */ new Set();
457
560
  for (const element of Array.from(form.elements)) {
@@ -463,13 +566,11 @@ var SubmitOnceController = class extends Controller {
463
566
  }
464
567
  return Array.from(controls);
465
568
  }
466
- /** Resolves the real submitter, falling back to the first enabled submit control. */
467
569
  #resolveSubmitter(event, controls) {
468
570
  const submitter = this.#eventSubmitter(event);
469
571
  if (submitter && controls.includes(submitter)) return submitter;
470
572
  return controls.find((control) => !control.matches(":disabled")) ?? controls[0] ?? null;
471
573
  }
472
- /** Reads a native or Turbo submitter without assuming `SubmitEvent` exists. */
473
574
  #eventSubmitter(event) {
474
575
  if (typeof SubmitEvent !== "undefined" && event instanceof SubmitEvent) {
475
576
  return this.#isNativeSubmitControl(event.submitter) ? event.submitter : null;
@@ -478,21 +579,18 @@ var SubmitOnceController = class extends Controller {
478
579
  const submitter = detail?.formSubmission?.submitter;
479
580
  return this.#isNativeSubmitControl(submitter) ? submitter : null;
480
581
  }
481
- /** Finds the form represented by an action/Turbo event and verifies this scope owns it. */
482
582
  #eventForm(event) {
483
583
  const detail = event.detail;
484
584
  const target = event.target;
485
585
  const candidate = (target instanceof HTMLFormElement ? target : null) ?? (target instanceof Element ? target.closest("form") : null) ?? detail?.formSubmission?.formElement ?? detail?.form ?? this.#eventSubmitter(event)?.form ?? (this.element instanceof HTMLFormElement ? this.element : null);
486
586
  return candidate && this.#ownsForm(candidate) ? candidate : null;
487
587
  }
488
- /** The only unambiguous form for a direct `finish()` call. */
489
588
  #defaultForm() {
490
589
  if (this.element instanceof HTMLFormElement && this.#ownsForm(this.element))
491
590
  return this.element;
492
591
  if (this.#sessions.size === 1) return this.#sessions.keys().next().value ?? null;
493
592
  return null;
494
593
  }
495
- /** Prevents an ancestor controller from also taking over a nested instance's form. */
496
594
  #ownsForm(form) {
497
595
  let candidate = form;
498
596
  while (candidate) {
@@ -502,29 +600,33 @@ var SubmitOnceController = class extends Controller {
502
600
  }
503
601
  return false;
504
602
  }
505
- /** Returns the active session for a form-shaped external event value. */
506
603
  #sessionFor(candidate) {
507
604
  if (!(candidate instanceof HTMLFormElement)) return void 0;
508
605
  return this.#sessions.get(candidate);
509
606
  }
510
- /** Effective native submit controls, including implicit buttons and image inputs. */
511
607
  #isNativeSubmitControl(element) {
512
608
  if (element instanceof HTMLButtonElement) return element.type === "submit";
513
609
  return element instanceof HTMLInputElement && (element.type === "submit" || element.type === "image");
514
610
  }
515
- /** Finds a structured label part belonging to this button, not a nested control. */
516
611
  #partInside(control, parts) {
517
612
  return parts.find(
518
613
  (part) => control.contains(part) && part.closest("button, input[type=submit], input[type=image]") === control
519
614
  ) ?? null;
520
615
  }
521
- /** Reads an optional boolean `success` from Turbo or a manual completion event. */
522
616
  #eventSuccess(event) {
523
617
  const success = event.detail?.success;
524
618
  return typeof success === "boolean" ? success : void 0;
525
619
  }
620
+ get #safeTimeout() {
621
+ return this.#numbers.read(
622
+ this,
623
+ "timeout",
624
+ this.timeoutValue,
625
+ _SubmitOnceController.values.timeout.default,
626
+ _SubmitOnceController.valueConstraints.timeout
627
+ );
628
+ }
526
629
  };
527
630
 
528
631
  export { SubmitOnceController };
529
- //# sourceMappingURL=submit_once_controller.js.map
530
632
  //# sourceMappingURL=submit_once_controller.js.map