stimeo-ui 0.7.0 → 0.8.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.
data/dist/index.js CHANGED
@@ -1025,14 +1025,18 @@ var AutoSubmitController = class extends Controller {
1025
1025
  message: { type: String, default: "" }
1026
1026
  };
1027
1027
  static actions = ["submit"];
1028
- static events = ["submit", "done"];
1028
+ static events = ["submit", "done", "reconcile"];
1029
1029
  /** Debounce timer registry; one `clearAll()` in disconnect tears it down. */
1030
1030
  #timers = new SafeTimeout();
1031
1031
  /** Id of the pending debounce timer, so a new keystroke can reset it. */
1032
1032
  #pendingId = 0;
1033
+ /** The form the listeners are attached to; target callbacks rebind it. */
1034
+ #boundForm = null;
1035
+ /** Rewinds the transient state hooks just before Turbo snapshots the page. */
1036
+ #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
1033
1037
  /** Clears `aria-busy` and emits completion once Turbo finishes the submit. */
1034
1038
  #onSubmitEnd = () => {
1035
- this.#form.removeAttribute("aria-busy");
1039
+ this.#boundForm?.removeAttribute("aria-busy");
1036
1040
  const message = this.messageValue;
1037
1041
  this.dispatch("done", { detail: { message: message || void 0 } });
1038
1042
  if (this.announceValue && message) {
@@ -1046,15 +1050,23 @@ var AutoSubmitController = class extends Controller {
1046
1050
  }
1047
1051
  });
1048
1052
  connect() {
1049
- this.#form.addEventListener("turbo:submit-end", this.#onSubmitEnd);
1050
- this.#composition.observe(this.#form);
1053
+ this.#beforeCache.activate();
1054
+ this.#bindForm(this.#resolveForm());
1051
1055
  }
1052
1056
  disconnect() {
1057
+ this.#beforeCache.deactivate();
1058
+ this.#bindForm(null);
1059
+ this.#composition.disconnect();
1053
1060
  this.#timers.clearAll();
1054
1061
  this.#pendingId = 0;
1055
- this.#composition.disconnect();
1056
- this.#form.removeAttribute("data-auto-submit-pending");
1057
- this.#form.removeEventListener("turbo:submit-end", this.#onSubmitEnd);
1062
+ }
1063
+ /** Follows a `form` target added (or swapped in) at runtime. */
1064
+ formTargetConnected() {
1065
+ this.#bindForm(this.#resolveForm());
1066
+ }
1067
+ /** Follows a `form` target removed (or swapped out) at runtime. */
1068
+ formTargetDisconnected() {
1069
+ this.#bindForm(this.#resolveForm());
1058
1070
  }
1059
1071
  /**
1060
1072
  * Schedules a debounced submit. Wired to `input`/`change`; the `on` value is an
@@ -1068,21 +1080,58 @@ var AutoSubmitController = class extends Controller {
1068
1080
  }
1069
1081
  /** Schedules (and coalesces) the debounced submit for the given trigger. */
1070
1082
  #schedule(trigger) {
1071
- this.#form.setAttribute("data-auto-submit-pending", "true");
1072
- if (this.#pendingId) this.#timers.clear(this.#pendingId);
1083
+ const form = this.#boundForm;
1084
+ if (!form) return;
1085
+ form.setAttribute("data-auto-submit-pending", "true");
1086
+ this.#cancelPending();
1073
1087
  this.#pendingId = this.#timers.set(() => {
1074
1088
  this.#pendingId = 0;
1075
- this.#form.removeAttribute("data-auto-submit-pending");
1089
+ form.removeAttribute("data-auto-submit-pending");
1076
1090
  this.dispatch("submit", { detail: { trigger } });
1077
- if (this.#form.checkValidity()) {
1078
- this.#form.setAttribute("aria-busy", "true");
1091
+ if (form.checkValidity()) {
1092
+ form.setAttribute("aria-busy", "true");
1079
1093
  }
1080
- this.#form.requestSubmit();
1094
+ form.requestSubmit();
1081
1095
  }, this.debounceValue);
1082
1096
  }
1083
- /** Resolves the form element (explicit `form` target, else the controller root). */
1084
- get #form() {
1085
- return this.hasFormTarget ? this.formTarget : this.element;
1097
+ /** Cancels the pending debounced submit, if any (`clear` no-ops on unknown ids). */
1098
+ #cancelPending() {
1099
+ this.#timers.clear(this.#pendingId);
1100
+ this.#pendingId = 0;
1101
+ }
1102
+ /**
1103
+ * Replaces the subscribed form symmetrically. The outgoing form loses the
1104
+ * `turbo:submit-end`/composition listeners, its pending hook, and any pending
1105
+ * debounced submit (which described the outgoing form). An in-flight `aria-busy`
1106
+ * is left for `turbo:submit-end` or the pre-cache rewind — removing it here
1107
+ * would wipe a legitimately in-progress submission.
1108
+ */
1109
+ #bindForm(form) {
1110
+ if (form === this.#boundForm) return;
1111
+ const previous = this.#boundForm;
1112
+ if (previous) {
1113
+ this.#cancelPending();
1114
+ previous.removeAttribute("data-auto-submit-pending");
1115
+ previous.removeEventListener("turbo:submit-end", this.#onSubmitEnd);
1116
+ this.#composition.unobserve(previous);
1117
+ }
1118
+ this.#boundForm = form;
1119
+ if (!form) return;
1120
+ form.addEventListener("turbo:submit-end", this.#onSubmitEnd);
1121
+ this.#composition.observe(form);
1122
+ }
1123
+ /** Resolves the form: the explicit `form` target, else a `<form>` root, else null. */
1124
+ #resolveForm() {
1125
+ if (this.hasFormTarget) return this.formTarget;
1126
+ return this.element instanceof HTMLFormElement ? this.element : null;
1127
+ }
1128
+ /** Returns the transient state hooks to their initial (absent) state. */
1129
+ #rewindForCache() {
1130
+ const inProgress = this.#pendingId !== 0 || this.#boundForm?.hasAttribute("aria-busy") === true;
1131
+ this.#cancelPending();
1132
+ this.#boundForm?.removeAttribute("data-auto-submit-pending");
1133
+ this.#boundForm?.removeAttribute("aria-busy");
1134
+ if (inProgress) this.dispatch("reconcile", { detail: {} });
1086
1135
  }
1087
1136
  /** Whether `type` is one of the whitespace-separated event types in `on`. */
1088
1137
  #triggers(type) {
@@ -2098,6 +2147,11 @@ var CalendarController = class extends Controller {
2098
2147
  }
2099
2148
  };
2100
2149
 
2150
+ // src/utils/reduced_motion.ts
2151
+ function prefersReducedMotion() {
2152
+ return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
2153
+ }
2154
+
2101
2155
  // src/utils/roving_tabindex.ts
2102
2156
  var RovingTabindex = class {
2103
2157
  /** Returns the current ordered item elements; called on every operation. */
@@ -2140,11 +2194,13 @@ function rovingMove(current, length, delta, wrap) {
2140
2194
  }
2141
2195
 
2142
2196
  // src/controllers/carousel_controller.ts
2197
+ var DEFAULT_INTERVAL = 5e3;
2198
+ var OBSERVED_ATTRIBUTES = ["aria-selected", "data-state", "hidden"];
2143
2199
  var CarouselController = class extends Controller {
2144
2200
  static targets = ["slide", "viewport", "prev", "next", "picker", "playToggle"];
2145
2201
  static values = {
2146
2202
  autoplay: { type: Boolean, default: false },
2147
- interval: { type: Number, default: 5e3 },
2203
+ interval: { type: Number, default: DEFAULT_INTERVAL },
2148
2204
  loop: { type: Boolean, default: true }
2149
2205
  };
2150
2206
  static actions = [
@@ -2159,37 +2215,107 @@ var CarouselController = class extends Controller {
2159
2215
  static events = ["change", "pause", "play", "reconcile"];
2160
2216
  #roving = new RovingTabindex(() => this.pickerTargets);
2161
2217
  #reconcileTargets = new MicrotaskCoalescer(() => this.#reconcileTargetSet());
2218
+ #intervals = new SafeInterval();
2219
+ /** Unreachable step controls and the toggle of a carousel that cannot rotate. */
2220
+ #ariaDisabled = new AttributeLease("aria-disabled");
2221
+ /** The slide container's live-region politeness, which tracks the rotation. */
2222
+ #ariaLive = new AttributeLease("aria-live");
2223
+ /** Pairs with {@link CarouselController.#ariaLive}: only the changed slide is read. */
2224
+ #ariaAtomic = new AttributeLease("aria-atomic");
2225
+ #beforeCache = new BeforeCacheReset(() => this.#returnLeases());
2226
+ /**
2227
+ * Events an authored action binding already took. The delegated listener runs
2228
+ * later — it sits on the controller element, above every control — so it can
2229
+ * consume the mark and stand down, letting the two wirings coexist without
2230
+ * handling one interaction twice.
2231
+ */
2232
+ #handledEvents = /* @__PURE__ */ new WeakSet();
2162
2233
  /**
2163
2234
  * Whether `connect()` has run. Scheduling is already inert outside that window
2164
- * ({@link MicrotaskCoalescer}), so this only gates the Tab stop a picker
2165
- * present at mount authored for itself.
2235
+ * ({@link MicrotaskCoalescer}), so this gates the Value callbacks Stimulus
2236
+ * delivers ahead of `connect()`.
2166
2237
  */
2167
2238
  #connected = false;
2168
- #intervals = new SafeInterval();
2169
2239
  /** Index of the visible slide. */
2170
2240
  #index = 0;
2171
- /** User intent to autoplay (toggled by the play button / focus hard-stop). */
2172
- #playing = false;
2173
- /** Pointer is hovering the carousel: a temporary, auto-resuming suspension. */
2241
+ /** The visible slide element, which identifies it across a changing target set. */
2242
+ #activeSlide = null;
2243
+ /** Slide count at the last resolved state, so a changed total is reportable. */
2244
+ #total = 0;
2245
+ /** Pointer rests on the carousel: a suspension that lifts on `mouseleave`. */
2174
2246
  #pointerPaused = false;
2247
+ /** Focus is inside the carousel: a suspension that lifts when it leaves. */
2248
+ #focusPaused = false;
2249
+ /** The tab is in the background: a suspension that lifts when it returns. */
2250
+ #hiddenPaused = false;
2175
2251
  /** Id of the live autoplay interval, or null when stopped. */
2176
2252
  #timerId = null;
2253
+ /** Delay the live interval was armed with, so an `interval` change re-arms it. */
2254
+ #timerInterval = 0;
2255
+ /** Follows state-attribute rewrites on retained slides and pickers. */
2256
+ #observer = null;
2257
+ /** Keeps a hidden tab from advancing behind the user's back. */
2258
+ #onVisibilityChange = () => {
2259
+ this.#hiddenPaused = document.visibilityState === "hidden";
2260
+ this.#syncTimer();
2261
+ };
2177
2262
  /**
2178
- * Renders the initial slide and starts autoplay when requested.
2263
+ * Renders the initial slide, wires the delegated listeners, and starts autoplay
2264
+ * when requested.
2179
2265
  *
2180
- * `findIndex` makes the authored pre-selection first-wins when several pickers
2181
- * are marked; `#render` then writes an explicit value onto every picker.
2266
+ * Every suspension is re-derived from the environment rather than carried, so an
2267
+ * in-page move — which Stimulus delivers to the *same* controller instance as
2268
+ * `disconnect()` then `connect()` — cannot strand the carousel in a suspension
2269
+ * whose lifting event will never arrive. The attribute observer starts after the
2270
+ * first render so the controller's own opening writes are not fed back to it.
2182
2271
  */
2183
2272
  connect() {
2184
- const preselected = this.pickerTargets.findIndex(
2185
- (picker) => picker.getAttribute("aria-selected") === "true"
2186
- );
2187
- this.#index = preselected === -1 ? 0 : preselected;
2188
- this.#playing = this.#initialPlaying();
2273
+ this.#pointerPaused = this.element.matches(":hover");
2274
+ this.#focusPaused = this.element.contains(document.activeElement);
2275
+ this.#hiddenPaused = document.visibilityState === "hidden";
2276
+ if (this.autoplayValue && prefersReducedMotion()) this.autoplayValue = false;
2277
+ this.#index = this.#resolveIndex();
2189
2278
  this.#render({ focus: false });
2279
+ this.#total = this.slideTargets.length;
2190
2280
  this.#syncTimer();
2191
2281
  this.#connected = true;
2192
2282
  this.#reconcileTargets.activate();
2283
+ this.#beforeCache.activate();
2284
+ this.element.addEventListener("click", this.#onClick);
2285
+ this.element.addEventListener("keydown", this.#onKeydown);
2286
+ this.element.addEventListener("focusin", this.#onFocusin);
2287
+ this.element.addEventListener("focusout", this.#onFocusout);
2288
+ this.element.addEventListener("mouseenter", this.#onMouseenter);
2289
+ this.element.addEventListener("mouseleave", this.#onMouseleave);
2290
+ document.addEventListener("visibilitychange", this.#onVisibilityChange);
2291
+ this.#observer = new MutationObserver(this.#onAttributeMutations);
2292
+ this.#observer.observe(this.element, {
2293
+ subtree: true,
2294
+ attributes: true,
2295
+ attributeFilter: OBSERVED_ATTRIBUTES
2296
+ });
2297
+ }
2298
+ /** Releases every listener and observer, returns the leased ARIA, drops the suspensions. */
2299
+ disconnect() {
2300
+ this.#connected = false;
2301
+ this.element.removeEventListener("click", this.#onClick);
2302
+ this.element.removeEventListener("keydown", this.#onKeydown);
2303
+ this.element.removeEventListener("focusin", this.#onFocusin);
2304
+ this.element.removeEventListener("focusout", this.#onFocusout);
2305
+ this.element.removeEventListener("mouseenter", this.#onMouseenter);
2306
+ this.element.removeEventListener("mouseleave", this.#onMouseleave);
2307
+ document.removeEventListener("visibilitychange", this.#onVisibilityChange);
2308
+ this.#observer?.disconnect();
2309
+ this.#observer = null;
2310
+ this.#reconcileTargets.cancel();
2311
+ this.#intervals.clearAll();
2312
+ this.#timerId = null;
2313
+ this.#pointerPaused = false;
2314
+ this.#focusPaused = false;
2315
+ this.#hiddenPaused = false;
2316
+ this.#activeSlide = null;
2317
+ this.#returnLeases();
2318
+ this.#beforeCache.deactivate();
2193
2319
  }
2194
2320
  /**
2195
2321
  * Re-establishes the single selected picker when one is added after connect.
@@ -2201,7 +2327,6 @@ var CarouselController = class extends Controller {
2201
2327
  * the selection.
2202
2328
  */
2203
2329
  pickerTargetConnected(picker) {
2204
- if (!this.#connected) return;
2205
2330
  picker.tabIndex = -1;
2206
2331
  this.#reconcileTargets.schedule();
2207
2332
  }
@@ -2217,74 +2342,173 @@ var CarouselController = class extends Controller {
2217
2342
  slideTargetDisconnected() {
2218
2343
  this.#reconcileTargets.schedule();
2219
2344
  }
2220
- /**
2221
- * Resolves the starting autoplay intent. The play toggle's `aria-pressed` is the
2222
- * source of truth **when present**, so a Turbo Drive cache restore / morph that
2223
- * re-runs `connect()` against existing DOM does not silently resume autoplay the
2224
- * user had stopped (e.g. by focusing into the carousel). Only when no toggle
2225
- * carries `aria-pressed` does it fall back to the declarative `autoplay` value.
2226
- */
2227
- #initialPlaying() {
2228
- if (this.hasPlayToggleTarget && this.playToggleTarget.hasAttribute("aria-pressed")) {
2229
- return this.playToggleTarget.getAttribute("aria-pressed") === "true";
2230
- }
2231
- return this.autoplayValue;
2345
+ /** Follows a rotation intent the page changed at runtime. */
2346
+ autoplayValueChanged() {
2347
+ if (this.#connected) this.#syncTimer();
2232
2348
  }
2233
- /** Clears the autoplay interval so it never fires after teardown. */
2234
- disconnect() {
2235
- this.#connected = false;
2236
- this.#reconcileTargets.cancel();
2237
- this.#intervals.clearAll();
2238
- this.#timerId = null;
2349
+ /** Re-arms the live interval at the new delay without reporting a state change. */
2350
+ intervalValueChanged() {
2351
+ if (this.#connected) this.#syncTimer();
2239
2352
  }
2240
- /** Advances to the next slide. Bound via `data-action`. */
2241
- next() {
2353
+ /** Re-publishes the step controls and re-evaluates the non-looping end. */
2354
+ loopValueChanged() {
2355
+ if (!this.#connected) return;
2356
+ this.#render({ focus: false });
2357
+ this.#syncTimer();
2358
+ }
2359
+ /** Advances to the next slide. Delegated; `data-action` wiring is optional. */
2360
+ next(event) {
2361
+ if (event?.defaultPrevented) return;
2362
+ this.#markHandled(event);
2242
2363
  this.#select(this.#step(1), { focus: false });
2243
2364
  }
2244
- /** Returns to the previous slide. Bound via `data-action`. */
2245
- prev() {
2365
+ /** Returns to the previous slide. Delegated; `data-action` wiring is optional. */
2366
+ prev(event) {
2367
+ if (event?.defaultPrevented) return;
2368
+ this.#markHandled(event);
2246
2369
  this.#select(this.#step(-1), { focus: false });
2247
2370
  }
2248
2371
  /** Jumps to the slide whose picker was activated (click / Enter / Space). */
2249
2372
  goto(event) {
2250
- const target = event.currentTarget;
2251
- const index = this.pickerTargets.indexOf(target);
2373
+ if (event.defaultPrevented) return;
2374
+ this.#markHandled(event);
2375
+ const index = this.#pickerIndexFor(event.currentTarget);
2252
2376
  if (index !== -1) this.#select(index, { focus: false });
2253
2377
  }
2254
- /** Toggles autoplay on the user's explicit request and syncs the timer. */
2255
- togglePlay() {
2256
- this.#playing = !this.#playing;
2257
- this.#syncTimer();
2378
+ /**
2379
+ * Flips the rotation intent on the user's explicit request.
2380
+ *
2381
+ * The intent is written back to the `autoplay` Value, which is where every other
2382
+ * path reads it from. A carousel with nothing to advance to has no intent to
2383
+ * flip: the toggle is marked `aria-disabled` and does nothing.
2384
+ */
2385
+ togglePlay(event) {
2386
+ if (event?.defaultPrevented) return;
2387
+ this.#markHandled(event);
2388
+ this.#togglePlay();
2258
2389
  }
2259
2390
  /**
2260
- * Suspends autoplay. Hover (`mouseenter`) is a temporary suspension that resumes
2261
- * on leave; keyboard focus (`focusin`) is a hard stop that turns autoplay off so
2262
- * it cannot resume without an explicit play (WCAG 2.2.2).
2391
+ * Suspends rotation. A `focus`-family event records that focus is inside the
2392
+ * carousel; anything else — hover, or a bare programmatic call — records the
2393
+ * pointer suspension. Both lift through the matching {@link resume}, and
2394
+ * neither touches the rotation intent.
2263
2395
  */
2264
2396
  pause(event) {
2265
- if (event?.type.startsWith("focus")) {
2266
- this.#playing = false;
2267
- } else {
2268
- this.#pointerPaused = true;
2269
- }
2397
+ this.#markHandled(event);
2398
+ if (isFocusEvent(event)) this.#focusPaused = true;
2399
+ else this.#pointerPaused = true;
2270
2400
  this.#syncTimer();
2271
2401
  }
2272
2402
  /**
2273
- * Lifts a hover suspension (`mouseleave`) and resumes autoplay if it is still
2274
- * on. A `focusout` does nothing here: the focus pause was a hard stop, so the
2275
- * user must press play to restart.
2403
+ * Lifts the matching suspension. A `focusout` whose `relatedTarget` is still
2404
+ * inside the carousel is focus moving between its own controls, which leaves the
2405
+ * focus suspension in place — releasing it there would stop and restart the
2406
+ * interval on every Tab press.
2276
2407
  */
2277
2408
  resume(event) {
2278
- if (event?.type.startsWith("focus")) return;
2279
- this.#pointerPaused = false;
2409
+ this.#markHandled(event);
2410
+ if (isFocusEvent(event)) {
2411
+ if (this.#focusStaysInside(event)) return;
2412
+ this.#focusPaused = false;
2413
+ } else {
2414
+ this.#pointerPaused = false;
2415
+ }
2280
2416
  this.#syncTimer();
2281
2417
  }
2282
- /** Picker roving: arrows move focus only; Home/End activate first/last slide. */
2418
+ /** Picker roving for authored bindings; the delegated path is `#onKeydown`. */
2283
2419
  onPickerKeydown(event) {
2420
+ const index = this.#pickerIndexFor(event.currentTarget);
2421
+ if (index === -1) return;
2422
+ this.#markHandled(event);
2423
+ this.#handlePickerKeydown(event, index);
2424
+ }
2425
+ /** Delegated activation for pickers and step controls without authored actions. */
2426
+ #onClick = (event) => this.#delegate(event, () => {
2427
+ const index = this.#pickerIndexFor(event.target);
2428
+ if (index !== -1) {
2429
+ this.#select(index, { focus: false });
2430
+ } else if (hits(this.nextTargets, event.target)) {
2431
+ this.#select(this.#step(1), { focus: false });
2432
+ } else if (hits(this.prevTargets, event.target)) {
2433
+ this.#select(this.#step(-1), { focus: false });
2434
+ } else if (hits(this.playToggleTargets, event.target)) {
2435
+ this.#togglePlay();
2436
+ }
2437
+ });
2438
+ /** Delegated picker roving for pickers without authored actions. */
2439
+ #onKeydown = (event) => this.#delegate(event, () => {
2440
+ const index = this.#pickerIndexFor(event.target);
2441
+ if (index !== -1) this.#handlePickerKeydown(event, index);
2442
+ });
2443
+ /** Focus arriving anywhere inside suspends the rotation. */
2444
+ #onFocusin = (event) => this.#delegate(event, () => {
2445
+ this.#focusPaused = true;
2446
+ this.#syncTimer();
2447
+ });
2448
+ /** Focus genuinely leaving lifts the suspension; moves between own controls do not. */
2449
+ #onFocusout = (event) => this.#delegate(event, () => {
2450
+ if (this.#focusStaysInside(event)) return;
2451
+ this.#focusPaused = false;
2452
+ this.#syncTimer();
2453
+ });
2454
+ /** Pointer entry suspends the rotation. */
2455
+ #onMouseenter = (event) => this.#delegate(event, () => {
2456
+ this.#pointerPaused = true;
2457
+ this.#syncTimer();
2458
+ });
2459
+ /** Pointer exit lifts the suspension. */
2460
+ #onMouseleave = (event) => this.#delegate(event, () => {
2461
+ this.#pointerPaused = false;
2462
+ this.#syncTimer();
2463
+ });
2464
+ /**
2465
+ * Schedules one reconciliation when a state attribute is rewritten in place —
2466
+ * the only shape of change no target callback reports.
2467
+ *
2468
+ * The filter is the `attributeFilter` alone: those three attributes belong to
2469
+ * the slides and pickers, and a coalesced pass over an unrelated one costs a
2470
+ * repaint that writes nothing. Narrowing further here would add a branch no
2471
+ * test could distinguish.
2472
+ */
2473
+ #onAttributeMutations = () => {
2474
+ this.#reconcileTargets.schedule();
2475
+ };
2476
+ /**
2477
+ * Records that an action binding took this event.
2478
+ *
2479
+ * An authored binding sits on the control, so it runs while the event is still
2480
+ * below the controller element and always precedes the delegated listener.
2481
+ * Marking is the one signal that keeps the two wirings from both acting.
2482
+ */
2483
+ #markHandled(event) {
2484
+ if (event) this.#handledEvents.add(event);
2485
+ }
2486
+ /** Runs a delegated handler unless an action binding, or a descendant, took the event. */
2487
+ #delegate(event, run) {
2488
+ if (this.#handledEvents.delete(event) || event.defaultPrevented) return;
2489
+ run();
2490
+ }
2491
+ /** Position of the picker that is or contains `node`, or `-1` when none does. */
2492
+ #pickerIndexFor(node) {
2493
+ return this.pickerTargets.findIndex(
2494
+ (picker) => node instanceof Node && (picker === node || picker.contains(node))
2495
+ );
2496
+ }
2497
+ /** Whether a focus transition lands on another control of this same carousel. */
2498
+ #focusStaysInside(event) {
2499
+ const next = event.relatedTarget;
2500
+ return next instanceof Node && this.element.contains(next);
2501
+ }
2502
+ /** Flips the rotation intent, unless there is nothing to rotate to. */
2503
+ #togglePlay() {
2504
+ if (!this.#canAutoplay()) return;
2505
+ this.autoplayValue = !this.autoplayValue;
2506
+ this.#syncTimer();
2507
+ }
2508
+ /** Applies the APG picker key map: arrows, Home, and End all move focus only. */
2509
+ #handlePickerKeydown(event, current) {
2284
2510
  if (event.defaultPrevented) return;
2285
2511
  if (isReservedArrowChord(event)) return;
2286
- const current = this.pickerTargets.indexOf(event.currentTarget);
2287
- if (current === -1) return;
2288
2512
  const length = this.pickerTargets.length;
2289
2513
  switch (event.key) {
2290
2514
  case "ArrowRight":
@@ -2296,24 +2520,44 @@ var CarouselController = class extends Controller {
2296
2520
  this.#roving.setActive(rovingMove(current, length, step, "wrap"), { focus: true });
2297
2521
  return;
2298
2522
  }
2523
+ // A chorded Home/End is a document-level shortcut (`Control+Home` scrolls
2524
+ // the page) that belongs to the browser, exactly as a chorded arrow does.
2299
2525
  case "Home":
2526
+ if (hasModifier(event)) return;
2300
2527
  event.preventDefault();
2301
- this.#select(0, { focus: true });
2528
+ this.#roving.setActive(0, { focus: true });
2302
2529
  return;
2303
2530
  case "End":
2531
+ if (hasModifier(event)) return;
2304
2532
  event.preventDefault();
2305
- this.#select(length - 1, { focus: true });
2533
+ this.#roving.setActive(length - 1, { focus: true });
2306
2534
  return;
2307
2535
  }
2308
2536
  }
2309
- /** Resolves the index one step away from the current one, honoring `loop`. */
2537
+ /**
2538
+ * Resolves the index one step away from the current one, honoring `loop`.
2539
+ *
2540
+ * Bounding is {@link CarouselController.#clampToSlides}'s job alone, so an
2541
+ * empty set is allowed to fall out of the arithmetic here rather than being
2542
+ * special-cased in two places that could disagree.
2543
+ */
2310
2544
  #step(delta) {
2311
2545
  const total = this.slideTargets.length;
2312
- if (total === 0) return 0;
2313
2546
  const next = this.#index + delta;
2314
2547
  if (this.loopValue) return (next + total) % total;
2315
2548
  return Math.min(total - 1, Math.max(0, next));
2316
2549
  }
2550
+ /**
2551
+ * Confines an index to the slide range, so no index can hide every slide.
2552
+ *
2553
+ * The single place an index is bounded: an empty set collapses to the first
2554
+ * position, which is also where a non-numeric step from that empty set lands.
2555
+ */
2556
+ #clampToSlides(index) {
2557
+ const last = this.slideTargets.length - 1;
2558
+ if (last < 0) return 0;
2559
+ return Math.min(last, Math.max(0, index));
2560
+ }
2317
2561
  /**
2318
2562
  * Changes the active slide, updates state hooks, and emits `change` — but only
2319
2563
  * when the index actually changes, so a `next`/`prev` clamped at the end (or an
@@ -2321,73 +2565,159 @@ var CarouselController = class extends Controller {
2321
2565
  * (matching the "emit on real change" policy of flash/masonry/bulk-select).
2322
2566
  */
2323
2567
  #select(index, { focus }) {
2324
- const changed = index !== this.#index;
2325
- this.#index = index;
2568
+ const target = this.#clampToSlides(index);
2569
+ const changed = target !== this.#index;
2570
+ this.#index = target;
2326
2571
  this.#render({ focus });
2327
2572
  this.#syncTimer();
2328
- if (changed) this.dispatch("change", { detail: { index, total: this.slideTargets.length } });
2573
+ if (changed) {
2574
+ this.dispatch("change", { detail: { index: target, total: this.slideTargets.length } });
2575
+ }
2329
2576
  }
2330
2577
  /**
2331
- * Reflects `this.#index` onto slides and pickers (state hooks + roving).
2578
+ * Reflects `this.#index` onto the slides, the pickers, and the step controls.
2579
+ *
2580
+ * Every observed attribute is written only when its value actually changes: the
2581
+ * same writes are watched by {@link CarouselController.#onAttributeMutations},
2582
+ * and an unconditional rewrite would feed the controller its own output.
2332
2583
  *
2333
2584
  * @stimeoRenderRoot
2334
2585
  */
2335
2586
  #render({ focus }) {
2587
+ this.#activeSlide = this.slideTargets[this.#index] ?? null;
2336
2588
  this.slideTargets.forEach((slide, i) => {
2337
2589
  const active = i === this.#index;
2338
- slide.setAttribute("data-state", active ? "active" : "inactive");
2339
- slide.hidden = !active;
2590
+ setAttributeIfChanged(slide, "data-state", active ? "active" : "inactive");
2591
+ if (slide.hidden !== !active) slide.hidden = !active;
2592
+ slide.toggleAttribute("inert", !active);
2340
2593
  });
2341
2594
  const pickerIndex = Math.min(this.#index, this.pickerTargets.length - 1);
2342
2595
  this.pickerTargets.forEach((picker, i) => {
2343
- picker.setAttribute("aria-selected", i === pickerIndex ? "true" : "false");
2596
+ setAttributeIfChanged(picker, "aria-selected", i === pickerIndex ? "true" : "false");
2344
2597
  });
2345
2598
  this.#roving.setActive(pickerIndex, { focus });
2599
+ this.#syncStepControls();
2346
2600
  }
2347
- /** Keeps the live active slide when possible and otherwise selects the nearest survivor. */
2348
- #reconcileTargetSet() {
2349
- const activeSlide = this.slideTargets.findIndex(
2350
- (slide) => slide.getAttribute("data-state") === "active"
2351
- );
2352
- const selectedPicker = this.pickerTargets.findIndex(
2601
+ /** Marks the step control a non-looping carousel has no slide left to reach. */
2602
+ #syncStepControls() {
2603
+ const last = this.slideTargets.length - 1;
2604
+ const atStart = !this.loopValue && this.#index <= 0;
2605
+ const atEnd = !this.loopValue && this.#index >= last;
2606
+ for (const button of this.prevTargets) {
2607
+ this.#ariaDisabled.write(button, atStart ? "true" : null);
2608
+ }
2609
+ for (const button of this.nextTargets) {
2610
+ this.#ariaDisabled.write(button, atEnd ? "true" : null);
2611
+ }
2612
+ }
2613
+ /**
2614
+ * Resolves which slide is current from the strongest evidence available.
2615
+ *
2616
+ * A single `data-state="active"` is unambiguous and wins, which is what restores
2617
+ * the visible slide after a Turbo cache restore even with no pickers present.
2618
+ * Competing claims — a slide inserted already marked active — are settled by the
2619
+ * element the last render actually showed, so a newcomer never displaces what the
2620
+ * reader is looking at. With no claim at all the authored picker selection
2621
+ * decides, first in DOM order; failing that the previous position is kept.
2622
+ */
2623
+ #resolveIndex() {
2624
+ const slides = this.slideTargets;
2625
+ const claims = [];
2626
+ slides.forEach((slide, index) => {
2627
+ if (slide.getAttribute("data-state") === "active") claims.push(index);
2628
+ });
2629
+ if (claims.length > 1 && this.#activeSlide !== null) {
2630
+ const live = slides.indexOf(this.#activeSlide);
2631
+ if (claims.includes(live)) return live;
2632
+ }
2633
+ const claimed = claims[0];
2634
+ if (claimed !== void 0) return claimed;
2635
+ const selected = this.pickerTargets.findIndex(
2353
2636
  (picker) => picker.getAttribute("aria-selected") === "true"
2354
2637
  );
2355
- const lastSlide = this.slideTargets.length - 1;
2356
- const candidate = activeSlide !== -1 ? activeSlide : selectedPicker !== -1 ? selectedPicker : this.#index;
2357
- const previous = this.#index;
2358
- this.#index = lastSlide < 0 ? 0 : Math.min(lastSlide, Math.max(0, candidate));
2638
+ return this.#clampToSlides(selected === -1 ? this.#index : selected);
2639
+ }
2640
+ /** Re-resolves the active slide after the target set changed and reports the move. */
2641
+ #reconcileTargetSet() {
2642
+ const previousIndex = this.#index;
2643
+ const previousTotal = this.#total;
2644
+ this.#index = this.#resolveIndex();
2359
2645
  this.#render({ focus: false });
2646
+ this.#total = this.slideTargets.length;
2360
2647
  this.#syncTimer();
2361
- if (this.#index !== previous) {
2362
- this.dispatch("reconcile", {
2363
- detail: { index: this.#index, total: this.slideTargets.length }
2364
- });
2648
+ if (this.#index !== previousIndex || this.#total !== previousTotal) {
2649
+ this.dispatch("reconcile", { detail: { index: this.#index, total: this.#total } });
2365
2650
  }
2366
2651
  }
2652
+ /** Whether autoplay has anywhere left to advance to. */
2653
+ #canAutoplay() {
2654
+ const total = this.slideTargets.length;
2655
+ if (total <= 1) return false;
2656
+ return this.loopValue || this.#index < total - 1;
2657
+ }
2658
+ /** The advance delay; a non-finite or non-positive declaration falls back. */
2659
+ get #interval() {
2660
+ const declared = this.intervalValue;
2661
+ return Number.isFinite(declared) && declared > 0 ? declared : DEFAULT_INTERVAL;
2662
+ }
2367
2663
  /**
2368
- * Drives the autoplay interval toward the desired state. Autoplay should run
2369
- * only when the user wants it (`playing`), the pointer is not hovering, and more
2370
- * than one slide exists. Transitions emit `play`/`pause` and keep the toggle's
2371
- * `aria-pressed` in sync.
2664
+ * Drives the autoplay interval toward the desired state and publishes it.
2665
+ *
2666
+ * Rotation runs when the intent is on, nothing suspends it, and a slide is left
2667
+ * to advance to. A carousel that has run out normalizes the intent to `false`, so
2668
+ * `aria-pressed` never claims a rotation that cannot happen. Crossing the
2669
+ * run/stop boundary emits `play`/`pause`; re-arming at a new `interval` is the
2670
+ * same state and stays silent.
2372
2671
  */
2373
2672
  #syncTimer() {
2374
- if (!this.loopValue && this.#index >= this.slideTargets.length - 1) {
2375
- this.#playing = false;
2376
- }
2377
- const shouldRun = this.#playing && !this.#pointerPaused && this.slideTargets.length > 1;
2378
- if (shouldRun && this.#timerId === null) {
2379
- this.#timerId = this.#intervals.set(() => this.next(), this.intervalValue);
2380
- this.dispatch("play");
2381
- } else if (!shouldRun && this.#timerId !== null) {
2673
+ const canAutoplay = this.#canAutoplay();
2674
+ if (this.autoplayValue && !canAutoplay) this.autoplayValue = false;
2675
+ const shouldRun = this.autoplayValue && canAutoplay && !this.#pointerPaused && !this.#focusPaused && !this.#hiddenPaused;
2676
+ const wasRunning = this.#timerId !== null;
2677
+ const interval = this.#interval;
2678
+ if (this.#timerId !== null && (!shouldRun || interval !== this.#timerInterval)) {
2382
2679
  this.#intervals.clear(this.#timerId);
2383
2680
  this.#timerId = null;
2384
- this.dispatch("pause");
2385
2681
  }
2386
- if (this.hasPlayToggleTarget) {
2387
- this.playToggleTarget.setAttribute("aria-pressed", this.#playing ? "true" : "false");
2682
+ if (shouldRun && this.#timerId === null) {
2683
+ this.#timerInterval = interval;
2684
+ this.#timerId = this.#intervals.set(() => this.next(), interval);
2685
+ }
2686
+ setAttributeIfChanged(this.element, "data-state", shouldRun ? "playing" : "paused");
2687
+ if (shouldRun !== wasRunning) {
2688
+ if (shouldRun) this.dispatch("play");
2689
+ else this.dispatch("pause");
2690
+ }
2691
+ for (const toggle of this.playToggleTargets) {
2692
+ toggle.setAttribute("aria-pressed", this.autoplayValue ? "true" : "false");
2693
+ this.#ariaDisabled.write(toggle, canAutoplay ? null : "true");
2388
2694
  }
2695
+ if (this.hasViewportTarget) {
2696
+ this.#ariaLive.write(this.viewportTarget, shouldRun ? "off" : "polite");
2697
+ this.#ariaAtomic.write(this.viewportTarget, "false");
2698
+ }
2699
+ }
2700
+ /** Hands every leased attribute back to the value the consumer authored. */
2701
+ #returnLeases() {
2702
+ this.#ariaDisabled.returnAll();
2703
+ this.#ariaLive.returnAll();
2704
+ this.#ariaAtomic.returnAll();
2389
2705
  }
2390
2706
  };
2707
+ function isFocusEvent(event) {
2708
+ return event?.type.startsWith("focus") === true;
2709
+ }
2710
+ function hasModifier(event) {
2711
+ return event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
2712
+ }
2713
+ function hits(elements, node) {
2714
+ return elements.some(
2715
+ (element) => node instanceof Node && (element === node || element.contains(node))
2716
+ );
2717
+ }
2718
+ function setAttributeIfChanged(element, name, value) {
2719
+ if (element.getAttribute(name) !== value) element.setAttribute(name, value);
2720
+ }
2391
2721
 
2392
2722
  // src/utils/announce.ts
2393
2723
  function announce(message, options = {}) {
@@ -4201,7 +4531,7 @@ var TabindexLoan = class {
4201
4531
 
4202
4532
  // src/controllers/conditional_fields_controller.ts
4203
4533
  var DISABLED_MARKER = "data-conditional-disabled";
4204
- var OBSERVED_ATTRIBUTES = [
4534
+ var OBSERVED_ATTRIBUTES2 = [
4205
4535
  "hidden",
4206
4536
  "aria-hidden",
4207
4537
  "data-visible",
@@ -4263,7 +4593,7 @@ var ConditionalFieldsController = class extends Controller {
4263
4593
  this.#settle();
4264
4594
  this.#observer.observe(this.element, {
4265
4595
  attributes: true,
4266
- attributeFilter: OBSERVED_ATTRIBUTES,
4596
+ attributeFilter: OBSERVED_ATTRIBUTES2,
4267
4597
  childList: true,
4268
4598
  subtree: true
4269
4599
  });
@@ -4751,13 +5081,6 @@ var ContextMenuController = class extends Controller {
4751
5081
  return this.hasMenuTarget && !this.menuTarget.hidden;
4752
5082
  }
4753
5083
  };
4754
-
4755
- // src/utils/reduced_motion.ts
4756
- function prefersReducedMotion() {
4757
- return typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
4758
- }
4759
-
4760
- // src/controllers/count_up_controller.ts
4761
5084
  var CountUpController = class extends Controller {
4762
5085
  static values = {
4763
5086
  duration: { type: Number, default: 1200 },
@@ -5039,6 +5362,22 @@ var CountdownController = class extends Controller {
5039
5362
  return this.element.getAttribute("data-state") ?? "paused";
5040
5363
  }
5041
5364
  };
5365
+
5366
+ // src/utils/half_width.ts
5367
+ var FULL_WIDTH_SHIFT = 65248;
5368
+ function halfWidthChar(char) {
5369
+ if (char >= "\uFF01" && char <= "\uFF5E") {
5370
+ return String.fromCharCode(char.charCodeAt(0) - FULL_WIDTH_SHIFT);
5371
+ }
5372
+ return char === "\u3000" ? " " : char;
5373
+ }
5374
+ function toHalfWidth(text) {
5375
+ let out = "";
5376
+ for (const char of text) out += halfWidthChar(char);
5377
+ return out;
5378
+ }
5379
+
5380
+ // src/controllers/currency_input_controller.ts
5042
5381
  var CurrencyInputController = class extends Controller {
5043
5382
  static targets = ["display", "field", "srValue"];
5044
5383
  static values = {
@@ -5050,19 +5389,92 @@ var CurrencyInputController = class extends Controller {
5050
5389
  static events = ["change"];
5051
5390
  /** Last committed numeric value, to suppress duplicate `change` dispatches. */
5052
5391
  #lastValue = null;
5392
+ #started = false;
5393
+ /** Validated mirrors of the Values; the hot path never reads a raw Value. */
5394
+ #locale = "en-US";
5395
+ #currency = "";
5396
+ #precision = 2;
5397
+ /** Formatters rebuilt only when a Value changes, never per keystroke. */
5398
+ #grouping;
5399
+ #fixed;
5400
+ #accessible;
5401
+ #group = ",";
5402
+ #decimal = ".";
5403
+ /** The locale's non-Latin digits mapped back to ASCII (empty for Latin locales). */
5404
+ #digits = /* @__PURE__ */ new Map();
5405
+ /** Holds mid-composition input so the IME's uncommitted text is never rewritten. */
5406
+ #composition = new CompositionTracker({
5407
+ onEnd: () => this.#reformat(false)
5408
+ });
5409
+ /** Re-validates on declaration changes and re-renders the committed display. */
5410
+ localeValueChanged() {
5411
+ this.#applyValueChange();
5412
+ }
5413
+ currencyValueChanged() {
5414
+ this.#applyValueChange();
5415
+ }
5416
+ precisionValueChanged() {
5417
+ this.#applyValueChange();
5418
+ }
5419
+ /**
5420
+ * Scans the display under the *outgoing* configuration (its separators wrote
5421
+ * that text), then revalidates and re-renders under the new one — so a locale
5422
+ * switch re-interprets the value, never the old text with new separators.
5423
+ */
5424
+ #applyValueChange() {
5425
+ const scan = this.#started && this.hasDisplayTarget ? this.#scan(this.displayTarget.value) : null;
5426
+ this.#revalidate();
5427
+ if (!scan || !this.hasDisplayTarget) return;
5428
+ if (document.activeElement === this.displayTarget) {
5429
+ const formatted = this.#render(scan.parts);
5430
+ this.displayTarget.value = formatted;
5431
+ this.#reflect(scan.value, formatted);
5432
+ } else if (scan.value === null) {
5433
+ this.displayTarget.value = "";
5434
+ this.#reflect(null, "");
5435
+ } else {
5436
+ const rounded = round(scan.value, this.#precision);
5437
+ const formatted = this.#fixed.format(rounded);
5438
+ this.displayTarget.value = formatted;
5439
+ this.#reflect(rounded, formatted);
5440
+ }
5441
+ }
5053
5442
  /** Normalizes any pre-filled display value to its fixed-precision form. */
5054
5443
  connect() {
5444
+ this.#started = true;
5055
5445
  if (!this.hasDisplayTarget) return;
5056
- const parsed = this.#parse(this.displayTarget.value);
5057
- this.#lastValue = parsed === null ? null : round(parsed, this.precisionValue);
5058
- if (this.displayTarget.value.trim() !== "") {
5059
- this.#reformat(true);
5060
- } else {
5446
+ const { value } = this.#scan(this.displayTarget.value);
5447
+ this.#lastValue = value === null ? null : round(value, this.#precision);
5448
+ if (value === null) {
5449
+ this.displayTarget.value = "";
5061
5450
  this.#reflect(null, "");
5451
+ } else {
5452
+ this.#reformat(true);
5062
5453
  }
5063
5454
  }
5455
+ disconnect() {
5456
+ this.#started = false;
5457
+ this.#composition.disconnect();
5458
+ }
5459
+ /** Tracks composition on an arriving (or swapped-in) display and normalizes it. */
5460
+ displayTargetConnected(target) {
5461
+ this.#composition.observe(target);
5462
+ if (this.#started) this.#reformat(true);
5463
+ }
5464
+ displayTargetDisconnected(target) {
5465
+ this.#composition.unobserve(target);
5466
+ }
5467
+ /** Syncs a late-arriving hidden field without touching the display or events. */
5468
+ fieldTargetConnected() {
5469
+ if (this.#started) this.#resync();
5470
+ }
5471
+ /** Syncs a late-arriving screen-reader span the same way. */
5472
+ srValueTargetConnected() {
5473
+ if (this.#started) this.#resync();
5474
+ }
5064
5475
  /** Re-groups digits as the user types, preserving the caret position. */
5065
- onInput() {
5476
+ onInput(event) {
5477
+ if (this.#composition.isComposing(event)) return;
5066
5478
  this.#reformat(false);
5067
5479
  }
5068
5480
  /** Applies the fixed-precision rounding on blur. */
@@ -5071,112 +5483,212 @@ var CurrencyInputController = class extends Controller {
5071
5483
  }
5072
5484
  /**
5073
5485
  * Parses the display value, rewrites it grouped (optionally at fixed
5074
- * precision), keeps the caret stable by digit count, and syncs the field,
5075
- * the screen-reader span, and the `change` event.
5486
+ * precision), keeps the caret stable by significant characters, and syncs the
5487
+ * field, the screen-reader span, and the `change` event.
5488
+ *
5489
+ * @stimeoRenderRoot
5076
5490
  */
5077
5491
  #reformat(fixedPrecision) {
5078
5492
  if (!this.hasDisplayTarget) return;
5079
5493
  const raw = this.displayTarget.value;
5080
- const number = this.#parse(raw);
5081
- if (number === null) {
5082
- this.displayTarget.value = "";
5083
- this.#reflect(null, "");
5494
+ const { parts, value } = this.#scan(raw);
5495
+ if (fixedPrecision) {
5496
+ if (value === null) {
5497
+ this.displayTarget.value = "";
5498
+ this.#reflect(null, "");
5499
+ return;
5500
+ }
5501
+ const rounded = round(value, this.#precision);
5502
+ const formatted2 = this.#fixed.format(rounded);
5503
+ this.displayTarget.value = formatted2;
5504
+ this.#reflect(rounded, formatted2);
5084
5505
  return;
5085
5506
  }
5086
- const value = fixedPrecision ? round(number, this.precisionValue) : number;
5087
- const caret = this.displayTarget.selectionStart;
5088
- const digitsBeforeCaret = typeof caret === "number" ? countDigits(raw.slice(0, caret)) : null;
5089
- const formatted = this.#formatNumber(value, fixedPrecision);
5090
- this.displayTarget.value = formatted;
5091
- if (digitsBeforeCaret !== null) this.#restoreCaret(formatted, digitsBeforeCaret);
5092
- this.#reflect(value, formatted);
5507
+ const formatted = this.#render(parts);
5508
+ if (formatted !== raw) {
5509
+ const caret = this.displayTarget.selectionStart;
5510
+ const anchor = caret === null ? null : this.#significantBefore(raw, caret);
5511
+ this.displayTarget.value = formatted;
5512
+ if (anchor !== null) this.#restoreCaret(formatted, anchor);
5513
+ }
5514
+ this.#reflect(value, value === null ? "" : formatted);
5515
+ }
5516
+ /**
5517
+ * The in-progress rendering: grouped integer, sign and fraction as typed.
5518
+ *
5519
+ * @stimeoRenderRoot
5520
+ */
5521
+ #render(parts) {
5522
+ const int = parts.int === "" ? "" : this.#grouping.format(BigInt(parts.int));
5523
+ const frac = parts.hasDot ? this.#decimal + parts.frac : "";
5524
+ return parts.sign + int + frac;
5093
5525
  }
5094
- /** Restores the caret to sit just after the n-th digit of the new string. */
5095
- #restoreCaret(formatted, digitsBefore) {
5526
+ /** Restores the caret to sit just after the n-th significant character. */
5527
+ #restoreCaret(formatted, significantBefore) {
5096
5528
  let seen = 0;
5097
5529
  let position = formatted.length;
5098
5530
  for (let i = 0; i < formatted.length; i++) {
5099
- if (seen >= digitsBefore) {
5531
+ if (seen >= significantBefore) {
5100
5532
  position = i;
5101
5533
  break;
5102
5534
  }
5103
- if (/\d/.test(formatted[i])) seen += 1;
5535
+ if (this.#isSignificant(formatted[i])) seen += 1;
5104
5536
  }
5105
5537
  try {
5106
5538
  this.displayTarget.setSelectionRange(position, position);
5107
5539
  } catch {
5108
5540
  }
5109
5541
  }
5110
- /** Writes the normalized value to the field, the SR span, and `change`. */
5111
- #reflect(value, formatted) {
5542
+ /**
5543
+ * Counts the characters before `caret` that survive into the rendering,
5544
+ * applying the same acceptance rules as {@link #scan} — a rejected keystroke
5545
+ * (a mid-string sign, a second decimal mark) must not shift the anchor.
5546
+ */
5547
+ #significantBefore(text, caret) {
5548
+ let count = 0;
5549
+ let sawSign = false;
5550
+ let sawDigit = false;
5551
+ let sawDot = false;
5552
+ for (const ch of this.#normalize(text.slice(0, caret))) {
5553
+ if (ch >= "0" && ch <= "9") {
5554
+ count += 1;
5555
+ sawDigit = true;
5556
+ } else if ((ch === "-" || ch === "+") && !sawSign && !sawDigit && !sawDot) {
5557
+ count += 1;
5558
+ sawSign = true;
5559
+ } else if (this.#isDecimalMark(ch) && !sawDot) {
5560
+ count += 1;
5561
+ sawDot = true;
5562
+ }
5563
+ }
5564
+ return count;
5565
+ }
5566
+ /** Digits, signs, and the decimal mark anchor the caret; grouping does not. */
5567
+ #isSignificant(ch) {
5568
+ if (ch >= "0" && ch <= "9") return true;
5569
+ if (ch === "-" || ch === "+") return true;
5570
+ return ch === this.#decimal || ch === "." && this.#group !== ".";
5571
+ }
5572
+ /** Writes the normalized value to the field, the SR span, and the empty hook. */
5573
+ #write(value) {
5112
5574
  const isEmpty = value === null;
5113
5575
  if (this.hasFieldTarget) this.fieldTarget.value = isEmpty ? "" : String(value);
5114
5576
  if (this.hasSrValueTarget) {
5115
- this.srValueTarget.textContent = isEmpty ? "" : this.#accessibleText(value);
5577
+ this.srValueTarget.textContent = isEmpty ? "" : this.#accessible.format(value);
5116
5578
  }
5117
5579
  this.element.toggleAttribute("data-stimeo--currency-input-empty", isEmpty);
5580
+ }
5581
+ /** {@link #write}, then reports a moved value as `change` (`""` rides with `null`). */
5582
+ #reflect(value, formatted) {
5583
+ this.#write(value);
5118
5584
  if (value !== this.#lastValue) {
5119
5585
  this.#lastValue = value;
5120
- if (!isEmpty) this.dispatch("change", { detail: { value, formatted } });
5586
+ this.dispatch("change", { detail: { value, formatted } });
5587
+ }
5588
+ }
5589
+ /**
5590
+ * Scans arbitrary input text into its number-shaped parts and value. Keeps
5591
+ * digits, one leading sign, one decimal mark, and the fraction verbatim;
5592
+ * everything else (grouping, symbols, words) is dropped. An ASCII "." is only
5593
+ * a decimal mark where it is not the locale's grouping separator. Precision
5594
+ * plays no part here — rounding belongs to the blur-time format.
5595
+ */
5596
+ #scan(text) {
5597
+ let sign = "";
5598
+ let int = "";
5599
+ let hasDot = false;
5600
+ let frac = "";
5601
+ for (const ch of this.#normalize(text)) {
5602
+ if (ch >= "0" && ch <= "9") {
5603
+ if (hasDot) frac += ch;
5604
+ else int += ch;
5605
+ } else if ((ch === "-" || ch === "+") && sign === "" && int === "" && !hasDot) {
5606
+ sign = ch;
5607
+ } else if (this.#isDecimalMark(ch) && !hasDot) {
5608
+ hasDot = true;
5609
+ }
5121
5610
  }
5611
+ const parts = { sign, int, hasDot, frac };
5612
+ const value = Number(`${sign}${int}.${frac}`);
5613
+ return { parts, value: Number.isFinite(value) ? value : null };
5122
5614
  }
5123
- /** Parses arbitrary input text into a finite number, or `null` when blank. */
5124
- #parse(text) {
5125
- if (text.trim() === "") return null;
5126
- const { decimal } = this.#separators();
5127
- let cleaned = "";
5128
- let sawDot = false;
5129
- for (let i = 0; i < text.length; i++) {
5130
- const ch = text[i];
5131
- if (ch >= "0" && ch <= "9") cleaned += ch;
5132
- else if ((ch === "-" || ch === "+") && cleaned === "") cleaned += ch;
5133
- else if ((ch === decimal || ch === ".") && !sawDot) {
5134
- cleaned += ".";
5135
- sawDot = true;
5615
+ /** Whether `ch` reads as this locale's decimal mark. */
5616
+ #isDecimalMark(ch) {
5617
+ return ch === this.#decimal || ch === "." && this.#group !== ".";
5618
+ }
5619
+ /** Re-syncs field / srValue / hook from the current display without dispatching. */
5620
+ #resync() {
5621
+ if (!this.hasDisplayTarget) return;
5622
+ this.#write(this.#scan(this.displayTarget.value).value);
5623
+ }
5624
+ /**
5625
+ * Validates the declared Values, falling back to each Value's default when a
5626
+ * declaration cannot be interpreted (a malformed locale or currency tag, a
5627
+ * precision outside `Intl`'s 0–100 integer range), and rebuilds the cached
5628
+ * formatters from the validated set.
5629
+ */
5630
+ #revalidate() {
5631
+ this.#locale = "en-US";
5632
+ try {
5633
+ new Intl.NumberFormat(this.localeValue);
5634
+ this.#locale = this.localeValue;
5635
+ } catch {
5636
+ }
5637
+ const precision = this.precisionValue;
5638
+ this.#precision = Number.isInteger(precision) && precision >= 0 && precision <= 100 ? precision : 2;
5639
+ this.#currency = "";
5640
+ if (this.currencyValue !== "") {
5641
+ try {
5642
+ new Intl.NumberFormat(this.#locale, { style: "currency", currency: this.currencyValue });
5643
+ this.#currency = this.currencyValue;
5644
+ } catch {
5136
5645
  }
5137
5646
  }
5138
- if (cleaned === "" || cleaned === "-" || cleaned === "+" || cleaned === ".") return null;
5139
- const value = Number(cleaned);
5140
- return Number.isFinite(value) ? value : null;
5141
- }
5142
- /** Formats a number with grouping for the display field. */
5143
- #formatNumber(value, fixedPrecision) {
5144
- const formatter = new Intl.NumberFormat(this.localeValue, {
5647
+ this.#grouping = new Intl.NumberFormat(this.#locale, {
5648
+ useGrouping: true,
5649
+ maximumFractionDigits: 0
5650
+ });
5651
+ this.#fixed = new Intl.NumberFormat(this.#locale, {
5145
5652
  useGrouping: true,
5146
- minimumFractionDigits: fixedPrecision ? this.precisionValue : 0,
5147
- maximumFractionDigits: this.precisionValue
5653
+ minimumFractionDigits: this.#precision,
5654
+ maximumFractionDigits: this.#precision
5148
5655
  });
5149
- return formatter.format(value);
5150
- }
5151
- /** The text announced to assistive tech (currency-aware when configured). */
5152
- #accessibleText(value) {
5153
- if (this.currencyValue) {
5154
- return new Intl.NumberFormat(this.localeValue, {
5155
- style: "currency",
5156
- currency: this.currencyValue
5157
- }).format(value);
5158
- }
5159
- return new Intl.NumberFormat(this.localeValue, {
5160
- minimumFractionDigits: this.precisionValue,
5161
- maximumFractionDigits: this.precisionValue
5162
- }).format(value);
5163
- }
5164
- /** Resolves the locale's grouping and decimal separator characters. */
5165
- #separators() {
5166
- const parts = new Intl.NumberFormat(this.localeValue).formatToParts(11111.1);
5167
- const group = parts.find((p) => p.type === "group")?.value ?? ",";
5168
- const decimal = parts.find((p) => p.type === "decimal")?.value ?? ".";
5169
- return { group, decimal };
5656
+ this.#accessible = this.#currency ? new Intl.NumberFormat(this.#locale, { style: "currency", currency: this.#currency }) : this.#fixed;
5657
+ const parts = new Intl.NumberFormat(this.#locale).formatToParts(11111.1);
5658
+ this.#group = parts.find((p) => p.type === "group")?.value ?? ",";
5659
+ this.#decimal = parts.find((p) => p.type === "decimal")?.value ?? ".";
5660
+ this.#digits.clear();
5661
+ const digitFormatter = new Intl.NumberFormat(this.#locale, { useGrouping: false });
5662
+ for (let i = 0; i <= 9; i++) {
5663
+ const digit = digitFormatter.format(i);
5664
+ if (digit !== String(i)) this.#digits.set(digit, String(i));
5665
+ }
5666
+ }
5667
+ /**
5668
+ * Maps the locale's own digits to ASCII, folds full-width forms (the digits,
5669
+ * signs, and marks an IME confirms as 0-9+-.,) through the shared
5670
+ * half-width mapping, and reads U+2212 MINUS SIGN as "-", which some locales'
5671
+ * formatted output uses for negatives.
5672
+ */
5673
+ #normalize(text) {
5674
+ let out = "";
5675
+ for (const ch of text) {
5676
+ const mapped = this.#digits.get(ch);
5677
+ if (mapped !== void 0) {
5678
+ out += mapped;
5679
+ } else if (ch === "\u2212") {
5680
+ out += "-";
5681
+ } else {
5682
+ out += halfWidthChar(ch);
5683
+ }
5684
+ }
5685
+ return out;
5170
5686
  }
5171
5687
  };
5172
- function countDigits(text) {
5173
- let count = 0;
5174
- for (const ch of text) if (ch >= "0" && ch <= "9") count += 1;
5175
- return count;
5176
- }
5177
5688
  function round(value, precision) {
5178
5689
  const factor = 10 ** Math.max(0, precision);
5179
5690
  const rounded = Math.round(value * factor) / factor;
5691
+ if (!Number.isFinite(rounded)) return value;
5180
5692
  return rounded === 0 ? 0 : rounded;
5181
5693
  }
5182
5694
  var SORT_CYCLE = ["none", "ascending", "descending"];
@@ -5901,10 +6413,10 @@ var DirectUploadController = class extends Controller {
5901
6413
  announceErrorText: { type: String, default: "" },
5902
6414
  scope: { type: String, default: "" }
5903
6415
  };
5904
- static events = ["progress", "done", "error"];
6416
+ static events = ["progress", "done", "error", "reconcile"];
5905
6417
  #timeouts = new SafeTimeout();
5906
6418
  #rows = /* @__PURE__ */ new Map();
5907
- #beforeCache = new BeforeCacheReset(() => this.#reset());
6419
+ #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
5908
6420
  /** The validated `scope` selector; a broken declaration falls back to `""`. */
5909
6421
  #scopeSelector = "";
5910
6422
  #onInitialize = (event) => {
@@ -6104,6 +6616,16 @@ var DirectUploadController = class extends Controller {
6104
6616
  * Returns the widget to its pre-upload state just before Turbo caches the
6105
6617
  * page, so the snapshot never replays rows for uploads that cannot resume.
6106
6618
  */
6619
+ /**
6620
+ * Rewinds for the snapshot and reports what that discarded. An upload in flight
6621
+ * cannot survive the navigation, so a consumer mirroring the rows would keep a
6622
+ * progress bar that never resolves.
6623
+ */
6624
+ #rewindForCache() {
6625
+ const ids = [...this.#rows.keys()];
6626
+ this.#reset();
6627
+ if (ids.length > 0) this.dispatch("reconcile", { detail: { ids } });
6628
+ }
6107
6629
  #reset() {
6108
6630
  for (const row of this.#rows.values()) row.remove();
6109
6631
  this.#rows.clear();
@@ -7003,36 +7525,122 @@ var EmptyStateController = class extends Controller {
7003
7525
  }
7004
7526
  }
7005
7527
  };
7006
- var FileDropzoneController = class extends Controller {
7007
- static targets = ["zone", "trigger", "input", "list", "item", "itemTemplate", "status"];
7008
- static values = {
7009
- maxSize: { type: Number, default: 0 },
7010
- maxFiles: { type: Number, default: 0 },
7011
- dragLabel: { type: String, default: "Drop files to add them" }
7012
- };
7013
- static actions = ["onChange", "onDragLeave", "onDragOver", "onDrop", "openDialog"];
7014
- static events = ["change", "reject"];
7015
- /** Selected files paired with their rendered item and any preview objectURL. */
7528
+
7529
+ // src/utils/detach_gate.ts
7530
+ var DetachGate = class _DetachGate {
7531
+ /** Set while a probe is queued, waiting for a reconnect to cancel it. */
7532
+ #pending = false;
7533
+ /**
7534
+ * True while a probe is queued — the last disconnect was ambiguous and no
7535
+ * reconnect has cancelled it yet. Read it from `connect()` to tell the
7536
+ * reconnect half of an in-page move from a first connect: a controller whose
7537
+ * initialisation restarts a measurement (a min-duration floor, an elapsed
7538
+ * counter) must skip it for the move, where nothing actually restarted.
7539
+ */
7540
+ get pending() {
7541
+ return this.#pending;
7542
+ }
7543
+ /**
7544
+ * True when the disconnect is definitely a real detach — the element left
7545
+ * the document, or `data-controller` no longer lists the identifier. False
7546
+ * means ambiguous (in-page move or observed-root exit), NOT "alive".
7547
+ */
7548
+ static isDetached(host) {
7549
+ if (!host.element.isConnected) return true;
7550
+ const tokens = (host.element.getAttribute("data-controller") ?? "").split(/\s+/);
7551
+ return !tokens.includes(host.identifier);
7552
+ }
7553
+ /**
7554
+ * Call from `disconnect()`: runs `teardown` synchronously on a definite
7555
+ * detach (fast path), otherwise defers it one microtask — a reconnect
7556
+ * ({@link cancel} from `connect()`) keeps the state, no reconnect runs it.
7557
+ * One microtask is the whole probe window: Stimulus reconnects a moved
7558
+ * element within the same mutation batch, before the checkpoint drains.
7559
+ */
7560
+ disconnected(host, teardown) {
7561
+ if (_DetachGate.isDetached(host)) {
7562
+ this.#pending = false;
7563
+ teardown();
7564
+ return;
7565
+ }
7566
+ this.#pending = true;
7567
+ queueMicrotask(() => {
7568
+ if (!this.#pending) return;
7569
+ this.#pending = false;
7570
+ teardown();
7571
+ });
7572
+ }
7573
+ /**
7574
+ * Disarms a pending probe. Call from `connect()` (the reconnect that proves
7575
+ * an in-page move) and from the head of any teardown path not routed through
7576
+ * {@link disconnected} (disabled-toggle, Escape), so an orphaned probe can
7577
+ * never run the teardown a second time.
7578
+ */
7579
+ cancel() {
7580
+ this.#pending = false;
7581
+ }
7582
+ };
7583
+
7584
+ // src/controllers/file_dropzone_controller.ts
7585
+ var DRAGOVER_ATTRIBUTE = "data-dragover";
7586
+ var INVALID_ATTRIBUTE = "data-stimeo--file-dropzone-invalid";
7587
+ var FileDropzoneController = class extends Controller {
7588
+ static targets = [
7589
+ "zone",
7590
+ "trigger",
7591
+ "input",
7592
+ "list",
7593
+ "item",
7594
+ "itemTemplate",
7595
+ "name",
7596
+ "thumb",
7597
+ "remove"
7598
+ ];
7599
+ static values = {
7600
+ maxSize: { type: Number, default: 0 },
7601
+ maxFiles: { type: Number, default: 0 },
7602
+ allowDuplicates: { type: Boolean, default: false },
7603
+ announceDragText: { type: String, default: "" },
7604
+ announceAddedText: { type: String, default: "" },
7605
+ announceRemovedText: { type: String, default: "" },
7606
+ announceRejectedTypeText: { type: String, default: "" },
7607
+ announceRejectedSizeText: { type: String, default: "" },
7608
+ announceRejectedDuplicateText: { type: String, default: "" },
7609
+ announceRejectedCountText: { type: String, default: "" }
7610
+ };
7611
+ static actions = ["onChange", "onDragLeave", "onDragOver", "onDrop", "openDialog"];
7612
+ static events = ["change", "reject", "reconcile"];
7613
+ /** Selected files paired with their rendered item and any preview objectURL. */
7016
7614
  #entries = [];
7017
- /** Prevents initial and teardown target callbacks from binding outside controller lifetime. */
7018
- #connected = false;
7019
- /** Wires file removal as a delegated listener on the list container. */
7615
+ /** Whether a drag is currently over the zone; the source for `data-dragover`. */
7616
+ #dragging = false;
7617
+ /** Whether this connection already reported its unusable item template. */
7618
+ #warnedTemplate = false;
7619
+ #gate = new DetachGate();
7620
+ #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
7621
+ /** Subscribes to the Turbo cache rewind and re-arms the template diagnostic. */
7020
7622
  connect() {
7021
- this.#connected = true;
7022
- if (this.hasListTarget) this.#bindList(this.listTarget);
7623
+ this.#gate.cancel();
7624
+ this.#warnedTemplate = false;
7625
+ this.#beforeCache.activate();
7023
7626
  }
7024
- /** Revokes any outstanding preview URLs so none leaks across navigations. */
7627
+ /**
7628
+ * Releases the delegated listeners. The selection itself survives an in-page
7629
+ * move and is released only once the gate proves a real detach — revoking a
7630
+ * preview URL on a move would leave a live item pointing at a dead `blob:`.
7631
+ */
7025
7632
  disconnect() {
7026
- this.#connected = false;
7027
7633
  for (const list of this.listTargets) list.removeEventListener("click", this.#onItemClick);
7028
- for (const entry of this.#entries) {
7029
- if (entry.url) URL.revokeObjectURL(entry.url);
7030
- }
7031
- this.#entries.length = 0;
7634
+ this.#beforeCache.deactivate();
7635
+ this.#gate.disconnected(this, () => this.#teardown());
7032
7636
  }
7033
- /** Rebinds removal and restores client-only previews when Turbo replaces the list target. */
7637
+ /**
7638
+ * Binds removal and restores client-only previews for every list this controller
7639
+ * renders into — the one present at connect and any Turbo puts in its place.
7640
+ * This is the only place the listener is attached, so the pair with
7641
+ * {@link listTargetDisconnected} keeps it from outliving the element it is on.
7642
+ */
7034
7643
  listTargetConnected(list) {
7035
- if (!this.#connected) return;
7036
7644
  this.#bindList(list);
7037
7645
  }
7038
7646
  /** Releases only the list target that actually disconnected. */
@@ -7048,82 +7656,147 @@ var FileDropzoneController = class extends Controller {
7048
7656
  }
7049
7657
  /** Opens the native file dialog. Bound via `data-action` (trigger click). */
7050
7658
  openDialog() {
7659
+ if (this.#isDisabled) return;
7051
7660
  this.inputTarget.click();
7052
7661
  }
7053
- /** Adds the files chosen through the native dialog. */
7662
+ /**
7663
+ * Adds the files chosen through the native dialog. The input holds only what
7664
+ * the dialog just returned, so the accepted set is written back over it once
7665
+ * the batch is validated.
7666
+ */
7054
7667
  onChange() {
7055
- if (this.inputTarget.files) this.#addFiles(this.inputTarget.files);
7056
- this.inputTarget.value = "";
7668
+ const files = this.inputTarget.files;
7669
+ if (files) this.#addFiles(files);
7670
+ else this.#syncInput();
7057
7671
  }
7058
- /** Marks the zone as a drop target and announces the affordance in words. */
7672
+ /**
7673
+ * Marks the zone as a drop target and announces the affordance once per drag.
7674
+ * Bound via `data-action` (dragover). Leaving the default alone is what refuses
7675
+ * the drop, so a disabled field and a drag an inner dropzone already claimed
7676
+ * both fall through untouched.
7677
+ */
7059
7678
  onDragOver(event) {
7679
+ if (event.defaultPrevented || this.#isDisabled) return;
7060
7680
  event.preventDefault();
7061
- if (this.hasZoneTarget) this.zoneTarget.setAttribute("data-dragover", "");
7062
- this.#setStatus(this.dragLabelValue);
7681
+ if (this.#dragging) return;
7682
+ this.#dragging = true;
7683
+ if (this.hasZoneTarget) this.zoneTarget.setAttribute(DRAGOVER_ATTRIBUTE, "");
7684
+ announce(fillTemplate(this.announceDragTextValue, { total: this.#entries.length }));
7063
7685
  }
7064
- /** Clears the drag-over flag when the pointer leaves the zone. */
7065
- onDragLeave() {
7066
- if (this.hasZoneTarget) this.zoneTarget.removeAttribute("data-dragover");
7686
+ /**
7687
+ * Clears the drag-over flag when the pointer leaves the zone. Bound via
7688
+ * `data-action` (dragleave). `dragleave` bubbles from every descendant the
7689
+ * pointer crosses, so the flag only drops when the element being entered is
7690
+ * outside the zone (or there is none, the pointer having left the window).
7691
+ */
7692
+ onDragLeave(event) {
7693
+ const next = event.relatedTarget;
7694
+ if (this.hasZoneTarget && next instanceof Node && this.zoneTarget.contains(next)) return;
7695
+ this.#endDrag();
7067
7696
  }
7068
- /** Accepts dropped files, clearing the drag-over state. */
7697
+ /** Accepts dropped files, clearing the drag-over state. Bound via `data-action` (drop). */
7069
7698
  onDrop(event) {
7699
+ if (event.defaultPrevented) return;
7700
+ this.#endDrag();
7701
+ if (this.#isDisabled) return;
7070
7702
  event.preventDefault();
7071
- if (this.hasZoneTarget) this.zoneTarget.removeAttribute("data-dragover");
7072
7703
  if (event.dataTransfer?.files) this.#addFiles(event.dataTransfer.files);
7073
7704
  }
7074
7705
  /**
7075
7706
  * Removes the file whose remove button was clicked. Delegated on the list
7076
7707
  * container rather than bound per item via `data-action`, so it works the instant
7077
7708
  * an item is appended without waiting on Stimulus to wire a freshly created element.
7709
+ * Only the item's declared `remove` target counts, so an authored second control
7710
+ * inside an item does what it says instead of silently discarding the file. The
7711
+ * button has to belong to a tracked item, which is what makes a list this
7712
+ * controller no longer renders into inert — its items moved out with it.
7078
7713
  */
7079
7714
  #onItemClick = (event) => {
7080
- const list = event.currentTarget;
7081
- const button = event.target.closest("button");
7082
- if (!this.#connected || !list || !button || !this.hasListTarget || list !== this.listTarget || !list.contains(button)) {
7083
- return;
7084
- }
7715
+ const button = event.target.closest(
7716
+ 'button[data-stimeo--file-dropzone-target~="remove"]'
7717
+ );
7085
7718
  const index = this.#entries.findIndex((entry) => entry.item.contains(button));
7086
7719
  if (index !== -1) this.#removeAt(index);
7087
7720
  };
7088
- /** Validates each incoming file and renders the accepted ones. */
7721
+ /** Validates each incoming file, renders the accepted ones, and reports the batch. */
7089
7722
  #addFiles(files) {
7090
- let changed = false;
7091
- if (this.hasZoneTarget) this.zoneTarget.removeAttribute("data-stimeo--file-dropzone-invalid");
7723
+ this.#rehome();
7724
+ if (this.hasZoneTarget) this.zoneTarget.removeAttribute(INVALID_ATTRIBUTE);
7725
+ const rejected = /* @__PURE__ */ new Map();
7726
+ const turnedAway = [];
7727
+ let addedName = "";
7728
+ let added = 0;
7092
7729
  for (const file of Array.from(files)) {
7093
7730
  const reason = this.#validate(file);
7094
- if (reason) {
7095
- if (this.hasZoneTarget) {
7096
- this.zoneTarget.setAttribute("data-stimeo--file-dropzone-invalid", "");
7097
- }
7098
- this.#setStatus(file.name);
7099
- this.dispatch("reject", { detail: { file, reason } });
7731
+ if (reason !== null) {
7732
+ if (this.hasZoneTarget) this.zoneTarget.setAttribute(INVALID_ATTRIBUTE, "");
7733
+ const batch = rejected.get(reason);
7734
+ if (batch) batch.count += 1;
7735
+ else rejected.set(reason, { name: file.name, count: 1 });
7736
+ turnedAway.push({ file, reason });
7100
7737
  continue;
7101
7738
  }
7102
- this.#appendFile(file);
7103
- this.#setStatus(file.name);
7104
- changed = true;
7739
+ if (!this.#appendFile(file)) continue;
7740
+ if (added === 0) addedName = file.name;
7741
+ added += 1;
7742
+ }
7743
+ this.#syncInput();
7744
+ if (added > 0) {
7745
+ this.#announce(this.announceAddedTextValue, addedName, added);
7746
+ this.dispatch("change", { detail: { files: this.#files } });
7747
+ }
7748
+ for (const { file, reason } of turnedAway) {
7749
+ this.dispatch("reject", { detail: { file, reason } });
7750
+ }
7751
+ for (const [reason, batch] of rejected) {
7752
+ this.#announce(this.#rejectText(reason), batch.name, batch.count);
7105
7753
  }
7106
- if (changed) this.dispatch("change", { detail: { files: this.#files } });
7107
7754
  }
7108
- /** Returns the rejection reason for `file`, or `null` when it is acceptable. */
7755
+ /**
7756
+ * Returns the rejection reason for `file`, or `null` when it is acceptable.
7757
+ * The file's own defects are decided first, so a full list still tells the user
7758
+ * which files it would never have taken.
7759
+ */
7109
7760
  #validate(file) {
7110
- const limit = this.#effectiveMaxFiles;
7111
- if (limit > 0 && this.#entries.length >= limit) return "count";
7112
7761
  if (!this.#matchesAccept(file)) return "type";
7113
7762
  if (this.maxSizeValue > 0 && file.size > this.maxSizeValue) return "size";
7763
+ if (!this.allowDuplicatesValue && this.#entries.some((entry) => this.#isSame(entry.file, file))) {
7764
+ return "duplicate";
7765
+ }
7766
+ const limit = this.#effectiveMaxFiles;
7767
+ if (limit > 0 && this.#entries.length >= limit) return "count";
7114
7768
  return null;
7115
7769
  }
7116
- /** Builds one preview item (name, optional thumbnail, remove button). */
7770
+ /**
7771
+ * Whether two files are the same selection. `File` objects from separate picks
7772
+ * are never the same reference, so identity is the triple the platform exposes.
7773
+ */
7774
+ #isSame(a, b) {
7775
+ return a.name === b.name && a.size === b.size && a.lastModified === b.lastModified;
7776
+ }
7777
+ /**
7778
+ * Builds one preview item (name, optional thumbnail, remove button) and reports
7779
+ * whether it was rendered.
7780
+ */
7117
7781
  #appendFile(file) {
7118
- if (!this.hasItemTemplateTarget || !this.hasListTarget) return;
7782
+ if (!this.hasListTarget) return this.#warnTemplate('a "list" target to render into');
7783
+ if (!this.hasItemTemplateTarget) return this.#warnTemplate('an "itemTemplate" target');
7119
7784
  const fragment = this.itemTemplateTarget.content.cloneNode(true);
7120
- const item = fragment.querySelector('[data-stimeo--file-dropzone-target="item"]');
7121
- const name = fragment.querySelector('[data-file-dropzone-slot="name"]');
7122
- const thumb = fragment.querySelector('[data-file-dropzone-slot="thumb"]');
7123
- const button = fragment.querySelector("button");
7124
- if (!item) return;
7125
- if (name) name.textContent = file.name;
7126
- if (button) button.setAttribute("aria-label", `Remove ${file.name}`);
7785
+ const item = this.#slot(fragment, "item");
7786
+ const name = this.#slot(fragment, "name");
7787
+ const thumb = this.#slot(fragment, "thumb");
7788
+ const button = fragment.querySelector(
7789
+ 'button[data-stimeo--file-dropzone-target~="remove"]'
7790
+ );
7791
+ const removeName = button?.getAttribute("aria-label")?.trim() ?? "";
7792
+ if (!item) return this.#warnTemplate('an "item" root');
7793
+ if (!name) return this.#warnTemplate('a "name" element');
7794
+ if (!button) return this.#warnTemplate('a "remove" target <button>');
7795
+ if (removeName === "") {
7796
+ return this.#warnTemplate('a non-empty aria-label on its "remove" target');
7797
+ }
7798
+ name.textContent = file.name;
7799
+ button.setAttribute("aria-label", fillTemplate(removeName, { name: file.name }));
7127
7800
  let url;
7128
7801
  if (thumb && file.type.startsWith("image/")) {
7129
7802
  url = URL.createObjectURL(file);
@@ -7135,6 +7808,29 @@ var FileDropzoneController = class extends Controller {
7135
7808
  }
7136
7809
  this.listTarget.appendChild(fragment);
7137
7810
  this.#entries.push({ file, item, url });
7811
+ return true;
7812
+ }
7813
+ /** Resolves one declared part inside a cloned item template. */
7814
+ #slot(fragment, name) {
7815
+ return fragment.querySelector(`[data-stimeo--file-dropzone-target~="${name}"]`);
7816
+ }
7817
+ /**
7818
+ * Reports an unusable item template to the author, once per connection.
7819
+ *
7820
+ * The addition itself stays a no-op — nothing about the selection, the native
7821
+ * input, the announcements, or the events changes. Without this line the only
7822
+ * symptom is a picker that accepts no file at all, and the causes the Inspector
7823
+ * cannot see statically (a server-rendered template, a name that renders empty
7824
+ * from a missing translation) would have no diagnostic anywhere.
7825
+ */
7826
+ #warnTemplate(missing) {
7827
+ if (!this.#warnedTemplate) {
7828
+ this.#warnedTemplate = true;
7829
+ console.warn(
7830
+ `Stimeo UI: "${this.identifier}" added no file because its item template lacks ${missing}.`
7831
+ );
7832
+ }
7833
+ return false;
7138
7834
  }
7139
7835
  /** Removes entry `index`, revokes its preview, and re-homes focus. */
7140
7836
  #removeAt(index) {
@@ -7143,7 +7839,8 @@ var FileDropzoneController = class extends Controller {
7143
7839
  if (entry.url) URL.revokeObjectURL(entry.url);
7144
7840
  entry.item.remove();
7145
7841
  this.#entries.splice(index, 1);
7146
- this.#setStatus(entry.file.name);
7842
+ this.#syncInput();
7843
+ this.#announce(this.announceRemovedTextValue, entry.file.name, 1);
7147
7844
  this.dispatch("change", { detail: { files: this.#files } });
7148
7845
  const buttons = this.#removeButtons;
7149
7846
  if (buttons.length === 0) {
@@ -7166,18 +7863,109 @@ var FileDropzoneController = class extends Controller {
7166
7863
  return type === token;
7167
7864
  });
7168
7865
  }
7169
- /** Updates the live region so assistive tech announces the change. */
7170
- #setStatus(text) {
7171
- if (this.hasStatusTarget) this.statusTarget.textContent = text;
7866
+ /**
7867
+ * Sends one consumer-worded message to the page's shared announcer. `{name}` is
7868
+ * the file the message is about, `{count}` how many files it covers, and
7869
+ * `{total}` how many are selected once the batch has settled.
7870
+ */
7871
+ #announce(template, name, count) {
7872
+ announce(fillTemplate(template, { name, count, total: this.#entries.length }));
7873
+ }
7874
+ /** The consumer's wording for one rejection reason. */
7875
+ #rejectText(reason) {
7876
+ switch (reason) {
7877
+ case "type":
7878
+ return this.announceRejectedTypeTextValue;
7879
+ case "size":
7880
+ return this.announceRejectedSizeTextValue;
7881
+ case "duplicate":
7882
+ return this.announceRejectedDuplicateTextValue;
7883
+ case "count":
7884
+ return this.announceRejectedCountTextValue;
7885
+ }
7886
+ }
7887
+ /**
7888
+ * Mirrors the accepted set onto the native input, so a plain form submit carries
7889
+ * the dropped files. Skipped where `DataTransfer` cannot be constructed: the
7890
+ * widget keeps working and the consumer still receives every `File` on `change`.
7891
+ */
7892
+ #syncInput() {
7893
+ if (!this.hasInputTarget) return;
7894
+ const transfer = this.#newTransfer();
7895
+ if (!transfer) return;
7896
+ for (const entry of this.#entries) transfer.items.add(entry.file);
7897
+ this.inputTarget.files = transfer.files;
7898
+ }
7899
+ /** A usable empty `DataTransfer`, or `null` where the platform has none. */
7900
+ #newTransfer() {
7901
+ try {
7902
+ return new DataTransfer();
7903
+ } catch {
7904
+ return null;
7905
+ }
7906
+ }
7907
+ /**
7908
+ * Moves surviving preview items back under the current list. A morph that
7909
+ * empties the list in place leaves the selection with no rendering, and the
7910
+ * files it holds cannot be rebuilt from the DOM, so the items are re-homed
7911
+ * rather than forgotten.
7912
+ */
7913
+ #rehome() {
7914
+ if (!this.hasListTarget) return;
7915
+ for (const entry of this.#entries) {
7916
+ if (!this.listTarget.contains(entry.item)) this.listTarget.appendChild(entry.item);
7917
+ }
7918
+ }
7919
+ /** Drops the drag-over state, whether the drag ended in a drop or left the zone. */
7920
+ #endDrag() {
7921
+ this.#dragging = false;
7922
+ if (this.hasZoneTarget) this.zoneTarget.removeAttribute(DRAGOVER_ATTRIBUTE);
7923
+ }
7924
+ /**
7925
+ * Discards the selection and every state attribute this controller wrote, so
7926
+ * neither a cached snapshot nor a stranded subtree keeps items whose files are
7927
+ * gone. Silent: `change` means a selection the user changed, and the cache
7928
+ * rewind reports itself as `reconcile` instead.
7929
+ */
7930
+ #rewindForCache() {
7931
+ const had = this.#entries.length > 0;
7932
+ this.#reset();
7933
+ if (had) this.dispatch("reconcile", { detail: { files: this.#files } });
7934
+ }
7935
+ #reset() {
7936
+ for (const entry of this.#entries) {
7937
+ if (entry.url) URL.revokeObjectURL(entry.url);
7938
+ entry.item.remove();
7939
+ }
7940
+ this.#entries.length = 0;
7941
+ this.#syncInput();
7942
+ this.#endDrag();
7943
+ if (this.hasZoneTarget) this.zoneTarget.removeAttribute(INVALID_ATTRIBUTE);
7944
+ }
7945
+ /** Releases the selection once the disconnect is known to be a real detach. */
7946
+ #teardown() {
7947
+ this.#gate.cancel();
7948
+ this.#reset();
7949
+ }
7950
+ /** Whether the field refuses input, natively or through an ancestor `fieldset`. */
7951
+ get #isDisabled() {
7952
+ return this.inputTarget.disabled || inheritsFieldsetDisabled(this.inputTarget);
7172
7953
  }
7173
7954
  /** Effective file cap: `maxFiles`, or 1 when the input is single-select. */
7174
7955
  get #effectiveMaxFiles() {
7175
7956
  if (this.maxFilesValue > 0) return this.maxFilesValue;
7176
7957
  return this.inputTarget.multiple ? 0 : 1;
7177
7958
  }
7178
- /** The remove buttons currently in the list, in order. */
7959
+ /** The declared remove button of each rendered item, in selection order. */
7179
7960
  get #removeButtons() {
7180
- return Array.from(this.listTarget.querySelectorAll("button"));
7961
+ const buttons = [];
7962
+ for (const entry of this.#entries) {
7963
+ const button = entry.item.querySelector(
7964
+ 'button[data-stimeo--file-dropzone-target~="remove"]'
7965
+ );
7966
+ if (button) buttons.push(button);
7967
+ }
7968
+ return buttons;
7181
7969
  }
7182
7970
  /** The accepted files in selection order. */
7183
7971
  get #files() {
@@ -7273,7 +8061,7 @@ var FlashController = class extends Controller {
7273
8061
  max: { type: Number, default: 0 }
7274
8062
  };
7275
8063
  static actions = ["dismiss"];
7276
- static events = ["show", "dismiss"];
8064
+ static events = ["show", "dismiss", "reconcile"];
7277
8065
  #timers = new SafeTimeout();
7278
8066
  #observer = null;
7279
8067
  /** Whether the controller is between `connect()` and `disconnect()`. */
@@ -7319,12 +8107,14 @@ var FlashController = class extends Controller {
7319
8107
  * only: `dismiss` reports a dismissal, and freezing the page is not one.
7320
8108
  */
7321
8109
  #rewindForCache() {
8110
+ const removed = this.#order.length;
7322
8111
  for (const message of [...this.#order]) {
7323
8112
  message.remove();
7324
8113
  this.#forget(message);
7325
8114
  }
7326
8115
  for (const message of this.#leaving) message.remove();
7327
8116
  this.#leaving.clear();
8117
+ if (removed > 0) this.dispatch("reconcile", { detail: { removed } });
7328
8118
  }
7329
8119
  /** Follows a `region` element swapped in — or arriving — at runtime (Turbo Stream). */
7330
8120
  regionTargetConnected() {
@@ -7581,7 +8371,7 @@ var FocusController = class extends Controller {
7581
8371
  this.dispatch("deactivate", { detail: {} });
7582
8372
  }
7583
8373
  };
7584
- var OBSERVED_ATTRIBUTES2 = ["hidden", "id"];
8374
+ var OBSERVED_ATTRIBUTES3 = ["hidden", "id"];
7585
8375
  var FormFieldController = class _FormFieldController extends Controller {
7586
8376
  static targets = ["control", "description", "error"];
7587
8377
  static values = {
@@ -7620,7 +8410,7 @@ var FormFieldController = class _FormFieldController extends Controller {
7620
8410
  this.#reconcileDom();
7621
8411
  this.#observer.observe(this.element, {
7622
8412
  attributes: true,
7623
- attributeFilter: OBSERVED_ATTRIBUTES2,
8413
+ attributeFilter: OBSERVED_ATTRIBUTES3,
7624
8414
  characterData: true,
7625
8415
  childList: true,
7626
8416
  subtree: true
@@ -8117,61 +8907,6 @@ var FormValidationController = class _FormValidationController extends Controlle
8117
8907
  }
8118
8908
  };
8119
8909
 
8120
- // src/utils/detach_gate.ts
8121
- var DetachGate = class _DetachGate {
8122
- /** Set while a probe is queued, waiting for a reconnect to cancel it. */
8123
- #pending = false;
8124
- /**
8125
- * True while a probe is queued — the last disconnect was ambiguous and no
8126
- * reconnect has cancelled it yet. Read it from `connect()` to tell the
8127
- * reconnect half of an in-page move from a first connect: a controller whose
8128
- * initialisation restarts a measurement (a min-duration floor, an elapsed
8129
- * counter) must skip it for the move, where nothing actually restarted.
8130
- */
8131
- get pending() {
8132
- return this.#pending;
8133
- }
8134
- /**
8135
- * True when the disconnect is definitely a real detach — the element left
8136
- * the document, or `data-controller` no longer lists the identifier. False
8137
- * means ambiguous (in-page move or observed-root exit), NOT "alive".
8138
- */
8139
- static isDetached(host) {
8140
- if (!host.element.isConnected) return true;
8141
- const tokens = (host.element.getAttribute("data-controller") ?? "").split(/\s+/);
8142
- return !tokens.includes(host.identifier);
8143
- }
8144
- /**
8145
- * Call from `disconnect()`: runs `teardown` synchronously on a definite
8146
- * detach (fast path), otherwise defers it one microtask — a reconnect
8147
- * ({@link cancel} from `connect()`) keeps the state, no reconnect runs it.
8148
- * One microtask is the whole probe window: Stimulus reconnects a moved
8149
- * element within the same mutation batch, before the checkpoint drains.
8150
- */
8151
- disconnected(host, teardown) {
8152
- if (_DetachGate.isDetached(host)) {
8153
- this.#pending = false;
8154
- teardown();
8155
- return;
8156
- }
8157
- this.#pending = true;
8158
- queueMicrotask(() => {
8159
- if (!this.#pending) return;
8160
- this.#pending = false;
8161
- teardown();
8162
- });
8163
- }
8164
- /**
8165
- * Disarms a pending probe. Call from `connect()` (the reconnect that proves
8166
- * an in-page move) and from the head of any teardown path not routed through
8167
- * {@link disconnected} (disabled-toggle, Escape), so an orphaned probe can
8168
- * never run the teardown a second time.
8169
- */
8170
- cancel() {
8171
- this.#pending = false;
8172
- }
8173
- };
8174
-
8175
8910
  // src/utils/min_duration_floor.ts
8176
8911
  var MinDurationFloor = class {
8177
8912
  #timers;
@@ -8229,7 +8964,7 @@ var FrameLoadingController = class extends Controller {
8229
8964
  minDuration: { type: Number, default: 0 },
8230
8965
  restoreFocus: { type: Boolean, default: true }
8231
8966
  };
8232
- static events = ["start", "end"];
8967
+ static events = ["start", "end", "reconcile"];
8233
8968
  #timeouts = new SafeTimeout();
8234
8969
  #floor = new MinDurationFloor(this.#timeouts);
8235
8970
  #gate = new DetachGate();
@@ -8305,6 +9040,7 @@ var FrameLoadingController = class extends Controller {
8305
9040
  this.#loading = false;
8306
9041
  this.#floor.cancel();
8307
9042
  this.#rewindHooks();
9043
+ this.dispatch("reconcile", { detail: {} });
8308
9044
  }
8309
9045
  /**
8310
9046
  * Clears every hook the loading state writes. Shared by the three ways a load can
@@ -8736,6 +9472,11 @@ var DEFAULT_TOKENS = {
8736
9472
  "*": "[A-Za-z0-9]"
8737
9473
  };
8738
9474
  var UNMASK_ATTR = "data-stimeo--input-mask-unmask";
9475
+ function acceptedForm(regex, char) {
9476
+ if (regex.test(char)) return char;
9477
+ const half = halfWidthChar(char);
9478
+ return half !== char && regex.test(half) ? half : null;
9479
+ }
8739
9480
  function applyMask(value, pattern, tokens) {
8740
9481
  let masked = "";
8741
9482
  let unmasked = "";
@@ -8747,17 +9488,21 @@ function applyMask(value, pattern, tokens) {
8747
9488
  if (regex) totalTokens += 1;
8748
9489
  if (valueIndex >= value.length) continue;
8749
9490
  if (regex) {
8750
- while (valueIndex < value.length && !regex.test(value[valueIndex] ?? "")) valueIndex += 1;
8751
- const char = value[valueIndex];
8752
- if (char === void 0) continue;
8753
- masked += char;
8754
- unmasked += char;
9491
+ let taken = null;
9492
+ while (valueIndex < value.length) {
9493
+ taken = acceptedForm(regex, value[valueIndex] ?? "");
9494
+ if (taken !== null) break;
9495
+ valueIndex += 1;
9496
+ }
9497
+ if (taken === null) continue;
9498
+ masked += taken;
9499
+ unmasked += taken;
8755
9500
  tokenFlags.push(true);
8756
9501
  valueIndex += 1;
8757
9502
  } else {
8758
9503
  masked += patternChar;
8759
9504
  tokenFlags.push(false);
8760
- if (value[valueIndex] === patternChar) valueIndex += 1;
9505
+ if (halfWidthChar(value[valueIndex] ?? "") === halfWidthChar(patternChar)) valueIndex += 1;
8761
9506
  }
8762
9507
  }
8763
9508
  return {
@@ -8767,67 +9512,169 @@ function applyMask(value, pattern, tokens) {
8767
9512
  tokenFlags
8768
9513
  };
8769
9514
  }
9515
+ function compileTokens(declaration) {
9516
+ const map = /* @__PURE__ */ new Map();
9517
+ for (const [key, source] of Object.entries({ ...DEFAULT_TOKENS, ...parseTokens(declaration) })) {
9518
+ try {
9519
+ map.set(key, new RegExp(`^(?:${source})$`));
9520
+ } catch {
9521
+ }
9522
+ }
9523
+ return map;
9524
+ }
9525
+ function parseTokens(declaration) {
9526
+ let parsed;
9527
+ try {
9528
+ parsed = JSON.parse(declaration);
9529
+ } catch {
9530
+ return {};
9531
+ }
9532
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return {};
9533
+ const tokens = {};
9534
+ for (const [key, source] of Object.entries(parsed)) {
9535
+ if (typeof source === "string") tokens[key] = source;
9536
+ }
9537
+ return tokens;
9538
+ }
9539
+ function deletionOf(event) {
9540
+ const inputType = event instanceof InputEvent ? event.inputType : "";
9541
+ if (!inputType.startsWith("delete")) return null;
9542
+ return inputType.endsWith("Forward") ? "forward" : "backward";
9543
+ }
9544
+ function tokenCharOffset(tokenFlags, n) {
9545
+ let seen = 0;
9546
+ for (let i = 0; i < tokenFlags.length; i += 1) {
9547
+ if (!tokenFlags[i]) continue;
9548
+ seen += 1;
9549
+ if (seen === n) return i;
9550
+ }
9551
+ return -1;
9552
+ }
8770
9553
  var InputMaskController = class extends Controller {
8771
9554
  static values = {
8772
9555
  pattern: { type: String, default: "" },
8773
- tokens: { type: Object, default: {} },
9556
+ tokens: { type: String, default: "" },
8774
9557
  unmaskToHidden: { type: Boolean, default: true }
8775
9558
  };
8776
9559
  static actions = ["format"];
8777
- static events = ["change"];
9560
+ static events = ["change", "reconcile"];
9561
+ /** The value this controller last committed, and the baseline both events compare. */
9562
+ #lastValue = null;
9563
+ #started = false;
9564
+ /** Validated token map; the hot path never parses the `tokens` declaration. */
9565
+ #tokens = compileTokens("");
9566
+ /** Holds mid-composition input so the IME's uncommitted text is never rewritten. */
9567
+ #composition = new CompositionTracker({ onEnd: () => this.#reformat("edit") });
9568
+ /** Re-parses the declaration and re-formats under the tokens it now selects. */
9569
+ tokensValueChanged() {
9570
+ this.#tokens = compileTokens(this.tokensValue);
9571
+ if (this.#started) this.#reformat("reconcile");
9572
+ }
9573
+ patternValueChanged() {
9574
+ if (this.#started) this.#reformat("reconcile");
9575
+ }
9576
+ /** Clears a sink it stops maintaining so a submit cannot carry a stale raw value. */
9577
+ unmaskToHiddenValueChanged() {
9578
+ if (!this.#started) return;
9579
+ if (!this.unmaskToHiddenValue) {
9580
+ const sink = this.#resolveSink();
9581
+ if (sink) sink.value = "";
9582
+ }
9583
+ this.#reformat("reconcile");
9584
+ }
8778
9585
  connect() {
8779
- this.#apply();
9586
+ this.#started = true;
9587
+ this.#composition.observe(this.element);
9588
+ this.#lastValue = this.element.value;
9589
+ this.#reformat("reconcile");
9590
+ }
9591
+ disconnect() {
9592
+ this.#started = false;
9593
+ this.#composition.disconnect();
8780
9594
  }
8781
9595
  /** Formats the field on input, preserving the caret. Bound via `data-action`. */
8782
- format() {
8783
- this.#apply();
9596
+ format(event) {
9597
+ if (this.#composition.isComposing(event)) return;
9598
+ this.#reformat("edit", deletionOf(event));
8784
9599
  }
8785
- /** Core reformat: mask the current value, restore the caret, sync, and announce. */
8786
- #apply() {
8787
- if (!this.patternValue) return;
9600
+ /**
9601
+ * Core reformat: mask the current value, restore the caret, sync the sink and
9602
+ * the state hooks, and report a moved value under the event `cause` selects.
9603
+ */
9604
+ #reformat(cause, deletion = null) {
8788
9605
  const input = this.element;
8789
- const previous = input.value;
8790
- const caret = input.selectionStart ?? previous.length;
8791
- const tokens = this.#tokenRegexes();
8792
- const significant = this.#countSignificant(previous.slice(0, caret), tokens);
8793
- const result = applyMask(previous, this.patternValue, tokens);
9606
+ const raw = input.value;
9607
+ if (this.patternValue === "") {
9608
+ this.#flag("data-mask-complete", false);
9609
+ this.#flag("data-mask-empty", raw.length === 0);
9610
+ this.#lastValue = raw;
9611
+ return;
9612
+ }
9613
+ const caret = input.selectionStart ?? raw.length;
9614
+ let anchor = this.#significantBefore(raw.slice(0, caret));
9615
+ let result = applyMask(raw, this.patternValue, this.#tokens);
9616
+ if (deletion !== null && result.masked === this.#lastValue) {
9617
+ const target = deletion === "backward" ? anchor : anchor + 1;
9618
+ const offset = tokenCharOffset(result.tokenFlags, target);
9619
+ if (offset >= 0) {
9620
+ anchor = target - 1;
9621
+ result = applyMask(
9622
+ result.masked.slice(0, offset) + result.masked.slice(offset + 1),
9623
+ this.patternValue,
9624
+ this.#tokens
9625
+ );
9626
+ }
9627
+ }
9628
+ if (input.readOnly || input.disabled || inheritsFieldsetDisabled(input)) {
9629
+ this.#publish(result, raw);
9630
+ this.#lastValue = raw;
9631
+ return;
9632
+ }
8794
9633
  input.value = result.masked;
8795
- this.#restoreCaret(input, result.tokenFlags, significant);
9634
+ this.#restoreCaret(result.tokenFlags, anchor);
9635
+ this.#publish(result, result.masked);
9636
+ if (result.masked === this.#lastValue) return;
9637
+ this.#lastValue = result.masked;
9638
+ const detail = { masked: result.masked, unmasked: result.unmasked, complete: result.complete };
9639
+ if (cause === "edit") this.dispatch("change", { detail });
9640
+ else this.dispatch("reconcile", { detail });
9641
+ }
9642
+ /** Syncs the raw-value sink and the state hooks for the `shown` field text. */
9643
+ #publish(result, shown) {
8796
9644
  if (this.unmaskToHiddenValue) {
8797
- const unmask = this.#unmaskField();
8798
- if (unmask) unmask.value = result.unmasked;
9645
+ const sink = this.#resolveSink();
9646
+ if (sink) sink.value = result.unmasked;
8799
9647
  }
8800
9648
  this.#flag("data-mask-complete", result.complete);
8801
- this.#flag("data-mask-empty", result.masked.length === 0);
8802
- if (result.masked !== previous) {
8803
- this.dispatch("change", {
8804
- detail: { masked: result.masked, unmasked: result.unmasked, complete: result.complete }
8805
- });
8806
- }
9649
+ this.#flag("data-mask-empty", shown.length === 0);
9650
+ }
9651
+ /**
9652
+ * How many token slots the text before the caret fills. Masking that prefix is
9653
+ * what makes a rejected character — or a literal that also matches a token —
9654
+ * count exactly as the rendering counts it, so the caret cannot drift.
9655
+ */
9656
+ #significantBefore(prefix) {
9657
+ return applyMask(prefix, this.patternValue, this.#tokens).unmasked.length;
8807
9658
  }
8808
9659
  /** Places the caret after the `n`-th token char (skipping following literals). */
8809
- #restoreCaret(input, tokenFlags, n) {
9660
+ #restoreCaret(tokenFlags, n) {
8810
9661
  let position;
8811
9662
  if (n <= 0) {
8812
9663
  let i = 0;
8813
9664
  while (i < tokenFlags.length && !tokenFlags[i]) i += 1;
8814
9665
  position = i;
8815
9666
  } else {
8816
- let seen = 0;
8817
- position = tokenFlags.length;
8818
- for (let i = 0; i < tokenFlags.length; i += 1) {
8819
- if (!tokenFlags[i]) continue;
8820
- seen += 1;
8821
- if (seen === n) {
8822
- let j = i + 1;
8823
- while (j < tokenFlags.length && !tokenFlags[j]) j += 1;
8824
- position = j;
8825
- break;
8826
- }
9667
+ const offset = tokenCharOffset(tokenFlags, n);
9668
+ if (offset < 0) {
9669
+ position = tokenFlags.length;
9670
+ } else {
9671
+ let j = offset + 1;
9672
+ while (j < tokenFlags.length && !tokenFlags[j]) j += 1;
9673
+ position = j;
8827
9674
  }
8828
9675
  }
8829
9676
  try {
8830
- input.setSelectionRange(position, position);
9677
+ this.element.setSelectionRange(position, position);
8831
9678
  } catch {
8832
9679
  }
8833
9680
  }
@@ -8836,52 +9683,47 @@ var InputMaskController = class extends Controller {
8836
9683
  * can coexist in one form:
8837
9684
  *
8838
9685
  * 1. **Explicit pairing** — a sink whose attribute value names this input's
8839
- * `id` (`data-stimeo--input-mask-unmask="zip"`), looked up across the form
8840
- * (or the document when the input is form-less).
8841
- * 2. **Nearest container** — otherwise, walking up from the input (stopping at
8842
- * the form boundary), the first *value-less* sink in the closest ancestor.
8843
- * Wrapped input+sink pairs each find their own sink, and the single
8844
- * form-level sink keeps working unchanged. A sink claimed by another
8845
- * input's id is never matched here.
8846
- */
8847
- #unmaskField() {
9686
+ * `id` (`data-stimeo--input-mask-unmask="zip"`).
9687
+ * 2. **Nearest container** — otherwise, walking up from the input, the first
9688
+ * *value-less* sink in the closest ancestor. Wrapped input+sink pairs each
9689
+ * find their own sink, and the single form-level sink keeps working
9690
+ * unchanged. A sink claimed by another input's id is never matched here.
9691
+ *
9692
+ * Both steps only ever consider the associated form's own controls, so a sink
9693
+ * belonging to a different form (or to none) is never written, while one the
9694
+ * `form` attribute associates from elsewhere in the document still resolves —
9695
+ * for either the input or the sink, neither of which needs to contain or be
9696
+ * contained by the form. A form-less input reads the document instead.
9697
+ */
9698
+ #resolveSink() {
9699
+ const candidates = this.#sinkCandidates();
8848
9700
  const id = this.element.id;
8849
9701
  if (id.length > 0) {
8850
- const scope = this.element.form ?? document;
8851
- const quoted = id.replace(/["\\]/g, "\\$&");
8852
- const paired = scope.querySelector(`input[${UNMASK_ATTR}="${quoted}"]`);
9702
+ const paired = candidates.find((sink) => sink.getAttribute(UNMASK_ATTR) === id);
8853
9703
  if (paired) return paired;
8854
9704
  }
9705
+ const free = candidates.filter((sink) => sink.getAttribute(UNMASK_ATTR) === "");
8855
9706
  for (let node = this.element.parentElement; node !== null; node = node.parentElement) {
8856
- const sink = node.querySelector(`input[${UNMASK_ATTR}=""]`);
9707
+ const ancestor = node;
9708
+ const sink = free.find((candidate) => ancestor.contains(candidate));
8857
9709
  if (sink) return sink;
8858
- if (node === this.element.form) break;
8859
9710
  }
8860
9711
  return null;
8861
9712
  }
8862
- /** Counts characters in `text` that fill any token (the caret-significant chars). */
8863
- #countSignificant(text, tokens) {
8864
- let count = 0;
8865
- for (const char of text) {
8866
- for (const regex of tokens.values()) {
8867
- if (regex.test(char)) {
8868
- count += 1;
8869
- break;
8870
- }
8871
- }
8872
- }
8873
- return count;
8874
- }
8875
- /** Compiles the effective token map (defaults with the user `tokens` merged over). */
8876
- #tokenRegexes() {
8877
- const map = /* @__PURE__ */ new Map();
8878
- for (const [key, source] of Object.entries({ ...DEFAULT_TOKENS, ...this.tokensValue })) {
8879
- try {
8880
- map.set(key, new RegExp(`^(?:${source})$`));
8881
- } catch {
8882
- }
9713
+ /**
9714
+ * Every sink the input's form owns, in document order — one collection read per
9715
+ * resolution, so the cost tracks the form's own controls and not the depth of
9716
+ * the markup around it. `form.elements` lists controls the `form` attribute
9717
+ * associates from anywhere in the document, and lists nothing another form owns.
9718
+ */
9719
+ #sinkCandidates() {
9720
+ const form = this.element.form;
9721
+ if (form === null) {
9722
+ return Array.from(document.querySelectorAll(`input[${UNMASK_ATTR}]`));
8883
9723
  }
8884
- return map;
9724
+ return Array.from(form.elements).filter(
9725
+ (element) => element instanceof HTMLInputElement && element.hasAttribute(UNMASK_ATTR)
9726
+ );
8885
9727
  }
8886
9728
  /** Sets a boolean `data-*` flag to `"true"` when `on`, else removes it. */
8887
9729
  #flag(name, on) {
@@ -11821,103 +12663,266 @@ var NavigationMenuController = class extends Controller {
11821
12663
  get #isAnyOpen() {
11822
12664
  return this.#openTrigger !== null;
11823
12665
  }
11824
- };
11825
- var NestedFormController = class extends Controller {
11826
- static targets = ["list", "template", "add", "remove", "destroyFlag"];
11827
- static values = {
11828
- min: { type: Number, default: 0 },
11829
- max: { type: Number, default: 0 },
11830
- indexPlaceholder: { type: String, default: "__INDEX__" },
11831
- announce: { type: Boolean, default: true },
11832
- countMessage: { type: String, default: "" }
11833
- };
11834
- static actions = ["add"];
11835
- static events = ["add", "remove"];
11836
- /** Monotonic source for unique row indices; never a row-state counter. */
11837
- #lastIndex = 0;
11838
- /** Delegated click handler for the per-row remove buttons (dynamic-safe). */
11839
- #onClick = (event) => {
11840
- const target = event.target;
11841
- const button = target?.closest('[data-stimeo--nested-form-target="remove"]');
11842
- if (!button || !this.element.contains(button)) return;
11843
- const row = this.#rowContaining(button);
11844
- if (row) this.#removeRow(row);
11845
- };
11846
- connect() {
11847
- this.element.addEventListener("click", this.#onClick);
11848
- this.#refresh();
12666
+ };
12667
+ var ROOT_SELECTOR = '[data-controller~="stimeo--nested-form"]';
12668
+ var REMOVE_SELECTOR = '[data-stimeo--nested-form-target="remove"]';
12669
+ var DESTROY_FLAG_SELECTOR = '[data-stimeo--nested-form-target="destroyFlag"]';
12670
+ var DESTROYED_VALUES = /* @__PURE__ */ new Set(["1", "true"]);
12671
+ var NestedFormController = class extends Controller {
12672
+ static targets = ["list", "template", "add", "remove", "destroyFlag"];
12673
+ static values = {
12674
+ min: { type: Number, default: 0 },
12675
+ max: { type: Number, default: 0 },
12676
+ indexPlaceholder: { type: String, default: "__INDEX__" },
12677
+ announce: { type: Boolean, default: true },
12678
+ countMessage: { type: String, default: "" }
12679
+ };
12680
+ static actions = ["add"];
12681
+ static events = ["add", "remove", "reconcile"];
12682
+ /** Monotonic source for unique row indices; never a row-state counter. */
12683
+ #lastIndex = 0;
12684
+ #warnedMissing = false;
12685
+ #warnedTemplate = false;
12686
+ /** The state last written to the hooks; reconciliation reports only real moves. */
12687
+ #published = null;
12688
+ /** Watches the list for row changes the controller did not perform itself. */
12689
+ #observer = null;
12690
+ #reconcile = new MicrotaskCoalescer(() => this.#reconcileNow());
12691
+ /** Restores the authored add-button `disabled` when a lease ends. */
12692
+ #addDisabled = new AttributeLease("disabled");
12693
+ #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
12694
+ /** Makes the root a programmatic focus destination when no other candidate survives. */
12695
+ #tabindex = new TabindexLoan();
12696
+ /**
12697
+ * Delegated click handler for the per-row remove buttons (dynamic-safe). Only
12698
+ * buttons whose nearest nested-form root is this instance are acted on, so a
12699
+ * nested inner form's buttons never remove an outer row.
12700
+ */
12701
+ #onClick = (event) => {
12702
+ const target = event.target;
12703
+ const button = target?.closest(REMOVE_SELECTOR);
12704
+ if (!button || this.#ownerOf(button) !== this.element) return;
12705
+ const row = this.#rowContaining(button);
12706
+ if (row) this.#removeRow(row);
12707
+ };
12708
+ connect() {
12709
+ this.#warnedMissing = false;
12710
+ this.#warnedTemplate = false;
12711
+ this.element.addEventListener("click", this.#onClick);
12712
+ this.#reconcile.activate();
12713
+ this.#beforeCache.activate();
12714
+ if (!this.hasListTarget || !this.hasTemplateTarget) this.#warnMissing();
12715
+ this.#refresh();
12716
+ }
12717
+ disconnect() {
12718
+ this.element.removeEventListener("click", this.#onClick);
12719
+ this.#observer?.disconnect();
12720
+ this.#observer = null;
12721
+ this.#reconcile.cancel();
12722
+ this.#beforeCache.deactivate();
12723
+ this.#rewindForCache();
12724
+ this.#tabindex.returnAll();
12725
+ this.#published = null;
12726
+ }
12727
+ /** Follows an arriving or swapped-in list: rebind to the primary, then reconcile. */
12728
+ listTargetConnected() {
12729
+ this.#rebindObserver();
12730
+ this.#reconcile.schedule();
12731
+ }
12732
+ /** Follows a departing list the same way — the primary may have changed. */
12733
+ listTargetDisconnected() {
12734
+ this.#rebindObserver();
12735
+ this.#reconcile.schedule();
12736
+ }
12737
+ /**
12738
+ * Points the observer at the current primary list. Re-deriving on every list
12739
+ * arrival and departure makes the binding independent of the order Stimulus
12740
+ * reports an overlapping swap in — a staggered swap (successor appended before
12741
+ * the old list leaves) ends observed and reconciled either way.
12742
+ */
12743
+ #rebindObserver() {
12744
+ this.#observer?.disconnect();
12745
+ this.#observer = null;
12746
+ if (!this.hasListTarget) return;
12747
+ this.#observer = new MutationObserver(() => this.#reconcile.schedule());
12748
+ this.#observer.observe(this.listTarget, {
12749
+ childList: true,
12750
+ subtree: true,
12751
+ attributes: true,
12752
+ attributeFilter: ["value"]
12753
+ });
12754
+ }
12755
+ /** Returns the lease with a departing add button; a new one re-arms on refresh. */
12756
+ addTargetDisconnected(target) {
12757
+ this.#addDisabled.return(target);
12758
+ this.#reconcile.schedule();
12759
+ }
12760
+ addTargetConnected() {
12761
+ this.#reconcile.schedule();
11849
12762
  }
11850
- disconnect() {
11851
- this.element.removeEventListener("click", this.#onClick);
12763
+ /** Re-clamps when application code or a Turbo morph changes `min`. */
12764
+ minValueChanged() {
12765
+ this.#reconcile.schedule();
12766
+ }
12767
+ /** Re-clamps when application code or a Turbo morph changes `max`. */
12768
+ maxValueChanged() {
12769
+ this.#reconcile.schedule();
11852
12770
  }
11853
12771
  /**
11854
12772
  * Clones the template row, replaces the index placeholder with a unique value,
11855
- * appends it, focuses its first control, and announces the new count. No-ops at
11856
- * `max`.
12773
+ * appends it, focuses its first tab stop, and announces the new count. No-ops at
12774
+ * `max`, when the required targets are missing (named on the console once per
12775
+ * connection), or when the template does not produce exactly one root element
12776
+ * (also named once; the insertion is rolled back so nothing accumulates).
11857
12777
  */
11858
12778
  add() {
11859
- if (!this.hasTemplateTarget || !this.hasListTarget || this.#atMax) return;
12779
+ if (!this.hasListTarget || !this.hasTemplateTarget) {
12780
+ this.#warnMissing();
12781
+ return;
12782
+ }
12783
+ if (this.#atMax) return;
11860
12784
  const index = this.#nextIndex();
11861
12785
  const markup = this.templateTarget.innerHTML.replaceAll(
11862
12786
  this.indexPlaceholderValue,
11863
12787
  String(index)
11864
12788
  );
11865
- this.listTarget.insertAdjacentHTML("beforeend", markup);
11866
- const row = this.listTarget.lastElementChild;
11867
- if (!row) return;
12789
+ const list = this.listTarget;
12790
+ const beforeNodes = list.childNodes.length;
12791
+ const beforeElements = list.childElementCount;
12792
+ list.insertAdjacentHTML("beforeend", markup);
12793
+ const added = Array.from(list.children).slice(beforeElements);
12794
+ if (added.length !== 1) {
12795
+ while (list.childNodes.length > beforeNodes) list.lastChild?.remove();
12796
+ this.#warnBadTemplate(added.length);
12797
+ return;
12798
+ }
12799
+ const row = added[0];
11868
12800
  this.#refresh();
11869
- this.#firstControl(row)?.focus();
12801
+ firstTabStop(row)?.focus();
11870
12802
  this.dispatch("add", { detail: { index, element: row } });
11871
12803
  this.#announce();
11872
12804
  }
11873
12805
  /**
11874
- * Removes a row: a persisted row (one carrying a `destroyFlag`) has its flag set
11875
- * to `1` and is hidden so Rails destroys it on submit; an unsaved row is dropped
11876
- * from the DOM. Returns focus to a neighboring row. No-ops at `min`.
12806
+ * Removes a row: a persisted row (one carrying its own `destroyFlag`) has the
12807
+ * flag set to `1` and is hidden so Rails destroys it on submit; an unsaved row
12808
+ * is dropped from the DOM. Returns focus to a surviving row. No-ops at `min`.
11877
12809
  */
11878
12810
  #removeRow(row) {
11879
- if (this.#effectiveRows.length <= this.minValue) return;
11880
- const neighbors = this.#effectiveRows;
11881
- const position = neighbors.indexOf(row);
11882
- const neighbor = neighbors[position + 1] ?? neighbors[position - 1] ?? null;
11883
- const flag = row.querySelector(
11884
- '[data-stimeo--nested-form-target="destroyFlag"]'
11885
- );
12811
+ const rows = this.#effectiveRows;
12812
+ if (this.#destroyed(row)) {
12813
+ row.hidden = true;
12814
+ this.#focusAfterRemove(this.#positionAmong(rows, row));
12815
+ return;
12816
+ }
12817
+ if (rows.length <= this.minValue) return;
12818
+ const position = rows.indexOf(row);
12819
+ const flag = this.#destroyFlagOf(row);
11886
12820
  const persisted = flag !== null;
11887
- if (persisted) {
12821
+ if (flag) {
11888
12822
  flag.value = "1";
11889
12823
  row.hidden = true;
11890
12824
  } else {
11891
12825
  row.remove();
11892
12826
  }
11893
12827
  this.#refresh();
11894
- const focusTarget = neighbor ? this.#firstControl(neighbor) : this.hasAddTarget ? this.addTarget : null;
11895
- focusTarget?.focus();
12828
+ this.#focusAfterRemove(Math.max(0, position));
11896
12829
  this.dispatch("remove", { detail: { element: row, persisted } });
11897
12830
  this.#announce();
11898
12831
  }
11899
- /** Recomputes the live count and the min/max state hooks from the DOM. */
12832
+ /**
12833
+ * The index of the first effective row following `row` in document order.
12834
+ * -1 (no following row) feeds the focus slices as a negative index, which
12835
+ * yields the same fully-reversed nearest-first order as `rows.length` would.
12836
+ */
12837
+ #positionAmong(rows, row) {
12838
+ return rows.findIndex(
12839
+ (candidate) => (row.compareDocumentPosition(candidate) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0
12840
+ );
12841
+ }
12842
+ /**
12843
+ * Moves focus to the first tab stop of the nearest surviving row — following
12844
+ * rows first, then preceding ones — falling back to the add button and finally
12845
+ * to the root via a temporary `tabindex`.
12846
+ */
12847
+ #focusAfterRemove(position) {
12848
+ const rows = this.#effectiveRows;
12849
+ const ordered = [...rows.slice(position), ...rows.slice(0, position).reverse()];
12850
+ for (const row of ordered) {
12851
+ const stop = firstTabStop(row);
12852
+ if (stop) {
12853
+ stop.focus();
12854
+ return;
12855
+ }
12856
+ }
12857
+ if (this.hasAddTarget && isTabStop(this.addTarget)) {
12858
+ this.addTarget.focus();
12859
+ return;
12860
+ }
12861
+ this.#tabindex.lend(this.element);
12862
+ this.element.focus();
12863
+ }
12864
+ /** Recomputes the count and min/max hooks from the DOM and records them as published. */
11900
12865
  #refresh() {
12866
+ if (!this.hasListTarget) return;
11901
12867
  const count = this.#effectiveRows.length;
12868
+ const atMin = count <= this.minValue;
12869
+ const atMax = this.maxValue > 0 && count >= this.maxValue;
11902
12870
  this.element.setAttribute("data-nested-count", String(count));
11903
- this.#reflect("data-nested-at-max", this.maxValue > 0 && count >= this.maxValue);
11904
- this.#reflect("data-nested-at-min", count <= this.minValue);
11905
- if (this.hasAddTarget) this.addTarget.disabled = this.#atMax;
12871
+ this.#reflect("data-nested-at-max", atMax);
12872
+ this.#reflect("data-nested-at-min", atMin);
12873
+ if (this.hasAddTarget) {
12874
+ if (this.maxValue > 0) this.#addDisabled.write(this.addTarget, atMax ? "" : null);
12875
+ else this.#addDisabled.return(this.addTarget);
12876
+ }
12877
+ this.#published = { count, atMin, atMax };
12878
+ }
12879
+ /**
12880
+ * Applies row changes the controller did not perform itself (Turbo Streams,
12881
+ * morphs, runtime Value changes): refreshes the hooks and reports a moved
12882
+ * public state as `reconcile`. The controller's own operations refresh
12883
+ * synchronously first, so their observer echo arrives here as a no-move.
12884
+ */
12885
+ #reconcileNow() {
12886
+ const previous = this.#published;
12887
+ this.#refresh();
12888
+ const current = this.#published;
12889
+ if (!previous || !current) return;
12890
+ const moved = previous.count !== current.count || previous.atMin !== current.atMin || previous.atMax !== current.atMax;
12891
+ if (moved) this.dispatch("reconcile", { detail: { ...current } });
11906
12892
  }
11907
12893
  /** Bridges the count change to the shared announcer when configured. */
11908
12894
  #announce() {
11909
- if (!this.announceValue || !this.countMessageValue) return;
11910
- const message = this.countMessageValue.replaceAll(
11911
- "{count}",
11912
- String(this.#effectiveRows.length)
11913
- );
11914
- window.dispatchEvent(new CustomEvent("stimeo--announcer:announce", { detail: { message } }));
12895
+ if (!this.announceValue || this.countMessageValue === "") return;
12896
+ announce(fillTemplate(this.countMessageValue, { count: this.#effectiveRows.length }));
11915
12897
  }
11916
12898
  /** Sets `attribute` to `"true"` when `on`, else removes it. */
11917
12899
  #reflect(attribute, on) {
11918
12900
  if (on) this.element.setAttribute(attribute, "true");
11919
12901
  else this.element.removeAttribute(attribute);
11920
12902
  }
12903
+ /** Returns the disabled lease so an authored value never leaks into a snapshot. */
12904
+ #rewindForCache() {
12905
+ if (this.hasAddTarget) this.#addDisabled.return(this.addTarget);
12906
+ }
12907
+ /** Names the missing required target(s) once per connection. */
12908
+ #warnMissing() {
12909
+ if (this.#warnedMissing) return;
12910
+ this.#warnedMissing = true;
12911
+ const missing = [
12912
+ this.hasListTarget ? null : 'a "list" target',
12913
+ this.hasTemplateTarget ? null : 'a "template" target'
12914
+ ].filter((part) => part !== null).join(" and ");
12915
+ console.warn(
12916
+ `Stimeo UI: "${this.identifier}" cannot manage rows because its markup lacks ${missing}.`
12917
+ );
12918
+ }
12919
+ /** Names a template that does not produce exactly one element, once per connection. */
12920
+ #warnBadTemplate(produced) {
12921
+ if (this.#warnedTemplate) return;
12922
+ this.#warnedTemplate = true;
12923
+ const reason = produced === 0 ? "produces no element" : "must produce exactly one root element";
12924
+ console.warn(`Stimeo UI: "${this.identifier}" added no row because its template ${reason}.`);
12925
+ }
11921
12926
  /** A strictly-increasing unique index (collision-free even on rapid adds). */
11922
12927
  #nextIndex() {
11923
12928
  const index = Math.max(Date.now(), this.#lastIndex + 1);
@@ -11927,24 +12932,41 @@ var NestedFormController = class extends Controller {
11927
12932
  get #atMax() {
11928
12933
  return this.maxValue > 0 && this.#effectiveRows.length >= this.maxValue;
11929
12934
  }
11930
- /** Direct child rows of the list that are not flagged for destruction. */
12935
+ /**
12936
+ * Direct child rows of the list whose own destroy flag is not set. Callers
12937
+ * reach this only behind a list-presence gate (`add`, `#refresh`, and the
12938
+ * click path through `#rowContaining`).
12939
+ */
11931
12940
  get #effectiveRows() {
11932
- return Array.from(this.listTarget.children).filter((row) => !row.hidden);
12941
+ return Array.from(this.listTarget.children).filter(
12942
+ (row) => !this.#destroyed(row)
12943
+ );
12944
+ }
12945
+ /** Whether `row` is flagged for destruction; the flag value is the truth source. */
12946
+ #destroyed(row) {
12947
+ const flag = this.#destroyFlagOf(row);
12948
+ return flag !== null && DESTROYED_VALUES.has(flag.value);
12949
+ }
12950
+ /** The row's own destroy flag, skipping flags owned by a nested inner form. */
12951
+ #destroyFlagOf(row) {
12952
+ for (const flag of row.querySelectorAll(DESTROY_FLAG_SELECTOR)) {
12953
+ if (this.#ownerOf(flag) === this.element) return flag;
12954
+ }
12955
+ return null;
12956
+ }
12957
+ /** The nearest nested-form root that owns `el`. */
12958
+ #ownerOf(el) {
12959
+ return el.closest(ROOT_SELECTOR);
11933
12960
  }
11934
12961
  /** The nearest ancestor of `el` that is a direct child of the list, else null. */
11935
12962
  #rowContaining(el) {
12963
+ if (!this.hasListTarget) return null;
11936
12964
  let node = el;
11937
12965
  while (node && node.parentElement !== this.listTarget) {
11938
12966
  node = node.parentElement;
11939
12967
  }
11940
12968
  return node;
11941
12969
  }
11942
- /** First visible focusable control inside `row` (skips hidden inputs). */
11943
- #firstControl(row) {
11944
- return row.querySelector(
11945
- 'input:not([type="hidden"]), select, textarea, button, [tabindex]'
11946
- );
11947
- }
11948
12970
  };
11949
12971
  var NetworkStatusController = class extends Controller {
11950
12972
  static targets = ["offline", "online"];
@@ -12578,134 +13600,213 @@ var OptimisticController = class extends Controller {
12578
13600
  }
12579
13601
  }
12580
13602
  };
13603
+ var DEFAULT_PATTERN = "[0-9]";
13604
+ function compilePattern(source) {
13605
+ try {
13606
+ return new RegExp(`^${source}$`);
13607
+ } catch {
13608
+ return null;
13609
+ }
13610
+ }
13611
+ function hasModifier2(event) {
13612
+ return event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
13613
+ }
12581
13614
  var OtpController = class extends Controller {
12582
13615
  static targets = ["field", "value", "error"];
12583
13616
  static values = {
12584
- length: { type: Number, default: 6 },
12585
- pattern: { type: String, default: "[0-9]" }
13617
+ pattern: { type: String, default: DEFAULT_PATTERN }
12586
13618
  };
12587
- static actions = ["onInput", "onKeydown", "onPaste"];
12588
- static events = ["change", "complete", "invalid"];
13619
+ static actions = ["onInput", "onKeydown", "onPaste", "onPointerDown", "clear"];
13620
+ static events = ["change", "complete", "invalid", "reconcile"];
13621
+ /** Validated matcher; the hot path never compiles a raw declaration. */
13622
+ #pattern = new RegExp(`^${DEFAULT_PATTERN}$`);
13623
+ /** Source of {@link #pattern}, reported in `invalid` so consumers can word it. */
13624
+ #patternSource = DEFAULT_PATTERN;
13625
+ /** Combined value carried by the last dispatch; keeps a no-op sync silent. */
13626
+ #lastValue = null;
13627
+ /** Field whose confirming `input` after `compositionend` is already handled. */
13628
+ #confirmedField = null;
13629
+ /** True between connect and disconnect, so pre-connect Value changes stay silent. */
13630
+ #connected = false;
13631
+ /** Digit each field last committed, restored when rejected input replaced it. */
13632
+ #committed = /* @__PURE__ */ new WeakMap();
13633
+ /** Collapses one batch of field target callbacks into a single reconciliation. */
13634
+ #reconcile = new MicrotaskCoalescer(() => this.#reconcileFields());
13635
+ #ariaInvalid = new AttributeLease("aria-invalid");
13636
+ #ariaErrorMessage = new AttributeLease("aria-errormessage");
13637
+ #ariaDescribedBy = new AttributeLease("aria-describedby");
13638
+ #errorHidden = new AttributeLease("hidden");
13639
+ #state = new AttributeLease("data-state");
13640
+ /** Rewinds the transient error surface before Turbo freezes the page. */
13641
+ #beforeCache = new BeforeCacheReset(() => this.#clearError());
12589
13642
  /** Owns IME lifecycle state across every digit field. */
12590
13643
  #composition = new CompositionTracker({
13644
+ onStart: () => {
13645
+ this.#confirmedField = null;
13646
+ },
12591
13647
  onEnd: (event) => {
12592
- const input = event.currentTarget;
12593
- if (input) this.#handleInputValidation(input);
13648
+ const input = this.#fieldFrom(event);
13649
+ if (!input) return;
13650
+ this.#confirmedField = input;
13651
+ const committed = event.data ?? "";
13652
+ this.#accept(input, committed.length > input.value.length ? committed : input.value);
12594
13653
  }
12595
13654
  });
12596
13655
  connect() {
12597
- for (const field of this.fieldTargets) {
12598
- field.addEventListener("focus", this.#onFieldFocus);
12599
- this.#composition.observe(field);
12600
- }
13656
+ this.#connected = true;
13657
+ for (const field of this.fieldTargets) this.#bind(field);
13658
+ document.addEventListener("reset", this.#onReset, true);
13659
+ this.#beforeCache.activate();
13660
+ this.#reconcile.activate();
13661
+ this.#adopt();
13662
+ this.#lastValue = this.#sync();
12601
13663
  }
12602
13664
  disconnect() {
12603
- for (const field of this.fieldTargets) {
12604
- field.removeEventListener("focus", this.#onFieldFocus);
12605
- }
13665
+ this.#connected = false;
13666
+ for (const field of this.fieldTargets) this.#unbind(field);
12606
13667
  this.#composition.disconnect();
13668
+ document.removeEventListener("reset", this.#onReset, true);
13669
+ this.#beforeCache.deactivate();
13670
+ this.#reconcile.cancel();
13671
+ this.#clearError();
13672
+ this.#state.return(this.element);
12607
13673
  }
12608
13674
  /**
12609
13675
  * Stimulus lifecycle callback when a new field target enters the DOM.
12610
- * Ensures new additions are also wired with overwriting support.
13676
+ * Wires the new field and folds the wider digit count into one reconciliation,
13677
+ * which stays inert until `connect()` opens the window.
12611
13678
  */
12612
13679
  fieldTargetConnected(element) {
12613
- element.addEventListener("focus", this.#onFieldFocus);
12614
- this.#composition.observe(element);
13680
+ this.#bind(element);
13681
+ this.#adoptField(element);
13682
+ this.#reconcile.schedule();
12615
13683
  }
12616
- /** Removes focus listeners when fields are dropped. */
13684
+ /** Releases a dropped field's listeners and leases, then reconciles the rest. */
12617
13685
  fieldTargetDisconnected(element) {
12618
- element.removeEventListener("focus", this.#onFieldFocus);
12619
- this.#composition.unobserve(element);
13686
+ this.#unbind(element);
13687
+ this.#returnFieldLeases(element);
13688
+ this.#reconcile.schedule();
13689
+ }
13690
+ /**
13691
+ * Re-validates a changed `pattern` declaration once and drops any entered digit
13692
+ * the new pattern no longer accepts, so the combined value stays interpretable.
13693
+ */
13694
+ patternValueChanged() {
13695
+ const compiled = compilePattern(this.patternValue);
13696
+ this.#patternSource = compiled ? this.patternValue : DEFAULT_PATTERN;
13697
+ this.#pattern = compiled ?? new RegExp(`^${DEFAULT_PATTERN}$`);
13698
+ if (!this.#connected) return;
13699
+ let dropped = false;
13700
+ for (const field of this.fieldTargets) {
13701
+ if (field.value === "" || !this.#isWritable(field)) continue;
13702
+ if (this.#pattern.test(field.value)) continue;
13703
+ this.#writeField(field, "");
13704
+ dropped = true;
13705
+ }
13706
+ if (dropped) this.#syncAndDispatch();
12620
13707
  }
12621
- /** Handles keystroke inputs and advances focus to the next field. */
13708
+ /** Handles keystroke inputs, distributes autofilled text, and advances focus. */
12622
13709
  onInput(event) {
12623
- const input = event.currentTarget;
13710
+ const input = this.#fieldFrom(event);
12624
13711
  if (!input) return;
13712
+ const confirmed = this.#confirmedField;
13713
+ this.#confirmedField = null;
13714
+ if (confirmed === input) return;
12625
13715
  if (this.#composition.isComposing(event)) return;
12626
- this.#handleInputValidation(input);
13716
+ this.#accept(input);
12627
13717
  }
12628
- /** Handles Backspace retreating, arrows, and home/end navigation. */
13718
+ /** Handles Backspace clearing, arrows, and home/end navigation. */
12629
13719
  onKeydown(event) {
12630
13720
  if (isReservedArrowChord(event)) return;
12631
- const input = event.currentTarget;
13721
+ const input = this.#fieldFrom(event);
12632
13722
  if (!input) return;
12633
- const index = this.fieldTargets.indexOf(input);
12634
- if (index === -1) return;
13723
+ this.#confirmedField = null;
12635
13724
  if (this.#composition.isComposing(event)) return;
13725
+ const index = this.fieldTargets.indexOf(input);
13726
+ const fields = this.fieldTargets;
12636
13727
  switch (logicalArrowKey(event.key, this.element)) {
12637
13728
  case "Backspace":
12638
- if (!input.value) {
12639
- if (index > 0) {
12640
- event.preventDefault();
12641
- const prevField = this.fieldTargets[index - 1];
12642
- if (prevField) {
12643
- prevField.value = "";
12644
- prevField.removeAttribute("data-filled");
12645
- prevField.focus();
12646
- this.#clearError();
12647
- this.#syncAndDispatch();
12648
- }
12649
- }
12650
- } else {
12651
- input.value = "";
12652
- input.removeAttribute("data-filled");
13729
+ if (hasModifier2(event)) break;
13730
+ if (input.value && this.#isWritable(input)) {
13731
+ event.preventDefault();
13732
+ this.#writeField(input, "");
12653
13733
  this.#clearError();
12654
13734
  this.#syncAndDispatch();
13735
+ } else {
13736
+ const previous = this.#writableBefore(index);
13737
+ if (previous) {
13738
+ event.preventDefault();
13739
+ this.#writeField(previous, "");
13740
+ previous.focus();
13741
+ this.#clearError();
13742
+ this.#syncAndDispatch();
13743
+ }
12655
13744
  }
12656
13745
  break;
12657
- case "ArrowLeft":
12658
- if (index > 0) {
13746
+ case "ArrowLeft": {
13747
+ const previous = this.#focusableBefore(index);
13748
+ if (previous) {
12659
13749
  event.preventDefault();
12660
- this.fieldTargets[index - 1]?.focus();
13750
+ previous.focus();
12661
13751
  }
12662
13752
  break;
12663
- case "ArrowRight":
12664
- if (index < this.lengthValue - 1) {
13753
+ }
13754
+ case "ArrowRight": {
13755
+ const next = this.#focusableAfter(index);
13756
+ if (next) {
12665
13757
  event.preventDefault();
12666
- this.fieldTargets[index + 1]?.focus();
13758
+ next.focus();
12667
13759
  }
12668
13760
  break;
12669
- case "Home":
12670
- event.preventDefault();
12671
- this.fieldTargets[0]?.focus();
13761
+ }
13762
+ case "Home": {
13763
+ if (hasModifier2(event)) break;
13764
+ const first = fields.find((field) => this.#isFocusable(field));
13765
+ if (first) {
13766
+ event.preventDefault();
13767
+ first.focus();
13768
+ }
12672
13769
  break;
12673
- case "End":
12674
- event.preventDefault();
12675
- this.fieldTargets[this.lengthValue - 1]?.focus();
13770
+ }
13771
+ case "End": {
13772
+ if (hasModifier2(event)) break;
13773
+ const last = fields.slice().reverse().find((field) => this.#isFocusable(field));
13774
+ if (last) {
13775
+ event.preventDefault();
13776
+ last.focus();
13777
+ }
12676
13778
  break;
13779
+ }
12677
13780
  }
12678
13781
  }
12679
- /** Divides pasted string characters across available input fields. */
13782
+ /** Divides pasted string characters across the available input fields. */
12680
13783
  onPaste(event) {
12681
- const input = event.currentTarget;
13784
+ const input = this.#fieldFrom(event);
12682
13785
  if (!input) return;
12683
- const startIndex = this.fieldTargets.indexOf(input);
12684
- if (startIndex === -1) return;
12685
13786
  event.preventDefault();
12686
- const rawText = event.clipboardData?.getData("text") || "";
12687
- const text = this.#normalizeValue(rawText);
12688
- const regex = new RegExp(`^${this.patternValue}$`);
12689
- const validChars = Array.from(text).filter((char) => regex.test(char));
12690
- if (validChars.length === 0) {
12691
- this.#showError();
12692
- return;
12693
- }
12694
- const limit = Math.min(validChars.length, this.lengthValue - startIndex);
12695
- let lastFocusedIndex = startIndex;
12696
- for (let i = 0; i < limit; i++) {
12697
- const fieldIndex = startIndex + i;
12698
- const field = this.fieldTargets[fieldIndex];
12699
- const char = validChars[i];
12700
- if (field && char) {
12701
- field.value = char;
12702
- field.setAttribute("data-filled", "true");
12703
- lastFocusedIndex = fieldIndex;
12704
- }
13787
+ this.#confirmedField = null;
13788
+ this.#distribute(input, toHalfWidth(event.clipboardData?.getData("text") ?? ""));
13789
+ }
13790
+ /**
13791
+ * Redirects a pointer landing on an empty field to the earliest empty one, so
13792
+ * a passcode is entered in order. Filled fields stay directly reachable for
13793
+ * correction, and keyboard focus is left alone.
13794
+ */
13795
+ onPointerDown(event) {
13796
+ const input = this.#fieldFrom(event);
13797
+ if (input?.value !== "") return;
13798
+ const first = this.fieldTargets.find((field) => this.#isWritable(field) && field.value === "");
13799
+ if (!first || first === input) return;
13800
+ event.preventDefault();
13801
+ first.focus();
13802
+ }
13803
+ /** Empties every writable field and restarts entry at the first of them. */
13804
+ clear() {
13805
+ for (const field of this.fieldTargets) {
13806
+ if (this.#isWritable(field)) this.#writeField(field, "");
12705
13807
  }
12706
13808
  this.#clearError();
12707
- const focusTargetIndex = lastFocusedIndex < this.lengthValue - 1 ? lastFocusedIndex + 1 : lastFocusedIndex;
12708
- this.fieldTargets[focusTargetIndex]?.focus();
13809
+ this.fieldTargets.find((field) => this.#isWritable(field))?.focus();
12709
13810
  this.#syncAndDispatch();
12710
13811
  }
12711
13812
  #onFieldFocus = (event) => {
@@ -12714,54 +13815,199 @@ var OtpController = class extends Controller {
12714
13815
  input.select();
12715
13816
  }
12716
13817
  };
12717
- #handleInputValidation(input) {
12718
- const index = this.fieldTargets.indexOf(input);
12719
- if (index === -1) return;
12720
- const rawValue = input.value;
12721
- const normalized = this.#normalizeValue(rawValue);
12722
- const regex = new RegExp(`^${this.patternValue}$`);
12723
- if (normalized && regex.test(normalized)) {
12724
- input.value = normalized;
12725
- input.setAttribute("data-filled", "true");
13818
+ /** Reconciles derived state after a non-cancelled reset restores the fields. */
13819
+ #onReset = (event) => {
13820
+ const form = event.target;
13821
+ if (!(form instanceof HTMLFormElement) || !this.#ownedBy(form)) return;
13822
+ queueMicrotask(() => {
13823
+ if (event.defaultPrevented) return;
13824
+ this.#adopt();
13825
+ this.#syncAndDispatch();
13826
+ });
13827
+ };
13828
+ /** Whether a form owns at least one field or the hidden combined value. */
13829
+ #ownedBy(form) {
13830
+ if (this.fieldTargets.some((field) => field.form === form)) return true;
13831
+ return this.hasValueTarget && this.valueTarget.form === form;
13832
+ }
13833
+ #bind(field) {
13834
+ field.addEventListener("focus", this.#onFieldFocus);
13835
+ this.#composition.observe(field);
13836
+ }
13837
+ #unbind(field) {
13838
+ field.removeEventListener("focus", this.#onFieldFocus);
13839
+ this.#composition.unobserve(field);
13840
+ if (this.#confirmedField === field) this.#confirmedField = null;
13841
+ }
13842
+ /** Reads every field back so a restored or reset group starts consistent. */
13843
+ #adopt() {
13844
+ for (const field of this.fieldTargets) this.#adoptField(field);
13845
+ this.#clearError();
13846
+ if (this.hasErrorTarget) this.errorTarget.setAttribute("hidden", "");
13847
+ }
13848
+ /** Takes one field's current value as the truth behind its derived state. */
13849
+ #adoptField(field) {
13850
+ this.#committed.set(field, field.value);
13851
+ this.#markFilled(field, field.value);
13852
+ }
13853
+ /**
13854
+ * Absorbs a batch of field additions or removals as one value transition.
13855
+ *
13856
+ * The page, not the user, moved the value here, so it is reported as
13857
+ * `reconcile`: automation listening for `change` must not read a re-render as
13858
+ * an edit, and a passcode that happens to end up full must not fire the
13859
+ * `complete` that submits it.
13860
+ */
13861
+ #reconcileFields() {
13862
+ const previous = this.#lastValue;
13863
+ const combined = this.#sync();
13864
+ if (combined === previous) return;
13865
+ this.#lastValue = combined;
13866
+ this.dispatch("reconcile", { detail: { value: combined } });
13867
+ }
13868
+ /**
13869
+ * Validates the text an entry point received and distributes what it accepts.
13870
+ * `text` defaults to the field's own value; a confirmation passes the string it
13871
+ * committed, which `maxlength` would otherwise have truncated.
13872
+ */
13873
+ #accept(input, text = input.value) {
13874
+ const raw = toHalfWidth(text);
13875
+ if (raw === "") {
13876
+ this.#writeField(input, "");
12726
13877
  this.#clearError();
12727
- if (index < this.lengthValue - 1) {
12728
- const nextField = this.fieldTargets[index + 1];
12729
- nextField?.focus();
12730
- }
12731
- } else if (normalized) {
12732
- input.value = "";
12733
- input.removeAttribute("data-filled");
13878
+ this.#syncAndDispatch();
13879
+ return;
13880
+ }
13881
+ this.#distribute(input, raw);
13882
+ }
13883
+ /**
13884
+ * Fills `text`'s accepted characters into the writable fields at and after the
13885
+ * entry point, then leaves focus on the field after the last one filled.
13886
+ */
13887
+ #distribute(from, text) {
13888
+ const accepted = Array.from(text).filter((char) => this.#pattern.test(char));
13889
+ let reached = false;
13890
+ const slots = this.fieldTargets.filter((field) => {
13891
+ reached ||= field === from;
13892
+ return reached && this.#isWritable(field);
13893
+ });
13894
+ const filled = Math.min(accepted.length, slots.length);
13895
+ if (filled === 0) {
13896
+ this.#restore(from);
12734
13897
  this.#showError();
12735
- } else {
12736
- input.removeAttribute("data-filled");
13898
+ this.#sync();
13899
+ return;
12737
13900
  }
13901
+ for (let i = 0; i < filled; i++) {
13902
+ const field = slots[i];
13903
+ const char = accepted[i];
13904
+ if (field && char) this.#writeField(field, char);
13905
+ }
13906
+ const last = slots[filled - 1];
13907
+ if (last) (this.#writableAfter(last) ?? last).focus();
13908
+ this.#clearError();
12738
13909
  this.#syncAndDispatch();
12739
13910
  }
12740
- #normalizeValue(val) {
12741
- return val.replace(/[0-9]/g, (s) => String.fromCharCode(s.charCodeAt(0) - 65248));
13911
+ /** Restores the digit a field committed before rejected input replaced it. */
13912
+ #restore(field) {
13913
+ this.#writeField(field, this.#committed.get(field) ?? "");
12742
13914
  }
12743
- #showError() {
12744
- if (this.hasErrorTarget) {
12745
- this.errorTarget.removeAttribute("hidden");
12746
- }
12747
- this.dispatch("invalid", { detail: { pattern: this.patternValue } });
13915
+ /** Commits one field's value and the derived hook that reports it as entered. */
13916
+ #writeField(field, value) {
13917
+ field.value = value;
13918
+ this.#committed.set(field, value);
13919
+ this.#markFilled(field, value);
12748
13920
  }
12749
- #clearError() {
12750
- if (this.hasErrorTarget) {
12751
- this.errorTarget.setAttribute("hidden", "true");
12752
- }
13921
+ #markFilled(field, value) {
13922
+ if (value) field.setAttribute("data-filled", "true");
13923
+ else field.removeAttribute("data-filled");
12753
13924
  }
12754
- #syncAndDispatch() {
13925
+ #isWritable(field) {
13926
+ return !field.disabled && !field.readOnly;
13927
+ }
13928
+ #isFocusable(field) {
13929
+ return !field.disabled;
13930
+ }
13931
+ #writableAfter(field) {
12755
13932
  const fields = this.fieldTargets;
12756
- const combinedValue = fields.map((f) => f.value).join("");
13933
+ return fields.slice(fields.indexOf(field) + 1).find((next) => this.#isWritable(next)) ?? null;
13934
+ }
13935
+ #writableBefore(index) {
13936
+ return this.#before(index).find((field) => this.#isWritable(field)) ?? null;
13937
+ }
13938
+ #focusableAfter(index) {
13939
+ return this.fieldTargets.slice(index + 1).find((field) => this.#isFocusable(field)) ?? null;
13940
+ }
13941
+ #focusableBefore(index) {
13942
+ return this.#before(index).find((field) => this.#isFocusable(field)) ?? null;
13943
+ }
13944
+ /** Fields before `index`, nearest first; empty at the first field. */
13945
+ #before(index) {
13946
+ return this.fieldTargets.slice(0, Math.max(index, 0)).reverse();
13947
+ }
13948
+ /** The event's field target, or `null` when the wiring points somewhere else. */
13949
+ #fieldFrom(event) {
13950
+ const input = event.currentTarget;
13951
+ return this.fieldTargets.find((field) => field === input) ?? null;
13952
+ }
13953
+ #combinedValue() {
13954
+ return this.fieldTargets.map((field) => field.value).join("");
13955
+ }
13956
+ /** Every field carries a character, and there is at least one field. */
13957
+ #isComplete() {
13958
+ const fields = this.fieldTargets;
13959
+ return fields.length > 0 && fields.every((field) => field.value.length > 0);
13960
+ }
13961
+ /** Mirrors the combined value into the form and the root's readable state. */
13962
+ #sync() {
13963
+ const combined = this.#combinedValue();
12757
13964
  if (this.hasValueTarget) {
12758
- this.valueTarget.value = combinedValue;
13965
+ this.valueTarget.value = combined;
13966
+ }
13967
+ this.#state.write(this.element, this.#stateName(combined));
13968
+ return combined;
13969
+ }
13970
+ #stateName(combined) {
13971
+ if (combined.length === 0) return "empty";
13972
+ return this.#isComplete() ? "complete" : "partial";
13973
+ }
13974
+ #syncAndDispatch() {
13975
+ const combined = this.#sync();
13976
+ if (combined === this.#lastValue) return;
13977
+ this.#lastValue = combined;
13978
+ this.dispatch("change", { detail: { value: combined } });
13979
+ if (this.#isComplete()) {
13980
+ this.dispatch("complete", { detail: { value: combined } });
12759
13981
  }
12760
- this.dispatch("change", { detail: { value: combinedValue } });
12761
- const isCompleted = fields.every((f) => f.value.length > 0) && combinedValue.length === this.lengthValue;
12762
- if (isCompleted) {
12763
- this.dispatch("complete", { detail: { value: combinedValue } });
13982
+ }
13983
+ /** Surfaces rejected input on every field and on the optional error target. */
13984
+ #showError() {
13985
+ const errorId = this.hasErrorTarget ? ensureId(this.errorTarget, "stimeo--otp-error") : null;
13986
+ for (const field of this.fieldTargets) {
13987
+ this.#ariaInvalid.write(field, "true");
13988
+ if (!errorId) continue;
13989
+ this.#ariaErrorMessage.write(field, errorId);
13990
+ this.#ariaDescribedBy.write(field, this.#describedByWith(field, errorId));
12764
13991
  }
13992
+ if (this.hasErrorTarget) this.#errorHidden.write(this.errorTarget, null);
13993
+ this.dispatch("invalid", { detail: { pattern: this.#patternSource } });
13994
+ }
13995
+ /** Returns every error lease, restoring the authored error surface. */
13996
+ #clearError() {
13997
+ this.#ariaInvalid.returnAll();
13998
+ this.#ariaErrorMessage.returnAll();
13999
+ this.#ariaDescribedBy.returnAll();
14000
+ this.#errorHidden.returnAll();
14001
+ }
14002
+ #returnFieldLeases(field) {
14003
+ this.#ariaInvalid.return(field);
14004
+ this.#ariaErrorMessage.return(field);
14005
+ this.#ariaDescribedBy.return(field);
14006
+ }
14007
+ /** The field's own description tokens with the error id appended once. */
14008
+ #describedByWith(field, errorId) {
14009
+ const tokens = (field.getAttribute("aria-describedby") ?? "").split(/\s+/).filter((token) => token.length > 0 && token !== errorId);
14010
+ return [...tokens, errorId].join(" ");
12765
14011
  }
12766
14012
  };
12767
14013
  var DIRECTION_BUTTON_SELECTOR = "[data-stimeo--overflow-indicator-direction-param]";
@@ -14988,7 +16234,7 @@ function isInteractiveHost(element) {
14988
16234
  }
14989
16235
 
14990
16236
  // src/controllers/radio_group_controller.ts
14991
- var OBSERVED_ATTRIBUTES3 = [
16237
+ var OBSERVED_ATTRIBUTES4 = [
14992
16238
  "aria-checked",
14993
16239
  "aria-disabled",
14994
16240
  "contenteditable",
@@ -15001,7 +16247,7 @@ var OBSERVED_ATTRIBUTES3 = [
15001
16247
  "type",
15002
16248
  "value"
15003
16249
  ];
15004
- var hasModifier = (event) => event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
16250
+ var hasModifier3 = (event) => event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
15005
16251
  var RadioGroupController = class extends Controller {
15006
16252
  static targets = ["radio", "field"];
15007
16253
  static actions = ["onKeydown", "select"];
@@ -15157,7 +16403,7 @@ var RadioGroupController = class extends Controller {
15157
16403
  #handleKeydown(event, radio) {
15158
16404
  if (event.defaultPrevented || !this.#isSupportedHost(radio)) return;
15159
16405
  if (event.isComposing || isReservedArrowChord(event)) return;
15160
- if ((event.key === "Home" || event.key === "End") && hasModifier(event)) return;
16406
+ if ((event.key === "Home" || event.key === "End") && hasModifier3(event)) return;
15161
16407
  if (event.key === " ") {
15162
16408
  if (this.#isActivationDisabled(radio)) {
15163
16409
  event.preventDefault();
@@ -15438,7 +16684,7 @@ var RadioGroupController = class extends Controller {
15438
16684
  subtree: true,
15439
16685
  childList: true,
15440
16686
  attributes: true,
15441
- attributeFilter: OBSERVED_ATTRIBUTES3
16687
+ attributeFilter: OBSERVED_ATTRIBUTES4
15442
16688
  });
15443
16689
  let ancestor = this.element.parentElement;
15444
16690
  while (ancestor) {
@@ -18722,7 +19968,7 @@ var SpinnerController = class extends Controller {
18722
19968
  timeout: { type: Number, default: 0 }
18723
19969
  };
18724
19970
  static actions = ["start", "stop"];
18725
- static events = ["hide", "show", "timeout"];
19971
+ static events = ["hide", "show", "timeout", "reconcile"];
18726
19972
  #timers = new SafeTimeout();
18727
19973
  #floor = new MinDurationFloor(this.#timers);
18728
19974
  #gate = new DetachGate();
@@ -18826,16 +20072,18 @@ var SpinnerController = class extends Controller {
18826
20072
  * Returns the loading state to idle for the snapshot Turbo is about to take,
18827
20073
  * so a page reached with the Back button is not restored mid-load with a
18828
20074
  * spinner nothing can stop. State only: `data-state`, the indicator's `hidden`,
18829
- * and `aria-busy`. No `hide` is dispatched — the load was never observed to
18830
- * finish, and a snapshot rewind is not a lifecycle event the consumer can act
18831
- * on. The live page keeps its timers, so a navigation that never completes
18832
- * leaves the running cycle intact.
20075
+ * and `aria-busy`. No `hide` is dispatched — that would claim the load finished.
20076
+ * The rewind reports itself as `reconcile` instead, so a consumer painting from
20077
+ * `show` can drop it before the snapshot is taken. The live page keeps its
20078
+ * timers, so a navigation that never completes leaves the running cycle intact.
18833
20079
  */
18834
20080
  #rewindForCache() {
20081
+ const running = this.element.getAttribute("data-state") !== "idle";
18835
20082
  this.#cancelTimeout();
18836
20083
  this.#setBusy(false);
18837
20084
  if (this.hasIndicatorTarget) this.indicatorTarget.hidden = true;
18838
20085
  this.element.setAttribute("data-state", "idle");
20086
+ if (running) this.dispatch("reconcile", { detail: {} });
18839
20087
  }
18840
20088
  /** Reflects busy state onto the controlled region (if present). */
18841
20089
  #setBusy(busy) {
@@ -19285,7 +20533,7 @@ var SubmitOnceController = class extends Controller {
19285
20533
  restoreFocus: { type: Boolean, default: false }
19286
20534
  };
19287
20535
  static actions = ["cancel", "finish", "start"];
19288
- static events = ["start", "end"];
20536
+ static events = ["start", "end", "reconcile"];
19289
20537
  #timers = new SafeTimeout();
19290
20538
  #gate = new DetachGate();
19291
20539
  #beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
@@ -19501,7 +20749,9 @@ var SubmitOnceController = class extends Controller {
19501
20749
  }
19502
20750
  /** Keeps Turbo's cached snapshot idle, without events, announcements, or focus moves. */
19503
20751
  #rewindForCache() {
20752
+ const forms = [...this.#sessions.keys()];
19504
20753
  this.#abandonSessions();
20754
+ if (forms.length > 0) this.dispatch("reconcile", { detail: { forms } });
19505
20755
  }
19506
20756
  /** Tears down a true detach; an in-page move is canceled by the next `connect()`. */
19507
20757
  #teardown() {
@@ -19581,7 +20831,7 @@ var SubmitOnceController = class extends Controller {
19581
20831
  return typeof success === "boolean" ? success : void 0;
19582
20832
  }
19583
20833
  };
19584
- var OBSERVED_ATTRIBUTES4 = [
20834
+ var OBSERVED_ATTRIBUTES5 = [
19585
20835
  "role",
19586
20836
  "aria-checked",
19587
20837
  "tabindex",
@@ -19694,7 +20944,7 @@ var SwitchController = class extends Controller {
19694
20944
  #observeAttributes() {
19695
20945
  this.#attributeObserver.observe(this.element, {
19696
20946
  attributes: true,
19697
- attributeFilter: OBSERVED_ATTRIBUTES4
20947
+ attributeFilter: OBSERVED_ATTRIBUTES5
19698
20948
  });
19699
20949
  let ancestor = this.element.parentElement;
19700
20950
  while (ancestor) {
@@ -20048,18 +21298,69 @@ var TextareaAutosizeController = class extends Controller {
20048
21298
  static actions = ["resize"];
20049
21299
  static events = ["resize"];
20050
21300
  #lastHeight = -1;
20051
- #onInput = () => {
21301
+ #lastWidth = -1;
21302
+ #started = false;
21303
+ #pending = false;
21304
+ #fonts = null;
21305
+ /**
21306
+ * Content triggers arriving while the box is collapsed (`display: none`
21307
+ * ancestors) are deferred: `scrollHeight` is 0 there, so measuring would
21308
+ * clamp the kept height to the `minRows` floor and dispatch a bogus
21309
+ * `resize`. The next report from a rendered box flushes the deferral.
21310
+ */
21311
+ #remeasure = () => {
21312
+ if (this.element.clientWidth === 0) {
21313
+ this.#pending = true;
21314
+ return;
21315
+ }
20052
21316
  this.resize();
20053
21317
  };
21318
+ /**
21319
+ * Re-measures when the content width changed (re-wrapping, including a
21320
+ * horizontal padding change under `border-box`), the applied inline height
21321
+ * was stripped (a Turbo morph syncing attributes from server HTML that has no
21322
+ * `style`), or a content trigger was deferred while the box was collapsed.
21323
+ * This instance's own height writes match none of these signals, so they
21324
+ * cannot re-enter; an authored inline height (e.g. a user dragging the native
21325
+ * resize handle) is left alone until the next content trigger. Reports from a
21326
+ * still-collapsed box are skipped (nothing is measurable there).
21327
+ */
21328
+ #layout = new LayoutObserver(() => {
21329
+ if (this.element.clientWidth === 0) return;
21330
+ const width = this.#contentWidth();
21331
+ const stripped = this.element.style.height === "";
21332
+ if (width === this.#lastWidth && !stripped && !this.#pending) return;
21333
+ this.#lastWidth = width;
21334
+ this.resize();
21335
+ });
20054
21336
  connect() {
20055
- this.element.addEventListener("input", this.#onInput);
21337
+ this.#lastHeight = Math.round(px(this.element.style.height));
21338
+ this.#lastWidth = this.#contentWidth();
21339
+ this.element.addEventListener("input", this.#remeasure);
21340
+ this.element.addEventListener("change", this.#remeasure);
21341
+ this.#layout.observe(this.element);
21342
+ this.#bindFonts();
21343
+ this.#started = true;
20056
21344
  this.resize();
20057
21345
  }
20058
21346
  disconnect() {
20059
- this.element.removeEventListener("input", this.#onInput);
21347
+ this.#started = false;
21348
+ this.element.removeEventListener("input", this.#remeasure);
21349
+ this.element.removeEventListener("change", this.#remeasure);
21350
+ this.#layout.disconnect();
21351
+ this.#unbindFonts();
21352
+ }
21353
+ /** Re-clamps when application code or a Turbo morph changes `minRows`. */
21354
+ minRowsValueChanged() {
21355
+ if (this.#started) this.#remeasure();
21356
+ }
21357
+ /** Re-clamps when application code or a Turbo morph changes `maxRows`. */
21358
+ maxRowsValueChanged() {
21359
+ if (this.#started) this.#remeasure();
20060
21360
  }
20061
21361
  /** Re-measures the content and applies the clamped height. */
20062
21362
  resize() {
21363
+ this.#pending = false;
20063
21364
  const el = this.element;
20064
21365
  const style = window.getComputedStyle(el);
20065
21366
  const lineHeight = this.#lineHeight(style);
@@ -20093,6 +21394,11 @@ var TextareaAutosizeController = class extends Controller {
20093
21394
  this.dispatch("resize", { detail: { height, rows } });
20094
21395
  }
20095
21396
  }
21397
+ /** Content-box width — the wrapping input, unaffected by this instance's height writes. */
21398
+ #contentWidth() {
21399
+ const style = window.getComputedStyle(this.element);
21400
+ return Math.max(0, this.element.clientWidth - px(style.paddingLeft) - px(style.paddingRight));
21401
+ }
20096
21402
  /** Resolved line height, falling back to ~1.2× font-size when `normal`. */
20097
21403
  #lineHeight(style) {
20098
21404
  const lh = px(style.lineHeight);
@@ -20100,6 +21406,20 @@ var TextareaAutosizeController = class extends Controller {
20100
21406
  const fontSize = px(style.fontSize);
20101
21407
  return fontSize > 0 ? fontSize * 1.2 : 16;
20102
21408
  }
21409
+ /** Subscribes to font completion when the current engine exposes `document.fonts`. */
21410
+ #bindFonts() {
21411
+ const fonts = this.element.ownerDocument.fonts;
21412
+ if (!fonts) return;
21413
+ this.#fonts = fonts;
21414
+ fonts.addEventListener("loadingdone", this.#remeasure);
21415
+ fonts.addEventListener("loadingerror", this.#remeasure);
21416
+ }
21417
+ /** Removes the font completion subscriptions. */
21418
+ #unbindFonts() {
21419
+ this.#fonts?.removeEventListener("loadingdone", this.#remeasure);
21420
+ this.#fonts?.removeEventListener("loadingerror", this.#remeasure);
21421
+ this.#fonts = null;
21422
+ }
20103
21423
  };
20104
21424
  var MODES = ["light", "dark", "system"];
20105
21425
  var isMode = (value) => typeof value === "string" && MODES.includes(value);
@@ -20247,7 +21567,7 @@ var PM = 1;
20247
21567
  var SECONDS_PER_MINUTE = 60;
20248
21568
  var SECONDS_PER_HOUR = 60 * SECONDS_PER_MINUTE;
20249
21569
  var SECONDS_PER_DAY = 24 * SECONDS_PER_HOUR;
20250
- var hasModifier2 = (event) => event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
21570
+ var hasModifier4 = (event) => event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
20251
21571
  var TimePickerController = class extends Controller {
20252
21572
  static targets = ["segment", "field"];
20253
21573
  static values = {
@@ -20329,7 +21649,7 @@ var TimePickerController = class extends Controller {
20329
21649
  const kind = this.#kindOf(segment);
20330
21650
  if (!kind) return;
20331
21651
  const key = logicalArrowKey(event.key, this.element);
20332
- if ((key === "Home" || key === "End") && hasModifier2(event)) return;
21652
+ if ((key === "Home" || key === "End") && hasModifier4(event)) return;
20333
21653
  switch (key) {
20334
21654
  case "ArrowUp":
20335
21655
  event.preventDefault();
@@ -20358,7 +21678,7 @@ var TimePickerController = class extends Controller {
20358
21678
  this.#commitRender();
20359
21679
  break;
20360
21680
  default:
20361
- if (kind !== "meridiem" && /^[0-9]$/.test(event.key) && !hasModifier2(event)) {
21681
+ if (kind !== "meridiem" && /^[0-9]$/.test(event.key) && !hasModifier4(event)) {
20362
21682
  event.preventDefault();
20363
21683
  this.#typeDigit(segment, kind, event.key);
20364
21684
  return;
@@ -20935,7 +22255,7 @@ var ITEM_ATTRIBUTES = [
20935
22255
  "contenteditable",
20936
22256
  "controls"
20937
22257
  ];
20938
- var hasModifier3 = (event) => event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
22258
+ var hasModifier5 = (event) => event.altKey || event.ctrlKey || event.metaKey || event.shiftKey;
20939
22259
  var ToggleGroupController = class extends Controller {
20940
22260
  static targets = ["item"];
20941
22261
  static values = {
@@ -21066,7 +22386,7 @@ var ToggleGroupController = class extends Controller {
21066
22386
  if (event.defaultPrevented || !this.#isSupportedHost(item)) return;
21067
22387
  if (event.isComposing) return;
21068
22388
  if (isReservedArrowChord(event)) return;
21069
- if ((event.key === "Home" || event.key === "End") && hasModifier3(event)) return;
22389
+ if ((event.key === "Home" || event.key === "End") && hasModifier5(event)) return;
21070
22390
  if (!this.#managedItems.has(item)) this.#reconcileHost(item, true);
21071
22391
  this.#ensureTabStop();
21072
22392
  const items = this.#managedTargets;