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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +62 -0
- data/dist/controllers/auto_submit_controller.js +94 -16
- data/dist/controllers/carousel_controller.js +451 -100
- data/dist/controllers/currency_input_controller.js +305 -74
- data/dist/controllers/direct_upload_controller.js +12 -2
- data/dist/controllers/file_dropzone_controller.js +386 -63
- data/dist/controllers/flash_controller.js +3 -1
- data/dist/controllers/frame_loading_controller.js +2 -1
- data/dist/controllers/input_mask_controller.js +251 -76
- data/dist/controllers/nested_form_controller.js +450 -42
- data/dist/controllers/otp_controller.js +472 -114
- data/dist/controllers/spinner_controller.js +7 -5
- data/dist/controllers/submit_once_controller.js +3 -1
- data/dist/controllers/textarea_autosize_controller.js +131 -3
- data/dist/index.js +1927 -607
- data/lib/stimeo/ui/version.rb +1 -1
- metadata +2 -2
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.#
|
|
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.#
|
|
1050
|
-
this.#
|
|
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
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
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.#
|
|
1072
|
-
if (
|
|
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
|
-
|
|
1089
|
+
form.removeAttribute("data-auto-submit-pending");
|
|
1076
1090
|
this.dispatch("submit", { detail: { trigger } });
|
|
1077
|
-
if (
|
|
1078
|
-
|
|
1091
|
+
if (form.checkValidity()) {
|
|
1092
|
+
form.setAttribute("aria-busy", "true");
|
|
1079
1093
|
}
|
|
1080
|
-
|
|
1094
|
+
form.requestSubmit();
|
|
1081
1095
|
}, this.debounceValue);
|
|
1082
1096
|
}
|
|
1083
|
-
/**
|
|
1084
|
-
|
|
1085
|
-
|
|
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:
|
|
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
|
|
2165
|
-
*
|
|
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
|
-
/**
|
|
2172
|
-
#
|
|
2173
|
-
/**
|
|
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
|
|
2263
|
+
* Renders the initial slide, wires the delegated listeners, and starts autoplay
|
|
2264
|
+
* when requested.
|
|
2179
2265
|
*
|
|
2180
|
-
*
|
|
2181
|
-
*
|
|
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
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
this
|
|
2188
|
-
this.#
|
|
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
|
-
|
|
2222
|
-
|
|
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
|
-
/**
|
|
2234
|
-
|
|
2235
|
-
this.#connected
|
|
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
|
-
/**
|
|
2241
|
-
|
|
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.
|
|
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
|
-
|
|
2251
|
-
|
|
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
|
-
/**
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
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
|
|
2261
|
-
*
|
|
2262
|
-
*
|
|
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
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
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
|
|
2274
|
-
*
|
|
2275
|
-
*
|
|
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
|
-
|
|
2279
|
-
|
|
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
|
|
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.#
|
|
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.#
|
|
2533
|
+
this.#roving.setActive(length - 1, { focus: true });
|
|
2306
2534
|
return;
|
|
2307
2535
|
}
|
|
2308
2536
|
}
|
|
2309
|
-
/**
|
|
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
|
|
2325
|
-
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
2596
|
+
setAttributeIfChanged(picker, "aria-selected", i === pickerIndex ? "true" : "false");
|
|
2344
2597
|
});
|
|
2345
2598
|
this.#roving.setActive(pickerIndex, { focus });
|
|
2599
|
+
this.#syncStepControls();
|
|
2346
2600
|
}
|
|
2347
|
-
/**
|
|
2348
|
-
#
|
|
2349
|
-
const
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
const
|
|
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
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
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 !==
|
|
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
|
|
2369
|
-
*
|
|
2370
|
-
*
|
|
2371
|
-
*
|
|
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
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
const
|
|
2378
|
-
|
|
2379
|
-
|
|
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
|
|
2387
|
-
this
|
|
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
|
|
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:
|
|
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
|
|
5057
|
-
this.#lastValue =
|
|
5058
|
-
if (
|
|
5059
|
-
this
|
|
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
|
|
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
|
|
5081
|
-
if (
|
|
5082
|
-
|
|
5083
|
-
|
|
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
|
|
5087
|
-
|
|
5088
|
-
|
|
5089
|
-
|
|
5090
|
-
|
|
5091
|
-
|
|
5092
|
-
|
|
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
|
|
5095
|
-
#restoreCaret(formatted,
|
|
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 >=
|
|
5531
|
+
if (seen >= significantBefore) {
|
|
5100
5532
|
position = i;
|
|
5101
5533
|
break;
|
|
5102
5534
|
}
|
|
5103
|
-
if (
|
|
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
|
-
/**
|
|
5111
|
-
|
|
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.#
|
|
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
|
-
|
|
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
|
-
/**
|
|
5124
|
-
#
|
|
5125
|
-
|
|
5126
|
-
|
|
5127
|
-
|
|
5128
|
-
|
|
5129
|
-
|
|
5130
|
-
|
|
5131
|
-
|
|
5132
|
-
|
|
5133
|
-
|
|
5134
|
-
|
|
5135
|
-
|
|
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
|
-
|
|
5139
|
-
|
|
5140
|
-
|
|
5141
|
-
|
|
5142
|
-
|
|
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:
|
|
5147
|
-
maximumFractionDigits: this
|
|
5653
|
+
minimumFractionDigits: this.#precision,
|
|
5654
|
+
maximumFractionDigits: this.#precision
|
|
5148
5655
|
});
|
|
5149
|
-
|
|
5150
|
-
|
|
5151
|
-
|
|
5152
|
-
|
|
5153
|
-
|
|
5154
|
-
|
|
5155
|
-
|
|
5156
|
-
|
|
5157
|
-
|
|
5158
|
-
}
|
|
5159
|
-
|
|
5160
|
-
|
|
5161
|
-
|
|
5162
|
-
|
|
5163
|
-
|
|
5164
|
-
|
|
5165
|
-
|
|
5166
|
-
|
|
5167
|
-
|
|
5168
|
-
|
|
5169
|
-
|
|
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.#
|
|
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
|
-
|
|
7007
|
-
|
|
7008
|
-
|
|
7009
|
-
|
|
7010
|
-
|
|
7011
|
-
|
|
7012
|
-
|
|
7013
|
-
|
|
7014
|
-
|
|
7015
|
-
|
|
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
|
-
/**
|
|
7018
|
-
#
|
|
7019
|
-
/**
|
|
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.#
|
|
7022
|
-
|
|
7623
|
+
this.#gate.cancel();
|
|
7624
|
+
this.#warnedTemplate = false;
|
|
7625
|
+
this.#beforeCache.activate();
|
|
7023
7626
|
}
|
|
7024
|
-
/**
|
|
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
|
-
|
|
7029
|
-
|
|
7030
|
-
}
|
|
7031
|
-
this.#entries.length = 0;
|
|
7634
|
+
this.#beforeCache.deactivate();
|
|
7635
|
+
this.#gate.disconnected(this, () => this.#teardown());
|
|
7032
7636
|
}
|
|
7033
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
7056
|
-
|
|
7668
|
+
const files = this.inputTarget.files;
|
|
7669
|
+
if (files) this.#addFiles(files);
|
|
7670
|
+
else this.#syncInput();
|
|
7057
7671
|
}
|
|
7058
|
-
/**
|
|
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
|
|
7062
|
-
this.#
|
|
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
|
-
/**
|
|
7065
|
-
|
|
7066
|
-
|
|
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
|
|
7081
|
-
|
|
7082
|
-
|
|
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
|
|
7721
|
+
/** Validates each incoming file, renders the accepted ones, and reports the batch. */
|
|
7089
7722
|
#addFiles(files) {
|
|
7090
|
-
|
|
7091
|
-
if (this.hasZoneTarget) this.zoneTarget.removeAttribute(
|
|
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
|
-
|
|
7097
|
-
|
|
7098
|
-
|
|
7099
|
-
|
|
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
|
-
|
|
7104
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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.
|
|
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
|
|
7121
|
-
const name = fragment
|
|
7122
|
-
const thumb = fragment
|
|
7123
|
-
const button = fragment.querySelector(
|
|
7124
|
-
|
|
7125
|
-
|
|
7126
|
-
|
|
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.#
|
|
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
|
-
/**
|
|
7170
|
-
|
|
7171
|
-
|
|
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
|
|
7959
|
+
/** The declared remove button of each rendered item, in selection order. */
|
|
7179
7960
|
get #removeButtons() {
|
|
7180
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
8751
|
-
|
|
8752
|
-
|
|
8753
|
-
|
|
8754
|
-
|
|
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:
|
|
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.#
|
|
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.#
|
|
9596
|
+
format(event) {
|
|
9597
|
+
if (this.#composition.isComposing(event)) return;
|
|
9598
|
+
this.#reformat("edit", deletionOf(event));
|
|
8784
9599
|
}
|
|
8785
|
-
/**
|
|
8786
|
-
|
|
8787
|
-
|
|
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
|
|
8790
|
-
|
|
8791
|
-
|
|
8792
|
-
|
|
8793
|
-
|
|
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(
|
|
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
|
|
8798
|
-
if (
|
|
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",
|
|
8802
|
-
|
|
8803
|
-
|
|
8804
|
-
|
|
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(
|
|
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
|
-
|
|
8817
|
-
|
|
8818
|
-
|
|
8819
|
-
|
|
8820
|
-
|
|
8821
|
-
|
|
8822
|
-
|
|
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
|
-
|
|
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"`)
|
|
8840
|
-
*
|
|
8841
|
-
*
|
|
8842
|
-
*
|
|
8843
|
-
*
|
|
8844
|
-
*
|
|
8845
|
-
*
|
|
8846
|
-
|
|
8847
|
-
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
8863
|
-
|
|
8864
|
-
|
|
8865
|
-
|
|
8866
|
-
|
|
8867
|
-
|
|
8868
|
-
|
|
8869
|
-
|
|
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
|
|
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
|
|
11826
|
-
|
|
11827
|
-
|
|
11828
|
-
|
|
11829
|
-
|
|
11830
|
-
|
|
11831
|
-
|
|
11832
|
-
|
|
11833
|
-
|
|
11834
|
-
|
|
11835
|
-
|
|
11836
|
-
|
|
11837
|
-
|
|
11838
|
-
|
|
11839
|
-
|
|
11840
|
-
|
|
11841
|
-
|
|
11842
|
-
|
|
11843
|
-
|
|
11844
|
-
|
|
11845
|
-
|
|
11846
|
-
|
|
11847
|
-
|
|
11848
|
-
|
|
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
|
-
|
|
11851
|
-
|
|
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
|
|
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.
|
|
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
|
|
11866
|
-
const
|
|
11867
|
-
|
|
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
|
-
|
|
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
|
|
11875
|
-
* to `1` and is hidden so Rails destroys it on submit; an unsaved row
|
|
11876
|
-
* from the DOM. Returns focus to a
|
|
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
|
-
|
|
11880
|
-
|
|
11881
|
-
|
|
11882
|
-
|
|
11883
|
-
|
|
11884
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
/**
|
|
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",
|
|
11904
|
-
this.#reflect("data-nested-at-min",
|
|
11905
|
-
if (this.hasAddTarget)
|
|
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 ||
|
|
11910
|
-
|
|
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
|
-
/**
|
|
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(
|
|
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
|
-
|
|
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
|
|
12593
|
-
if (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
|
-
|
|
12598
|
-
|
|
12599
|
-
|
|
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
|
-
|
|
12604
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
12614
|
-
this.#
|
|
13680
|
+
this.#bind(element);
|
|
13681
|
+
this.#adoptField(element);
|
|
13682
|
+
this.#reconcile.schedule();
|
|
12615
13683
|
}
|
|
12616
|
-
/**
|
|
13684
|
+
/** Releases a dropped field's listeners and leases, then reconciles the rest. */
|
|
12617
13685
|
fieldTargetDisconnected(element) {
|
|
12618
|
-
|
|
12619
|
-
this.#
|
|
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
|
|
13708
|
+
/** Handles keystroke inputs, distributes autofilled text, and advances focus. */
|
|
12622
13709
|
onInput(event) {
|
|
12623
|
-
const input = event
|
|
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.#
|
|
13716
|
+
this.#accept(input);
|
|
12627
13717
|
}
|
|
12628
|
-
/** Handles Backspace
|
|
13718
|
+
/** Handles Backspace clearing, arrows, and home/end navigation. */
|
|
12629
13719
|
onKeydown(event) {
|
|
12630
13720
|
if (isReservedArrowChord(event)) return;
|
|
12631
|
-
const input = event
|
|
13721
|
+
const input = this.#fieldFrom(event);
|
|
12632
13722
|
if (!input) return;
|
|
12633
|
-
|
|
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 (
|
|
12639
|
-
|
|
12640
|
-
|
|
12641
|
-
|
|
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
|
-
|
|
13746
|
+
case "ArrowLeft": {
|
|
13747
|
+
const previous = this.#focusableBefore(index);
|
|
13748
|
+
if (previous) {
|
|
12659
13749
|
event.preventDefault();
|
|
12660
|
-
|
|
13750
|
+
previous.focus();
|
|
12661
13751
|
}
|
|
12662
13752
|
break;
|
|
12663
|
-
|
|
12664
|
-
|
|
13753
|
+
}
|
|
13754
|
+
case "ArrowRight": {
|
|
13755
|
+
const next = this.#focusableAfter(index);
|
|
13756
|
+
if (next) {
|
|
12665
13757
|
event.preventDefault();
|
|
12666
|
-
|
|
13758
|
+
next.focus();
|
|
12667
13759
|
}
|
|
12668
13760
|
break;
|
|
12669
|
-
|
|
12670
|
-
|
|
12671
|
-
|
|
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
|
-
|
|
12674
|
-
|
|
12675
|
-
|
|
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
|
|
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
|
-
|
|
12687
|
-
|
|
12688
|
-
|
|
12689
|
-
|
|
12690
|
-
|
|
12691
|
-
|
|
12692
|
-
|
|
12693
|
-
|
|
12694
|
-
|
|
12695
|
-
|
|
12696
|
-
|
|
12697
|
-
|
|
12698
|
-
|
|
12699
|
-
|
|
12700
|
-
|
|
12701
|
-
|
|
12702
|
-
|
|
12703
|
-
|
|
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
|
-
|
|
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
|
-
|
|
12718
|
-
|
|
12719
|
-
|
|
12720
|
-
|
|
12721
|
-
|
|
12722
|
-
|
|
12723
|
-
|
|
12724
|
-
|
|
12725
|
-
|
|
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
|
-
|
|
12728
|
-
|
|
12729
|
-
|
|
12730
|
-
|
|
12731
|
-
|
|
12732
|
-
|
|
12733
|
-
|
|
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
|
-
|
|
12736
|
-
|
|
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
|
-
|
|
12741
|
-
|
|
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
|
-
|
|
12744
|
-
|
|
12745
|
-
|
|
12746
|
-
|
|
12747
|
-
this
|
|
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
|
-
#
|
|
12750
|
-
if (
|
|
12751
|
-
|
|
12752
|
-
}
|
|
13921
|
+
#markFilled(field, value) {
|
|
13922
|
+
if (value) field.setAttribute("data-filled", "true");
|
|
13923
|
+
else field.removeAttribute("data-filled");
|
|
12753
13924
|
}
|
|
12754
|
-
#
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
12761
|
-
|
|
12762
|
-
|
|
12763
|
-
|
|
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
|
|
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
|
|
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") &&
|
|
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:
|
|
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 —
|
|
18830
|
-
*
|
|
18831
|
-
*
|
|
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
|
|
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:
|
|
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
|
-
#
|
|
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.
|
|
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
|
|
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
|
|
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") &&
|
|
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) && !
|
|
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
|
|
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") &&
|
|
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;
|