stimeo-ui 0.6.0 → 0.7.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.
- package/CHANGELOG.md +62 -0
- package/dist/controllers/alert_dialog_controller.d.ts +2 -0
- package/dist/controllers/alert_dialog_controller.js +75 -4
- package/dist/controllers/alert_dialog_controller.js.map +1 -1
- package/dist/controllers/auto_submit_controller.d.ts +2 -0
- package/dist/controllers/auto_submit_controller.js.map +1 -1
- package/dist/controllers/avatar_controller.js +47 -5
- package/dist/controllers/avatar_controller.js.map +1 -1
- package/dist/controllers/breadcrumb_controller.d.ts +2 -0
- package/dist/controllers/breadcrumb_controller.js.map +1 -1
- package/dist/controllers/bulk_select_controller.d.ts +2 -0
- package/dist/controllers/bulk_select_controller.js.map +1 -1
- package/dist/controllers/calendar_controller.d.ts +2 -0
- package/dist/controllers/calendar_controller.js.map +1 -1
- package/dist/controllers/character_counter_controller.d.ts +52 -20
- package/dist/controllers/character_counter_controller.js +338 -63
- package/dist/controllers/character_counter_controller.js.map +1 -1
- package/dist/controllers/checkbox_controller.d.ts +3 -0
- package/dist/controllers/checkbox_controller.js.map +1 -1
- package/dist/controllers/color_picker_controller.d.ts +3 -0
- package/dist/controllers/color_picker_controller.js.map +1 -1
- package/dist/controllers/combobox_controller.d.ts +2 -0
- package/dist/controllers/combobox_controller.js.map +1 -1
- package/dist/controllers/command_palette_controller.d.ts +2 -0
- package/dist/controllers/command_palette_controller.js +75 -4
- package/dist/controllers/command_palette_controller.js.map +1 -1
- package/dist/controllers/conditional_fields_controller.d.ts +42 -14
- package/dist/controllers/conditional_fields_controller.js +345 -51
- package/dist/controllers/conditional_fields_controller.js.map +1 -1
- package/dist/controllers/confirm_controller.d.ts +2 -0
- package/dist/controllers/confirm_controller.js +75 -4
- package/dist/controllers/confirm_controller.js.map +1 -1
- package/dist/controllers/count_up_controller.d.ts +2 -0
- package/dist/controllers/count_up_controller.js.map +1 -1
- package/dist/controllers/countdown_controller.d.ts +4 -0
- package/dist/controllers/countdown_controller.js.map +1 -1
- package/dist/controllers/currency_input_controller.d.ts +2 -0
- package/dist/controllers/currency_input_controller.js.map +1 -1
- package/dist/controllers/data_grid_controller.d.ts +3 -0
- package/dist/controllers/data_grid_controller.js.map +1 -1
- package/dist/controllers/dialog_controller.js +75 -4
- package/dist/controllers/dialog_controller.js.map +1 -1
- package/dist/controllers/direct_upload_controller.d.ts +56 -24
- package/dist/controllers/direct_upload_controller.js +201 -45
- package/dist/controllers/direct_upload_controller.js.map +1 -1
- package/dist/controllers/dirty_form_controller.d.ts +14 -6
- package/dist/controllers/dirty_form_controller.js +192 -29
- package/dist/controllers/dirty_form_controller.js.map +1 -1
- package/dist/controllers/dismissible_controller.d.ts +2 -0
- package/dist/controllers/dismissible_controller.js +83 -18
- package/dist/controllers/dismissible_controller.js.map +1 -1
- package/dist/controllers/drawer_controller.js +75 -4
- package/dist/controllers/drawer_controller.js.map +1 -1
- package/dist/controllers/empty_state_controller.d.ts +2 -0
- package/dist/controllers/empty_state_controller.js.map +1 -1
- package/dist/controllers/file_dropzone_controller.d.ts +2 -0
- package/dist/controllers/file_dropzone_controller.js.map +1 -1
- package/dist/controllers/filter_controller.d.ts +2 -0
- package/dist/controllers/filter_controller.js.map +1 -1
- package/dist/controllers/flash_controller.d.ts +4 -0
- package/dist/controllers/flash_controller.js.map +1 -1
- package/dist/controllers/focus_controller.d.ts +2 -0
- package/dist/controllers/focus_controller.js +75 -4
- package/dist/controllers/focus_controller.js.map +1 -1
- package/dist/controllers/form_field_controller.d.ts +50 -10
- package/dist/controllers/form_field_controller.js +280 -62
- package/dist/controllers/form_field_controller.js.map +1 -1
- package/dist/controllers/form_validation_controller.d.ts +10 -8
- package/dist/controllers/form_validation_controller.js +208 -83
- package/dist/controllers/form_validation_controller.js.map +1 -1
- package/dist/controllers/frame_loading_controller.d.ts +2 -0
- package/dist/controllers/frame_loading_controller.js.map +1 -1
- package/dist/controllers/highlight_controller.d.ts +2 -0
- package/dist/controllers/highlight_controller.js.map +1 -1
- package/dist/controllers/idle_controller.d.ts +2 -0
- package/dist/controllers/idle_controller.js.map +1 -1
- package/dist/controllers/input_mask_controller.d.ts +2 -0
- package/dist/controllers/input_mask_controller.js.map +1 -1
- package/dist/controllers/lazy_frame_controller.d.ts +2 -0
- package/dist/controllers/lazy_frame_controller.js.map +1 -1
- package/dist/controllers/listbox_controller.d.ts +2 -0
- package/dist/controllers/listbox_controller.js.map +1 -1
- package/dist/controllers/local_time_controller.d.ts +2 -0
- package/dist/controllers/local_time_controller.js.map +1 -1
- package/dist/controllers/masonry_controller.d.ts +2 -0
- package/dist/controllers/masonry_controller.js.map +1 -1
- package/dist/controllers/meter_controller.d.ts +2 -0
- package/dist/controllers/meter_controller.js.map +1 -1
- package/dist/controllers/multi_select_controller.d.ts +2 -0
- package/dist/controllers/multi_select_controller.js.map +1 -1
- package/dist/controllers/nested_form_controller.d.ts +2 -0
- package/dist/controllers/nested_form_controller.js.map +1 -1
- package/dist/controllers/network_status_controller.d.ts +2 -0
- package/dist/controllers/network_status_controller.js.map +1 -1
- package/dist/controllers/number_input_controller.d.ts +2 -0
- package/dist/controllers/number_input_controller.js +47 -5
- package/dist/controllers/number_input_controller.js.map +1 -1
- package/dist/controllers/otp_controller.d.ts +3 -0
- package/dist/controllers/otp_controller.js.map +1 -1
- package/dist/controllers/overflow_indicator_controller.d.ts +2 -0
- package/dist/controllers/overflow_indicator_controller.js.map +1 -1
- package/dist/controllers/overflow_menu_controller.d.ts +2 -0
- package/dist/controllers/overflow_menu_controller.js +2 -1
- package/dist/controllers/overflow_menu_controller.js.map +1 -1
- package/dist/controllers/pagination_controller.d.ts +2 -0
- package/dist/controllers/pagination_controller.js +2 -1
- package/dist/controllers/pagination_controller.js.map +1 -1
- package/dist/controllers/password_reveal_controller.d.ts +2 -0
- package/dist/controllers/password_reveal_controller.js.map +1 -1
- package/dist/controllers/password_strength_controller.d.ts +2 -0
- package/dist/controllers/password_strength_controller.js.map +1 -1
- package/dist/controllers/persist_controller.d.ts +30 -16
- package/dist/controllers/persist_controller.js +432 -122
- package/dist/controllers/persist_controller.js.map +1 -1
- package/dist/controllers/pointer_drag_controller.d.ts +3 -0
- package/dist/controllers/pointer_drag_controller.js.map +1 -1
- package/dist/controllers/popover_controller.js +77 -4
- package/dist/controllers/popover_controller.js.map +1 -1
- package/dist/controllers/portal_controller.d.ts +2 -0
- package/dist/controllers/portal_controller.js.map +1 -1
- package/dist/controllers/preview_guard_controller.d.ts +2 -0
- package/dist/controllers/preview_guard_controller.js.map +1 -1
- package/dist/controllers/progress_controller.d.ts +3 -0
- package/dist/controllers/progress_controller.js.map +1 -1
- package/dist/controllers/radio_group_controller.d.ts +3 -0
- package/dist/controllers/radio_group_controller.js.map +1 -1
- package/dist/controllers/rating_controller.d.ts +2 -0
- package/dist/controllers/rating_controller.js +9 -5
- package/dist/controllers/rating_controller.js.map +1 -1
- package/dist/controllers/reading_progress_controller.d.ts +2 -0
- package/dist/controllers/reading_progress_controller.js.map +1 -1
- package/dist/controllers/resizable_controller.d.ts +2 -0
- package/dist/controllers/resizable_controller.js.map +1 -1
- package/dist/controllers/roving_controller.d.ts +2 -0
- package/dist/controllers/roving_controller.js.map +1 -1
- package/dist/controllers/scroll_area_controller.d.ts +23 -15
- package/dist/controllers/scroll_area_controller.js +462 -162
- package/dist/controllers/scroll_area_controller.js.map +1 -1
- package/dist/controllers/scroll_visibility_controller.d.ts +2 -0
- package/dist/controllers/scroll_visibility_controller.js.map +1 -1
- package/dist/controllers/scrollspy_controller.d.ts +2 -0
- package/dist/controllers/scrollspy_controller.js.map +1 -1
- package/dist/controllers/separator_controller.d.ts +54 -11
- package/dist/controllers/separator_controller.js +354 -38
- package/dist/controllers/separator_controller.js.map +1 -1
- package/dist/controllers/sidebar_controller.js +83 -10
- package/dist/controllers/sidebar_controller.js.map +1 -1
- package/dist/controllers/skeleton_controller.d.ts +2 -0
- package/dist/controllers/skeleton_controller.js.map +1 -1
- package/dist/controllers/slider_controller.d.ts +2 -0
- package/dist/controllers/slider_controller.js.map +1 -1
- package/dist/controllers/smart_sticky_header_controller.d.ts +2 -0
- package/dist/controllers/smart_sticky_header_controller.js.map +1 -1
- package/dist/controllers/spinner_controller.d.ts +2 -0
- package/dist/controllers/spinner_controller.js.map +1 -1
- package/dist/controllers/step_indicator_controller.d.ts +2 -0
- package/dist/controllers/step_indicator_controller.js.map +1 -1
- package/dist/controllers/stepper_controller.d.ts +2 -0
- package/dist/controllers/stepper_controller.js.map +1 -1
- package/dist/controllers/stick_to_bottom_controller.d.ts +2 -0
- package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
- package/dist/controllers/sticky_observer_controller.d.ts +2 -0
- package/dist/controllers/sticky_observer_controller.js.map +1 -1
- package/dist/controllers/submit_once_controller.d.ts +94 -38
- package/dist/controllers/submit_once_controller.js +399 -121
- package/dist/controllers/submit_once_controller.js.map +1 -1
- package/dist/controllers/switch_controller.d.ts +2 -0
- package/dist/controllers/switch_controller.js.map +1 -1
- package/dist/controllers/tags_input_controller.d.ts +2 -0
- package/dist/controllers/tags_input_controller.js.map +1 -1
- package/dist/controllers/textarea_autosize_controller.d.ts +2 -0
- package/dist/controllers/textarea_autosize_controller.js.map +1 -1
- package/dist/controllers/theme_controller.d.ts +2 -0
- package/dist/controllers/theme_controller.js +8 -6
- package/dist/controllers/theme_controller.js.map +1 -1
- package/dist/controllers/time_picker_controller.d.ts +2 -0
- package/dist/controllers/time_picker_controller.js.map +1 -1
- package/dist/controllers/toast_controller.d.ts +4 -0
- package/dist/controllers/toast_controller.js.map +1 -1
- package/dist/controllers/toggle_group_controller.d.ts +2 -0
- package/dist/controllers/toggle_group_controller.js.map +1 -1
- package/dist/controllers/toolbar_controller.js.map +1 -1
- package/dist/controllers/transition_controller.d.ts +2 -0
- package/dist/controllers/transition_controller.js.map +1 -1
- package/dist/controllers/tree_view_controller.d.ts +2 -0
- package/dist/controllers/tree_view_controller.js +2 -1
- package/dist/controllers/tree_view_controller.js.map +1 -1
- package/dist/index.js +2387 -861
- package/dist/index.js.map +1 -1
- package/dist/inspector/cli.d.ts +107 -3
- package/dist/inspector/cli.js +192 -14
- package/dist/inspector/cli.js.map +1 -1
- package/dist/inspector/cli_bin.js +254 -46
- package/dist/inspector/cli_bin.js.map +1 -1
- package/dist/inspector/examples.json +7 -7
- package/dist/inspector/manifest.json +434 -44
- package/dist/positioning/index.d.ts +2 -0
- package/dist/positioning/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -229,8 +229,90 @@ var EscapeLayer = class _EscapeLayer {
|
|
|
229
229
|
}
|
|
230
230
|
};
|
|
231
231
|
|
|
232
|
+
// src/utils/focus_candidate.ts
|
|
233
|
+
function inheritsFieldsetDisabled(control) {
|
|
234
|
+
let fieldset = control.closest("fieldset[disabled]");
|
|
235
|
+
while (fieldset) {
|
|
236
|
+
const legend = Array.from(fieldset.children).find((child) => child.tagName === "LEGEND");
|
|
237
|
+
if (!legend?.contains(control)) return true;
|
|
238
|
+
fieldset = fieldset.parentElement?.closest("fieldset[disabled]") ?? null;
|
|
239
|
+
}
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
function canTakeFocus(element) {
|
|
243
|
+
if (element.closest("[hidden], [inert]")) return false;
|
|
244
|
+
if (element instanceof HTMLInputElement && element.type === "hidden") return false;
|
|
245
|
+
if (!("disabled" in element)) return true;
|
|
246
|
+
if (element.disabled) return false;
|
|
247
|
+
return !inheritsFieldsetDisabled(element);
|
|
248
|
+
}
|
|
249
|
+
var TAB_STOP_CANDIDATE_SELECTOR = [
|
|
250
|
+
"a[href]",
|
|
251
|
+
"area[href]",
|
|
252
|
+
"button",
|
|
253
|
+
"input",
|
|
254
|
+
"select",
|
|
255
|
+
"textarea",
|
|
256
|
+
"summary",
|
|
257
|
+
"iframe",
|
|
258
|
+
"audio[controls]",
|
|
259
|
+
"video[controls]",
|
|
260
|
+
"[tabindex]",
|
|
261
|
+
"[contenteditable]"
|
|
262
|
+
].join(",");
|
|
263
|
+
function isRenderedForFocus(element) {
|
|
264
|
+
const check = element.checkVisibility;
|
|
265
|
+
return typeof check === "function" ? check.call(element, { visibilityProperty: true }) : true;
|
|
266
|
+
}
|
|
267
|
+
function authoredTabindex(element) {
|
|
268
|
+
const value = element.getAttribute("tabindex");
|
|
269
|
+
if (value === null || !/^[+-]?\d+$/.test(value.trim())) return null;
|
|
270
|
+
return Number(value);
|
|
271
|
+
}
|
|
272
|
+
function hasNativeTabStop(element) {
|
|
273
|
+
if (element instanceof HTMLAnchorElement || element instanceof HTMLAreaElement) {
|
|
274
|
+
return element.hasAttribute("href");
|
|
275
|
+
}
|
|
276
|
+
if (element instanceof HTMLButtonElement || element instanceof HTMLSelectElement || element instanceof HTMLTextAreaElement) {
|
|
277
|
+
return true;
|
|
278
|
+
}
|
|
279
|
+
if (element instanceof HTMLInputElement) return element.type !== "hidden";
|
|
280
|
+
if (element instanceof HTMLIFrameElement) return true;
|
|
281
|
+
if (element.tagName === "AUDIO" || element.tagName === "VIDEO") {
|
|
282
|
+
return element.hasAttribute("controls");
|
|
283
|
+
}
|
|
284
|
+
if (element instanceof HTMLElement && element.tagName === "SUMMARY") {
|
|
285
|
+
const details = element.parentElement;
|
|
286
|
+
return details instanceof HTMLDetailsElement && Array.from(details.children).find((child) => child.tagName === "SUMMARY") === element;
|
|
287
|
+
}
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
function hasEditableTabStop(element) {
|
|
291
|
+
const value = element.getAttribute("contenteditable")?.toLowerCase();
|
|
292
|
+
return value === "" || value === "true" || value === "plaintext-only";
|
|
293
|
+
}
|
|
294
|
+
function isTabStop(element) {
|
|
295
|
+
if (!canTakeFocus(element) || !isRenderedForFocus(element)) return false;
|
|
296
|
+
const tabindex = authoredTabindex(element);
|
|
297
|
+
if (tabindex !== null) return tabindex >= 0;
|
|
298
|
+
return hasNativeTabStop(element) || hasEditableTabStop(element);
|
|
299
|
+
}
|
|
300
|
+
function tabStopsWithin(root) {
|
|
301
|
+
return Array.from(root.querySelectorAll(TAB_STOP_CANDIDATE_SELECTOR)).filter(
|
|
302
|
+
isTabStop
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
function firstTabStop(root) {
|
|
306
|
+
for (const candidate of root.querySelectorAll(TAB_STOP_CANDIDATE_SELECTOR)) {
|
|
307
|
+
if (isTabStop(candidate)) return candidate;
|
|
308
|
+
}
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
function hasTabStop(root) {
|
|
312
|
+
return firstTabStop(root) !== null;
|
|
313
|
+
}
|
|
314
|
+
|
|
232
315
|
// src/utils/focus_trap.ts
|
|
233
|
-
var FOCUSABLE = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
234
316
|
var FocusTrap = class {
|
|
235
317
|
/** The element focused before activation, restored on deactivation. */
|
|
236
318
|
#previouslyFocused = null;
|
|
@@ -392,9 +474,7 @@ var FocusTrap = class {
|
|
|
392
474
|
}
|
|
393
475
|
/** Collects the container's currently focusable descendants in DOM order. */
|
|
394
476
|
#focusableElements() {
|
|
395
|
-
return
|
|
396
|
-
(el) => !el.hidden
|
|
397
|
-
);
|
|
477
|
+
return tabStopsWithin(this.#getContainer());
|
|
398
478
|
}
|
|
399
479
|
};
|
|
400
480
|
|
|
@@ -1029,17 +1109,21 @@ var AttributeLease = class {
|
|
|
1029
1109
|
written: value
|
|
1030
1110
|
});
|
|
1031
1111
|
}
|
|
1032
|
-
|
|
1033
|
-
else element.setAttribute(this.#attribute, value);
|
|
1112
|
+
this.#reflect(element, value);
|
|
1034
1113
|
}
|
|
1035
1114
|
/** Returns one lease without overwriting a value subsequently authored by a consumer. */
|
|
1036
1115
|
return(element) {
|
|
1037
1116
|
const record = this.#records.get(element);
|
|
1038
1117
|
if (!record) return;
|
|
1039
1118
|
this.#records.delete(element);
|
|
1040
|
-
|
|
1041
|
-
if (
|
|
1042
|
-
|
|
1119
|
+
const stillOwned = element.getAttribute(this.#attribute) === record.written;
|
|
1120
|
+
if (stillOwned) this.#reflect(element, record.original);
|
|
1121
|
+
}
|
|
1122
|
+
/** Reflects only a real value transition, avoiding self-triggered mutation work. */
|
|
1123
|
+
#reflect(element, value) {
|
|
1124
|
+
if (element.getAttribute(this.#attribute) === value) return;
|
|
1125
|
+
if (value === null) element.removeAttribute(this.#attribute);
|
|
1126
|
+
else element.setAttribute(this.#attribute, value);
|
|
1043
1127
|
}
|
|
1044
1128
|
/** Returns every outstanding lease using the same ownership check as {@link return}. */
|
|
1045
1129
|
returnAll() {
|
|
@@ -1056,6 +1140,7 @@ var AvatarController = class extends Controller {
|
|
|
1056
1140
|
/** Collapses one morph's Value, target, and direct-attribute signals into one pass. */
|
|
1057
1141
|
#reconcile = new MicrotaskCoalescer(() => this.#render());
|
|
1058
1142
|
/** Preserves directly-authored source and visibility while targets are controlled. */
|
|
1143
|
+
#beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
|
|
1059
1144
|
#src = new AttributeLease("src");
|
|
1060
1145
|
#imageHidden = new AttributeLease("hidden");
|
|
1061
1146
|
#fallbackHidden = new AttributeLease("hidden");
|
|
@@ -1071,6 +1156,7 @@ var AvatarController = class extends Controller {
|
|
|
1071
1156
|
connect() {
|
|
1072
1157
|
this.#connected = true;
|
|
1073
1158
|
this.#reconcile.activate();
|
|
1159
|
+
this.#beforeCache.activate();
|
|
1074
1160
|
this.#srcObserver.observe(this.element, {
|
|
1075
1161
|
attributes: true,
|
|
1076
1162
|
attributeFilter: ["src"],
|
|
@@ -1082,6 +1168,7 @@ var AvatarController = class extends Controller {
|
|
|
1082
1168
|
disconnect() {
|
|
1083
1169
|
this.#connected = false;
|
|
1084
1170
|
this.#reconcile.cancel();
|
|
1171
|
+
this.#beforeCache.deactivate();
|
|
1085
1172
|
this.#srcObserver.disconnect();
|
|
1086
1173
|
}
|
|
1087
1174
|
/** Repaints when application code or a Turbo morph changes the source Value. */
|
|
@@ -1189,6 +1276,12 @@ var AvatarController = class extends Controller {
|
|
|
1189
1276
|
}
|
|
1190
1277
|
this.element.setAttribute("data-state", state);
|
|
1191
1278
|
}
|
|
1279
|
+
/** Returns borrowed image and fallback semantics before Turbo snapshots the page. */
|
|
1280
|
+
#rewindForCache() {
|
|
1281
|
+
this.#src.returnAll();
|
|
1282
|
+
this.#imageHidden.returnAll();
|
|
1283
|
+
this.#fallbackHidden.returnAll();
|
|
1284
|
+
}
|
|
1192
1285
|
};
|
|
1193
1286
|
|
|
1194
1287
|
// src/utils/blur_deferral.ts
|
|
@@ -2295,110 +2388,284 @@ var CarouselController = class extends Controller {
|
|
|
2295
2388
|
}
|
|
2296
2389
|
}
|
|
2297
2390
|
};
|
|
2298
|
-
|
|
2391
|
+
|
|
2392
|
+
// src/utils/announce.ts
|
|
2393
|
+
function announce(message, options = {}) {
|
|
2394
|
+
const text = message.trim();
|
|
2395
|
+
if (text.length === 0) return;
|
|
2396
|
+
window.dispatchEvent(
|
|
2397
|
+
new CustomEvent("stimeo--announcer:announce", {
|
|
2398
|
+
detail: { message: text, assertive: options.assertive === true }
|
|
2399
|
+
})
|
|
2400
|
+
);
|
|
2401
|
+
}
|
|
2402
|
+
function fillTemplate(template, values) {
|
|
2403
|
+
return template.replace(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g, (match, name) => {
|
|
2404
|
+
const replacement = values[name];
|
|
2405
|
+
return replacement === void 0 ? match : String(replacement);
|
|
2406
|
+
});
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
// src/controllers/character_counter_controller.ts
|
|
2299
2410
|
var CharacterCounterController = class _CharacterCounterController extends Controller {
|
|
2300
2411
|
static targets = ["input", "output"];
|
|
2301
2412
|
static values = {
|
|
2302
2413
|
max: { type: Number, default: 0 },
|
|
2303
2414
|
warnAt: { type: Number, default: 0 },
|
|
2304
|
-
mode: { type: String, default: "remaining" }
|
|
2415
|
+
mode: { type: String, default: "remaining" },
|
|
2416
|
+
announceText: { type: String, default: "" }
|
|
2305
2417
|
};
|
|
2306
|
-
static events = ["change"];
|
|
2307
|
-
/** Delay (ms) before
|
|
2418
|
+
static events = ["change", "reconcile"];
|
|
2419
|
+
/** Delay (ms) before one settled count is sent to the shared announcer. */
|
|
2308
2420
|
static #announceDelay = 200;
|
|
2309
2421
|
#timeouts = new SafeTimeout();
|
|
2422
|
+
#ariaInvalid = new AttributeLease("aria-invalid");
|
|
2423
|
+
#beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
|
|
2424
|
+
#repaint = new MicrotaskCoalescer(() => this.#reconcile(true));
|
|
2425
|
+
#composition = new CompositionTracker({
|
|
2426
|
+
onEnd: (event) => this.#commitFrom(event.currentTarget)
|
|
2427
|
+
});
|
|
2310
2428
|
#announceId = null;
|
|
2311
|
-
|
|
2312
|
-
#
|
|
2429
|
+
#boundField = null;
|
|
2430
|
+
#lastLength = null;
|
|
2431
|
+
#lastDetail = null;
|
|
2432
|
+
#reflectedOver = null;
|
|
2313
2433
|
#onInput = (event) => {
|
|
2434
|
+
const field = this.#boundField;
|
|
2435
|
+
if (!field || event.currentTarget !== field || this.#field !== field) return;
|
|
2314
2436
|
if (this.#composition.isComposing(event)) return;
|
|
2315
|
-
this.#
|
|
2437
|
+
this.#commit(field);
|
|
2316
2438
|
};
|
|
2439
|
+
/** Reflects the current DOM state and opens the mutation-reconciliation window. */
|
|
2317
2440
|
connect() {
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
this.#composition.observe(field);
|
|
2322
|
-
this.#update({ announce: false });
|
|
2441
|
+
this.#repaint.activate();
|
|
2442
|
+
this.#beforeCache.activate();
|
|
2443
|
+
this.#reconcile(false);
|
|
2323
2444
|
}
|
|
2445
|
+
/** Releases listeners, timers, borrowed ARIA, and controller-owned state hooks. */
|
|
2324
2446
|
disconnect() {
|
|
2325
|
-
|
|
2326
|
-
|
|
2447
|
+
this.#repaint.cancel();
|
|
2448
|
+
this.#beforeCache.deactivate();
|
|
2449
|
+
this.#cancelAnnouncement();
|
|
2450
|
+
this.#bindField(null);
|
|
2327
2451
|
this.#composition.disconnect();
|
|
2328
|
-
this.#
|
|
2329
|
-
this.#
|
|
2452
|
+
this.#clearStateHooks();
|
|
2453
|
+
this.#lastLength = null;
|
|
2454
|
+
this.#lastDetail = null;
|
|
2455
|
+
}
|
|
2456
|
+
/** Rebinds and repaints after an input target is added or replaced at runtime. */
|
|
2457
|
+
inputTargetConnected() {
|
|
2458
|
+
this.#repaint.schedule();
|
|
2459
|
+
}
|
|
2460
|
+
/** Releases and repaints after an input target is removed or replaced at runtime. */
|
|
2461
|
+
inputTargetDisconnected() {
|
|
2462
|
+
this.#repaint.schedule();
|
|
2463
|
+
}
|
|
2464
|
+
/** Initializes a display target inserted after the controller connected. */
|
|
2465
|
+
outputTargetConnected() {
|
|
2466
|
+
this.#repaint.schedule();
|
|
2467
|
+
}
|
|
2468
|
+
/** Reconciles the optional display target set after a removal or replacement. */
|
|
2469
|
+
outputTargetDisconnected() {
|
|
2470
|
+
this.#repaint.schedule();
|
|
2471
|
+
}
|
|
2472
|
+
/** Repaints when application code or a Turbo morph changes `max`. */
|
|
2473
|
+
maxValueChanged() {
|
|
2474
|
+
this.#repaint.schedule();
|
|
2475
|
+
}
|
|
2476
|
+
/** Repaints when application code or a Turbo morph changes `warnAt`. */
|
|
2477
|
+
warnAtValueChanged() {
|
|
2478
|
+
this.#repaint.schedule();
|
|
2479
|
+
}
|
|
2480
|
+
/** Repaints when application code or a Turbo morph changes `mode`. */
|
|
2481
|
+
modeValueChanged() {
|
|
2482
|
+
this.#repaint.schedule();
|
|
2483
|
+
}
|
|
2484
|
+
/** Cancels a pending old message when its consumer-authored template changes. */
|
|
2485
|
+
announceTextValueChanged() {
|
|
2486
|
+
this.#repaint.schedule();
|
|
2487
|
+
}
|
|
2488
|
+
/** Rebinds one mutation batch and reports a changed controller-derived state. */
|
|
2489
|
+
#reconcile(report) {
|
|
2490
|
+
const previous = this.#lastDetail;
|
|
2491
|
+
const owedField = this.#announceId === null ? null : this.#boundField;
|
|
2492
|
+
this.#cancelAnnouncement();
|
|
2493
|
+
this.#bindField(this.#field);
|
|
2494
|
+
if (this.#composition.isComposing()) return;
|
|
2495
|
+
const field = this.#boundField;
|
|
2496
|
+
if (!field) {
|
|
2497
|
+
this.#renderEmpty();
|
|
2498
|
+
this.#lastLength = null;
|
|
2499
|
+
this.#lastDetail = null;
|
|
2500
|
+
return;
|
|
2501
|
+
}
|
|
2502
|
+
const reading = this.#render(field);
|
|
2503
|
+
const detail = this.#detail(reading);
|
|
2504
|
+
this.#lastLength = reading.length;
|
|
2505
|
+
this.#lastDetail = detail;
|
|
2506
|
+
if (owedField === field) this.#scheduleAnnouncement(reading);
|
|
2507
|
+
if (report && previous && this.#detailsDiffer(previous, detail)) {
|
|
2508
|
+
this.dispatch("reconcile", { detail });
|
|
2509
|
+
}
|
|
2510
|
+
}
|
|
2511
|
+
/** Commits a composition only when it came from the field still owned here. */
|
|
2512
|
+
#commitFrom(target) {
|
|
2513
|
+
const field = this.#boundField;
|
|
2514
|
+
if (!field || target !== field || this.#field !== field) return;
|
|
2515
|
+
this.#commit(field);
|
|
2516
|
+
}
|
|
2517
|
+
/** Reflects one confirmed user input and reports only a real length transition. */
|
|
2518
|
+
#commit(field) {
|
|
2519
|
+
const reading = this.#render(field);
|
|
2520
|
+
const previousLength = this.#lastLength;
|
|
2521
|
+
const previousDetail = this.#lastDetail;
|
|
2522
|
+
const detail = this.#detail(reading);
|
|
2523
|
+
this.#lastLength = reading.length;
|
|
2524
|
+
this.#lastDetail = detail;
|
|
2525
|
+
if (previousLength === reading.length) {
|
|
2526
|
+
if (previousDetail && this.#detailsDiffer(previousDetail, detail)) {
|
|
2527
|
+
this.dispatch("reconcile", { detail });
|
|
2528
|
+
}
|
|
2529
|
+
return;
|
|
2530
|
+
}
|
|
2531
|
+
this.dispatch("change", { detail });
|
|
2532
|
+
this.#scheduleAnnouncement(reading);
|
|
2533
|
+
}
|
|
2534
|
+
/** Selects the public event state from the richer internal reading. */
|
|
2535
|
+
#detail(reading) {
|
|
2536
|
+
return {
|
|
2537
|
+
length: reading.length,
|
|
2538
|
+
remaining: reading.remaining,
|
|
2539
|
+
over: reading.over
|
|
2540
|
+
};
|
|
2541
|
+
}
|
|
2542
|
+
/** Compares exactly the state carried by `change` and `reconcile`. */
|
|
2543
|
+
#detailsDiffer(left, right) {
|
|
2544
|
+
return left.length !== right.length || left.remaining !== right.remaining || left.over !== right.over;
|
|
2330
2545
|
}
|
|
2331
2546
|
/**
|
|
2332
|
-
*
|
|
2333
|
-
*
|
|
2334
|
-
*
|
|
2547
|
+
* Synchronizes visible count, state hooks, and temporary validation ARIA.
|
|
2548
|
+
*
|
|
2549
|
+
* @stimeoRenderRoot
|
|
2335
2550
|
*/
|
|
2336
|
-
#
|
|
2337
|
-
const field = this.#field;
|
|
2338
|
-
if (!field) return;
|
|
2551
|
+
#render(field) {
|
|
2339
2552
|
const length = field.value.length;
|
|
2340
|
-
const
|
|
2341
|
-
const
|
|
2342
|
-
const
|
|
2343
|
-
const
|
|
2553
|
+
const max = this.#normalizeCount(this.maxValue);
|
|
2554
|
+
const warnAt = this.#normalizeCount(this.warnAtValue);
|
|
2555
|
+
const remaining = max > 0 ? max - length : null;
|
|
2556
|
+
const over = remaining !== null && remaining < 0;
|
|
2557
|
+
const near = remaining !== null && warnAt > 0 && !over && remaining <= warnAt;
|
|
2344
2558
|
this.#toggle("data-over-limit", over);
|
|
2345
2559
|
this.#toggle("data-near-limit", near);
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
this.#
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2560
|
+
this.#reflectInvalid(field, over);
|
|
2561
|
+
const text = this.#format(length, remaining, max);
|
|
2562
|
+
this.#writeOutput(text);
|
|
2563
|
+
return { length, remaining, over, max, text };
|
|
2564
|
+
}
|
|
2565
|
+
/** Clears derived output when the declarative input set has no usable field. */
|
|
2566
|
+
#renderEmpty() {
|
|
2567
|
+
this.#ariaInvalid.returnAll();
|
|
2568
|
+
this.#reflectedOver = null;
|
|
2569
|
+
this.#clearStateHooks();
|
|
2570
|
+
this.#writeOutput("");
|
|
2571
|
+
}
|
|
2572
|
+
/** Replaces the observed field symmetrically, returning state from the old one. */
|
|
2573
|
+
#bindField(field) {
|
|
2574
|
+
if (field === this.#boundField) return;
|
|
2575
|
+
const previous = this.#boundField;
|
|
2576
|
+
if (previous) {
|
|
2577
|
+
previous.removeEventListener("input", this.#onInput);
|
|
2578
|
+
this.#composition.unobserve(previous);
|
|
2579
|
+
this.#ariaInvalid.return(previous);
|
|
2580
|
+
}
|
|
2581
|
+
this.#boundField = field;
|
|
2582
|
+
this.#lastLength = null;
|
|
2583
|
+
this.#reflectedOver = null;
|
|
2584
|
+
if (!field) return;
|
|
2585
|
+
field.addEventListener("input", this.#onInput);
|
|
2586
|
+
this.#composition.observe(field);
|
|
2587
|
+
}
|
|
2588
|
+
/** Leases `aria-invalid` only on the edge into over-limit, then returns it. */
|
|
2589
|
+
#reflectInvalid(field, over) {
|
|
2590
|
+
if (this.#reflectedOver === over) return;
|
|
2591
|
+
this.#reflectedOver = over;
|
|
2592
|
+
if (over) this.#ariaInvalid.write(field, "true");
|
|
2593
|
+
else this.#ariaInvalid.return(field);
|
|
2371
2594
|
}
|
|
2372
|
-
/** Builds the count
|
|
2373
|
-
#format(length, remaining) {
|
|
2595
|
+
/** Builds the visible count for the normalized display mode. */
|
|
2596
|
+
#format(length, remaining, max) {
|
|
2374
2597
|
if (remaining === null) return String(length);
|
|
2375
|
-
switch (this
|
|
2598
|
+
switch (this.#mode) {
|
|
2376
2599
|
case "used":
|
|
2377
2600
|
return String(length);
|
|
2378
2601
|
case "both":
|
|
2379
|
-
return `${length}/${
|
|
2602
|
+
return `${length}/${max}`;
|
|
2380
2603
|
default:
|
|
2381
2604
|
return String(remaining);
|
|
2382
2605
|
}
|
|
2383
2606
|
}
|
|
2607
|
+
/** Debounces one i18n-neutral message into the shared polite announcer. */
|
|
2608
|
+
#scheduleAnnouncement(reading) {
|
|
2609
|
+
this.#cancelAnnouncement();
|
|
2610
|
+
const message = fillTemplate(this.announceTextValue, {
|
|
2611
|
+
count: reading.text,
|
|
2612
|
+
length: reading.length,
|
|
2613
|
+
remaining: reading.remaining ?? "",
|
|
2614
|
+
max: reading.max,
|
|
2615
|
+
over: String(reading.over)
|
|
2616
|
+
});
|
|
2617
|
+
if (message.trim().length === 0) return;
|
|
2618
|
+
this.#announceId = this.#timeouts.set(() => {
|
|
2619
|
+
announce(message);
|
|
2620
|
+
this.#announceId = null;
|
|
2621
|
+
}, _CharacterCounterController.#announceDelay);
|
|
2622
|
+
}
|
|
2623
|
+
/** Cancels the one outstanding announcement without touching visible output. */
|
|
2624
|
+
#cancelAnnouncement() {
|
|
2625
|
+
if (this.#announceId !== null) this.#timeouts.clear(this.#announceId);
|
|
2626
|
+
this.#announceId = null;
|
|
2627
|
+
}
|
|
2628
|
+
/** Writes the optional display only when its text actually changed. */
|
|
2384
2629
|
#writeOutput(text) {
|
|
2385
|
-
if (this.hasOutputTarget
|
|
2630
|
+
if (!this.hasOutputTarget || this.outputTarget.textContent === text) return;
|
|
2631
|
+
this.outputTarget.textContent = text;
|
|
2386
2632
|
}
|
|
2387
|
-
/**
|
|
2633
|
+
/** Reflects a presence-style state hook without redundant attribute writes. */
|
|
2388
2634
|
#toggle(name, on) {
|
|
2389
2635
|
if (on) {
|
|
2390
|
-
this.element.setAttribute(name, "true");
|
|
2391
|
-
} else {
|
|
2636
|
+
if (this.element.getAttribute(name) !== "true") this.element.setAttribute(name, "true");
|
|
2637
|
+
} else if (this.element.hasAttribute(name)) {
|
|
2392
2638
|
this.element.removeAttribute(name);
|
|
2393
2639
|
}
|
|
2394
2640
|
}
|
|
2395
|
-
/**
|
|
2641
|
+
/** Removes both state hooks owned by this controller. */
|
|
2642
|
+
#clearStateHooks() {
|
|
2643
|
+
this.#toggle("data-over-limit", false);
|
|
2644
|
+
this.#toggle("data-near-limit", false);
|
|
2645
|
+
}
|
|
2646
|
+
/** Returns borrowed ARIA and transient hooks before Turbo snapshots the page. */
|
|
2647
|
+
#rewindForCache() {
|
|
2648
|
+
this.#cancelAnnouncement();
|
|
2649
|
+
this.#ariaInvalid.returnAll();
|
|
2650
|
+
this.#reflectedOver = null;
|
|
2651
|
+
this.#clearStateHooks();
|
|
2652
|
+
}
|
|
2653
|
+
/** Converts a public count Value into a finite non-negative integer. */
|
|
2654
|
+
#normalizeCount(raw) {
|
|
2655
|
+
if (!Number.isFinite(raw)) return 0;
|
|
2656
|
+
return Math.max(0, Math.trunc(raw));
|
|
2657
|
+
}
|
|
2658
|
+
/** Falls back to `remaining` when an authored display mode is unknown. */
|
|
2659
|
+
get #mode() {
|
|
2660
|
+
if (this.modeValue === "used" || this.modeValue === "both") return this.modeValue;
|
|
2661
|
+
return "remaining";
|
|
2662
|
+
}
|
|
2663
|
+
/** The watched field: the first target, or a directly controlled form field. */
|
|
2396
2664
|
get #field() {
|
|
2397
2665
|
if (this.hasInputTarget) return this.inputTarget;
|
|
2398
|
-
const
|
|
2399
|
-
if (
|
|
2400
|
-
return
|
|
2401
|
-
}
|
|
2666
|
+
const element = this.element;
|
|
2667
|
+
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement)
|
|
2668
|
+
return element;
|
|
2402
2669
|
return null;
|
|
2403
2670
|
}
|
|
2404
2671
|
};
|
|
@@ -3900,7 +4167,54 @@ var CommandPaletteController = class _CommandPaletteController extends Controlle
|
|
|
3900
4167
|
}
|
|
3901
4168
|
}
|
|
3902
4169
|
};
|
|
4170
|
+
|
|
4171
|
+
// src/utils/tabindex_loan.ts
|
|
4172
|
+
var TabindexLoan = class {
|
|
4173
|
+
#value;
|
|
4174
|
+
#lent = /* @__PURE__ */ new Set();
|
|
4175
|
+
/** Returns live loans before Turbo can copy them into its page snapshot. */
|
|
4176
|
+
#beforeCache = new BeforeCacheReset(() => this.returnAll());
|
|
4177
|
+
/**
|
|
4178
|
+
* @param value - the `tabindex` to lend. `"-1"` (the default) is
|
|
4179
|
+
* programmatically focusable but not a Tab stop; `"0"` is a real Tab stop,
|
|
4180
|
+
* which a scroll region with no focusable content of its own needs.
|
|
4181
|
+
*/
|
|
4182
|
+
constructor(value = "-1") {
|
|
4183
|
+
this.#value = value;
|
|
4184
|
+
}
|
|
4185
|
+
/** Lends `element` the value; no-ops when it already carries a `tabindex`. */
|
|
4186
|
+
lend(element) {
|
|
4187
|
+
if (element.hasAttribute("tabindex")) return;
|
|
4188
|
+
element.setAttribute("tabindex", this.#value);
|
|
4189
|
+
this.#lent.add(element);
|
|
4190
|
+
this.#beforeCache.activate();
|
|
4191
|
+
}
|
|
4192
|
+
/** Takes back every loan whose value is still the one that was lent. */
|
|
4193
|
+
returnAll() {
|
|
4194
|
+
for (const element of this.#lent) {
|
|
4195
|
+
if (element.getAttribute("tabindex") === this.#value) element.removeAttribute("tabindex");
|
|
4196
|
+
}
|
|
4197
|
+
this.#lent.clear();
|
|
4198
|
+
this.#beforeCache.deactivate();
|
|
4199
|
+
}
|
|
4200
|
+
};
|
|
4201
|
+
|
|
4202
|
+
// src/controllers/conditional_fields_controller.ts
|
|
3903
4203
|
var DISABLED_MARKER = "data-conditional-disabled";
|
|
4204
|
+
var OBSERVED_ATTRIBUTES = [
|
|
4205
|
+
"hidden",
|
|
4206
|
+
"aria-hidden",
|
|
4207
|
+
"data-visible",
|
|
4208
|
+
DISABLED_MARKER,
|
|
4209
|
+
"disabled",
|
|
4210
|
+
"data-when-checked",
|
|
4211
|
+
"data-when-unchecked",
|
|
4212
|
+
"data-when-value",
|
|
4213
|
+
"checked",
|
|
4214
|
+
"selected",
|
|
4215
|
+
"value",
|
|
4216
|
+
"type"
|
|
4217
|
+
];
|
|
3904
4218
|
var ConditionalFieldsController = class extends Controller {
|
|
3905
4219
|
static targets = ["trigger", "region"];
|
|
3906
4220
|
static values = {
|
|
@@ -3908,56 +4222,171 @@ var ConditionalFieldsController = class extends Controller {
|
|
|
3908
4222
|
match: { type: String, default: "any" }
|
|
3909
4223
|
};
|
|
3910
4224
|
static actions = ["evaluate"];
|
|
3911
|
-
static events = ["change"];
|
|
4225
|
+
static events = ["change", "reconcile"];
|
|
4226
|
+
/** Logical state last committed for each current region; rebuilt on every connect. */
|
|
3912
4227
|
#lastVisible = /* @__PURE__ */ new WeakMap();
|
|
3913
|
-
|
|
3914
|
-
|
|
4228
|
+
/** Controller writes awaiting an observer callback, separated from authored morphs. */
|
|
4229
|
+
#internalWrites = /* @__PURE__ */ new WeakMap();
|
|
4230
|
+
#observing = false;
|
|
4231
|
+
/** Coalesces one target/morph/mutation batch into one full DOM reconciliation. */
|
|
4232
|
+
#reconcile = new MicrotaskCoalescer(() => this.#reconcileDom());
|
|
4233
|
+
/** Provides the last-resort focus landmark without claiming an authored tabindex. */
|
|
4234
|
+
#rootTabindex = new TabindexLoan();
|
|
4235
|
+
/** Watches retained targets and controls whose declarative or reflected state changed. */
|
|
4236
|
+
#observer = new MutationObserver((records) => this.#onMutations(records));
|
|
4237
|
+
/** Evaluates only events whose source is one of this instance's live trigger targets. */
|
|
4238
|
+
#onTriggerInput = (event) => {
|
|
4239
|
+
const target = event.target;
|
|
4240
|
+
const triggers = this.triggerTargets;
|
|
4241
|
+
if (target instanceof Element && triggers.some((trigger) => trigger === target)) {
|
|
4242
|
+
this.#commitChange(triggers);
|
|
4243
|
+
}
|
|
4244
|
+
};
|
|
4245
|
+
/** Reconciles property-only retained-element changes after Turbo morphs. */
|
|
4246
|
+
#onMorph = () => {
|
|
4247
|
+
this.#reconcile.schedule();
|
|
4248
|
+
};
|
|
4249
|
+
/** Reconciles the settled defaults after a non-cancelled owning form reset. */
|
|
4250
|
+
#onReset = (event) => {
|
|
4251
|
+
const form = event.target;
|
|
4252
|
+
if (form instanceof HTMLFormElement && this.#hasTriggerOwnedBy(form)) {
|
|
4253
|
+
queueMicrotask(() => {
|
|
4254
|
+
if (!event.defaultPrevented) this.#reconcile.schedule();
|
|
4255
|
+
});
|
|
4256
|
+
}
|
|
3915
4257
|
};
|
|
4258
|
+
/** Reflects initial live state and opens every retained-DOM reconciliation path. */
|
|
3916
4259
|
connect() {
|
|
3917
|
-
this
|
|
3918
|
-
this
|
|
3919
|
-
this.
|
|
4260
|
+
this.#lastVisible = /* @__PURE__ */ new WeakMap();
|
|
4261
|
+
this.#internalWrites = /* @__PURE__ */ new WeakMap();
|
|
4262
|
+
this.#reconcile.activate();
|
|
4263
|
+
this.#settle();
|
|
4264
|
+
this.#observer.observe(this.element, {
|
|
4265
|
+
attributes: true,
|
|
4266
|
+
attributeFilter: OBSERVED_ATTRIBUTES,
|
|
4267
|
+
childList: true,
|
|
4268
|
+
subtree: true
|
|
4269
|
+
});
|
|
4270
|
+
this.#observing = true;
|
|
4271
|
+
this.element.addEventListener("change", this.#onTriggerInput);
|
|
4272
|
+
this.element.addEventListener("input", this.#onTriggerInput);
|
|
4273
|
+
this.element.addEventListener("turbo:morph-element", this.#onMorph);
|
|
4274
|
+
document.addEventListener("reset", this.#onReset, true);
|
|
3920
4275
|
}
|
|
4276
|
+
/** Releases all external resources and returns the temporary focus landmark loan. */
|
|
3921
4277
|
disconnect() {
|
|
3922
|
-
this.
|
|
3923
|
-
this
|
|
4278
|
+
this.#reconcile.cancel();
|
|
4279
|
+
this.#observing = false;
|
|
4280
|
+
this.#observer.disconnect();
|
|
4281
|
+
this.element.removeEventListener("change", this.#onTriggerInput);
|
|
4282
|
+
this.element.removeEventListener("input", this.#onTriggerInput);
|
|
4283
|
+
this.element.removeEventListener("turbo:morph-element", this.#onMorph);
|
|
4284
|
+
document.removeEventListener("reset", this.#onReset, true);
|
|
4285
|
+
this.#rootTabindex.returnAll();
|
|
4286
|
+
}
|
|
4287
|
+
/** Reconciles after a trigger target is inserted or restored. */
|
|
4288
|
+
triggerTargetConnected() {
|
|
4289
|
+
this.#reconcile.schedule();
|
|
3924
4290
|
}
|
|
3925
|
-
/**
|
|
4291
|
+
/** Reconciles after a trigger target is removed or replaced. */
|
|
4292
|
+
triggerTargetDisconnected() {
|
|
4293
|
+
this.#reconcile.schedule();
|
|
4294
|
+
}
|
|
4295
|
+
/** Initializes a region target inserted or restored at runtime. */
|
|
4296
|
+
regionTargetConnected() {
|
|
4297
|
+
this.#reconcile.schedule();
|
|
4298
|
+
}
|
|
4299
|
+
/** Reconciles the remaining region set after a target leaves. */
|
|
4300
|
+
regionTargetDisconnected() {
|
|
4301
|
+
this.#reconcile.schedule();
|
|
4302
|
+
}
|
|
4303
|
+
/** Reconciles when application code or a morph changes the match policy. */
|
|
4304
|
+
matchValueChanged() {
|
|
4305
|
+
this.#reconcile.schedule();
|
|
4306
|
+
}
|
|
4307
|
+
/** Reconciles control ownership when hidden controls become enabled or excluded. */
|
|
4308
|
+
disableHiddenValueChanged() {
|
|
4309
|
+
this.#reconcile.schedule();
|
|
4310
|
+
}
|
|
4311
|
+
/** Re-evaluates every region and repairs all reflected DOM state. */
|
|
3926
4312
|
evaluate() {
|
|
3927
|
-
|
|
3928
|
-
|
|
3929
|
-
|
|
4313
|
+
this.#commitChange();
|
|
4314
|
+
}
|
|
4315
|
+
/**
|
|
4316
|
+
* Computes one settled plan, shows destinations before hiding sources, and then
|
|
4317
|
+
* returns logical transitions in DOM order after every region is internally coherent.
|
|
4318
|
+
*/
|
|
4319
|
+
#settle(triggers = this.triggerTargets) {
|
|
4320
|
+
const regions = this.regionTargets;
|
|
4321
|
+
const plan = /* @__PURE__ */ new Map();
|
|
4322
|
+
for (const region of regions) plan.set(region, this.#isVisible(region, triggers));
|
|
4323
|
+
const changed = /* @__PURE__ */ new Set();
|
|
4324
|
+
const applicationOrder = [
|
|
4325
|
+
...regions.filter((region) => plan.get(region) === true),
|
|
4326
|
+
...regions.filter((region) => plan.get(region) === false)
|
|
4327
|
+
];
|
|
4328
|
+
for (const region of applicationOrder) {
|
|
4329
|
+
const visible = plan.get(region);
|
|
4330
|
+
if (visible !== void 0 && this.#applyRegion(region, visible, plan)) {
|
|
4331
|
+
changed.add(region);
|
|
4332
|
+
}
|
|
4333
|
+
}
|
|
4334
|
+
return regions.filter((region) => changed.has(region)).map((region) => ({ region, visible: plan.get(region) === true }));
|
|
4335
|
+
}
|
|
4336
|
+
/** Reports logical transitions caused by user input or the public action. */
|
|
4337
|
+
#commitChange(triggers = this.triggerTargets) {
|
|
4338
|
+
for (const detail of this.#settle(triggers)) {
|
|
4339
|
+
this.dispatch("change", { detail });
|
|
4340
|
+
}
|
|
4341
|
+
}
|
|
4342
|
+
/** Reports logical transitions derived from DOM, lifecycle, reset, or Value state. */
|
|
4343
|
+
#reconcileDom() {
|
|
4344
|
+
for (const detail of this.#settle()) {
|
|
4345
|
+
this.dispatch("reconcile", { detail });
|
|
3930
4346
|
}
|
|
3931
4347
|
}
|
|
3932
|
-
/** Applies
|
|
3933
|
-
#applyRegion(region, visible) {
|
|
4348
|
+
/** Applies one planned state while keeping event history separate from DOM repair. */
|
|
4349
|
+
#applyRegion(region, visible, plan) {
|
|
3934
4350
|
const previous = this.#lastVisible.get(region) ?? !region.hidden;
|
|
3935
|
-
|
|
4351
|
+
const changed = visible !== previous;
|
|
3936
4352
|
this.#lastVisible.set(region, visible);
|
|
3937
|
-
if (visible)
|
|
3938
|
-
|
|
3939
|
-
|
|
3940
|
-
|
|
3941
|
-
|
|
3942
|
-
|
|
3943
|
-
|
|
3944
|
-
|
|
3945
|
-
|
|
3946
|
-
|
|
3947
|
-
|
|
3948
|
-
|
|
3949
|
-
|
|
3950
|
-
|
|
4353
|
+
if (!visible) this.#retreatFocus(region, plan);
|
|
4354
|
+
this.#syncRegionState(region, visible);
|
|
4355
|
+
this.#syncRegionControls(region, visible);
|
|
4356
|
+
return changed;
|
|
4357
|
+
}
|
|
4358
|
+
/** Reflects visibility idempotently, recording writes so the observer ignores its own work. */
|
|
4359
|
+
#syncRegionState(region, visible) {
|
|
4360
|
+
this.#writeAttribute(region, "hidden", visible ? null : "");
|
|
4361
|
+
this.#writeAttribute(region, "aria-hidden", visible ? null : "true");
|
|
4362
|
+
this.#writeAttribute(region, "data-visible", visible ? "true" : null);
|
|
4363
|
+
}
|
|
4364
|
+
/** Enables/disables descendant controls, touching only state carrying our marker. */
|
|
4365
|
+
#syncRegionControls(region, visible) {
|
|
4366
|
+
const shouldDisable = !visible && this.disableHiddenValue;
|
|
4367
|
+
const controls = region.querySelectorAll("input, textarea, select, button");
|
|
4368
|
+
for (const control of controls) {
|
|
4369
|
+
const marked = control.hasAttribute(DISABLED_MARKER);
|
|
4370
|
+
if (shouldDisable) {
|
|
4371
|
+
if (!control.disabled) {
|
|
4372
|
+
this.#writeAttribute(control, "disabled", "");
|
|
4373
|
+
this.#writeAttribute(control, DISABLED_MARKER, "true");
|
|
4374
|
+
} else if (marked && control.getAttribute(DISABLED_MARKER) !== "true") {
|
|
4375
|
+
this.#writeAttribute(control, DISABLED_MARKER, "true");
|
|
4376
|
+
}
|
|
4377
|
+
} else if (marked) {
|
|
4378
|
+
this.#writeAttribute(control, "disabled", null);
|
|
4379
|
+
this.#writeAttribute(control, DISABLED_MARKER, null);
|
|
4380
|
+
}
|
|
3951
4381
|
}
|
|
3952
4382
|
}
|
|
3953
4383
|
/** Whether a region's declared condition holds across `triggers` per `match`. */
|
|
3954
4384
|
#isVisible(region, triggers) {
|
|
3955
4385
|
const predicate = this.#predicateFor(region);
|
|
3956
4386
|
if (predicate === null) return !region.hidden;
|
|
3957
|
-
|
|
3958
|
-
return this.matchValue === "all" ? triggers.every(predicate) : triggers.some(predicate);
|
|
4387
|
+
return triggers.length > 0 && (this.matchValue === "all" ? triggers.every(predicate) : triggers.some(predicate));
|
|
3959
4388
|
}
|
|
3960
|
-
/** Builds the per-trigger predicate from the region's
|
|
4389
|
+
/** Builds the deterministic per-trigger predicate from the region's condition. */
|
|
3961
4390
|
#predicateFor(region) {
|
|
3962
4391
|
if (region.hasAttribute("data-when-checked")) {
|
|
3963
4392
|
return (trigger) => this.#isChecked(trigger);
|
|
@@ -3974,36 +4403,88 @@ var ConditionalFieldsController = class extends Controller {
|
|
|
3974
4403
|
#isChecked(trigger) {
|
|
3975
4404
|
return trigger instanceof HTMLInputElement && trigger.checked;
|
|
3976
4405
|
}
|
|
3977
|
-
/** A trigger
|
|
4406
|
+
/** A trigger has a value when a radio/checkbox is selected, or its control value matches. */
|
|
3978
4407
|
#matchesValue(trigger, wanted) {
|
|
3979
4408
|
if (trigger instanceof HTMLInputElement && (trigger.type === "checkbox" || trigger.type === "radio")) {
|
|
3980
4409
|
return trigger.checked && trigger.value === wanted;
|
|
3981
4410
|
}
|
|
3982
4411
|
return trigger.value === wanted;
|
|
3983
4412
|
}
|
|
3984
|
-
/**
|
|
3985
|
-
#
|
|
3986
|
-
|
|
3987
|
-
|
|
3988
|
-
|
|
3989
|
-
|
|
3990
|
-
|
|
3991
|
-
|
|
3992
|
-
|
|
4413
|
+
/** Moves focus to the first safe trigger, or to the controller landmark as a fallback. */
|
|
4414
|
+
#retreatFocus(region, plan) {
|
|
4415
|
+
const active = document.activeElement;
|
|
4416
|
+
if (active && region.contains(active)) this.#moveFocus(plan);
|
|
4417
|
+
}
|
|
4418
|
+
/** Moves focus after the caller establishes that the active node is being hidden. */
|
|
4419
|
+
#moveFocus(plan) {
|
|
4420
|
+
const destination = this.triggerTargets.find(
|
|
4421
|
+
(trigger) => canTakeFocus(trigger) && this.#survivesPlan(trigger, plan)
|
|
4422
|
+
);
|
|
4423
|
+
if (destination) {
|
|
4424
|
+
this.#rootTabindex.returnAll();
|
|
4425
|
+
destination.focus();
|
|
4426
|
+
return;
|
|
4427
|
+
}
|
|
4428
|
+
this.#rootTabindex.lend(this.element);
|
|
4429
|
+
this.element.focus();
|
|
4430
|
+
}
|
|
4431
|
+
/** Whether a focus candidate sits outside every region this pass will hide. */
|
|
4432
|
+
#survivesPlan(candidate, plan) {
|
|
4433
|
+
return [...plan].every(([region, visible]) => visible || !region.contains(candidate));
|
|
4434
|
+
}
|
|
4435
|
+
/** Records one idempotent attribute write before MutationObserver sees it. */
|
|
4436
|
+
#writeAttribute(element, name, value) {
|
|
4437
|
+
if (element.getAttribute(name) !== value) {
|
|
4438
|
+
if (this.#observing) {
|
|
4439
|
+
let writes = this.#internalWrites.get(element);
|
|
4440
|
+
if (!writes) {
|
|
4441
|
+
writes = /* @__PURE__ */ new Map();
|
|
4442
|
+
this.#internalWrites.set(element, writes);
|
|
3993
4443
|
}
|
|
3994
|
-
|
|
3995
|
-
control.disabled = false;
|
|
3996
|
-
control.removeAttribute(DISABLED_MARKER);
|
|
4444
|
+
writes.set(name, value);
|
|
3997
4445
|
}
|
|
4446
|
+
if (value === null) element.removeAttribute(name);
|
|
4447
|
+
else element.setAttribute(name, value);
|
|
3998
4448
|
}
|
|
3999
4449
|
}
|
|
4000
|
-
/**
|
|
4001
|
-
#
|
|
4002
|
-
const
|
|
4003
|
-
|
|
4004
|
-
|
|
4005
|
-
|
|
4006
|
-
|
|
4450
|
+
/** Separates authored records from controller writes and queues one relevant repair. */
|
|
4451
|
+
#onMutations(records) {
|
|
4452
|
+
const triggers = this.triggerTargets;
|
|
4453
|
+
const regions = this.regionTargets;
|
|
4454
|
+
const touchedWrites = /* @__PURE__ */ new Map();
|
|
4455
|
+
let needsReconcile = false;
|
|
4456
|
+
for (const record of records) {
|
|
4457
|
+
if (record.type === "attributes" && record.target instanceof Element) {
|
|
4458
|
+
const name = record.attributeName;
|
|
4459
|
+
const writes = this.#internalWrites.get(record.target);
|
|
4460
|
+
if (name && writes?.has(name)) {
|
|
4461
|
+
let names = touchedWrites.get(record.target);
|
|
4462
|
+
if (!names) {
|
|
4463
|
+
names = /* @__PURE__ */ new Set();
|
|
4464
|
+
touchedWrites.set(record.target, names);
|
|
4465
|
+
}
|
|
4466
|
+
names.add(name);
|
|
4467
|
+
if (record.target.getAttribute(name) === writes.get(name)) continue;
|
|
4468
|
+
}
|
|
4469
|
+
}
|
|
4470
|
+
if (record.target instanceof Element && this.#elementAffectsState(record.target, triggers, regions)) {
|
|
4471
|
+
needsReconcile = true;
|
|
4472
|
+
}
|
|
4473
|
+
}
|
|
4474
|
+
for (const [element, names] of touchedWrites) {
|
|
4475
|
+
const writes = this.#internalWrites.get(element);
|
|
4476
|
+
for (const name of names) writes?.delete(name);
|
|
4477
|
+
}
|
|
4478
|
+
if (needsReconcile) this.#reconcile.schedule();
|
|
4479
|
+
}
|
|
4480
|
+
/** Whether an authored mutation target belongs to a trigger, region, or managed control. */
|
|
4481
|
+
#elementAffectsState(target, triggers, regions) {
|
|
4482
|
+
return triggers.some((trigger) => trigger === target || trigger.contains(target)) || regions.some((region) => region === target || region.contains(target));
|
|
4483
|
+
}
|
|
4484
|
+
/** Whether a form owns at least one current trigger, including `form=` associations. */
|
|
4485
|
+
#hasTriggerOwnedBy(form) {
|
|
4486
|
+
return this.triggerTargets.some((trigger) => trigger.form === form);
|
|
4487
|
+
}
|
|
4007
4488
|
};
|
|
4008
4489
|
var ConfirmController = class extends Controller {
|
|
4009
4490
|
static targets = ["dialog", "title", "message", "confirm", "cancel"];
|
|
@@ -4356,25 +4837,6 @@ var CountUpController = class extends Controller {
|
|
|
4356
4837
|
this.element.removeAttribute("data-count-up-label");
|
|
4357
4838
|
}
|
|
4358
4839
|
};
|
|
4359
|
-
|
|
4360
|
-
// src/utils/announce.ts
|
|
4361
|
-
function announce(message, options = {}) {
|
|
4362
|
-
const text = message.trim();
|
|
4363
|
-
if (text.length === 0) return;
|
|
4364
|
-
window.dispatchEvent(
|
|
4365
|
-
new CustomEvent("stimeo--announcer:announce", {
|
|
4366
|
-
detail: { message: text, assertive: options.assertive === true }
|
|
4367
|
-
})
|
|
4368
|
-
);
|
|
4369
|
-
}
|
|
4370
|
-
function fillTemplate(template, values) {
|
|
4371
|
-
return template.replace(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g, (match, name) => {
|
|
4372
|
-
const replacement = values[name];
|
|
4373
|
-
return replacement === void 0 ? match : String(replacement);
|
|
4374
|
-
});
|
|
4375
|
-
}
|
|
4376
|
-
|
|
4377
|
-
// src/controllers/countdown_controller.ts
|
|
4378
4840
|
var SECOND_MS = 1e3;
|
|
4379
4841
|
var CountdownController = class extends Controller {
|
|
4380
4842
|
static targets = ["days", "hours", "minutes", "seconds", "status"];
|
|
@@ -5432,101 +5894,165 @@ var DialogController = class extends Controller {
|
|
|
5432
5894
|
};
|
|
5433
5895
|
var REMOVE_DELAY = 4e3;
|
|
5434
5896
|
var DirectUploadController = class extends Controller {
|
|
5435
|
-
static targets = ["list", "row"
|
|
5897
|
+
static targets = ["list", "row"];
|
|
5436
5898
|
static values = {
|
|
5437
|
-
announce: { type: Boolean, default: true },
|
|
5438
5899
|
removeOnDone: { type: Boolean, default: false },
|
|
5439
|
-
|
|
5440
|
-
|
|
5900
|
+
announceDoneText: { type: String, default: "" },
|
|
5901
|
+
announceErrorText: { type: String, default: "" },
|
|
5441
5902
|
scope: { type: String, default: "" }
|
|
5442
5903
|
};
|
|
5443
5904
|
static events = ["progress", "done", "error"];
|
|
5444
5905
|
#timeouts = new SafeTimeout();
|
|
5445
5906
|
#rows = /* @__PURE__ */ new Map();
|
|
5907
|
+
#beforeCache = new BeforeCacheReset(() => this.#reset());
|
|
5908
|
+
/** The validated `scope` selector; a broken declaration falls back to `""`. */
|
|
5909
|
+
#scopeSelector = "";
|
|
5446
5910
|
#onInitialize = (event) => {
|
|
5447
5911
|
if (!this.#inScope(event)) return;
|
|
5448
5912
|
const detail = this.#detail(event);
|
|
5449
|
-
this.#rowFor(detail.id, detail
|
|
5913
|
+
this.#rowFor(detail.id, this.#name(detail));
|
|
5450
5914
|
};
|
|
5451
5915
|
#onProgress = (event) => {
|
|
5452
5916
|
if (!this.#inScope(event)) return;
|
|
5453
5917
|
const detail = this.#detail(event);
|
|
5454
|
-
this.#updateProgress(this.#key(detail.id),
|
|
5918
|
+
this.#updateProgress(this.#key(detail.id), detail.progress ?? 0, this.#name(detail));
|
|
5455
5919
|
};
|
|
5456
5920
|
#onError = (event) => {
|
|
5457
5921
|
if (!this.#inScope(event)) return;
|
|
5458
5922
|
const detail = this.#detail(event);
|
|
5459
|
-
this.#fail(this.#key(detail.id), detail.error ?? "");
|
|
5923
|
+
const rendered = this.#fail(this.#key(detail.id), detail.error ?? "", this.#name(detail));
|
|
5924
|
+
if (rendered) event.preventDefault();
|
|
5460
5925
|
};
|
|
5461
5926
|
#onEnd = (event) => {
|
|
5462
5927
|
if (!this.#inScope(event)) return;
|
|
5463
5928
|
const detail = this.#detail(event);
|
|
5464
|
-
this.#complete(this.#key(detail.id), detail
|
|
5929
|
+
this.#complete(this.#key(detail.id), this.#name(detail));
|
|
5465
5930
|
};
|
|
5466
5931
|
connect() {
|
|
5467
5932
|
document.addEventListener("direct-upload:initialize", this.#onInitialize);
|
|
5468
5933
|
document.addEventListener("direct-upload:progress", this.#onProgress);
|
|
5469
5934
|
document.addEventListener("direct-upload:error", this.#onError);
|
|
5470
5935
|
document.addEventListener("direct-upload:end", this.#onEnd);
|
|
5936
|
+
this.#beforeCache.activate();
|
|
5937
|
+
this.#rescheduleRemovals();
|
|
5471
5938
|
}
|
|
5472
5939
|
disconnect() {
|
|
5473
5940
|
document.removeEventListener("direct-upload:initialize", this.#onInitialize);
|
|
5474
5941
|
document.removeEventListener("direct-upload:progress", this.#onProgress);
|
|
5475
5942
|
document.removeEventListener("direct-upload:error", this.#onError);
|
|
5476
5943
|
document.removeEventListener("direct-upload:end", this.#onEnd);
|
|
5944
|
+
this.#beforeCache.deactivate();
|
|
5477
5945
|
this.#timeouts.clearAll();
|
|
5478
|
-
|
|
5946
|
+
}
|
|
5947
|
+
/** Validates `scope` once so the per-event path never parses or throws. */
|
|
5948
|
+
scopeValueChanged() {
|
|
5949
|
+
const selector = this.scopeValue;
|
|
5950
|
+
if (selector.length > 0) {
|
|
5951
|
+
try {
|
|
5952
|
+
this.element.matches(selector);
|
|
5953
|
+
this.#scopeSelector = selector;
|
|
5954
|
+
return;
|
|
5955
|
+
} catch {
|
|
5956
|
+
}
|
|
5957
|
+
}
|
|
5958
|
+
this.#scopeSelector = "";
|
|
5479
5959
|
}
|
|
5480
5960
|
/** Updates a row's progress and the aggregate, emitting `progress`. */
|
|
5481
|
-
#updateProgress(id, percent) {
|
|
5482
|
-
const row = this.#rowFor(id);
|
|
5483
|
-
if (row === null) return;
|
|
5484
|
-
const clamped =
|
|
5485
|
-
row.setAttribute("aria-valuenow", String(clamped));
|
|
5486
|
-
row.setAttribute("aria-valuetext", `${clamped}%`);
|
|
5487
|
-
row.style.setProperty("--stimeo--upload-progress", `${clamped}%`);
|
|
5488
|
-
this.#setField(row, "percent", `${clamped}%`);
|
|
5961
|
+
#updateProgress(id, percent, name) {
|
|
5962
|
+
const row = this.#rowFor(id, name);
|
|
5963
|
+
if (row === null || this.#isSettled(row)) return;
|
|
5964
|
+
const clamped = this.#applyProgress(row, percent);
|
|
5489
5965
|
this.#syncAggregate();
|
|
5490
5966
|
this.dispatch("progress", { detail: { id, percent: clamped } });
|
|
5491
5967
|
}
|
|
5492
|
-
/** Marks a row done
|
|
5493
|
-
#complete(id, name
|
|
5968
|
+
/** Marks a not-yet-settled row done at 100%, announces it, and emits `done`. */
|
|
5969
|
+
#complete(id, name) {
|
|
5494
5970
|
const row = this.#rowFor(id, name);
|
|
5495
|
-
if (row === null) return;
|
|
5971
|
+
if (row === null || this.#isSettled(row)) return;
|
|
5496
5972
|
row.setAttribute("data-upload-state", "done");
|
|
5497
|
-
this.#
|
|
5973
|
+
this.#applyProgress(row, 100);
|
|
5974
|
+
this.#syncAggregate();
|
|
5975
|
+
this.#announce(this.announceDoneTextValue, name, row);
|
|
5498
5976
|
this.dispatch("done", { detail: { id } });
|
|
5499
5977
|
if (this.removeOnDoneValue) {
|
|
5500
5978
|
this.#timeouts.set(() => this.#removeRow(id), REMOVE_DELAY);
|
|
5501
5979
|
}
|
|
5502
5980
|
}
|
|
5503
|
-
/**
|
|
5504
|
-
|
|
5505
|
-
|
|
5506
|
-
|
|
5981
|
+
/**
|
|
5982
|
+
* Marks a not-yet-settled row failed, announces it, and emits `error`.
|
|
5983
|
+
* Returns whether the failure is rendered by this widget (used to decide the
|
|
5984
|
+
* `direct-upload:error` default), which also holds when the row already
|
|
5985
|
+
* displays an earlier failure.
|
|
5986
|
+
*/
|
|
5987
|
+
#fail(id, error, name) {
|
|
5988
|
+
const row = this.#rowFor(id, name);
|
|
5989
|
+
if (row === null) return false;
|
|
5990
|
+
if (this.#isSettled(row)) return row.getAttribute("data-upload-state") === "error";
|
|
5507
5991
|
row.setAttribute("data-upload-state", "error");
|
|
5508
|
-
this.#announce(this.
|
|
5992
|
+
this.#announce(this.announceErrorTextValue, name, row);
|
|
5509
5993
|
this.dispatch("error", { detail: { id, error } });
|
|
5994
|
+
return true;
|
|
5995
|
+
}
|
|
5996
|
+
/** Whether the row reached a terminal state; settled rows are never rewritten. */
|
|
5997
|
+
#isSettled(row) {
|
|
5998
|
+
const state = row.getAttribute("data-upload-state");
|
|
5999
|
+
return state === "done" || state === "error";
|
|
6000
|
+
}
|
|
6001
|
+
/** Re-arms `removeOnDone` for completed rows after a reconnect. */
|
|
6002
|
+
#rescheduleRemovals() {
|
|
6003
|
+
if (!this.removeOnDoneValue) return;
|
|
6004
|
+
this.#prune();
|
|
6005
|
+
for (const [id, row] of this.#rows) {
|
|
6006
|
+
if (row.getAttribute("data-upload-state") === "done") {
|
|
6007
|
+
this.#timeouts.set(() => this.#removeRow(id), REMOVE_DELAY);
|
|
6008
|
+
}
|
|
6009
|
+
}
|
|
5510
6010
|
}
|
|
5511
|
-
/** Returns
|
|
6011
|
+
/** Returns the live row for `id`, creating (and labeling) one on first sight. */
|
|
5512
6012
|
#rowFor(id, name) {
|
|
5513
6013
|
const key = this.#key(id);
|
|
5514
6014
|
const existing = this.#rows.get(key);
|
|
5515
|
-
if (existing !== void 0)
|
|
6015
|
+
if (existing !== void 0) {
|
|
6016
|
+
if (this.#tracksRow(existing)) {
|
|
6017
|
+
this.#applyName(existing, name);
|
|
6018
|
+
return existing;
|
|
6019
|
+
}
|
|
6020
|
+
existing.remove();
|
|
6021
|
+
this.#rows.delete(key);
|
|
6022
|
+
}
|
|
5516
6023
|
if (!this.hasRowTarget || !this.hasListTarget) return null;
|
|
5517
6024
|
const clone = this.rowTarget.content.firstElementChild?.cloneNode(true);
|
|
5518
6025
|
if (!(clone instanceof HTMLElement)) return null;
|
|
5519
|
-
|
|
5520
|
-
this.#setField(clone, "name", name);
|
|
5521
|
-
clone.setAttribute("aria-label", name);
|
|
5522
|
-
}
|
|
5523
|
-
clone.setAttribute("aria-valuenow", "0");
|
|
6026
|
+
this.#applyName(clone, name);
|
|
5524
6027
|
clone.setAttribute("data-upload-state", "uploading");
|
|
5525
|
-
clone
|
|
6028
|
+
this.#applyProgress(clone, 0);
|
|
5526
6029
|
this.listTarget.appendChild(clone);
|
|
5527
6030
|
this.#rows.set(key, clone);
|
|
6031
|
+
this.#syncAggregate();
|
|
5528
6032
|
return clone;
|
|
5529
6033
|
}
|
|
6034
|
+
/**
|
|
6035
|
+
* Writes the event's file name into `[data-field="name"]` and, unless the row
|
|
6036
|
+
* already carries a non-blank `aria-label` — authored on the template or
|
|
6037
|
+
* applied by an earlier event — makes it the accessible name too. The visible
|
|
6038
|
+
* name never depends on the label: an authored label keeps its wording while
|
|
6039
|
+
* the field still shows which file this row tracks.
|
|
6040
|
+
*/
|
|
6041
|
+
#applyName(row, name) {
|
|
6042
|
+
if (name.length === 0) return;
|
|
6043
|
+
this.#setField(row, "name", name);
|
|
6044
|
+
if ((row.getAttribute("aria-label") ?? "").trim().length > 0) return;
|
|
6045
|
+
row.setAttribute("aria-label", name);
|
|
6046
|
+
}
|
|
6047
|
+
/** Writes one progress value to every per-row hook; returns the clamped percent. */
|
|
6048
|
+
#applyProgress(row, percent) {
|
|
6049
|
+
const clamped = Math.max(0, Math.min(100, Math.round(percent)));
|
|
6050
|
+
row.setAttribute("aria-valuenow", String(clamped));
|
|
6051
|
+
row.setAttribute("aria-valuetext", `${clamped}%`);
|
|
6052
|
+
row.style.setProperty("--stimeo--upload-progress", `${clamped}%`);
|
|
6053
|
+
this.#setField(row, "percent", `${clamped}%`);
|
|
6054
|
+
return clamped;
|
|
6055
|
+
}
|
|
5530
6056
|
#removeRow(id) {
|
|
5531
6057
|
const row = this.#rows.get(id);
|
|
5532
6058
|
if (row === void 0) return;
|
|
@@ -5534,10 +6060,15 @@ var DirectUploadController = class extends Controller {
|
|
|
5534
6060
|
this.#rows.delete(id);
|
|
5535
6061
|
this.#syncAggregate();
|
|
5536
6062
|
}
|
|
5537
|
-
/**
|
|
6063
|
+
/**
|
|
6064
|
+
* Reflects the average progress across live rows on the controller element,
|
|
6065
|
+
* withdrawing both hooks once no rows remain.
|
|
6066
|
+
*/
|
|
5538
6067
|
#syncAggregate() {
|
|
6068
|
+
this.#prune();
|
|
5539
6069
|
if (this.#rows.size === 0) {
|
|
5540
6070
|
this.element.removeAttribute("data-upload-progress");
|
|
6071
|
+
this.element.style.removeProperty("--stimeo--upload-progress");
|
|
5541
6072
|
return;
|
|
5542
6073
|
}
|
|
5543
6074
|
let total = 0;
|
|
@@ -5548,11 +6079,48 @@ var DirectUploadController = class extends Controller {
|
|
|
5548
6079
|
this.element.setAttribute("data-upload-progress", String(overall));
|
|
5549
6080
|
this.element.style.setProperty("--stimeo--upload-progress", `${overall}%`);
|
|
5550
6081
|
}
|
|
5551
|
-
/**
|
|
5552
|
-
|
|
5553
|
-
|
|
5554
|
-
|
|
5555
|
-
|
|
6082
|
+
/**
|
|
6083
|
+
* Whether a bookkept row is still this widget's live UI: connected, and — when
|
|
6084
|
+
* a `list` target is present — inside the *current* one, so re-pointing the
|
|
6085
|
+
* target attribute at a new element retires rows kept alive in the old list.
|
|
6086
|
+
*/
|
|
6087
|
+
#tracksRow(row) {
|
|
6088
|
+
if (!row.isConnected) return false;
|
|
6089
|
+
return !this.hasListTarget || this.listTarget.contains(row);
|
|
6090
|
+
}
|
|
6091
|
+
/**
|
|
6092
|
+
* Retires rows that left the live UI (list swap, external removal). A retired
|
|
6093
|
+
* clone is removed outright — generated rows only ever live under the current
|
|
6094
|
+
* `list`, so the before-cache rewind never has an untracked leftover to miss.
|
|
6095
|
+
*/
|
|
6096
|
+
#prune() {
|
|
6097
|
+
for (const [id, row] of this.#rows) {
|
|
6098
|
+
if (this.#tracksRow(row)) continue;
|
|
6099
|
+
row.remove();
|
|
6100
|
+
this.#rows.delete(id);
|
|
6101
|
+
}
|
|
6102
|
+
}
|
|
6103
|
+
/**
|
|
6104
|
+
* Returns the widget to its pre-upload state just before Turbo caches the
|
|
6105
|
+
* page, so the snapshot never replays rows for uploads that cannot resume.
|
|
6106
|
+
*/
|
|
6107
|
+
#reset() {
|
|
6108
|
+
for (const row of this.#rows.values()) row.remove();
|
|
6109
|
+
this.#rows.clear();
|
|
6110
|
+
this.#timeouts.clearAll();
|
|
6111
|
+
this.#syncAggregate();
|
|
6112
|
+
}
|
|
6113
|
+
/**
|
|
6114
|
+
* Sends one consumer-worded message to the page's shared announcer. `{name}`
|
|
6115
|
+
* resolves to the row's displayed name first — the event that settles an
|
|
6116
|
+
* upload may omit the file although an earlier event already named the row —
|
|
6117
|
+
* then the event's own name, then the accessible name (which an authored
|
|
6118
|
+
* label owns, so it is the last resort, not the primary source).
|
|
6119
|
+
*/
|
|
6120
|
+
#announce(template, name, row) {
|
|
6121
|
+
const stored = this.#field(row, "name")?.textContent ?? "";
|
|
6122
|
+
const label = stored.length > 0 ? stored : name.length > 0 ? name : row.getAttribute("aria-label") ?? "";
|
|
6123
|
+
announce(fillTemplate(template, { name: label }));
|
|
5556
6124
|
}
|
|
5557
6125
|
#field(row, name) {
|
|
5558
6126
|
return row.querySelector(`[data-field="${name}"]`);
|
|
@@ -5564,76 +6132,185 @@ var DirectUploadController = class extends Controller {
|
|
|
5564
6132
|
#detail(event) {
|
|
5565
6133
|
return event.detail ?? {};
|
|
5566
6134
|
}
|
|
6135
|
+
/** The file name every ActiveStorage `direct-upload:*` event carries. */
|
|
6136
|
+
#name(detail) {
|
|
6137
|
+
return detail.file?.name ?? "";
|
|
6138
|
+
}
|
|
5567
6139
|
/**
|
|
5568
6140
|
* Whether an event belongs to this controller. With `scope` set, only events
|
|
5569
6141
|
* whose target (the file input) sits inside an element matching `scope` are
|
|
5570
|
-
* handled, so several upload widgets on one page do not cross-populate.
|
|
5571
|
-
* with `closest()` from the target itself, so the chatty `progress`
|
|
5572
|
-
* pays a document-wide query. Empty `scope` handles all.
|
|
6142
|
+
* handled, so several upload widgets on one page do not cross-populate.
|
|
6143
|
+
* Resolved with `closest()` from the target itself, so the chatty `progress`
|
|
6144
|
+
* stream never pays a document-wide query. Empty `scope` handles all.
|
|
5573
6145
|
*/
|
|
5574
6146
|
#inScope(event) {
|
|
5575
|
-
if (this.
|
|
6147
|
+
if (this.#scopeSelector.length === 0) return true;
|
|
5576
6148
|
const target = event.target;
|
|
5577
|
-
return target instanceof Element && target.closest(this
|
|
6149
|
+
return target instanceof Element && target.closest(this.#scopeSelector) !== null;
|
|
5578
6150
|
}
|
|
5579
6151
|
#key(id) {
|
|
5580
6152
|
return String(id ?? "");
|
|
5581
6153
|
}
|
|
5582
6154
|
};
|
|
6155
|
+
var visitCoordinators = /* @__PURE__ */ new WeakMap();
|
|
6156
|
+
var TurboVisitCoordinator = class {
|
|
6157
|
+
#document;
|
|
6158
|
+
#participants = /* @__PURE__ */ new Set();
|
|
6159
|
+
#onBeforeVisit = (event) => {
|
|
6160
|
+
let blocked = event.defaultPrevented;
|
|
6161
|
+
let message;
|
|
6162
|
+
for (const participant of this.#participantsInDomOrder()) {
|
|
6163
|
+
if (!participant.form.isConnected) continue;
|
|
6164
|
+
const decision = participant.evaluate(event);
|
|
6165
|
+
blocked ||= event.defaultPrevented;
|
|
6166
|
+
if (decision === null) continue;
|
|
6167
|
+
if (decision.kind === "block") {
|
|
6168
|
+
blocked = true;
|
|
6169
|
+
} else {
|
|
6170
|
+
message ??= decision.message;
|
|
6171
|
+
}
|
|
6172
|
+
}
|
|
6173
|
+
if (blocked) {
|
|
6174
|
+
event.preventDefault();
|
|
6175
|
+
return;
|
|
6176
|
+
}
|
|
6177
|
+
if (message === void 0) return;
|
|
6178
|
+
if (!this.#document.defaultView?.confirm(message)) event.preventDefault();
|
|
6179
|
+
};
|
|
6180
|
+
constructor(document2) {
|
|
6181
|
+
this.#document = document2;
|
|
6182
|
+
}
|
|
6183
|
+
add(participant) {
|
|
6184
|
+
if (this.#participants.size === 0) {
|
|
6185
|
+
this.#document.addEventListener("turbo:before-visit", this.#onBeforeVisit);
|
|
6186
|
+
}
|
|
6187
|
+
this.#participants.add(participant);
|
|
6188
|
+
}
|
|
6189
|
+
/** Removes a participant and reports whether the coordinator became empty. */
|
|
6190
|
+
remove(participant) {
|
|
6191
|
+
this.#participants.delete(participant);
|
|
6192
|
+
if (this.#participants.size > 0) return false;
|
|
6193
|
+
this.#document.removeEventListener("turbo:before-visit", this.#onBeforeVisit);
|
|
6194
|
+
return true;
|
|
6195
|
+
}
|
|
6196
|
+
#participantsInDomOrder() {
|
|
6197
|
+
return Array.from(this.#participants).filter(
|
|
6198
|
+
(participant) => participant.form.isConnected && participant.form.ownerDocument === this.#document
|
|
6199
|
+
).sort((left, right) => {
|
|
6200
|
+
const position = left.form.compareDocumentPosition(right.form);
|
|
6201
|
+
return Number(Boolean(position & Node.DOCUMENT_POSITION_PRECEDING)) - Number(Boolean(position & Node.DOCUMENT_POSITION_FOLLOWING));
|
|
6202
|
+
});
|
|
6203
|
+
}
|
|
6204
|
+
};
|
|
6205
|
+
function registerVisitParticipant(document2, participant) {
|
|
6206
|
+
const coordinator = visitCoordinators.get(document2) ?? new TurboVisitCoordinator(document2);
|
|
6207
|
+
visitCoordinators.set(document2, coordinator);
|
|
6208
|
+
coordinator.add(participant);
|
|
6209
|
+
return () => {
|
|
6210
|
+
if (coordinator.remove(participant)) visitCoordinators.delete(document2);
|
|
6211
|
+
};
|
|
6212
|
+
}
|
|
5583
6213
|
var DirtyFormController = class extends Controller {
|
|
5584
6214
|
static values = {
|
|
5585
6215
|
message: { type: String, default: "You have unsaved changes that will be lost." },
|
|
5586
6216
|
confirmBridge: { type: Boolean, default: false }
|
|
5587
6217
|
};
|
|
5588
|
-
static actions = ["markClean"];
|
|
6218
|
+
static actions = ["markClean", "acceptRestore"];
|
|
5589
6219
|
static events = ["dirty", "guard"];
|
|
5590
6220
|
#baseline = "";
|
|
5591
6221
|
#dirty = false;
|
|
5592
6222
|
#beforeunloadBound = false;
|
|
5593
|
-
|
|
6223
|
+
#timeouts = new SafeTimeout();
|
|
6224
|
+
/** Whether the active submission still represents the form's current values. */
|
|
5594
6225
|
#submitting = false;
|
|
6226
|
+
#submitAttempt = null;
|
|
6227
|
+
#submittedBaseline = null;
|
|
6228
|
+
#activeSubmission = null;
|
|
6229
|
+
#unregisterVisit = null;
|
|
5595
6230
|
#onFieldChange = () => {
|
|
5596
6231
|
this.#submitting = false;
|
|
5597
6232
|
this.#evaluate();
|
|
5598
6233
|
};
|
|
5599
|
-
#onSubmit = () => {
|
|
6234
|
+
#onSubmit = (event) => {
|
|
6235
|
+
const attempt = { event, snapshot: this.#serialize() };
|
|
6236
|
+
this.#submitAttempt = attempt;
|
|
5600
6237
|
this.#submitting = true;
|
|
6238
|
+
this.#timeouts.set(() => {
|
|
6239
|
+
if (this.#submitAttempt !== attempt) return;
|
|
6240
|
+
this.#submitting = false;
|
|
6241
|
+
}, 0);
|
|
5601
6242
|
};
|
|
5602
|
-
#
|
|
5603
|
-
this.#
|
|
6243
|
+
#onFormData = () => {
|
|
6244
|
+
if (this.#submitAttempt === null) return;
|
|
6245
|
+
this.#submitAttempt.snapshot = this.#serialize();
|
|
6246
|
+
};
|
|
6247
|
+
#onSubmitStart = (event) => {
|
|
6248
|
+
const detail = event.detail;
|
|
6249
|
+
this.#activeSubmission = detail?.formSubmission ?? event;
|
|
6250
|
+
this.#submittedBaseline = this.#submitAttempt?.snapshot ?? this.#serialize();
|
|
6251
|
+
this.#submitAttempt = null;
|
|
6252
|
+
this.#submitting = this.#serialize() === this.#submittedBaseline;
|
|
5604
6253
|
};
|
|
5605
6254
|
#onSubmitEnd = (event) => {
|
|
6255
|
+
const detail = event.detail;
|
|
6256
|
+
if (detail?.formSubmission !== void 0 && (this.#activeSubmission === null || detail.formSubmission !== this.#activeSubmission)) {
|
|
6257
|
+
return;
|
|
6258
|
+
}
|
|
6259
|
+
const submittedBaseline = this.#submittedBaseline;
|
|
5606
6260
|
this.#submitting = false;
|
|
5607
|
-
|
|
5608
|
-
|
|
6261
|
+
this.#submittedBaseline = null;
|
|
6262
|
+
this.#activeSubmission = null;
|
|
6263
|
+
if (detail?.success === false) return;
|
|
6264
|
+
if (submittedBaseline === null) {
|
|
6265
|
+
this.markClean();
|
|
6266
|
+
return;
|
|
6267
|
+
}
|
|
6268
|
+
this.#baseline = submittedBaseline;
|
|
6269
|
+
this.#evaluate();
|
|
5609
6270
|
};
|
|
5610
6271
|
#onBeforeUnload = (event) => {
|
|
5611
|
-
if (!this.#dirty || this.#
|
|
6272
|
+
if (!this.#dirty || this.#guardSuppressed()) return;
|
|
5612
6273
|
event.preventDefault();
|
|
5613
6274
|
event.returnValue = this.messageValue;
|
|
5614
6275
|
};
|
|
5615
6276
|
connect() {
|
|
6277
|
+
this.#resetSubmission();
|
|
5616
6278
|
this.#baseline = this.#serialize();
|
|
6279
|
+
this.#setDirty(false);
|
|
5617
6280
|
this.element.removeAttribute("data-dirty");
|
|
5618
6281
|
this.element.addEventListener("input", this.#onFieldChange);
|
|
5619
6282
|
this.element.addEventListener("change", this.#onFieldChange);
|
|
5620
6283
|
this.element.addEventListener("submit", this.#onSubmit);
|
|
6284
|
+
this.element.addEventListener("formdata", this.#onFormData);
|
|
6285
|
+
this.element.addEventListener("turbo:submit-start", this.#onSubmitStart);
|
|
5621
6286
|
this.element.addEventListener("turbo:submit-end", this.#onSubmitEnd);
|
|
5622
|
-
|
|
6287
|
+
this.#unregisterVisit = registerVisitParticipant(this.element.ownerDocument, {
|
|
6288
|
+
form: this.element,
|
|
6289
|
+
evaluate: (event) => this.#evaluateVisit(event)
|
|
6290
|
+
});
|
|
5623
6291
|
}
|
|
5624
6292
|
disconnect() {
|
|
5625
6293
|
this.element.removeEventListener("input", this.#onFieldChange);
|
|
5626
6294
|
this.element.removeEventListener("change", this.#onFieldChange);
|
|
5627
6295
|
this.element.removeEventListener("submit", this.#onSubmit);
|
|
6296
|
+
this.element.removeEventListener("formdata", this.#onFormData);
|
|
6297
|
+
this.element.removeEventListener("turbo:submit-start", this.#onSubmitStart);
|
|
5628
6298
|
this.element.removeEventListener("turbo:submit-end", this.#onSubmitEnd);
|
|
5629
|
-
|
|
6299
|
+
this.#unregisterVisit?.();
|
|
6300
|
+
this.#unregisterVisit = null;
|
|
5630
6301
|
this.#unbindBeforeUnload();
|
|
6302
|
+
this.#resetSubmission();
|
|
5631
6303
|
}
|
|
5632
6304
|
/** Re-baselines to the current values and clears the dirty state (e.g. after a save). */
|
|
5633
6305
|
markClean() {
|
|
5634
6306
|
this.#baseline = this.#serialize();
|
|
5635
6307
|
this.#setDirty(false);
|
|
5636
6308
|
}
|
|
6309
|
+
/** Adopts restored values without erasing an already-dirty user revision. */
|
|
6310
|
+
acceptRestore() {
|
|
6311
|
+
if (this.#dirty) return;
|
|
6312
|
+
this.markClean();
|
|
6313
|
+
}
|
|
5637
6314
|
/** Recomputes dirty against the baseline and flips state when it changes. */
|
|
5638
6315
|
#evaluate() {
|
|
5639
6316
|
this.#setDirty(this.#serialize() !== this.#baseline);
|
|
@@ -5650,21 +6327,14 @@ var DirtyFormController = class extends Controller {
|
|
|
5650
6327
|
}
|
|
5651
6328
|
this.dispatch("dirty", { detail: { dirty } });
|
|
5652
6329
|
}
|
|
5653
|
-
/**
|
|
5654
|
-
#
|
|
5655
|
-
if (!this.#dirty || this.#
|
|
6330
|
+
/** Evaluates this form for the document-level Turbo visit coordinator. */
|
|
6331
|
+
#evaluateVisit(event) {
|
|
6332
|
+
if (!this.#dirty || this.#guardSuppressed()) return null;
|
|
5656
6333
|
const guard = this.dispatch("guard", { detail: { event }, cancelable: true });
|
|
5657
|
-
if (guard.defaultPrevented) {
|
|
5658
|
-
|
|
5659
|
-
|
|
5660
|
-
}
|
|
5661
|
-
if (this.confirmBridgeValue) {
|
|
5662
|
-
event.preventDefault();
|
|
5663
|
-
return;
|
|
5664
|
-
}
|
|
5665
|
-
if (!window.confirm(this.messageValue)) {
|
|
5666
|
-
event.preventDefault();
|
|
5667
|
-
}
|
|
6334
|
+
if (guard.defaultPrevented) return { kind: "block" };
|
|
6335
|
+
if (!this.element.isConnected || !this.#dirty || this.#guardSuppressed()) return null;
|
|
6336
|
+
if (this.confirmBridgeValue) return { kind: "block" };
|
|
6337
|
+
return { kind: "confirm", message: this.messageValue };
|
|
5668
6338
|
}
|
|
5669
6339
|
#bindBeforeUnload() {
|
|
5670
6340
|
if (this.#beforeunloadBound) return;
|
|
@@ -5676,6 +6346,16 @@ var DirtyFormController = class extends Controller {
|
|
|
5676
6346
|
window.removeEventListener("beforeunload", this.#onBeforeUnload);
|
|
5677
6347
|
this.#beforeunloadBound = false;
|
|
5678
6348
|
}
|
|
6349
|
+
#guardSuppressed() {
|
|
6350
|
+
return this.#submitting && (this.#submitAttempt === null || !this.#submitAttempt.event.defaultPrevented);
|
|
6351
|
+
}
|
|
6352
|
+
#resetSubmission() {
|
|
6353
|
+
this.#timeouts.clearAll();
|
|
6354
|
+
this.#submitting = false;
|
|
6355
|
+
this.#submitAttempt = null;
|
|
6356
|
+
this.#submittedBaseline = null;
|
|
6357
|
+
this.#activeSubmission = null;
|
|
6358
|
+
}
|
|
5679
6359
|
/** Stable serialization of the form's controls for change detection. */
|
|
5680
6360
|
#serialize() {
|
|
5681
6361
|
const parts = [];
|
|
@@ -5683,15 +6363,15 @@ var DirtyFormController = class extends Controller {
|
|
|
5683
6363
|
const name = this.#nameOf(el);
|
|
5684
6364
|
if (name === null) continue;
|
|
5685
6365
|
if (el instanceof HTMLInputElement && (el.type === "checkbox" || el.type === "radio")) {
|
|
5686
|
-
parts.push(
|
|
6366
|
+
parts.push([el.type, name, el.value, el.checked ? 1 : 0]);
|
|
5687
6367
|
} else if (el instanceof HTMLSelectElement) {
|
|
5688
|
-
const value = el.multiple ? Array.from(el.selectedOptions).map((
|
|
5689
|
-
parts.push(
|
|
6368
|
+
const value = el.multiple ? Array.from(el.selectedOptions).map((option) => option.value) : el.value;
|
|
6369
|
+
parts.push([el.type, name, value]);
|
|
5690
6370
|
} else if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
|
|
5691
|
-
parts.push(
|
|
6371
|
+
parts.push([el.type, name, el.value]);
|
|
5692
6372
|
}
|
|
5693
6373
|
}
|
|
5694
|
-
return
|
|
6374
|
+
return JSON.stringify(parts);
|
|
5695
6375
|
}
|
|
5696
6376
|
/** A stable key for a control, or null for elements without value semantics. */
|
|
5697
6377
|
#nameOf(el) {
|
|
@@ -5775,35 +6455,29 @@ var DismissibleController = class extends Controller {
|
|
|
5775
6455
|
/** Yields available focus fallbacks in precedence order, stopping after focus succeeds. */
|
|
5776
6456
|
*#focusFallbacks(root) {
|
|
5777
6457
|
const explicitFallback = this.hasFallbackTarget ? this.fallbackTarget : null;
|
|
5778
|
-
if (explicitFallback && !root.contains(explicitFallback) && this.#
|
|
6458
|
+
if (explicitFallback && !root.contains(explicitFallback) && this.#isExplicitFocusAvailable(explicitFallback)) {
|
|
5779
6459
|
yield explicitFallback;
|
|
5780
6460
|
}
|
|
5781
|
-
const candidates = document
|
|
6461
|
+
const candidates = tabStopsWithin(document);
|
|
5782
6462
|
for (const element of candidates) {
|
|
5783
6463
|
if (element === explicitFallback || root.contains(element) || !(root.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_FOLLOWING)) {
|
|
5784
6464
|
continue;
|
|
5785
6465
|
}
|
|
5786
|
-
|
|
6466
|
+
yield element;
|
|
5787
6467
|
}
|
|
5788
6468
|
for (let index = candidates.length - 1; index >= 0; index -= 1) {
|
|
5789
|
-
const element = candidates
|
|
6469
|
+
const element = candidates[index];
|
|
6470
|
+
if (!element) continue;
|
|
5790
6471
|
if (element === explicitFallback || root.contains(element) || !(root.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_PRECEDING)) {
|
|
5791
6472
|
continue;
|
|
5792
6473
|
}
|
|
5793
|
-
|
|
6474
|
+
yield element;
|
|
5794
6475
|
}
|
|
5795
6476
|
}
|
|
5796
|
-
/** Whether
|
|
5797
|
-
#
|
|
5798
|
-
|
|
5799
|
-
|
|
5800
|
-
}
|
|
5801
|
-
if (element.matches(":disabled")) return false;
|
|
5802
|
-
if (element instanceof HTMLInputElement && element.type === "hidden") return false;
|
|
5803
|
-
for (let current = element; current; current = current.parentElement) {
|
|
5804
|
-
if (current.hidden || current.inert) return false;
|
|
5805
|
-
}
|
|
5806
|
-
return true;
|
|
6477
|
+
/** Whether an explicit fallback can receive programmatic focus right now. */
|
|
6478
|
+
#isExplicitFocusAvailable(element) {
|
|
6479
|
+
const programmaticallyFocusable = element.hasAttribute("tabindex") || isTabStop(element);
|
|
6480
|
+
return programmaticallyFocusable && canTakeFocus(element) && isRenderedForFocus(element);
|
|
5807
6481
|
}
|
|
5808
6482
|
};
|
|
5809
6483
|
var DrawerController = class extends Controller {
|
|
@@ -6907,6 +7581,7 @@ var FocusController = class extends Controller {
|
|
|
6907
7581
|
this.dispatch("deactivate", { detail: {} });
|
|
6908
7582
|
}
|
|
6909
7583
|
};
|
|
7584
|
+
var OBSERVED_ATTRIBUTES2 = ["hidden", "id"];
|
|
6910
7585
|
var FormFieldController = class _FormFieldController extends Controller {
|
|
6911
7586
|
static targets = ["control", "description", "error"];
|
|
6912
7587
|
static values = {
|
|
@@ -6916,31 +7591,85 @@ var FormFieldController = class _FormFieldController extends Controller {
|
|
|
6916
7591
|
static events = ["validate"];
|
|
6917
7592
|
/** Root attribute (CSS hook) reflecting the invalid state. */
|
|
6918
7593
|
static #INVALID_ATTR = "data-stimeo--form-field-invalid";
|
|
6919
|
-
/**
|
|
6920
|
-
|
|
6921
|
-
|
|
6922
|
-
|
|
7594
|
+
/** Collapses one target/morph batch into one silent ARIA reconciliation. */
|
|
7595
|
+
#reconcile = new MicrotaskCoalescer(() => this.#reconcileDom());
|
|
7596
|
+
/** ARIA ownership is scoped to the current singular control target. */
|
|
7597
|
+
#beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
|
|
7598
|
+
#ariaDescribedBy = new AttributeLease("aria-describedby");
|
|
7599
|
+
#ariaErrorMessage = new AttributeLease("aria-errormessage");
|
|
7600
|
+
#ariaInvalid = new AttributeLease("aria-invalid");
|
|
7601
|
+
/** Watches retained target ids, error visibility, and error content. */
|
|
7602
|
+
#observer = new MutationObserver((records) => {
|
|
7603
|
+
if (records.some((record) => this.#isRelevantMutation(record))) {
|
|
7604
|
+
this.#reconcile.schedule();
|
|
7605
|
+
}
|
|
7606
|
+
});
|
|
7607
|
+
#activeControl = null;
|
|
6923
7608
|
#baseDescribedBy = [];
|
|
6924
|
-
|
|
7609
|
+
#explicitInvalid = false;
|
|
7610
|
+
#initialized = false;
|
|
7611
|
+
/** Wires the initial graph and starts retained-target reconciliation. */
|
|
6925
7612
|
connect() {
|
|
6926
|
-
|
|
6927
|
-
|
|
6928
|
-
|
|
6929
|
-
|
|
6930
|
-
|
|
7613
|
+
this.#reconcile.activate();
|
|
7614
|
+
this.#ensureAssociationIds();
|
|
7615
|
+
this.#beforeCache.activate();
|
|
7616
|
+
if (!this.#initialized) {
|
|
7617
|
+
this.#explicitInvalid = this.element.hasAttribute(_FormFieldController.#INVALID_ATTR) && this.#shownErrors().length === 0;
|
|
7618
|
+
this.#initialized = true;
|
|
6931
7619
|
}
|
|
6932
|
-
this.#
|
|
6933
|
-
this.#
|
|
7620
|
+
this.#reconcileDom();
|
|
7621
|
+
this.#observer.observe(this.element, {
|
|
7622
|
+
attributes: true,
|
|
7623
|
+
attributeFilter: OBSERVED_ATTRIBUTES2,
|
|
7624
|
+
characterData: true,
|
|
7625
|
+
childList: true,
|
|
7626
|
+
subtree: true
|
|
7627
|
+
});
|
|
7628
|
+
}
|
|
7629
|
+
/** Releases observers, queued work, and ARIA borrowed on the current control. */
|
|
7630
|
+
disconnect() {
|
|
7631
|
+
this.#reconcile.cancel();
|
|
7632
|
+
this.#observer.disconnect();
|
|
7633
|
+
this.#beforeCache.deactivate();
|
|
7634
|
+
this.#activeControl && this.#releaseControl(this.#activeControl);
|
|
7635
|
+
}
|
|
7636
|
+
/** Reconciles a control inserted or replaced at runtime. */
|
|
7637
|
+
controlTargetConnected() {
|
|
7638
|
+
this.#reconcile.schedule();
|
|
7639
|
+
}
|
|
7640
|
+
/** Schedules restoration of authored ARIA when a control leaves this field. */
|
|
7641
|
+
controlTargetDisconnected() {
|
|
7642
|
+
this.#reconcile.schedule();
|
|
7643
|
+
}
|
|
7644
|
+
/** Reconciles a description inserted or replaced at runtime. */
|
|
7645
|
+
descriptionTargetConnected() {
|
|
7646
|
+
this.#reconcile.schedule();
|
|
7647
|
+
}
|
|
7648
|
+
/** Removes a departed description from the control's association graph. */
|
|
7649
|
+
descriptionTargetDisconnected() {
|
|
7650
|
+
this.#reconcile.schedule();
|
|
7651
|
+
}
|
|
7652
|
+
/** Reconciles an error region inserted or replaced at runtime. */
|
|
7653
|
+
errorTargetConnected() {
|
|
7654
|
+
this.#reconcile.schedule();
|
|
7655
|
+
}
|
|
7656
|
+
/** Removes a departed error from invalid state and the association graph. */
|
|
7657
|
+
errorTargetDisconnected() {
|
|
7658
|
+
this.#reconcile.schedule();
|
|
6934
7659
|
}
|
|
6935
7660
|
/**
|
|
6936
7661
|
* Marks the field invalid and shows the error message. Bound via `data-action`
|
|
6937
7662
|
* (`#setError`) or callable directly.
|
|
6938
7663
|
*
|
|
7664
|
+
* A non-empty shown message is announced exactly once through the shared
|
|
7665
|
+
* assertive announcer. Initial/server reconciliation is deliberately silent.
|
|
7666
|
+
*
|
|
6939
7667
|
* @param arg - Either the message string, or the action event whose
|
|
6940
7668
|
* `data-stimeo--form-field-message-param` supplies it. When no message is
|
|
6941
7669
|
* resolvable, any already-populated error targets are simply (re)shown.
|
|
7670
|
+
* @param options - Programmatic overrides. Stimulus actions use the Values.
|
|
6942
7671
|
*/
|
|
6943
|
-
setError(arg) {
|
|
7672
|
+
setError(arg, options = {}) {
|
|
6944
7673
|
const message = this.#resolveMessage(arg);
|
|
6945
7674
|
if (message !== null && this.hasErrorTarget) {
|
|
6946
7675
|
this.errorTargets[0]?.replaceChildren(document.createTextNode(message));
|
|
@@ -6948,70 +7677,86 @@ var FormFieldController = class _FormFieldController extends Controller {
|
|
|
6948
7677
|
for (const error of this.errorTargets) {
|
|
6949
7678
|
error.hidden = (error.textContent ?? "").trim() === "";
|
|
6950
7679
|
}
|
|
6951
|
-
this.#
|
|
6952
|
-
this
|
|
6953
|
-
|
|
6954
|
-
|
|
6955
|
-
}
|
|
7680
|
+
this.#explicitInvalid = true;
|
|
7681
|
+
this.#reconcileDom();
|
|
7682
|
+
const shownMessage = this.#shownMessage();
|
|
7683
|
+
const detail = { valid: false, message: shownMessage };
|
|
7684
|
+
this.dispatch("validate", { detail });
|
|
7685
|
+
announce(shownMessage, { assertive: true });
|
|
7686
|
+
if (options.focus ?? this.focusOnErrorValue) this.#activeControl?.focus();
|
|
6956
7687
|
}
|
|
6957
7688
|
/**
|
|
6958
7689
|
* Clears the error: empties and hides every error target and marks the field
|
|
6959
7690
|
* valid. Bound via `data-action` (`#clearError`) or callable directly.
|
|
7691
|
+
*
|
|
7692
|
+
* Clearing is silent: the visual/ARIA state and validation event are sufficient,
|
|
7693
|
+
* while success wording remains an explicit consumer announcement.
|
|
6960
7694
|
*/
|
|
6961
7695
|
clearError() {
|
|
6962
7696
|
for (const error of this.errorTargets) {
|
|
6963
7697
|
error.replaceChildren();
|
|
6964
7698
|
error.hidden = true;
|
|
6965
7699
|
}
|
|
6966
|
-
this.#
|
|
6967
|
-
this
|
|
7700
|
+
this.#explicitInvalid = false;
|
|
7701
|
+
this.#reconcileDom();
|
|
7702
|
+
const detail = { valid: true, message: "" };
|
|
7703
|
+
this.dispatch("validate", { detail });
|
|
6968
7704
|
}
|
|
6969
7705
|
/**
|
|
6970
|
-
*
|
|
6971
|
-
*
|
|
6972
|
-
* change (and survives Turbo morphing).
|
|
6973
|
-
*
|
|
6974
|
-
* @param force - When `true`, the field is marked invalid regardless of whether
|
|
6975
|
-
* a (visible, non-empty) error region exists. {@link setError} passes this so
|
|
6976
|
-
* the invalid state holds even with no error target; derivation from the DOM
|
|
6977
|
-
* (connect / {@link clearError}) leaves it `false`.
|
|
7706
|
+
* Rebuilds ids and derived ARIA from the settled target graph without reporting
|
|
7707
|
+
* a user validation action.
|
|
6978
7708
|
*/
|
|
6979
|
-
#
|
|
7709
|
+
#reconcileDom() {
|
|
7710
|
+
this.#ensureAssociationIds();
|
|
7711
|
+
this.#adoptCurrentControl();
|
|
6980
7712
|
const shown = this.#shownErrors();
|
|
6981
|
-
const invalid =
|
|
6982
|
-
|
|
6983
|
-
|
|
6984
|
-
|
|
6985
|
-
|
|
6986
|
-
|
|
6987
|
-
|
|
6988
|
-
const
|
|
6989
|
-
|
|
6990
|
-
|
|
6991
|
-
const
|
|
6992
|
-
|
|
6993
|
-
|
|
6994
|
-
|
|
6995
|
-
|
|
7713
|
+
const invalid = this.#explicitInvalid || shown.length > 0;
|
|
7714
|
+
this.element.toggleAttribute(_FormFieldController.#INVALID_ATTR, invalid);
|
|
7715
|
+
const control = this.#activeControl;
|
|
7716
|
+
if (!control) return;
|
|
7717
|
+
this.#ariaInvalid.write(control, invalid ? "true" : "false");
|
|
7718
|
+
const primaryErrorId = shown[0]?.id ?? null;
|
|
7719
|
+
this.#ariaErrorMessage.write(control, primaryErrorId);
|
|
7720
|
+
const associationIds = this.#orderedElements([...this.descriptionTargets, ...shown]).map(
|
|
7721
|
+
(element) => element.id
|
|
7722
|
+
);
|
|
7723
|
+
const describedBy = this.#uniqueTokens([...this.#baseDescribedBy, ...associationIds]);
|
|
7724
|
+
this.#ariaDescribedBy.write(control, describedBy.length > 0 ? describedBy.join(" ") : null);
|
|
7725
|
+
}
|
|
7726
|
+
/** Assigns stable ids before either capture or reflection uses them. */
|
|
7727
|
+
#ensureAssociationIds() {
|
|
7728
|
+
for (const description of this.descriptionTargets) {
|
|
7729
|
+
ensureId(description, "stimeo--form-field-desc");
|
|
6996
7730
|
}
|
|
6997
|
-
const
|
|
6998
|
-
|
|
6999
|
-
|
|
7000
|
-
|
|
7001
|
-
|
|
7002
|
-
|
|
7003
|
-
|
|
7004
|
-
|
|
7005
|
-
|
|
7731
|
+
for (const error of this.errorTargets) {
|
|
7732
|
+
ensureId(error, "stimeo--form-field-error");
|
|
7733
|
+
}
|
|
7734
|
+
}
|
|
7735
|
+
/** Switches ARIA ownership when the singular current control target changes. */
|
|
7736
|
+
#adoptCurrentControl() {
|
|
7737
|
+
const next = this.hasControlTarget ? this.controlTarget : null;
|
|
7738
|
+
if (next === this.#activeControl) return;
|
|
7739
|
+
this.#activeControl && this.#releaseControl(this.#activeControl);
|
|
7740
|
+
this.#activeControl = next;
|
|
7741
|
+
this.#baseDescribedBy = next ? this.#externalDescribedByTokens(next) : [];
|
|
7742
|
+
}
|
|
7743
|
+
/** Restores one departed control without overwriting later consumer edits. */
|
|
7744
|
+
#releaseControl(control) {
|
|
7745
|
+
this.#ariaDescribedBy.return(control);
|
|
7746
|
+
this.#ariaErrorMessage.return(control);
|
|
7747
|
+
this.#ariaInvalid.return(control);
|
|
7748
|
+
if (control === this.#activeControl) {
|
|
7749
|
+
this.#activeControl = null;
|
|
7750
|
+
this.#baseDescribedBy = [];
|
|
7006
7751
|
}
|
|
7007
7752
|
}
|
|
7008
|
-
/** Error targets currently visible and non-empty. */
|
|
7753
|
+
/** Error targets currently visible and non-empty, in document order. */
|
|
7009
7754
|
#shownErrors() {
|
|
7010
|
-
return this
|
|
7011
|
-
(error) => !error.hidden && (error.textContent ?? "").trim() !== ""
|
|
7755
|
+
return this.#orderedElements(
|
|
7756
|
+
this.errorTargets.filter((error) => !error.hidden && (error.textContent ?? "").trim() !== "")
|
|
7012
7757
|
);
|
|
7013
7758
|
}
|
|
7014
|
-
/** Text of the first shown error, for
|
|
7759
|
+
/** Text of the first shown error, for validation detail and announcement. */
|
|
7015
7760
|
#shownMessage() {
|
|
7016
7761
|
return (this.#shownErrors()[0]?.textContent ?? "").trim();
|
|
7017
7762
|
}
|
|
@@ -7022,17 +7767,50 @@ var FormFieldController = class _FormFieldController extends Controller {
|
|
|
7022
7767
|
return typeof message === "string" ? message : null;
|
|
7023
7768
|
}
|
|
7024
7769
|
/**
|
|
7025
|
-
* Tokens
|
|
7026
|
-
*
|
|
7770
|
+
* Tokens authored on a newly adopted control, excluding ids this controller
|
|
7771
|
+
* owns through its current description/error targets.
|
|
7027
7772
|
*/
|
|
7028
|
-
#externalDescribedByTokens() {
|
|
7029
|
-
if (!this.hasControlTarget) return [];
|
|
7773
|
+
#externalDescribedByTokens(control) {
|
|
7030
7774
|
const owned = /* @__PURE__ */ new Set([
|
|
7031
7775
|
...this.descriptionTargets.map((description) => description.id),
|
|
7032
7776
|
...this.errorTargets.map((error) => error.id)
|
|
7033
7777
|
]);
|
|
7034
|
-
const existing =
|
|
7035
|
-
return
|
|
7778
|
+
const existing = control.getAttribute("aria-describedby") ?? "";
|
|
7779
|
+
return this.#uniqueTokens(
|
|
7780
|
+
existing.split(/\s+/).filter((token) => token.length > 0 && !owned.has(token))
|
|
7781
|
+
);
|
|
7782
|
+
}
|
|
7783
|
+
/** Deduplicates ARIA tokens without disturbing their first occurrence. */
|
|
7784
|
+
#uniqueTokens(tokens) {
|
|
7785
|
+
const seen = /* @__PURE__ */ new Set();
|
|
7786
|
+
return tokens.filter((token) => {
|
|
7787
|
+
if (token.length === 0 || seen.has(token)) return false;
|
|
7788
|
+
seen.add(token);
|
|
7789
|
+
return true;
|
|
7790
|
+
});
|
|
7791
|
+
}
|
|
7792
|
+
/** Returns unique current targets in DOM order before serializing IDREF lists. */
|
|
7793
|
+
#orderedElements(elements) {
|
|
7794
|
+
const candidates = new Set(elements);
|
|
7795
|
+
return [this.element, ...this.element.querySelectorAll("*")].filter(
|
|
7796
|
+
(element) => candidates.has(element)
|
|
7797
|
+
);
|
|
7798
|
+
}
|
|
7799
|
+
/** Whether one retained-node mutation can alter this controller's derived state. */
|
|
7800
|
+
#isRelevantMutation(record) {
|
|
7801
|
+
const target = record.target;
|
|
7802
|
+
if (record.type === "attributes") {
|
|
7803
|
+
if (record.attributeName === "hidden")
|
|
7804
|
+
return this.errorTargets.includes(target);
|
|
7805
|
+
return record.attributeName === "id" && [...this.descriptionTargets, ...this.errorTargets].includes(target);
|
|
7806
|
+
}
|
|
7807
|
+
return this.errorTargets.some((error) => error === target || error.contains(target));
|
|
7808
|
+
}
|
|
7809
|
+
/** Returns borrowed control ARIA before Turbo snapshots the page. */
|
|
7810
|
+
#rewindForCache() {
|
|
7811
|
+
this.#ariaDescribedBy.returnAll();
|
|
7812
|
+
this.#ariaErrorMessage.returnAll();
|
|
7813
|
+
this.#ariaInvalid.returnAll();
|
|
7036
7814
|
}
|
|
7037
7815
|
};
|
|
7038
7816
|
var CONSTRAINT_MESSAGE_KEYS = [
|
|
@@ -7046,10 +7824,12 @@ var CONSTRAINT_MESSAGE_KEYS = [
|
|
|
7046
7824
|
["stepMismatch", "step-mismatch"],
|
|
7047
7825
|
["badInput", "bad-input"]
|
|
7048
7826
|
];
|
|
7049
|
-
var MESSAGE_ATTR_PREFIX = "data-stimeo--form-
|
|
7050
|
-
var MESSAGE_ATTR_GENERIC = "data-stimeo--form-
|
|
7051
|
-
var DISALLOW_ATTR = "data-stimeo--form-
|
|
7827
|
+
var MESSAGE_ATTR_PREFIX = "data-stimeo--form-validation-message-";
|
|
7828
|
+
var MESSAGE_ATTR_GENERIC = "data-stimeo--form-validation-message";
|
|
7829
|
+
var DISALLOW_ATTR = "data-stimeo--form-validation-disallow";
|
|
7830
|
+
var DISALLOW_WHITESPACE_MESSAGE = `${MESSAGE_ATTR_PREFIX}whitespace`;
|
|
7052
7831
|
var DISALLOW_WHITESPACE_DEFAULT = "Please enter a value that is not only whitespace.";
|
|
7832
|
+
var FORM_FIELD_INVALID_ATTR = "data-stimeo--form-field-invalid";
|
|
7053
7833
|
var FormValidationController = class _FormValidationController extends Controller {
|
|
7054
7834
|
static outlets = ["stimeo--form-field"];
|
|
7055
7835
|
static values = {
|
|
@@ -7062,14 +7842,21 @@ var FormValidationController = class _FormValidationController extends Controlle
|
|
|
7062
7842
|
static events = ["valid", "invalid"];
|
|
7063
7843
|
/** Marker recording that we added `novalidate`, so we only remove our own. */
|
|
7064
7844
|
static #NOVALIDATE_MARKER = "data-stimeo--form-validation-novalidate";
|
|
7065
|
-
/**
|
|
7066
|
-
#
|
|
7067
|
-
/**
|
|
7068
|
-
|
|
7069
|
-
|
|
7070
|
-
*
|
|
7071
|
-
|
|
7072
|
-
|
|
7845
|
+
/** Object-backed groups already interacted with — the input revalidation gate. */
|
|
7846
|
+
#touchedGroups = /* @__PURE__ */ new WeakSet();
|
|
7847
|
+
/** String-backed fallback radio groups already interacted with. */
|
|
7848
|
+
#touchedRadioGroups = /* @__PURE__ */ new Set();
|
|
7849
|
+
/**
|
|
7850
|
+
* Last message this controller wrote through `setCustomValidity`, kept as a
|
|
7851
|
+
* two-layer ownership ledger: a value is cleared only while the live message
|
|
7852
|
+
* still equals the recorded write. Iterable so disconnect can release every
|
|
7853
|
+
* surviving loan without touching a consumer's later custom error.
|
|
7854
|
+
*/
|
|
7855
|
+
#ownedCustomErrors = /* @__PURE__ */ new Map();
|
|
7856
|
+
/** Last invalid message routed to each field, suppressing duplicate reports. */
|
|
7857
|
+
#reportedErrors = /* @__PURE__ */ new WeakMap();
|
|
7858
|
+
/** Exact document that owns the delegated listeners for this connection. */
|
|
7859
|
+
#listenerDocument = null;
|
|
7073
7860
|
#onSubmit = (event) => {
|
|
7074
7861
|
if (event.target !== this.element) return;
|
|
7075
7862
|
const invalid = this.#validateAll();
|
|
@@ -7085,43 +7872,51 @@ var FormValidationController = class _FormValidationController extends Controlle
|
|
|
7085
7872
|
};
|
|
7086
7873
|
#onFocusOut = (event) => {
|
|
7087
7874
|
if (!this.validateOnBlurValue) return;
|
|
7088
|
-
const
|
|
7089
|
-
|
|
7090
|
-
|
|
7875
|
+
const snapshot = this.#snapshot();
|
|
7876
|
+
const group = snapshot.groupByControl.get(this.#controlFrom(event.target));
|
|
7877
|
+
if (!group) return;
|
|
7091
7878
|
const related = event.relatedTarget;
|
|
7092
|
-
if (field && related instanceof Node && field.element.contains(related)) return;
|
|
7093
|
-
this.#
|
|
7094
|
-
this.#
|
|
7879
|
+
if (group.field && related instanceof Node && group.field.element.contains(related)) return;
|
|
7880
|
+
if (snapshot.groupByControl.get(this.#controlFrom(related))?.key === group.key) return;
|
|
7881
|
+
this.#markTouched(group.key);
|
|
7882
|
+
this.#applyGroup(group);
|
|
7095
7883
|
};
|
|
7096
7884
|
#onInput = (event) => {
|
|
7097
7885
|
if (!this.revalidateOnInputValue) return;
|
|
7098
|
-
const
|
|
7099
|
-
if (!
|
|
7100
|
-
this.#
|
|
7886
|
+
const group = this.#snapshot().groupByControl.get(this.#controlFrom(event.target));
|
|
7887
|
+
if (!group || !this.#isTouched(group.key)) return;
|
|
7888
|
+
this.#applyGroup(group);
|
|
7101
7889
|
};
|
|
7102
7890
|
#onChange = (event) => {
|
|
7103
7891
|
if (!this.validateOnChangeValue) return;
|
|
7104
|
-
const
|
|
7105
|
-
if (!
|
|
7106
|
-
this.#
|
|
7107
|
-
this.#
|
|
7892
|
+
const group = this.#snapshot().groupByControl.get(this.#controlFrom(event.target));
|
|
7893
|
+
if (!group) return;
|
|
7894
|
+
this.#markTouched(group.key);
|
|
7895
|
+
this.#applyGroup(group);
|
|
7108
7896
|
};
|
|
7109
7897
|
/** Suppresses native bubbles and binds the submit / blur / input listeners. */
|
|
7110
7898
|
connect() {
|
|
7111
7899
|
if (setDefaultAttribute(this.element, "novalidate", "")) {
|
|
7112
7900
|
this.element.setAttribute(_FormValidationController.#NOVALIDATE_MARKER, "");
|
|
7113
7901
|
}
|
|
7114
|
-
|
|
7115
|
-
this.
|
|
7116
|
-
this.
|
|
7117
|
-
this.
|
|
7902
|
+
this.#listenerDocument = this.element.ownerDocument;
|
|
7903
|
+
this.#listenerDocument.addEventListener("submit", this.#onSubmit, true);
|
|
7904
|
+
this.#listenerDocument.addEventListener("focusout", this.#onFocusOut);
|
|
7905
|
+
this.#listenerDocument.addEventListener("input", this.#onInput);
|
|
7906
|
+
this.#listenerDocument.addEventListener("change", this.#onChange);
|
|
7118
7907
|
}
|
|
7119
7908
|
/** Tears down listeners and restores `novalidate` if we added it. */
|
|
7120
7909
|
disconnect() {
|
|
7121
|
-
|
|
7122
|
-
this
|
|
7123
|
-
this
|
|
7124
|
-
this
|
|
7910
|
+
this.#listenerDocument?.removeEventListener("submit", this.#onSubmit, true);
|
|
7911
|
+
this.#listenerDocument?.removeEventListener("focusout", this.#onFocusOut);
|
|
7912
|
+
this.#listenerDocument?.removeEventListener("input", this.#onInput);
|
|
7913
|
+
this.#listenerDocument?.removeEventListener("change", this.#onChange);
|
|
7914
|
+
this.#listenerDocument = null;
|
|
7915
|
+
for (const [control, message] of this.#ownedCustomErrors) {
|
|
7916
|
+
if (control.validationMessage === message) control.setCustomValidity("");
|
|
7917
|
+
}
|
|
7918
|
+
this.#ownedCustomErrors.clear();
|
|
7919
|
+
this.#touchedRadioGroups.clear();
|
|
7125
7920
|
if (this.element.hasAttribute(_FormValidationController.#NOVALIDATE_MARKER)) {
|
|
7126
7921
|
this.element.removeAttribute("novalidate");
|
|
7127
7922
|
this.element.removeAttribute(_FormValidationController.#NOVALIDATE_MARKER);
|
|
@@ -7129,8 +7924,8 @@ var FormValidationController = class _FormValidationController extends Controlle
|
|
|
7129
7924
|
}
|
|
7130
7925
|
/**
|
|
7131
7926
|
* Validates every control now, rendering or clearing each field's message, and
|
|
7132
|
-
* returns whether the whole form is valid. Marks every
|
|
7133
|
-
* later input re-validates it. Bound via `data-action`
|
|
7927
|
+
* returns whether the whole form is valid. Marks every field/group touched so
|
|
7928
|
+
* a later input from any sibling re-validates it. Bound via `data-action`
|
|
7134
7929
|
* (`#validate`) or callable directly (e.g. before a programmatic submit).
|
|
7135
7930
|
*/
|
|
7136
7931
|
validate() {
|
|
@@ -7144,33 +7939,42 @@ var FormValidationController = class _FormValidationController extends Controlle
|
|
|
7144
7939
|
* Each group's first invalid control supplies the message and the focus target.
|
|
7145
7940
|
*/
|
|
7146
7941
|
#validateAll() {
|
|
7942
|
+
const invalid = [];
|
|
7943
|
+
for (const group of this.#snapshot().groups.values()) {
|
|
7944
|
+
this.#markTouched(group.key);
|
|
7945
|
+
const firstInvalid = this.#applyGroup(group);
|
|
7946
|
+
if (firstInvalid) invalid.push(firstInvalid);
|
|
7947
|
+
}
|
|
7948
|
+
return invalid;
|
|
7949
|
+
}
|
|
7950
|
+
/** Builds the current field table in one pass over form controls and DOM depth. */
|
|
7951
|
+
#snapshot() {
|
|
7952
|
+
const fields = this.#fieldOutlets();
|
|
7147
7953
|
const groups = /* @__PURE__ */ new Map();
|
|
7954
|
+
const groupByControl = /* @__PURE__ */ new Map();
|
|
7148
7955
|
for (const control of this.#controls) {
|
|
7149
|
-
this.#
|
|
7150
|
-
const field = this.#fieldFor(control);
|
|
7956
|
+
const field = this.#fieldFor(control, fields);
|
|
7151
7957
|
const key = this.#keyFor(control, field);
|
|
7152
|
-
|
|
7153
|
-
if (group) {
|
|
7154
|
-
group
|
|
7155
|
-
|
|
7156
|
-
groups.set(key, { field, controls: [control] });
|
|
7958
|
+
let group = groups.get(key);
|
|
7959
|
+
if (!group) {
|
|
7960
|
+
group = { key, field, controls: [] };
|
|
7961
|
+
groups.set(key, group);
|
|
7157
7962
|
}
|
|
7963
|
+
group.controls.push(control);
|
|
7964
|
+
groupByControl.set(control, group);
|
|
7158
7965
|
}
|
|
7159
|
-
|
|
7160
|
-
|
|
7161
|
-
|
|
7162
|
-
|
|
7966
|
+
return { groups, groupByControl };
|
|
7967
|
+
}
|
|
7968
|
+
/** Records that a whole field/group, rather than one sibling control, was visited. */
|
|
7969
|
+
#markTouched(key) {
|
|
7970
|
+
if (typeof key === "string") {
|
|
7971
|
+
this.#touchedRadioGroups.add(key);
|
|
7972
|
+
} else {
|
|
7973
|
+
this.#touchedGroups.add(key);
|
|
7163
7974
|
}
|
|
7164
|
-
return invalid;
|
|
7165
7975
|
}
|
|
7166
|
-
|
|
7167
|
-
|
|
7168
|
-
const field = this.#fieldFor(control);
|
|
7169
|
-
const key = this.#keyFor(control, field);
|
|
7170
|
-
const controls = this.#controls.filter(
|
|
7171
|
-
(other) => this.#keyFor(other, this.#fieldFor(other)) === key
|
|
7172
|
-
);
|
|
7173
|
-
this.#applyGroup({ field, controls });
|
|
7976
|
+
#isTouched(key) {
|
|
7977
|
+
return typeof key === "string" ? this.#touchedRadioGroups.has(key) : this.#touchedGroups.has(key);
|
|
7174
7978
|
}
|
|
7175
7979
|
/**
|
|
7176
7980
|
* Runs native constraint validation across a field's controls and routes the
|
|
@@ -7184,51 +7988,63 @@ var FormValidationController = class _FormValidationController extends Controlle
|
|
|
7184
7988
|
const firstInvalid = group.controls.find((control) => !control.checkValidity()) ?? null;
|
|
7185
7989
|
if (group.field) {
|
|
7186
7990
|
if (firstInvalid) {
|
|
7187
|
-
|
|
7188
|
-
|
|
7991
|
+
const message = this.#messageFor(firstInvalid);
|
|
7992
|
+
const alreadyReported = group.field.element.hasAttribute(FORM_FIELD_INVALID_ATTR) && this.#reportedErrors.get(group.field) === message;
|
|
7993
|
+
if (!alreadyReported) group.field.setError(message, { focus: false });
|
|
7994
|
+
this.#reportedErrors.set(group.field, message);
|
|
7995
|
+
} else if (group.field.element.hasAttribute(FORM_FIELD_INVALID_ATTR) || this.#reportedErrors.has(group.field)) {
|
|
7189
7996
|
group.field.clearError();
|
|
7997
|
+
this.#reportedErrors.delete(group.field);
|
|
7190
7998
|
}
|
|
7191
7999
|
}
|
|
7192
8000
|
return firstInvalid;
|
|
7193
8001
|
}
|
|
7194
8002
|
/**
|
|
7195
8003
|
* Resolves the message to show for an invalid control: a per-constraint
|
|
7196
|
-
* override (`data-stimeo--form-
|
|
7197
|
-
* `ValidityState` flag, then a generic `data-stimeo--form-
|
|
8004
|
+
* override (`data-stimeo--form-validation-message-<constraint>`) for the first failing
|
|
8005
|
+
* `ValidityState` flag, then a generic `data-stimeo--form-validation-message`
|
|
7198
8006
|
* override, then the browser's native `validationMessage`. Authoring an override
|
|
7199
8007
|
* gives controlled, localizable, theme-able wording with **no consumer JS** —
|
|
7200
8008
|
* and sidesteps headless browsers that return an empty native message.
|
|
7201
8009
|
*/
|
|
7202
8010
|
#messageFor(control) {
|
|
8011
|
+
if (control.validity.customError) return control.validationMessage;
|
|
7203
8012
|
for (const [flag, key] of CONSTRAINT_MESSAGE_KEYS) {
|
|
7204
8013
|
if (control.validity[flag]) {
|
|
7205
|
-
return control
|
|
8014
|
+
return this.#authoredMessage(control, `${MESSAGE_ATTR_PREFIX}${key}`) ?? this.#authoredMessage(control, MESSAGE_ATTR_GENERIC) ?? control.validationMessage;
|
|
7206
8015
|
}
|
|
7207
8016
|
}
|
|
7208
|
-
return control.validationMessage || control
|
|
8017
|
+
return control.validationMessage || this.#authoredMessage(control, MESSAGE_ATTR_GENERIC) || "";
|
|
7209
8018
|
}
|
|
7210
8019
|
/**
|
|
7211
8020
|
* Applies (or clears) a declarative custom constraint via `setCustomValidity`,
|
|
7212
|
-
* for controls that opt in with `data-stimeo--form-
|
|
8021
|
+
* for controls that opt in with `data-stimeo--form-validation-disallow`. The supported
|
|
7213
8022
|
* rule is `"whitespace"` — a value that is non-empty but blank
|
|
7214
8023
|
* after trimming (which slips past `required` / `minlength`); its message follows
|
|
7215
|
-
* the
|
|
8024
|
+
* the whitespace-specific → generic → default chain.
|
|
7216
8025
|
*
|
|
7217
|
-
* Don't-clobber-authored-state: an unknown/absent rule is never
|
|
7218
|
-
* custom error
|
|
7219
|
-
*
|
|
8026
|
+
* Don't-clobber-authored-state: an unknown/absent rule is never written, an
|
|
8027
|
+
* existing consumer custom error wins, and a controller error is only cleared
|
|
8028
|
+
* while the live message still equals the recorded controller write.
|
|
7220
8029
|
*/
|
|
7221
8030
|
#syncCustomValidity(control) {
|
|
8031
|
+
const recorded = this.#ownedCustomErrors.get(control);
|
|
8032
|
+
const ownsCurrent = recorded !== void 0 && control.validationMessage === recorded;
|
|
7222
8033
|
const violates = control.getAttribute(DISALLOW_ATTR) === "whitespace" && control.value.length > 0 && control.value.trim() === "";
|
|
7223
|
-
if (violates) {
|
|
7224
|
-
control.setCustomValidity(
|
|
7225
|
-
|
|
7226
|
-
|
|
7227
|
-
this.#ownedCustomError.add(control);
|
|
7228
|
-
} else if (this.#ownedCustomError.has(control)) {
|
|
7229
|
-
this.#ownedCustomError.delete(control);
|
|
7230
|
-
control.setCustomValidity("");
|
|
8034
|
+
if (!violates) {
|
|
8035
|
+
if (ownsCurrent) control.setCustomValidity("");
|
|
8036
|
+
this.#ownedCustomErrors.delete(control);
|
|
8037
|
+
return;
|
|
7231
8038
|
}
|
|
8039
|
+
if (control.validity.customError && !ownsCurrent) return;
|
|
8040
|
+
const message = this.#authoredMessage(control, DISALLOW_WHITESPACE_MESSAGE) ?? this.#authoredMessage(control, MESSAGE_ATTR_GENERIC) ?? DISALLOW_WHITESPACE_DEFAULT;
|
|
8041
|
+
if (!ownsCurrent || recorded !== message) control.setCustomValidity(message);
|
|
8042
|
+
this.#ownedCustomErrors.set(control, message);
|
|
8043
|
+
}
|
|
8044
|
+
/** Returns a non-blank authored message, otherwise falls through to a fallback. */
|
|
8045
|
+
#authoredMessage(control, attribute) {
|
|
8046
|
+
const message = control.getAttribute(attribute);
|
|
8047
|
+
return message && message.trim().length > 0 ? message : null;
|
|
7232
8048
|
}
|
|
7233
8049
|
/**
|
|
7234
8050
|
* A grouping key that collects controls belonging to the same field: the owning
|
|
@@ -7252,18 +8068,32 @@ var FormValidationController = class _FormValidationController extends Controlle
|
|
|
7252
8068
|
* is deterministic and CSS-independent.
|
|
7253
8069
|
*/
|
|
7254
8070
|
#focusTargetFor(control) {
|
|
7255
|
-
if (
|
|
7256
|
-
const field = this.#fieldFor(control);
|
|
8071
|
+
if (canTakeFocus(control)) return control;
|
|
8072
|
+
const field = this.#fieldFor(control, this.#fieldOutlets());
|
|
7257
8073
|
if (!field?.hasControlTarget) return null;
|
|
7258
8074
|
const root = field.controlTarget;
|
|
7259
|
-
if (root
|
|
7260
|
-
return root
|
|
8075
|
+
if (isTabStop(root)) return root;
|
|
8076
|
+
return firstTabStop(root);
|
|
7261
8077
|
}
|
|
7262
|
-
/**
|
|
7263
|
-
#
|
|
8078
|
+
/** Pairs each live outlet element with its controller once per operation. */
|
|
8079
|
+
#fieldOutlets() {
|
|
8080
|
+
const fields = /* @__PURE__ */ new Map();
|
|
7264
8081
|
const elements = this.stimeoFormFieldOutletElements;
|
|
8082
|
+
const outlets = this.stimeoFormFieldOutlets;
|
|
7265
8083
|
for (let index = 0; index < elements.length; index++) {
|
|
7266
|
-
|
|
8084
|
+
const element = elements[index];
|
|
8085
|
+
const outlet = outlets[index];
|
|
8086
|
+
if (element && outlet) fields.set(element, outlet);
|
|
8087
|
+
}
|
|
8088
|
+
return fields;
|
|
8089
|
+
}
|
|
8090
|
+
/** The nearest configured field ancestor of `control`, if any. */
|
|
8091
|
+
#fieldFor(control, fields) {
|
|
8092
|
+
let ancestor = control;
|
|
8093
|
+
while (ancestor) {
|
|
8094
|
+
const field = fields.get(ancestor);
|
|
8095
|
+
if (field) return field;
|
|
8096
|
+
ancestor = ancestor.parentElement;
|
|
7267
8097
|
}
|
|
7268
8098
|
return void 0;
|
|
7269
8099
|
}
|
|
@@ -7277,11 +8107,12 @@ var FormValidationController = class _FormValidationController extends Controlle
|
|
|
7277
8107
|
}
|
|
7278
8108
|
/** Narrows an event target to a validatable control. */
|
|
7279
8109
|
#controlFrom(target) {
|
|
7280
|
-
return target instanceof Element && this.#isValidatable(target) ? target : null;
|
|
8110
|
+
return target instanceof Element && this.#isValidatable(target) && target.form === this.element ? target : null;
|
|
7281
8111
|
}
|
|
7282
8112
|
#isValidatable(element) {
|
|
7283
|
-
return (element instanceof HTMLInputElement || element instanceof HTMLSelectElement || element instanceof HTMLTextAreaElement) && // `willValidate` already excludes disabled, read-only, hidden,
|
|
7284
|
-
//
|
|
8113
|
+
return (element instanceof HTMLInputElement || element instanceof HTMLSelectElement || element instanceof HTMLTextAreaElement) && // `willValidate` already excludes disabled, read-only, input[type=hidden],
|
|
8114
|
+
// and button-type controls. The `hidden` attribute intentionally does not
|
|
8115
|
+
// bar a text mirror from constraint validation.
|
|
7285
8116
|
element.willValidate;
|
|
7286
8117
|
}
|
|
7287
8118
|
};
|
|
@@ -9960,37 +10791,6 @@ var ChipRow = class {
|
|
|
9960
10791
|
};
|
|
9961
10792
|
};
|
|
9962
10793
|
|
|
9963
|
-
// src/utils/tabindex_loan.ts
|
|
9964
|
-
var TabindexLoan = class {
|
|
9965
|
-
#value;
|
|
9966
|
-
#lent = /* @__PURE__ */ new Set();
|
|
9967
|
-
/** Returns live loans before Turbo can copy them into its page snapshot. */
|
|
9968
|
-
#beforeCache = new BeforeCacheReset(() => this.returnAll());
|
|
9969
|
-
/**
|
|
9970
|
-
* @param value - the `tabindex` to lend. `"-1"` (the default) is
|
|
9971
|
-
* programmatically focusable but not a Tab stop; `"0"` is a real Tab stop,
|
|
9972
|
-
* which a scroll region with no focusable content of its own needs.
|
|
9973
|
-
*/
|
|
9974
|
-
constructor(value = "-1") {
|
|
9975
|
-
this.#value = value;
|
|
9976
|
-
}
|
|
9977
|
-
/** Lends `element` the value; no-ops when it already carries a `tabindex`. */
|
|
9978
|
-
lend(element) {
|
|
9979
|
-
if (element.hasAttribute("tabindex")) return;
|
|
9980
|
-
element.setAttribute("tabindex", this.#value);
|
|
9981
|
-
this.#lent.add(element);
|
|
9982
|
-
this.#beforeCache.activate();
|
|
9983
|
-
}
|
|
9984
|
-
/** Takes back every loan whose value is still the one that was lent. */
|
|
9985
|
-
returnAll() {
|
|
9986
|
-
for (const element of this.#lent) {
|
|
9987
|
-
if (element.getAttribute("tabindex") === this.#value) element.removeAttribute("tabindex");
|
|
9988
|
-
}
|
|
9989
|
-
this.#lent.clear();
|
|
9990
|
-
this.#beforeCache.deactivate();
|
|
9991
|
-
}
|
|
9992
|
-
};
|
|
9993
|
-
|
|
9994
10794
|
// src/controllers/multi_select_controller.ts
|
|
9995
10795
|
var MultiSelectController = class extends Controller {
|
|
9996
10796
|
static targets = [
|
|
@@ -11348,6 +12148,7 @@ var NumberInputController = class _NumberInputController extends Controller {
|
|
|
11348
12148
|
/** Tracks IME lifecycle on the current input, including confirming keys without a signal. */
|
|
11349
12149
|
#composition = new CompositionTracker();
|
|
11350
12150
|
/** Restores authored custom-spinbutton ARIA when a target leaves or the controller stops. */
|
|
12151
|
+
#beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
|
|
11351
12152
|
#ariaValueNow = new AttributeLease("aria-valuenow");
|
|
11352
12153
|
#ariaValueMin = new AttributeLease("aria-valuemin");
|
|
11353
12154
|
#ariaValueMax = new AttributeLease("aria-valuemax");
|
|
@@ -11371,6 +12172,7 @@ var NumberInputController = class _NumberInputController extends Controller {
|
|
|
11371
12172
|
/** Normalizes any initial value and wires the focus/hold pointer guards. */
|
|
11372
12173
|
connect() {
|
|
11373
12174
|
this.#repaint.activate();
|
|
12175
|
+
this.#beforeCache.activate();
|
|
11374
12176
|
this.#globalGuards = new AbortController();
|
|
11375
12177
|
const { signal } = this.#globalGuards;
|
|
11376
12178
|
for (const button of this.incrementTargets) this.#wireButton(button, 1);
|
|
@@ -11384,6 +12186,7 @@ var NumberInputController = class _NumberInputController extends Controller {
|
|
|
11384
12186
|
disconnect() {
|
|
11385
12187
|
this.#repaint.cancel();
|
|
11386
12188
|
this.#composition.disconnect();
|
|
12189
|
+
this.#beforeCache.deactivate();
|
|
11387
12190
|
this.#globalGuards?.abort();
|
|
11388
12191
|
this.#globalGuards = null;
|
|
11389
12192
|
const buttons = /* @__PURE__ */ new Set([
|
|
@@ -11717,6 +12520,12 @@ var NumberInputController = class _NumberInputController extends Controller {
|
|
|
11717
12520
|
get #steppedRange() {
|
|
11718
12521
|
return { min: this.minValue, max: this.maxValue, step: this.stepValue };
|
|
11719
12522
|
}
|
|
12523
|
+
/** Returns borrowed range ARIA before Turbo snapshots the page. */
|
|
12524
|
+
#rewindForCache() {
|
|
12525
|
+
this.#ariaValueNow.returnAll();
|
|
12526
|
+
this.#ariaValueMin.returnAll();
|
|
12527
|
+
this.#ariaValueMax.returnAll();
|
|
12528
|
+
}
|
|
11720
12529
|
};
|
|
11721
12530
|
var TOGGLED_MARKER = "data-optimistic-toggled";
|
|
11722
12531
|
var OptimisticController = class extends Controller {
|
|
@@ -12201,25 +13010,6 @@ var OverflowIndicatorController = class extends Controller {
|
|
|
12201
13010
|
return direction === "start" || direction === "end" ? direction : null;
|
|
12202
13011
|
}
|
|
12203
13012
|
};
|
|
12204
|
-
|
|
12205
|
-
// src/utils/focus_candidate.ts
|
|
12206
|
-
function inheritsFieldsetDisabled(control) {
|
|
12207
|
-
let fieldset = control.closest("fieldset[disabled]");
|
|
12208
|
-
while (fieldset) {
|
|
12209
|
-
const legend = Array.from(fieldset.children).find((child) => child.tagName === "LEGEND");
|
|
12210
|
-
if (!legend?.contains(control)) return true;
|
|
12211
|
-
fieldset = fieldset.parentElement?.closest("fieldset[disabled]") ?? null;
|
|
12212
|
-
}
|
|
12213
|
-
return false;
|
|
12214
|
-
}
|
|
12215
|
-
function canTakeFocus(element) {
|
|
12216
|
-
if (element.closest("[hidden]")) return false;
|
|
12217
|
-
if (!("disabled" in element)) return true;
|
|
12218
|
-
if (element.disabled) return false;
|
|
12219
|
-
return !inheritsFieldsetDisabled(element);
|
|
12220
|
-
}
|
|
12221
|
-
|
|
12222
|
-
// src/controllers/overflow_menu_controller.ts
|
|
12223
13013
|
var BANKED = "data-stimeo--overflow-menu-banked";
|
|
12224
13014
|
var INDEX = "data-stimeo--overflow-menu-index";
|
|
12225
13015
|
var BOUNDARY = "data-stimeo--overflow-menu-boundary";
|
|
@@ -12997,171 +13787,377 @@ var PasswordStrengthController = class _PasswordStrengthController extends Contr
|
|
|
12997
13787
|
return Math.min(max, Math.max(1, bucketed));
|
|
12998
13788
|
}
|
|
12999
13789
|
};
|
|
13790
|
+
|
|
13791
|
+
// src/utils/safe_storage.ts
|
|
13792
|
+
function readLocalStorage(key) {
|
|
13793
|
+
try {
|
|
13794
|
+
return { ok: true, value: window.localStorage.getItem(key) };
|
|
13795
|
+
} catch (error) {
|
|
13796
|
+
return { ok: false, error };
|
|
13797
|
+
}
|
|
13798
|
+
}
|
|
13799
|
+
function writeLocalStorage(key, value) {
|
|
13800
|
+
try {
|
|
13801
|
+
window.localStorage.setItem(key, value);
|
|
13802
|
+
return { ok: true, value: void 0 };
|
|
13803
|
+
} catch (error) {
|
|
13804
|
+
return { ok: false, error };
|
|
13805
|
+
}
|
|
13806
|
+
}
|
|
13807
|
+
function removeLocalStorage(key) {
|
|
13808
|
+
try {
|
|
13809
|
+
window.localStorage.removeItem(key);
|
|
13810
|
+
return { ok: true, value: void 0 };
|
|
13811
|
+
} catch (error) {
|
|
13812
|
+
return { ok: false, error };
|
|
13813
|
+
}
|
|
13814
|
+
}
|
|
13815
|
+
|
|
13816
|
+
// src/controllers/persist_controller.ts
|
|
13000
13817
|
var NON_VALUE_TYPES = /* @__PURE__ */ new Set(["file", "submit", "reset", "button", "image"]);
|
|
13001
|
-
var
|
|
13818
|
+
var SENSITIVE_TYPES = /* @__PURE__ */ new Set(["password"]);
|
|
13819
|
+
var DEFAULT_EXCLUDE = ["authenticity_token", "_method", "utf8"];
|
|
13002
13820
|
var STORAGE_PREFIX = "stimeo--persist:";
|
|
13003
13821
|
var OCCURRENCE_SEP = "\0";
|
|
13822
|
+
var OWNERSHIP_ATTRIBUTE = "data-controller";
|
|
13823
|
+
var DEFAULT_DEBOUNCE = 400;
|
|
13824
|
+
var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
13825
|
+
var isPersistValue = (value) => typeof value === "string" || typeof value === "boolean" || Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
13826
|
+
var parsePayload = (raw) => {
|
|
13827
|
+
let parsed;
|
|
13828
|
+
try {
|
|
13829
|
+
parsed = JSON.parse(raw);
|
|
13830
|
+
} catch {
|
|
13831
|
+
return null;
|
|
13832
|
+
}
|
|
13833
|
+
if (!isRecord(parsed) || parsed.version !== 1 || !Array.isArray(parsed.fields)) return null;
|
|
13834
|
+
const fields = [];
|
|
13835
|
+
const keys = /* @__PURE__ */ new Set();
|
|
13836
|
+
for (const candidate of parsed.fields) {
|
|
13837
|
+
if (!isRecord(candidate)) return null;
|
|
13838
|
+
const { key, value } = candidate;
|
|
13839
|
+
if (typeof key !== "string" || key.length === 0 || keys.has(key)) return null;
|
|
13840
|
+
if (!isPersistValue(value)) return null;
|
|
13841
|
+
keys.add(key);
|
|
13842
|
+
fields.push({ key, value });
|
|
13843
|
+
}
|
|
13844
|
+
return { version: 1, fields };
|
|
13845
|
+
};
|
|
13846
|
+
var isPersistField = (node) => node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement;
|
|
13004
13847
|
var PersistController = class extends Controller {
|
|
13005
13848
|
static targets = ["field"];
|
|
13006
13849
|
static values = {
|
|
13007
13850
|
key: { type: String, default: "" },
|
|
13008
|
-
debounce: { type: Number, default:
|
|
13009
|
-
// A JSON list
|
|
13010
|
-
// type: that reader throws out of the value observer before any callback
|
|
13011
|
-
// runs, so one malformed attribute would stop the controller connecting.
|
|
13851
|
+
debounce: { type: Number, default: DEFAULT_DEBOUNCE },
|
|
13852
|
+
// A malformed JSON list must fall back without aborting Stimulus connection.
|
|
13012
13853
|
exclude: { type: String, default: "" },
|
|
13013
13854
|
clearOn: { type: String, default: "" }
|
|
13014
13855
|
};
|
|
13015
13856
|
static actions = ["clear"];
|
|
13016
|
-
static events = ["restore", "save", "clear"];
|
|
13857
|
+
static events = ["restore", "save", "clear", "error"];
|
|
13017
13858
|
#timeouts = new SafeTimeout();
|
|
13018
|
-
#
|
|
13019
|
-
#
|
|
13859
|
+
#restoreDynamic = new MicrotaskCoalescer(() => this.#restoreDynamicFields());
|
|
13860
|
+
#observer = new MutationObserver((records) => this.#onMutations(records));
|
|
13861
|
+
#pendingSave = null;
|
|
13862
|
+
#connected = false;
|
|
13863
|
+
#logicalKey = null;
|
|
13864
|
+
#payload = null;
|
|
13865
|
+
#registeredClearOn = null;
|
|
13866
|
+
#excluded = DEFAULT_EXCLUDE;
|
|
13867
|
+
#knownFields = /* @__PURE__ */ new WeakSet();
|
|
13868
|
+
#forcedRestore = /* @__PURE__ */ new Set();
|
|
13869
|
+
#onInput = (event) => {
|
|
13870
|
+
const field = event.target;
|
|
13871
|
+
if (!isPersistField(field) || !this.#ownsField(field)) return;
|
|
13872
|
+
if (this.hasFieldTarget && !this.fieldTargets.includes(field)) return;
|
|
13873
|
+
if (this.#keyOf(field) === null || !this.#persistable(field, this.#excluded)) return;
|
|
13020
13874
|
this.#scheduleSave();
|
|
13021
13875
|
};
|
|
13022
13876
|
#onClearEvent = () => {
|
|
13023
13877
|
this.clear();
|
|
13024
13878
|
};
|
|
13025
13879
|
connect() {
|
|
13026
|
-
if (this.#
|
|
13027
|
-
this.#
|
|
13880
|
+
if (this.#connected) return;
|
|
13881
|
+
this.#connected = true;
|
|
13882
|
+
this.#logicalKey = this.#resolveLogicalKey();
|
|
13883
|
+
this.#excluded = parseStringList(this.excludeValue, DEFAULT_EXCLUDE);
|
|
13884
|
+
this.#knownFields = /* @__PURE__ */ new WeakSet();
|
|
13885
|
+
this.#forcedRestore.clear();
|
|
13886
|
+
this.element.removeAttribute("data-persist-restored");
|
|
13028
13887
|
this.element.addEventListener("input", this.#onInput);
|
|
13029
13888
|
this.element.addEventListener("change", this.#onInput);
|
|
13030
|
-
|
|
13031
|
-
|
|
13032
|
-
|
|
13889
|
+
this.#syncClearOn();
|
|
13890
|
+
this.#restoreDynamic.activate();
|
|
13891
|
+
this.#observer.observe(this.element, {
|
|
13892
|
+
attributes: true,
|
|
13893
|
+
attributeFilter: [
|
|
13894
|
+
"checked",
|
|
13895
|
+
"data-controller",
|
|
13896
|
+
"data-stimeo--persist-target",
|
|
13897
|
+
"id",
|
|
13898
|
+
"multiple",
|
|
13899
|
+
"name",
|
|
13900
|
+
"selected",
|
|
13901
|
+
"type",
|
|
13902
|
+
"value"
|
|
13903
|
+
],
|
|
13904
|
+
childList: true,
|
|
13905
|
+
subtree: true
|
|
13906
|
+
});
|
|
13907
|
+
this.#loadActiveDraft();
|
|
13033
13908
|
}
|
|
13034
13909
|
disconnect() {
|
|
13910
|
+
this.#flushPendingSave();
|
|
13911
|
+
this.#connected = false;
|
|
13035
13912
|
this.element.removeEventListener("input", this.#onInput);
|
|
13036
13913
|
this.element.removeEventListener("change", this.#onInput);
|
|
13037
|
-
|
|
13038
|
-
|
|
13039
|
-
|
|
13040
|
-
|
|
13041
|
-
this.#timeouts.clear(this.#saveId);
|
|
13042
|
-
this.#saveId = null;
|
|
13043
|
-
this.#write();
|
|
13044
|
-
}
|
|
13914
|
+
this.#unbindClearOn();
|
|
13915
|
+
this.#observer.disconnect();
|
|
13916
|
+
this.#restoreDynamic.cancel();
|
|
13917
|
+
this.#forcedRestore.clear();
|
|
13045
13918
|
this.#timeouts.clearAll();
|
|
13919
|
+
this.element.removeAttribute("data-persist-restored");
|
|
13920
|
+
this.#payload = null;
|
|
13046
13921
|
}
|
|
13047
|
-
/** Drops the
|
|
13922
|
+
/** Drops the active draft after storage confirms the removal. */
|
|
13048
13923
|
clear() {
|
|
13049
|
-
const
|
|
13050
|
-
if (
|
|
13051
|
-
|
|
13052
|
-
|
|
13053
|
-
|
|
13924
|
+
const logicalKey = this.#logicalKey ?? this.#resolveLogicalKey();
|
|
13925
|
+
if (logicalKey === null) return;
|
|
13926
|
+
this.#cancelPendingSave();
|
|
13927
|
+
const result = removeLocalStorage(this.#storageKey(logicalKey));
|
|
13928
|
+
if (!result.ok) {
|
|
13929
|
+
this.#dispatchError(logicalKey, "remove", "unavailable");
|
|
13930
|
+
return;
|
|
13054
13931
|
}
|
|
13055
|
-
this.#
|
|
13932
|
+
this.#payload = null;
|
|
13056
13933
|
this.element.removeAttribute("data-persist-restored");
|
|
13057
|
-
this.dispatch("clear", { detail: { key:
|
|
13934
|
+
this.dispatch("clear", { detail: { key: logicalKey } });
|
|
13935
|
+
}
|
|
13936
|
+
/** Rebinds the external clear event when its Value changes. */
|
|
13937
|
+
clearOnValueChanged() {
|
|
13938
|
+
if (this.#connected) this.#syncClearOn();
|
|
13939
|
+
}
|
|
13940
|
+
/** Switches storage namespaces without writing old edits under the new key. */
|
|
13941
|
+
keyValueChanged() {
|
|
13942
|
+
if (this.#connected) this.#switchLogicalKey();
|
|
13943
|
+
}
|
|
13944
|
+
/** Reschedules a pending write against the current debounce delay. */
|
|
13945
|
+
debounceValueChanged() {
|
|
13946
|
+
if (this.#connected && this.#pendingSave !== null) this.#scheduleSave();
|
|
13947
|
+
}
|
|
13948
|
+
/** Restores every field the new exclusion list makes eligible. */
|
|
13949
|
+
excludeValueChanged() {
|
|
13950
|
+
const previous = this.#excluded;
|
|
13951
|
+
this.#excluded = parseStringList(this.excludeValue, DEFAULT_EXCLUDE);
|
|
13952
|
+
if (!this.#connected) return;
|
|
13953
|
+
for (const field of this.#candidateFields()) {
|
|
13954
|
+
if (this.#persistable(field, this.#excluded) && !this.#persistable(field, previous)) {
|
|
13955
|
+
this.#forcedRestore.add(field);
|
|
13956
|
+
}
|
|
13957
|
+
}
|
|
13958
|
+
this.#restoreDynamic.schedule();
|
|
13058
13959
|
}
|
|
13059
13960
|
/** Schedules a debounced save. */
|
|
13060
13961
|
#scheduleSave() {
|
|
13061
|
-
|
|
13062
|
-
|
|
13063
|
-
|
|
13064
|
-
|
|
13065
|
-
|
|
13066
|
-
|
|
13067
|
-
|
|
13068
|
-
|
|
13069
|
-
|
|
13070
|
-
|
|
13962
|
+
const logicalKey = this.#logicalKey;
|
|
13963
|
+
if (logicalKey === null) return;
|
|
13964
|
+
this.#cancelPendingSave();
|
|
13965
|
+
const id = this.#timeouts.set(() => {
|
|
13966
|
+
this.#pendingSave = null;
|
|
13967
|
+
this.#save(logicalKey);
|
|
13968
|
+
}, this.#debounceDelay);
|
|
13969
|
+
this.#pendingSave = { id, logicalKey };
|
|
13970
|
+
}
|
|
13971
|
+
/** Cancels the currently pending save, if any. */
|
|
13972
|
+
#cancelPendingSave() {
|
|
13973
|
+
const pending = this.#pendingSave;
|
|
13974
|
+
if (pending === null) return;
|
|
13975
|
+
this.#timeouts.clear(pending.id);
|
|
13976
|
+
this.#pendingSave = null;
|
|
13977
|
+
}
|
|
13978
|
+
/** Flushes a pending edit through the same success/error event path as a timer. */
|
|
13979
|
+
#flushPendingSave() {
|
|
13980
|
+
const pending = this.#pendingSave;
|
|
13981
|
+
if (pending === null) return;
|
|
13982
|
+
this.#cancelPendingSave();
|
|
13983
|
+
this.#save(pending.logicalKey);
|
|
13984
|
+
}
|
|
13985
|
+
/** Writes the active payload and emits `save` only after storage succeeds. */
|
|
13986
|
+
#save(logicalKey) {
|
|
13987
|
+
const payload = this.#serialize();
|
|
13988
|
+
const result = writeLocalStorage(this.#storageKey(logicalKey), JSON.stringify(payload));
|
|
13989
|
+
if (!result.ok) {
|
|
13990
|
+
this.#dispatchError(logicalKey, "write", "unavailable");
|
|
13991
|
+
return false;
|
|
13992
|
+
}
|
|
13993
|
+
this.#payload = payload;
|
|
13994
|
+
this.dispatch("save", { detail: { key: logicalKey } });
|
|
13995
|
+
return true;
|
|
13071
13996
|
}
|
|
13072
|
-
/** Serializes persistable
|
|
13073
|
-
#
|
|
13074
|
-
const
|
|
13075
|
-
|
|
13076
|
-
const data = {};
|
|
13077
|
-
for (const { field, key: fieldKey } of this.#fieldEntries()) {
|
|
13997
|
+
/** Serializes every persistable field into a versioned, typed payload. */
|
|
13998
|
+
#serialize() {
|
|
13999
|
+
const fields = [];
|
|
14000
|
+
for (const { field, key } of this.#fieldEntries()) {
|
|
13078
14001
|
if (field instanceof HTMLInputElement && field.type === "checkbox") {
|
|
13079
|
-
|
|
14002
|
+
fields.push({ key, value: field.checked });
|
|
13080
14003
|
} else if (field instanceof HTMLInputElement && field.type === "radio") {
|
|
13081
|
-
if (field.checked)
|
|
14004
|
+
if (field.checked) fields.push({ key, value: field.value });
|
|
13082
14005
|
} else if (field instanceof HTMLSelectElement && field.multiple) {
|
|
13083
|
-
|
|
14006
|
+
fields.push({
|
|
14007
|
+
key,
|
|
14008
|
+
value: Array.from(field.selectedOptions).map((option) => option.value)
|
|
14009
|
+
});
|
|
13084
14010
|
} else {
|
|
13085
|
-
|
|
14011
|
+
fields.push({ key, value: field.value });
|
|
13086
14012
|
}
|
|
13087
14013
|
}
|
|
13088
|
-
|
|
14014
|
+
return { version: 1, fields };
|
|
13089
14015
|
}
|
|
13090
|
-
/**
|
|
13091
|
-
#
|
|
13092
|
-
|
|
13093
|
-
|
|
13094
|
-
|
|
13095
|
-
|
|
13096
|
-
|
|
13097
|
-
|
|
13098
|
-
|
|
13099
|
-
} catch {
|
|
14016
|
+
/** Reads, validates, and restores the active namespace. */
|
|
14017
|
+
#loadActiveDraft() {
|
|
14018
|
+
this.element.removeAttribute("data-persist-restored");
|
|
14019
|
+
this.#payload = null;
|
|
14020
|
+
this.#knownFields = /* @__PURE__ */ new WeakSet();
|
|
14021
|
+
this.#forcedRestore.clear();
|
|
14022
|
+
const logicalKey = this.#logicalKey;
|
|
14023
|
+
if (logicalKey === null) {
|
|
14024
|
+
this.#markCurrentFieldsKnown();
|
|
13100
14025
|
return;
|
|
13101
14026
|
}
|
|
13102
|
-
|
|
13103
|
-
|
|
13104
|
-
|
|
13105
|
-
this.#
|
|
13106
|
-
|
|
14027
|
+
const result = readLocalStorage(this.#storageKey(logicalKey));
|
|
14028
|
+
if (!result.ok) {
|
|
14029
|
+
this.#dispatchError(logicalKey, "read", "unavailable");
|
|
14030
|
+
this.#markCurrentFieldsKnown();
|
|
14031
|
+
return;
|
|
13107
14032
|
}
|
|
13108
|
-
if (
|
|
14033
|
+
if (result.value === null) {
|
|
14034
|
+
this.#markCurrentFieldsKnown();
|
|
14035
|
+
return;
|
|
14036
|
+
}
|
|
14037
|
+
const payload = parsePayload(result.value);
|
|
14038
|
+
if (payload === null) {
|
|
14039
|
+
const removal = removeLocalStorage(this.#storageKey(logicalKey));
|
|
14040
|
+
this.#dispatchError(logicalKey, "read", "invalid-payload");
|
|
14041
|
+
if (!removal.ok) this.#dispatchError(logicalKey, "remove", "unavailable");
|
|
14042
|
+
this.#markCurrentFieldsKnown();
|
|
14043
|
+
return;
|
|
14044
|
+
}
|
|
14045
|
+
this.#payload = payload;
|
|
14046
|
+
this.#restoreEntries(null);
|
|
14047
|
+
}
|
|
14048
|
+
/** Restores fields inserted or made eligible after the initial connection. */
|
|
14049
|
+
#restoreDynamicFields() {
|
|
14050
|
+
const selected = new Set(this.#forcedRestore);
|
|
14051
|
+
this.#forcedRestore.clear();
|
|
14052
|
+
for (const { field } of this.#fieldEntries()) {
|
|
14053
|
+
if (!this.#knownFields.has(field)) selected.add(field);
|
|
14054
|
+
}
|
|
14055
|
+
this.#restoreEntries(selected);
|
|
14056
|
+
}
|
|
14057
|
+
/** Applies the active payload to all fields, or only the supplied candidates. */
|
|
14058
|
+
#restoreEntries(selected) {
|
|
14059
|
+
const entries = this.#fieldEntries();
|
|
14060
|
+
for (const { field } of entries) this.#knownFields.add(field);
|
|
14061
|
+
const payload = this.#payload;
|
|
14062
|
+
if (payload === null) return;
|
|
14063
|
+
const savedFields = new Map(payload.fields.map((field) => [field.key, field]));
|
|
14064
|
+
const restoredKeys = /* @__PURE__ */ new Set();
|
|
14065
|
+
const processedRadios = /* @__PURE__ */ new Set();
|
|
14066
|
+
for (const entry of entries) {
|
|
14067
|
+
if (selected !== null && !selected.has(entry.field)) continue;
|
|
14068
|
+
const saved = savedFields.get(entry.key);
|
|
14069
|
+
if (saved === void 0) continue;
|
|
14070
|
+
const { value } = saved;
|
|
14071
|
+
if (entry.field instanceof HTMLInputElement && entry.field.type === "radio") {
|
|
14072
|
+
if (processedRadios.has(entry.key)) continue;
|
|
14073
|
+
processedRadios.add(entry.key);
|
|
14074
|
+
const radios = entries.filter(
|
|
14075
|
+
({ key, field }) => key === entry.key && field instanceof HTMLInputElement && field.type === "radio"
|
|
14076
|
+
).map(({ field }) => field);
|
|
14077
|
+
if (typeof value !== "string" || !radios.some((radio) => radio.value === value)) continue;
|
|
14078
|
+
for (const radio of radios) radio.checked = radio.value === value;
|
|
14079
|
+
restoredKeys.add(entry.key);
|
|
14080
|
+
continue;
|
|
14081
|
+
}
|
|
14082
|
+
if (this.#applyValue(entry.field, value)) restoredKeys.add(entry.key);
|
|
14083
|
+
}
|
|
14084
|
+
if (restoredKeys.size > 0) {
|
|
14085
|
+
this.#observer.takeRecords();
|
|
13109
14086
|
this.element.setAttribute("data-persist-restored", "true");
|
|
13110
14087
|
this.dispatch("restore", { detail: { key: this.#logicalKey } });
|
|
13111
14088
|
}
|
|
13112
14089
|
}
|
|
13113
|
-
/**
|
|
14090
|
+
/** Applies a type-compatible stored value without coercion. */
|
|
13114
14091
|
#applyValue(field, value) {
|
|
13115
14092
|
if (field instanceof HTMLInputElement && field.type === "checkbox") {
|
|
13116
|
-
|
|
13117
|
-
|
|
13118
|
-
|
|
13119
|
-
}
|
|
13120
|
-
|
|
14093
|
+
if (typeof value !== "boolean") return false;
|
|
14094
|
+
field.checked = value;
|
|
14095
|
+
return true;
|
|
14096
|
+
}
|
|
14097
|
+
if (field instanceof HTMLSelectElement && field.multiple) {
|
|
14098
|
+
if (!Array.isArray(value)) return false;
|
|
14099
|
+
const available = new Set(Array.from(field.options).map((option) => option.value));
|
|
14100
|
+
const selected = value.filter((candidate) => available.has(candidate));
|
|
14101
|
+
if (value.length > 0 && selected.length === 0) return false;
|
|
14102
|
+
const selectedValues = new Set(selected);
|
|
13121
14103
|
for (const option of Array.from(field.options)) {
|
|
13122
|
-
option.selected =
|
|
14104
|
+
option.selected = selectedValues.has(option.value);
|
|
13123
14105
|
}
|
|
13124
|
-
|
|
13125
|
-
|
|
14106
|
+
return true;
|
|
14107
|
+
}
|
|
14108
|
+
if (typeof value !== "string") return false;
|
|
14109
|
+
if (field instanceof HTMLSelectElement) {
|
|
14110
|
+
const exists = Array.from(field.options).some((option) => option.value === value);
|
|
14111
|
+
if (!exists) return false;
|
|
13126
14112
|
}
|
|
14113
|
+
field.value = value;
|
|
14114
|
+
return true;
|
|
14115
|
+
}
|
|
14116
|
+
/** Every control this instance owns, before the exclusion list narrows them. */
|
|
14117
|
+
#candidateFields() {
|
|
14118
|
+
return this.hasFieldTarget ? this.fieldTargets.filter(isPersistField) : Array.from(this.element.querySelectorAll("input, textarea, select")).filter(
|
|
14119
|
+
(field) => this.#ownsField(field)
|
|
14120
|
+
);
|
|
13127
14121
|
}
|
|
13128
14122
|
/**
|
|
13129
|
-
*
|
|
13130
|
-
*
|
|
13131
|
-
* array text inputs) are disambiguated by DOM-order occurrence — the first keeps
|
|
13132
|
-
* its plain `name`, later ones get a NUL-separated index suffix — so each is
|
|
13133
|
-
* stored and restored individually instead of the last one clobbering the rest.
|
|
13134
|
-
* Radios are the exception: a group intentionally shares one key (one value per
|
|
13135
|
-
* name).
|
|
14123
|
+
* Pairs persistable fields with DOM-order occurrence keys. Every HTML radio
|
|
14124
|
+
* group (same name and form owner) occupies one shared occurrence slot.
|
|
13136
14125
|
*/
|
|
13137
14126
|
#fieldEntries() {
|
|
13138
14127
|
const entries = [];
|
|
13139
|
-
const
|
|
13140
|
-
for (const field of this.#
|
|
14128
|
+
const eligible = [];
|
|
14129
|
+
for (const field of this.#candidateFields()) {
|
|
13141
14130
|
const name = this.#keyOf(field);
|
|
13142
|
-
if (name === null) continue;
|
|
14131
|
+
if (name === null || !this.#persistable(field, this.#excluded)) continue;
|
|
14132
|
+
eligible.push({ field, name });
|
|
14133
|
+
}
|
|
14134
|
+
const nextOccurrence = /* @__PURE__ */ new Map();
|
|
14135
|
+
const radioSlots = /* @__PURE__ */ new Map();
|
|
14136
|
+
const allocate = (name) => {
|
|
14137
|
+
const occurrence = nextOccurrence.get(name) ?? 0;
|
|
14138
|
+
nextOccurrence.set(name, occurrence + 1);
|
|
14139
|
+
return occurrence === 0 ? name : `${name}${OCCURRENCE_SEP}${occurrence}`;
|
|
14140
|
+
};
|
|
14141
|
+
for (const { field, name } of eligible) {
|
|
13143
14142
|
if (field instanceof HTMLInputElement && field.type === "radio") {
|
|
13144
|
-
|
|
14143
|
+
const groups = radioSlots.get(name) ?? /* @__PURE__ */ new Map();
|
|
14144
|
+
radioSlots.set(name, groups);
|
|
14145
|
+
let key = groups.get(field.form);
|
|
14146
|
+
if (key === void 0) {
|
|
14147
|
+
key = allocate(name);
|
|
14148
|
+
groups.set(field.form, key);
|
|
14149
|
+
}
|
|
14150
|
+
entries.push({ field, key });
|
|
13145
14151
|
continue;
|
|
13146
14152
|
}
|
|
13147
|
-
|
|
13148
|
-
occurrence.set(name, seen + 1);
|
|
13149
|
-
entries.push({ field, key: seen === 0 ? name : `${name}${OCCURRENCE_SEP}${seen}` });
|
|
14153
|
+
entries.push({ field, key: allocate(name) });
|
|
13150
14154
|
}
|
|
13151
14155
|
return entries;
|
|
13152
14156
|
}
|
|
13153
|
-
/** The fields to persist: `field` targets, or the element's named controls. */
|
|
13154
|
-
#fields() {
|
|
13155
|
-
const candidates = this.hasFieldTarget ? this.fieldTargets : Array.from(this.element.querySelectorAll("input, textarea, select"));
|
|
13156
|
-
const excluded = parseStringList(this.excludeValue, DEFAULT_EXCLUDE);
|
|
13157
|
-
return candidates.filter((field) => this.#persistable(field, excluded));
|
|
13158
|
-
}
|
|
13159
14157
|
/** Whether a field carries a restorable, non-excluded value. */
|
|
13160
14158
|
#persistable(field, excluded) {
|
|
13161
|
-
if (
|
|
13162
|
-
|
|
13163
|
-
if (NON_VALUE_TYPES.has(type)) return false;
|
|
13164
|
-
if (excluded.includes(type)) return false;
|
|
14159
|
+
if (NON_VALUE_TYPES.has(field.type) || SENSITIVE_TYPES.has(field.type)) return false;
|
|
14160
|
+
if (excluded.includes(field.type)) return false;
|
|
13165
14161
|
if (field.name.length > 0 && excluded.includes(field.name)) return false;
|
|
13166
14162
|
return true;
|
|
13167
14163
|
}
|
|
@@ -13169,35 +14165,108 @@ var PersistController = class extends Controller {
|
|
|
13169
14165
|
#keyOf(field) {
|
|
13170
14166
|
return field.name || field.id || null;
|
|
13171
14167
|
}
|
|
13172
|
-
/**
|
|
13173
|
-
|
|
13174
|
-
|
|
13175
|
-
|
|
13176
|
-
|
|
13177
|
-
|
|
13178
|
-
|
|
13179
|
-
|
|
13180
|
-
|
|
14168
|
+
/** Whether this instance, rather than a nested Persist host, owns a field. */
|
|
14169
|
+
#ownsField(field) {
|
|
14170
|
+
return field.closest('[data-controller~="stimeo--persist"]') === this.element;
|
|
14171
|
+
}
|
|
14172
|
+
/** Marks all currently eligible fields without applying a payload. */
|
|
14173
|
+
#markCurrentFieldsKnown() {
|
|
14174
|
+
for (const { field } of this.#fieldEntries()) this.#knownFields.add(field);
|
|
14175
|
+
}
|
|
14176
|
+
/** Switches namespace after flushing the old namespace's pending edit. */
|
|
14177
|
+
#switchLogicalKey() {
|
|
14178
|
+
const next = this.#resolveLogicalKey();
|
|
14179
|
+
if (next === this.#logicalKey) return;
|
|
14180
|
+
this.#flushPendingSave();
|
|
14181
|
+
this.#logicalKey = next;
|
|
14182
|
+
this.#loadActiveDraft();
|
|
14183
|
+
}
|
|
14184
|
+
/** Synchronizes the exact event name owned by the `clearOn` Value. */
|
|
14185
|
+
#syncClearOn() {
|
|
14186
|
+
const next = this.#validEventName(this.clearOnValue);
|
|
14187
|
+
if (next === this.#registeredClearOn) return;
|
|
14188
|
+
this.#unbindClearOn();
|
|
14189
|
+
if (next === null) return;
|
|
14190
|
+
this.element.addEventListener(next, this.#onClearEvent);
|
|
14191
|
+
this.#registeredClearOn = next;
|
|
14192
|
+
}
|
|
14193
|
+
/** Removes the event listener using the name that was actually registered. */
|
|
14194
|
+
#unbindClearOn() {
|
|
14195
|
+
if (this.#registeredClearOn === null) return;
|
|
14196
|
+
this.element.removeEventListener(this.#registeredClearOn, this.#onClearEvent);
|
|
14197
|
+
this.#registeredClearOn = null;
|
|
14198
|
+
}
|
|
14199
|
+
/** Returns a non-whitespace event type, or null when the hook is disabled. */
|
|
14200
|
+
#validEventName(value) {
|
|
14201
|
+
const name = value.trim();
|
|
14202
|
+
return name.length > 0 && !/\s/.test(name) ? name : null;
|
|
14203
|
+
}
|
|
14204
|
+
/** Collects dynamic controls and select-option changes into one restore pass. */
|
|
14205
|
+
#onMutations(records) {
|
|
14206
|
+
let rootIdChanged = false;
|
|
14207
|
+
for (const record of records) {
|
|
14208
|
+
if (record.type === "attributes") {
|
|
14209
|
+
if (record.target === this.element && record.attributeName === "id") {
|
|
14210
|
+
rootIdChanged = true;
|
|
14211
|
+
}
|
|
14212
|
+
this.#collectAttributeCandidate(record.target, record.attributeName);
|
|
14213
|
+
continue;
|
|
14214
|
+
}
|
|
14215
|
+
for (const node of record.addedNodes) this.#collectRestoreCandidate(node);
|
|
14216
|
+
}
|
|
14217
|
+
if (rootIdChanged && this.keyValue.length === 0) this.#switchLogicalKey();
|
|
14218
|
+
this.#restoreDynamic.schedule();
|
|
13181
14219
|
}
|
|
13182
|
-
|
|
13183
|
-
|
|
13184
|
-
|
|
13185
|
-
|
|
13186
|
-
|
|
14220
|
+
/**
|
|
14221
|
+
* Adds the controls one mutated attribute can affect. Only an ownership change on a
|
|
14222
|
+
* descendant moves fields between Persist hosts, so that is the single case worth a
|
|
14223
|
+
* subtree sweep; every other observed attribute describes one control, and sweeping
|
|
14224
|
+
* from its container would re-apply the stored draft over edits still being debounced.
|
|
14225
|
+
*/
|
|
14226
|
+
#collectAttributeCandidate(target, attributeName) {
|
|
14227
|
+
if (attributeName === OWNERSHIP_ATTRIBUTE && target !== this.element) {
|
|
14228
|
+
this.#collectRestoreCandidate(target);
|
|
14229
|
+
return;
|
|
13187
14230
|
}
|
|
14231
|
+
this.#collectControlCandidate(target);
|
|
13188
14232
|
}
|
|
13189
|
-
|
|
13190
|
-
|
|
13191
|
-
|
|
13192
|
-
|
|
14233
|
+
/** Adds a native control, or the select that owns a mutated option. */
|
|
14234
|
+
#collectControlCandidate(node) {
|
|
14235
|
+
if (isPersistField(node)) {
|
|
14236
|
+
this.#forcedRestore.add(node);
|
|
14237
|
+
return;
|
|
14238
|
+
}
|
|
14239
|
+
if (node instanceof HTMLOptionElement) {
|
|
14240
|
+
const select = node.closest("select");
|
|
14241
|
+
if (select) this.#forcedRestore.add(select);
|
|
13193
14242
|
}
|
|
13194
14243
|
}
|
|
13195
|
-
|
|
13196
|
-
|
|
13197
|
-
|
|
13198
|
-
|
|
14244
|
+
/** Adds every control inside an inserted or newly owned subtree. */
|
|
14245
|
+
#collectRestoreCandidate(node) {
|
|
14246
|
+
this.#collectControlCandidate(node);
|
|
14247
|
+
if (!(node instanceof Element)) return;
|
|
14248
|
+
for (const field of node.querySelectorAll("input, textarea, select")) {
|
|
14249
|
+
this.#forcedRestore.add(field);
|
|
13199
14250
|
}
|
|
13200
14251
|
}
|
|
14252
|
+
/** Resolves the logical key from the Value, falling back to the host id. */
|
|
14253
|
+
#resolveLogicalKey() {
|
|
14254
|
+
const key = this.keyValue || this.element.id;
|
|
14255
|
+
return key.length > 0 ? key : null;
|
|
14256
|
+
}
|
|
14257
|
+
/** Namespaces one logical key within localStorage. */
|
|
14258
|
+
#storageKey(logicalKey) {
|
|
14259
|
+
return `${STORAGE_PREFIX}${logicalKey}`;
|
|
14260
|
+
}
|
|
14261
|
+
/** Normalizes invalid debounce Values to the documented default. */
|
|
14262
|
+
get #debounceDelay() {
|
|
14263
|
+
const value = this.debounceValue;
|
|
14264
|
+
return Number.isFinite(value) && value >= 0 ? value : DEFAULT_DEBOUNCE;
|
|
14265
|
+
}
|
|
14266
|
+
/** Dispatches one observable storage failure without exposing browser errors. */
|
|
14267
|
+
#dispatchError(key, operation, reason) {
|
|
14268
|
+
this.dispatch("error", { detail: { key, operation, reason } });
|
|
14269
|
+
}
|
|
13201
14270
|
};
|
|
13202
14271
|
var PointerDragController = class _PointerDragController extends Controller {
|
|
13203
14272
|
static targets = ["handle"];
|
|
@@ -13553,7 +14622,7 @@ var PointerDragController = class _PointerDragController extends Controller {
|
|
|
13553
14622
|
}
|
|
13554
14623
|
}
|
|
13555
14624
|
};
|
|
13556
|
-
var PopoverController = class
|
|
14625
|
+
var PopoverController = class extends Controller {
|
|
13557
14626
|
static targets = ["trigger", "panel"];
|
|
13558
14627
|
static values = {
|
|
13559
14628
|
closeOnScroll: { type: Boolean, default: false }
|
|
@@ -13563,8 +14632,6 @@ var PopoverController = class _PopoverController extends Controller {
|
|
|
13563
14632
|
#stopScrollDismiss = null;
|
|
13564
14633
|
/** Escape-stack membership while open; the shared resolver dismisses via it. */
|
|
13565
14634
|
#escapeLayer = new EscapeLayer();
|
|
13566
|
-
/** Selector for natively focusable elements used to find the first one. */
|
|
13567
|
-
static #FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
13568
14635
|
/** Starts closed and registers the standing dismissal listeners. */
|
|
13569
14636
|
connect() {
|
|
13570
14637
|
this.close();
|
|
@@ -13616,7 +14683,7 @@ var PopoverController = class _PopoverController extends Controller {
|
|
|
13616
14683
|
}
|
|
13617
14684
|
/** Moves focus to the first focusable element in the panel, or the panel itself. */
|
|
13618
14685
|
#focusFirst() {
|
|
13619
|
-
const first = this.panelTarget
|
|
14686
|
+
const first = firstTabStop(this.panelTarget);
|
|
13620
14687
|
if (first) {
|
|
13621
14688
|
first.focus();
|
|
13622
14689
|
return;
|
|
@@ -13921,7 +14988,7 @@ function isInteractiveHost(element) {
|
|
|
13921
14988
|
}
|
|
13922
14989
|
|
|
13923
14990
|
// src/controllers/radio_group_controller.ts
|
|
13924
|
-
var
|
|
14991
|
+
var OBSERVED_ATTRIBUTES3 = [
|
|
13925
14992
|
"aria-checked",
|
|
13926
14993
|
"aria-disabled",
|
|
13927
14994
|
"contenteditable",
|
|
@@ -14371,7 +15438,7 @@ var RadioGroupController = class extends Controller {
|
|
|
14371
15438
|
subtree: true,
|
|
14372
15439
|
childList: true,
|
|
14373
15440
|
attributes: true,
|
|
14374
|
-
attributeFilter:
|
|
15441
|
+
attributeFilter: OBSERVED_ATTRIBUTES3
|
|
14375
15442
|
});
|
|
14376
15443
|
let ancestor = this.element.parentElement;
|
|
14377
15444
|
while (ancestor) {
|
|
@@ -15661,15 +16728,60 @@ var RovingController = class extends Controller {
|
|
|
15661
16728
|
this.#roving.setActive(active === -1 ? 0 : active);
|
|
15662
16729
|
}
|
|
15663
16730
|
};
|
|
15664
|
-
|
|
15665
|
-
|
|
15666
|
-
|
|
15667
|
-
|
|
15668
|
-
|
|
15669
|
-
|
|
15670
|
-
|
|
15671
|
-
|
|
15672
|
-
|
|
16731
|
+
|
|
16732
|
+
// src/utils/style_property_lease.ts
|
|
16733
|
+
var StylePropertyLease = class {
|
|
16734
|
+
#property;
|
|
16735
|
+
#records = /* @__PURE__ */ new Map();
|
|
16736
|
+
/** @param property - The CSS property whose temporary values this lease owns. */
|
|
16737
|
+
constructor(property) {
|
|
16738
|
+
this.#property = property;
|
|
16739
|
+
}
|
|
16740
|
+
/** Writes or removes the leased declaration while preserving its authored value. */
|
|
16741
|
+
write(element, value, priority = "") {
|
|
16742
|
+
const existing = this.#records.get(element);
|
|
16743
|
+
if (existing) {
|
|
16744
|
+
existing.writtenValue = value;
|
|
16745
|
+
existing.writtenPriority = value === null ? "" : priority;
|
|
16746
|
+
} else {
|
|
16747
|
+
this.#records.set(element, {
|
|
16748
|
+
originalValue: element.style.getPropertyValue(this.#property),
|
|
16749
|
+
originalPriority: element.style.getPropertyPriority(this.#property),
|
|
16750
|
+
writtenValue: value,
|
|
16751
|
+
writtenPriority: value === null ? "" : priority
|
|
16752
|
+
});
|
|
16753
|
+
}
|
|
16754
|
+
this.#reflect(element, value, priority);
|
|
16755
|
+
}
|
|
16756
|
+
/** Returns one lease without overwriting a later consumer declaration. */
|
|
16757
|
+
return(element) {
|
|
16758
|
+
const record = this.#records.get(element);
|
|
16759
|
+
if (!record) return;
|
|
16760
|
+
this.#records.delete(element);
|
|
16761
|
+
const style = element.style;
|
|
16762
|
+
const stillOwned = style.getPropertyValue(this.#property) === (record.writtenValue ?? "") && style.getPropertyPriority(this.#property) === record.writtenPriority;
|
|
16763
|
+
if (stillOwned) {
|
|
16764
|
+
this.#reflect(element, record.originalValue, record.originalPriority);
|
|
16765
|
+
}
|
|
16766
|
+
}
|
|
16767
|
+
/** Returns every outstanding declaration lease. */
|
|
16768
|
+
returnAll() {
|
|
16769
|
+
for (const element of Array.from(this.#records.keys())) this.return(element);
|
|
16770
|
+
}
|
|
16771
|
+
/** Reflects only a real declaration transition. */
|
|
16772
|
+
#reflect(element, value, priority) {
|
|
16773
|
+
const style = element.style;
|
|
16774
|
+
const nextValue = value ?? "";
|
|
16775
|
+
const nextPriority = value === null ? "" : priority;
|
|
16776
|
+
if (style.getPropertyValue(this.#property) === nextValue && style.getPropertyPriority(this.#property) === nextPriority) {
|
|
16777
|
+
return;
|
|
16778
|
+
}
|
|
16779
|
+
if (value === null) style.removeProperty(this.#property);
|
|
16780
|
+
else style.setProperty(this.#property, value, priority);
|
|
16781
|
+
}
|
|
16782
|
+
};
|
|
16783
|
+
|
|
16784
|
+
// src/controllers/scroll_area_controller.ts
|
|
15673
16785
|
var EDGE_EPSILON = 1;
|
|
15674
16786
|
var ScrollAreaController = class extends Controller {
|
|
15675
16787
|
static targets = ["viewport"];
|
|
@@ -15677,31 +16789,60 @@ var ScrollAreaController = class extends Controller {
|
|
|
15677
16789
|
orientation: { type: String, default: "vertical" }
|
|
15678
16790
|
};
|
|
15679
16791
|
static events = ["reach"];
|
|
15680
|
-
#layout = new LayoutObserver(() => this.#
|
|
15681
|
-
/** Current element receiving scroll, resize, mutation, and keyboard-reach behavior. */
|
|
15682
|
-
#viewport = null;
|
|
15683
|
-
/** Guards target callbacks before connect and after disconnect. */
|
|
15684
|
-
#connected = false;
|
|
15685
|
-
/** Collapses target replacement callbacks into one final-DOM rebind. */
|
|
16792
|
+
#layout = new LayoutObserver(() => this.#refresh());
|
|
15686
16793
|
#rebind = new MicrotaskCoalescer(() => this.#syncViewport());
|
|
15687
|
-
|
|
15688
|
-
#content = null;
|
|
15689
|
-
/** Last edge reported via `reach`, so the event fires once per arrival. */
|
|
15690
|
-
#lastEdge = null;
|
|
15691
|
-
/** Whether this controller added `tabindex`, so teardown only removes its own. */
|
|
16794
|
+
#beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
|
|
15692
16795
|
#tabindex = new TabindexLoan("0");
|
|
15693
|
-
/** Temporarily owns a derived `role="region"` without losing an authored replacement. */
|
|
15694
16796
|
#role = new AttributeLease("role");
|
|
16797
|
+
#overflowState = new AttributeLease("data-overflow");
|
|
16798
|
+
#scrollState = new AttributeLease("data-scroll");
|
|
16799
|
+
#progress = new StylePropertyLease("--stimeo--scroll-progress");
|
|
16800
|
+
#viewport = null;
|
|
16801
|
+
#content = null;
|
|
16802
|
+
#nameSources = null;
|
|
16803
|
+
#nameGraph = null;
|
|
16804
|
+
#observedNameIds = /* @__PURE__ */ new Set();
|
|
16805
|
+
#observedNameSources = [];
|
|
16806
|
+
#fonts = null;
|
|
16807
|
+
#scrollFrame = null;
|
|
16808
|
+
#overflowing = false;
|
|
16809
|
+
#lastEdge = null;
|
|
16810
|
+
#ownedHostMutations = /* @__PURE__ */ new Map();
|
|
15695
16811
|
#onScroll = () => {
|
|
15696
|
-
this.#
|
|
16812
|
+
if (this.#scrollFrame !== null) return;
|
|
16813
|
+
this.#scrollFrame = requestAnimationFrame(() => {
|
|
16814
|
+
this.#scrollFrame = null;
|
|
16815
|
+
const viewport = this.#viewport;
|
|
16816
|
+
if (viewport) this.#syncPosition(viewport, this.#overflowing);
|
|
16817
|
+
});
|
|
16818
|
+
};
|
|
16819
|
+
#onLoad = () => {
|
|
16820
|
+
this.#refresh();
|
|
16821
|
+
};
|
|
16822
|
+
#onFontsSettled = () => {
|
|
16823
|
+
this.#refresh();
|
|
15697
16824
|
};
|
|
15698
16825
|
connect() {
|
|
15699
|
-
this.#
|
|
16826
|
+
this.#beforeCache.activate();
|
|
15700
16827
|
this.#rebind.activate();
|
|
15701
16828
|
this.#layout.observeViewport();
|
|
16829
|
+
this.#bindFonts();
|
|
15702
16830
|
this.#syncViewport();
|
|
15703
16831
|
}
|
|
15704
|
-
|
|
16832
|
+
disconnect() {
|
|
16833
|
+
this.#beforeCache.deactivate();
|
|
16834
|
+
this.#rebind.cancel();
|
|
16835
|
+
this.#cancelScrollFrame();
|
|
16836
|
+
if (this.#viewport) this.#unbindViewport(this.#viewport);
|
|
16837
|
+
this.#layout.disconnect();
|
|
16838
|
+
this.#unbindFonts();
|
|
16839
|
+
this.#clearHostState();
|
|
16840
|
+
}
|
|
16841
|
+
/** Re-measures when a retained viewport's orientation Value is morphed. */
|
|
16842
|
+
orientationValueChanged() {
|
|
16843
|
+
this.#refresh();
|
|
16844
|
+
}
|
|
16845
|
+
/** Schedules a complete resource rebind for a runtime viewport target. */
|
|
15705
16846
|
viewportTargetConnected() {
|
|
15706
16847
|
this.#rebind.schedule();
|
|
15707
16848
|
}
|
|
@@ -15709,149 +16850,295 @@ var ScrollAreaController = class extends Controller {
|
|
|
15709
16850
|
viewportTargetDisconnected() {
|
|
15710
16851
|
this.#rebind.schedule();
|
|
15711
16852
|
}
|
|
15712
|
-
/** Rebinds every viewport-owned resource against the final target in
|
|
16853
|
+
/** Rebinds every viewport-owned resource against the final target in the mutation batch. */
|
|
15713
16854
|
#syncViewport() {
|
|
15714
|
-
if (!this.#connected) return;
|
|
15715
16855
|
const next = this.hasViewportTarget ? this.viewportTarget : null;
|
|
15716
16856
|
if (next === this.#viewport) {
|
|
15717
|
-
this.#
|
|
16857
|
+
if (next) this.#refresh();
|
|
16858
|
+
else this.#clearHostState();
|
|
15718
16859
|
return;
|
|
15719
16860
|
}
|
|
15720
16861
|
if (this.#viewport) this.#unbindViewport(this.#viewport);
|
|
15721
16862
|
this.#viewport = next;
|
|
15722
|
-
if (!next)
|
|
16863
|
+
if (!next) {
|
|
16864
|
+
this.#clearHostState();
|
|
16865
|
+
return;
|
|
16866
|
+
}
|
|
15723
16867
|
next.addEventListener("scroll", this.#onScroll, { passive: true });
|
|
16868
|
+
next.addEventListener("load", this.#onLoad, true);
|
|
15724
16869
|
this.#layout.observe(next);
|
|
15725
|
-
|
|
15726
|
-
|
|
15727
|
-
if (this.#viewport !== next) return;
|
|
15728
|
-
this.#syncKeyboardReach(next, this.#syncOverflow(next));
|
|
15729
|
-
});
|
|
15730
|
-
this.#content.observe(next, {
|
|
15731
|
-
subtree: true,
|
|
15732
|
-
childList: true,
|
|
15733
|
-
attributes: true
|
|
15734
|
-
});
|
|
15735
|
-
}
|
|
15736
|
-
this.#update();
|
|
16870
|
+
this.#bindContentObserver(next);
|
|
16871
|
+
this.#refresh();
|
|
15737
16872
|
}
|
|
15738
|
-
|
|
15739
|
-
|
|
15740
|
-
this.#
|
|
15741
|
-
|
|
15742
|
-
|
|
15743
|
-
|
|
16873
|
+
/** Observes structural and attribute changes that can alter overflow or Tab stops. */
|
|
16874
|
+
#bindContentObserver(viewport) {
|
|
16875
|
+
this.#content = new MutationObserver((records) => {
|
|
16876
|
+
if (this.#viewport !== viewport || !this.#hasRelevantContentMutation(records)) return;
|
|
16877
|
+
this.#refresh();
|
|
16878
|
+
});
|
|
16879
|
+
this.#content.observe(viewport, {
|
|
16880
|
+
subtree: true,
|
|
16881
|
+
childList: true,
|
|
16882
|
+
attributes: true,
|
|
16883
|
+
characterData: true
|
|
16884
|
+
});
|
|
16885
|
+
}
|
|
16886
|
+
/** Ignores only state-hook mutations whose exact value this controller just wrote. */
|
|
16887
|
+
#hasRelevantContentMutation(records) {
|
|
16888
|
+
let relevant = false;
|
|
16889
|
+
const consumed = /* @__PURE__ */ new Set();
|
|
16890
|
+
for (const record of records) {
|
|
16891
|
+
const attribute = record.attributeName;
|
|
16892
|
+
if (record.type === "attributes" && record.target === this.element && attribute !== null && this.#ownedHostMutations.has(attribute) && this.element.getAttribute(attribute) === this.#ownedHostMutations.get(attribute)) {
|
|
16893
|
+
consumed.add(attribute);
|
|
16894
|
+
} else {
|
|
16895
|
+
relevant = true;
|
|
16896
|
+
}
|
|
16897
|
+
}
|
|
16898
|
+
for (const attribute of consumed) this.#ownedHostMutations.delete(attribute);
|
|
16899
|
+
return relevant;
|
|
15744
16900
|
}
|
|
15745
|
-
/** Releases every resource and
|
|
16901
|
+
/** Releases every resource and borrowed attribute owned by one former viewport. */
|
|
15746
16902
|
#unbindViewport(viewport) {
|
|
16903
|
+
this.#cancelScrollFrame();
|
|
15747
16904
|
viewport.removeEventListener("scroll", this.#onScroll);
|
|
16905
|
+
viewport.removeEventListener("load", this.#onLoad, true);
|
|
15748
16906
|
this.#layout.unobserve(viewport);
|
|
15749
16907
|
this.#content?.disconnect();
|
|
15750
16908
|
this.#content = null;
|
|
15751
|
-
this.#
|
|
15752
|
-
|
|
16909
|
+
this.#disconnectNameSources();
|
|
16910
|
+
this.#clearViewportAttributes(viewport);
|
|
16911
|
+
this.#ownedHostMutations.clear();
|
|
16912
|
+
this.#viewport = null;
|
|
16913
|
+
this.#overflowing = false;
|
|
15753
16914
|
this.#lastEdge = null;
|
|
15754
16915
|
}
|
|
15755
|
-
/**
|
|
15756
|
-
#
|
|
15757
|
-
const
|
|
15758
|
-
if (!
|
|
15759
|
-
|
|
15760
|
-
this.#
|
|
15761
|
-
|
|
15762
|
-
this
|
|
15763
|
-
this
|
|
15764
|
-
|
|
15765
|
-
|
|
15766
|
-
|
|
15767
|
-
|
|
15768
|
-
|
|
15769
|
-
this.#lastEdge = null;
|
|
15770
|
-
}
|
|
15771
|
-
}
|
|
15772
|
-
/**
|
|
15773
|
-
* Measures overflow and reflects the `data-overflow` hook.
|
|
15774
|
-
*
|
|
15775
|
-
* The write is skipped when the value is unchanged. An identical `setAttribute` still
|
|
15776
|
-
* queues a MutationRecord, and markup that puts the viewport target on the controller
|
|
15777
|
-
* element itself would then have the content observer trigger its own next callback.
|
|
15778
|
-
*/
|
|
15779
|
-
#syncOverflow(vp) {
|
|
15780
|
-
const overflowing = this.#measureOverflow(vp);
|
|
15781
|
-
const next = overflowing ? "true" : "false";
|
|
15782
|
-
if (this.element.getAttribute("data-overflow") !== next) {
|
|
15783
|
-
this.element.setAttribute("data-overflow", next);
|
|
15784
|
-
}
|
|
16916
|
+
/** Runs the full structural and positional measurement pass. */
|
|
16917
|
+
#refresh() {
|
|
16918
|
+
const viewport = this.#viewport;
|
|
16919
|
+
if (!viewport) return;
|
|
16920
|
+
this.#cancelScrollFrame();
|
|
16921
|
+
this.#syncNameSources(viewport);
|
|
16922
|
+
this.#overflowing = this.#syncOverflow(viewport);
|
|
16923
|
+
this.#syncKeyboardReach(viewport, this.#overflowing);
|
|
16924
|
+
this.#syncPosition(viewport, this.#overflowing);
|
|
16925
|
+
}
|
|
16926
|
+
/** Measures overflow and reflects the state hook without identical DOM writes. */
|
|
16927
|
+
#syncOverflow(viewport) {
|
|
16928
|
+
const overflowing = this.#measureOverflow(viewport);
|
|
16929
|
+
this.#writeHostAttribute(this.#overflowState, "data-overflow", overflowing ? "true" : "false");
|
|
15785
16930
|
return overflowing;
|
|
15786
16931
|
}
|
|
15787
|
-
|
|
15788
|
-
|
|
15789
|
-
const
|
|
15790
|
-
|
|
16932
|
+
/** Measures the primary-axis position and dispatches a newly established edge. */
|
|
16933
|
+
#syncPosition(viewport, overflowing) {
|
|
16934
|
+
const { position, progress } = this.#measurePosition(viewport);
|
|
16935
|
+
this.#writeHostAttribute(this.#scrollState, "data-scroll", position);
|
|
16936
|
+
this.#writeHostProgress(String(progress));
|
|
16937
|
+
const edge = overflowing && (position === "start" || position === "end") ? position : null;
|
|
16938
|
+
if (edge === this.#lastEdge) return;
|
|
16939
|
+
this.#lastEdge = edge;
|
|
16940
|
+
if (edge) this.dispatch("reach", { detail: { edge } });
|
|
16941
|
+
}
|
|
16942
|
+
/** Writes one leased host attribute and records self-generated observer input. */
|
|
16943
|
+
#writeHostAttribute(lease, attribute, value) {
|
|
16944
|
+
const before = this.element.getAttribute(attribute);
|
|
16945
|
+
lease.write(this.element, value);
|
|
16946
|
+
this.#recordOwnedHostMutation(attribute, before);
|
|
16947
|
+
}
|
|
16948
|
+
/** Writes the leased progress property and records its serialized style mutation. */
|
|
16949
|
+
#writeHostProgress(value) {
|
|
16950
|
+
const before = this.element.getAttribute("style");
|
|
16951
|
+
this.#progress.write(this.element, value);
|
|
16952
|
+
this.#recordOwnedHostMutation("style", before);
|
|
16953
|
+
}
|
|
16954
|
+
/** Records an exact host mutation only when the host is also the observed viewport. */
|
|
16955
|
+
#recordOwnedHostMutation(attribute, before) {
|
|
16956
|
+
if (this.#viewport !== this.element) return;
|
|
16957
|
+
const after = this.element.getAttribute(attribute);
|
|
16958
|
+
if (after !== before) this.#ownedHostMutations.set(attribute, after);
|
|
16959
|
+
}
|
|
16960
|
+
/** Returns host hooks to authored values and clears edge bookkeeping. */
|
|
16961
|
+
#clearHostState() {
|
|
16962
|
+
this.#overflowState.returnAll();
|
|
16963
|
+
this.#scrollState.returnAll();
|
|
16964
|
+
this.#progress.returnAll();
|
|
16965
|
+
this.#ownedHostMutations.clear();
|
|
16966
|
+
this.#overflowing = false;
|
|
16967
|
+
this.#lastEdge = null;
|
|
16968
|
+
}
|
|
16969
|
+
/** Whether the configured axis currently has a scroll range. */
|
|
16970
|
+
#measureOverflow(viewport) {
|
|
16971
|
+
const orientation = this.orientationValue;
|
|
16972
|
+
const vertical = orientation !== "horizontal" && viewport.scrollHeight > viewport.clientHeight + EDGE_EPSILON;
|
|
16973
|
+
const horizontal = orientation !== "vertical" && viewport.scrollWidth > viewport.clientWidth + EDGE_EPSILON;
|
|
15791
16974
|
return vertical || horizontal;
|
|
15792
16975
|
}
|
|
15793
|
-
/**
|
|
15794
|
-
|
|
15795
|
-
|
|
15796
|
-
|
|
15797
|
-
|
|
15798
|
-
|
|
15799
|
-
|
|
16976
|
+
/** Reports the primary-axis position bucket and normalized 0–1 progress. */
|
|
16977
|
+
#measurePosition(viewport) {
|
|
16978
|
+
const horizontalPrimary = this.orientationValue === "horizontal" || this.orientationValue === "both" && viewport.scrollHeight <= viewport.clientHeight + EDGE_EPSILON;
|
|
16979
|
+
const { position: scrollPosition, max: maxScroll } = logicalScrollMetrics(
|
|
16980
|
+
viewport,
|
|
16981
|
+
horizontalPrimary
|
|
16982
|
+
);
|
|
15800
16983
|
if (maxScroll <= EDGE_EPSILON) return { position: "start", progress: 0 };
|
|
15801
|
-
const progress = Math.min(1, Math.max(0,
|
|
15802
|
-
if (
|
|
15803
|
-
if (
|
|
16984
|
+
const progress = Math.min(1, Math.max(0, scrollPosition / maxScroll));
|
|
16985
|
+
if (scrollPosition <= EDGE_EPSILON) return { position: "start", progress };
|
|
16986
|
+
if (scrollPosition >= maxScroll - EDGE_EPSILON) return { position: "end", progress };
|
|
15804
16987
|
return { position: "middle", progress };
|
|
15805
16988
|
}
|
|
15806
|
-
/**
|
|
15807
|
-
|
|
15808
|
-
|
|
15809
|
-
|
|
15810
|
-
|
|
15811
|
-
|
|
15812
|
-
|
|
15813
|
-
|
|
15814
|
-
|
|
15815
|
-
if (!
|
|
15816
|
-
this.#role.write(vp, "region");
|
|
15817
|
-
}
|
|
16989
|
+
/** Borrows keyboard reach only when no usable descendant already owns a Tab stop. */
|
|
16990
|
+
#syncKeyboardReach(viewport, overflowing) {
|
|
16991
|
+
const wantsTabindex = overflowing && !hasTabStop(viewport);
|
|
16992
|
+
if (!wantsTabindex) {
|
|
16993
|
+
this.#clearViewportAttributes(viewport);
|
|
16994
|
+
return;
|
|
16995
|
+
}
|
|
16996
|
+
this.#tabindex.lend(viewport);
|
|
16997
|
+
if (this.#hasAccessibleName(viewport)) {
|
|
16998
|
+
if (!viewport.hasAttribute("role")) this.#role.write(viewport, "region");
|
|
15818
16999
|
} else {
|
|
15819
|
-
this.#
|
|
17000
|
+
this.#role.return(viewport);
|
|
15820
17001
|
}
|
|
15821
17002
|
}
|
|
15822
|
-
/**
|
|
15823
|
-
#
|
|
17003
|
+
/** Returns only the viewport attributes borrowed by this controller. */
|
|
17004
|
+
#clearViewportAttributes(viewport) {
|
|
15824
17005
|
this.#tabindex.returnAll();
|
|
15825
|
-
this.#role.return(
|
|
17006
|
+
this.#role.return(viewport);
|
|
17007
|
+
}
|
|
17008
|
+
/** Whether the viewport already has a non-empty accessible name. */
|
|
17009
|
+
#hasAccessibleName(viewport) {
|
|
17010
|
+
const sources = this.#resolveNameSources(viewport);
|
|
17011
|
+
if (sources.length > 0) {
|
|
17012
|
+
return sources.some((source) => {
|
|
17013
|
+
const label = source.getAttribute("aria-label")?.trim();
|
|
17014
|
+
return Boolean(label || source.textContent?.trim());
|
|
17015
|
+
});
|
|
17016
|
+
}
|
|
17017
|
+
return Boolean(viewport.getAttribute("aria-label")?.trim());
|
|
17018
|
+
}
|
|
17019
|
+
/** Resolves unique, live `aria-labelledby` references in authored order. */
|
|
17020
|
+
#resolveNameSources(viewport) {
|
|
17021
|
+
const ids = this.#nameReferenceIds(viewport);
|
|
17022
|
+
const sources = [];
|
|
17023
|
+
const seen = /* @__PURE__ */ new Set();
|
|
17024
|
+
for (const id of ids) {
|
|
17025
|
+
const source = viewport.ownerDocument.getElementById(id);
|
|
17026
|
+
if (source && !seen.has(source)) {
|
|
17027
|
+
seen.add(source);
|
|
17028
|
+
sources.push(source);
|
|
17029
|
+
}
|
|
17030
|
+
}
|
|
17031
|
+
return sources;
|
|
15826
17032
|
}
|
|
15827
|
-
/**
|
|
15828
|
-
|
|
15829
|
-
|
|
15830
|
-
|
|
15831
|
-
|
|
15832
|
-
* stop — leaving it unreachable by keyboard exactly while it has nothing else to
|
|
15833
|
-
* offer. Only rendered candidates count.
|
|
15834
|
-
*/
|
|
15835
|
-
#hasFocusableContent(vp) {
|
|
15836
|
-
return Array.from(vp.querySelectorAll(FOCUSABLE_SELECTOR)).some(
|
|
15837
|
-
(el) => this.#isRendered(el)
|
|
17033
|
+
/** Observes external name sources so a retained region never becomes unnamed. */
|
|
17034
|
+
#syncNameSources(viewport) {
|
|
17035
|
+
const ids = this.#nameReferenceIds(viewport);
|
|
17036
|
+
const sources = this.#resolveNameSources(viewport).filter(
|
|
17037
|
+
(source) => !viewport.contains(source)
|
|
15838
17038
|
);
|
|
17039
|
+
if (ids.length === this.#observedNameIds.size && ids.every((id) => this.#observedNameIds.has(id)) && sources.length === this.#observedNameSources.length && sources.every((source, index) => source === this.#observedNameSources[index])) {
|
|
17040
|
+
return;
|
|
17041
|
+
}
|
|
17042
|
+
this.#disconnectNameSources();
|
|
17043
|
+
this.#observedNameIds = new Set(ids);
|
|
17044
|
+
this.#observedNameSources = sources;
|
|
17045
|
+
if (ids.length > 0) {
|
|
17046
|
+
this.#nameGraph = new MutationObserver((records) => {
|
|
17047
|
+
if (this.#viewport === viewport && this.#hasRelevantNameGraphMutation(records)) {
|
|
17048
|
+
this.#refresh();
|
|
17049
|
+
}
|
|
17050
|
+
});
|
|
17051
|
+
const documentElement = viewport.ownerDocument.documentElement;
|
|
17052
|
+
if (documentElement) {
|
|
17053
|
+
this.#nameGraph.observe(documentElement, {
|
|
17054
|
+
subtree: true,
|
|
17055
|
+
childList: true,
|
|
17056
|
+
attributes: true,
|
|
17057
|
+
attributeFilter: ["id"],
|
|
17058
|
+
attributeOldValue: true
|
|
17059
|
+
});
|
|
17060
|
+
}
|
|
17061
|
+
}
|
|
17062
|
+
if (sources.length === 0) return;
|
|
17063
|
+
this.#nameSources = new MutationObserver(() => {
|
|
17064
|
+
this.#refresh();
|
|
17065
|
+
});
|
|
17066
|
+
for (const source of sources) {
|
|
17067
|
+
this.#nameSources.observe(source, {
|
|
17068
|
+
subtree: true,
|
|
17069
|
+
childList: true,
|
|
17070
|
+
characterData: true,
|
|
17071
|
+
attributes: true
|
|
17072
|
+
});
|
|
17073
|
+
}
|
|
15839
17074
|
}
|
|
15840
|
-
/**
|
|
15841
|
-
|
|
15842
|
-
|
|
15843
|
-
|
|
15844
|
-
* a class-driven `display: none` that no attribute reveals. The `hidden` walk in
|
|
15845
|
-
* front of it is not redundant: it is the one case a DOM-only environment with no
|
|
15846
|
-
* layout engine has to be told about explicitly.
|
|
15847
|
-
*/
|
|
15848
|
-
#isRendered(el) {
|
|
15849
|
-
if (el.closest("[hidden]") !== null) return false;
|
|
15850
|
-
const check = el.checkVisibility;
|
|
15851
|
-
return typeof check === "function" ? check.call(el, { visibilityProperty: true }) : true;
|
|
17075
|
+
/** Returns normalized unique label-reference ids in authored order. */
|
|
17076
|
+
#nameReferenceIds(viewport) {
|
|
17077
|
+
const tokens = viewport.getAttribute("aria-labelledby")?.trim().split(/\s+/).filter(Boolean);
|
|
17078
|
+
return Array.from(new Set(tokens));
|
|
15852
17079
|
}
|
|
15853
|
-
|
|
15854
|
-
|
|
17080
|
+
/** Whether document-graph changes can resolve or invalidate a referenced id. */
|
|
17081
|
+
#hasRelevantNameGraphMutation(records) {
|
|
17082
|
+
for (const record of records) {
|
|
17083
|
+
if (record.type === "attributes") {
|
|
17084
|
+
const current = record.target.id;
|
|
17085
|
+
if (this.#observedNameIds.has(current) || this.#observedNameIds.has(record.oldValue ?? "")) {
|
|
17086
|
+
return true;
|
|
17087
|
+
}
|
|
17088
|
+
continue;
|
|
17089
|
+
}
|
|
17090
|
+
for (const node of [...record.addedNodes, ...record.removedNodes]) {
|
|
17091
|
+
if (this.#nodeContainsObservedNameId(node)) return true;
|
|
17092
|
+
}
|
|
17093
|
+
}
|
|
17094
|
+
return false;
|
|
17095
|
+
}
|
|
17096
|
+
/** Whether a changed subtree contains one of the current label-reference ids. */
|
|
17097
|
+
#nodeContainsObservedNameId(node) {
|
|
17098
|
+
if (!(node instanceof Element)) return false;
|
|
17099
|
+
if (this.#observedNameIds.has(node.id)) return true;
|
|
17100
|
+
return Array.from(node.querySelectorAll("[id]")).some(
|
|
17101
|
+
(element) => this.#observedNameIds.has(element.id)
|
|
17102
|
+
);
|
|
17103
|
+
}
|
|
17104
|
+
/** Stops observing accessible-name sources. */
|
|
17105
|
+
#disconnectNameSources() {
|
|
17106
|
+
this.#nameSources?.disconnect();
|
|
17107
|
+
this.#nameSources = null;
|
|
17108
|
+
this.#nameGraph?.disconnect();
|
|
17109
|
+
this.#nameGraph = null;
|
|
17110
|
+
this.#observedNameIds.clear();
|
|
17111
|
+
this.#observedNameSources = [];
|
|
17112
|
+
}
|
|
17113
|
+
/** Subscribes to font completion when the current engine exposes `document.fonts`. */
|
|
17114
|
+
#bindFonts() {
|
|
17115
|
+
const fonts = this.element.ownerDocument.fonts;
|
|
17116
|
+
if (!fonts || this.#fonts === fonts) return;
|
|
17117
|
+
this.#unbindFonts();
|
|
17118
|
+
this.#fonts = fonts;
|
|
17119
|
+
fonts.addEventListener("loadingdone", this.#onFontsSettled);
|
|
17120
|
+
fonts.addEventListener("loadingerror", this.#onFontsSettled);
|
|
17121
|
+
}
|
|
17122
|
+
/** Removes the font completion subscriptions. */
|
|
17123
|
+
#unbindFonts() {
|
|
17124
|
+
this.#fonts?.removeEventListener("loadingdone", this.#onFontsSettled);
|
|
17125
|
+
this.#fonts?.removeEventListener("loadingerror", this.#onFontsSettled);
|
|
17126
|
+
this.#fonts = null;
|
|
17127
|
+
}
|
|
17128
|
+
/** Cancels a pending scroll frame. */
|
|
17129
|
+
#cancelScrollFrame() {
|
|
17130
|
+
if (this.#scrollFrame === null) return;
|
|
17131
|
+
cancelAnimationFrame(this.#scrollFrame);
|
|
17132
|
+
this.#scrollFrame = null;
|
|
17133
|
+
}
|
|
17134
|
+
/** Suspends live resources and returns all derived state before snapshotting. */
|
|
17135
|
+
#rewindForCache() {
|
|
17136
|
+
this.#rebind.cancel();
|
|
17137
|
+
this.#cancelScrollFrame();
|
|
17138
|
+
if (this.#viewport) this.#unbindViewport(this.#viewport);
|
|
17139
|
+
this.#layout.disconnect();
|
|
17140
|
+
this.#unbindFonts();
|
|
17141
|
+
this.#clearHostState();
|
|
15855
17142
|
}
|
|
15856
17143
|
};
|
|
15857
17144
|
var ScrollRestoreController = class extends Controller {
|
|
@@ -16440,93 +17727,190 @@ var ScrollspyController = class extends Controller {
|
|
|
16440
17727
|
return value.substring(1) || null;
|
|
16441
17728
|
}
|
|
16442
17729
|
};
|
|
17730
|
+
var DEFAULT_MIN2 = 0;
|
|
17731
|
+
var DEFAULT_MAX2 = 100;
|
|
16443
17732
|
var SeparatorController = class extends Controller {
|
|
16444
17733
|
static values = {
|
|
16445
17734
|
orientation: { type: String, default: "horizontal" },
|
|
16446
17735
|
focusable: { type: Boolean, default: false },
|
|
16447
|
-
|
|
17736
|
+
min: { type: Number, default: DEFAULT_MIN2 },
|
|
17737
|
+
max: { type: Number, default: DEFAULT_MAX2 },
|
|
17738
|
+
step: { type: Number, default: 1 },
|
|
17739
|
+
value: { type: Number, default: DEFAULT_MIN2 }
|
|
16448
17740
|
};
|
|
16449
17741
|
static actions = ["onKeydown"];
|
|
16450
17742
|
static events = ["change"];
|
|
17743
|
+
#role = new AttributeLease("role");
|
|
17744
|
+
#tabindex = new AttributeLease("tabindex");
|
|
17745
|
+
#orientation = new AttributeLease("aria-orientation");
|
|
17746
|
+
#minimum = new AttributeLease("aria-valuemin");
|
|
17747
|
+
#maximum = new AttributeLease("aria-valuemax");
|
|
17748
|
+
#current = new AttributeLease("aria-valuenow");
|
|
17749
|
+
/** Collapses a morph that changes several render Values into one silent repaint. */
|
|
17750
|
+
#repaint = new MicrotaskCoalescer(() => this.#render());
|
|
17751
|
+
/** Restores authored semantics before Turbo snapshots the element. */
|
|
17752
|
+
#beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
|
|
17753
|
+
/** Hydrates legacy ARIA input once, normalizes the current Value, and renders. */
|
|
16451
17754
|
connect() {
|
|
16452
|
-
|
|
16453
|
-
|
|
16454
|
-
|
|
16455
|
-
|
|
16456
|
-
|
|
16457
|
-
|
|
16458
|
-
|
|
16459
|
-
|
|
17755
|
+
this.#hydrateValues();
|
|
17756
|
+
this.#repaint.activate();
|
|
17757
|
+
this.#beforeCache.activate();
|
|
17758
|
+
const value = this.#currentValue();
|
|
17759
|
+
if (!Object.is(this.valueValue, value)) this.valueValue = value;
|
|
17760
|
+
this.#render();
|
|
17761
|
+
}
|
|
17762
|
+
/** Cancels repaint work and returns every semantic attribute this instance controlled. */
|
|
17763
|
+
disconnect() {
|
|
17764
|
+
this.#repaint.cancel();
|
|
17765
|
+
this.#beforeCache.deactivate();
|
|
17766
|
+
this.#returnAttributes();
|
|
17767
|
+
}
|
|
17768
|
+
/** Silently repaints an orientation changed by application code or a Turbo morph. */
|
|
17769
|
+
orientationValueChanged() {
|
|
17770
|
+
this.#repaint.schedule();
|
|
17771
|
+
}
|
|
17772
|
+
/** Silently adds or removes the focusable range semantics at runtime. */
|
|
17773
|
+
focusableValueChanged() {
|
|
17774
|
+
this.#repaint.schedule();
|
|
17775
|
+
}
|
|
17776
|
+
/** Silently repaints a minimum changed by application code or a Turbo morph. */
|
|
17777
|
+
minValueChanged() {
|
|
17778
|
+
this.#repaint.schedule();
|
|
17779
|
+
}
|
|
17780
|
+
/** Silently repaints a maximum changed by application code or a Turbo morph. */
|
|
17781
|
+
maxValueChanged() {
|
|
17782
|
+
this.#repaint.schedule();
|
|
17783
|
+
}
|
|
17784
|
+
/** Silently repaints a step changed by application code or a Turbo morph. */
|
|
17785
|
+
stepValueChanged() {
|
|
17786
|
+
this.#repaint.schedule();
|
|
17787
|
+
}
|
|
17788
|
+
/** Silently repaints a current value changed by application code or a Turbo morph. */
|
|
17789
|
+
valueValueChanged() {
|
|
17790
|
+
this.#repaint.schedule();
|
|
16460
17791
|
}
|
|
16461
17792
|
/** Adjusts the value on arrow / Home / End keys (focusable variant only). */
|
|
16462
17793
|
onKeydown(event) {
|
|
16463
|
-
if (isReservedArrowChord(event)) return;
|
|
16464
|
-
|
|
16465
|
-
const
|
|
17794
|
+
if (isReservedArrowChord(event) || !this.focusableValue) return;
|
|
17795
|
+
const horizontal = this.#effectiveOrientation === "horizontal";
|
|
17796
|
+
const range = this.#effectiveRange;
|
|
17797
|
+
const current = snapSteppedValue(this.valueValue, range);
|
|
16466
17798
|
let next = null;
|
|
16467
17799
|
switch (event.key) {
|
|
16468
17800
|
case "ArrowUp":
|
|
16469
|
-
if (horizontal) next =
|
|
17801
|
+
if (horizontal) next = stepSteppedValue(current, 1, range);
|
|
16470
17802
|
break;
|
|
16471
17803
|
case "ArrowDown":
|
|
16472
|
-
if (horizontal) next =
|
|
17804
|
+
if (horizontal) next = stepSteppedValue(current, -1, range);
|
|
16473
17805
|
break;
|
|
16474
17806
|
case "ArrowRight":
|
|
16475
|
-
if (!horizontal) next =
|
|
17807
|
+
if (!horizontal) next = stepSteppedValue(current, 1, range);
|
|
16476
17808
|
break;
|
|
16477
17809
|
case "ArrowLeft":
|
|
16478
|
-
if (!horizontal) next =
|
|
17810
|
+
if (!horizontal) next = stepSteppedValue(current, -1, range);
|
|
16479
17811
|
break;
|
|
16480
17812
|
case "Home":
|
|
16481
|
-
next =
|
|
17813
|
+
next = range.min;
|
|
16482
17814
|
break;
|
|
16483
17815
|
case "End":
|
|
16484
|
-
next =
|
|
17816
|
+
next = range.max;
|
|
16485
17817
|
break;
|
|
16486
17818
|
default:
|
|
16487
17819
|
return;
|
|
16488
17820
|
}
|
|
16489
17821
|
if (next === null) return;
|
|
16490
17822
|
event.preventDefault();
|
|
16491
|
-
|
|
16492
|
-
if (clamped === this.#value) return;
|
|
16493
|
-
this.element.setAttribute("aria-valuenow", String(clamped));
|
|
16494
|
-
this.dispatch("change", { detail: { value: clamped } });
|
|
17823
|
+
this.#commit(next, current);
|
|
16495
17824
|
}
|
|
16496
|
-
|
|
16497
|
-
|
|
17825
|
+
/** Stores a normalized user value, renders immediately, and reports real changes. */
|
|
17826
|
+
#commit(raw, previous) {
|
|
17827
|
+
const value = snapSteppedValue(raw, this.#effectiveRange);
|
|
17828
|
+
if (!Object.is(this.valueValue, value)) this.valueValue = value;
|
|
17829
|
+
this.#render();
|
|
17830
|
+
if (value !== previous) this.dispatch("change", { detail: { value } });
|
|
16498
17831
|
}
|
|
16499
|
-
|
|
16500
|
-
|
|
17832
|
+
/**
|
|
17833
|
+
* Reflects live Values into the connected element without dispatching.
|
|
17834
|
+
*
|
|
17835
|
+
* @stimeoRenderRoot
|
|
17836
|
+
*/
|
|
17837
|
+
#render() {
|
|
17838
|
+
const range = this.#effectiveRange;
|
|
17839
|
+
const value = snapSteppedValue(this.valueValue, range);
|
|
17840
|
+
this.#role.write(this.element, "separator");
|
|
17841
|
+
this.#orientation.write(this.element, this.#effectiveOrientation);
|
|
17842
|
+
if (!this.focusableValue) {
|
|
17843
|
+
this.#tabindex.write(this.element, null);
|
|
17844
|
+
this.#minimum.write(this.element, null);
|
|
17845
|
+
this.#maximum.write(this.element, null);
|
|
17846
|
+
this.#current.write(this.element, null);
|
|
17847
|
+
return;
|
|
17848
|
+
}
|
|
17849
|
+
this.#tabindex.write(this.element, "0");
|
|
17850
|
+
this.#minimum.write(this.element, String(range.min));
|
|
17851
|
+
this.#maximum.write(this.element, String(range.max));
|
|
17852
|
+
this.#current.write(this.element, String(value));
|
|
16501
17853
|
}
|
|
16502
|
-
|
|
16503
|
-
|
|
17854
|
+
/** Effective orientation; unknown strings fall back to the horizontal contract. */
|
|
17855
|
+
get #effectiveOrientation() {
|
|
17856
|
+
return this.orientationValue === "vertical" ? "vertical" : "horizontal";
|
|
16504
17857
|
}
|
|
16505
|
-
|
|
16506
|
-
|
|
17858
|
+
/** Finite, ordered range shared by rendering and keyboard stepping. */
|
|
17859
|
+
get #effectiveRange() {
|
|
17860
|
+
const min = Number.isFinite(this.minValue) ? this.minValue : DEFAULT_MIN2;
|
|
17861
|
+
const candidateMax = Number.isFinite(this.maxValue) ? this.maxValue : DEFAULT_MAX2;
|
|
17862
|
+
return {
|
|
17863
|
+
min,
|
|
17864
|
+
max: Math.max(min, candidateMax),
|
|
17865
|
+
step: this.stepValue,
|
|
17866
|
+
base: min
|
|
17867
|
+
};
|
|
16507
17868
|
}
|
|
16508
|
-
|
|
16509
|
-
|
|
16510
|
-
return
|
|
17869
|
+
/** Current normalized value derived from the live declarative inputs. */
|
|
17870
|
+
#currentValue() {
|
|
17871
|
+
return snapSteppedValue(this.valueValue, this.#effectiveRange);
|
|
17872
|
+
}
|
|
17873
|
+
/** Copies valid authored ARIA into Values only when no explicit Value exists. */
|
|
17874
|
+
#hydrateValues() {
|
|
17875
|
+
if (!this.#hasInput("orientation")) {
|
|
17876
|
+
const orientation = this.element.getAttribute("aria-orientation");
|
|
17877
|
+
if (orientation === "horizontal" || orientation === "vertical") {
|
|
17878
|
+
this.orientationValue = orientation;
|
|
17879
|
+
}
|
|
17880
|
+
}
|
|
17881
|
+
const min = this.#finiteAttribute("aria-valuemin");
|
|
17882
|
+
if (!this.#hasInput("min") && min !== null) this.minValue = min;
|
|
17883
|
+
const max = this.#finiteAttribute("aria-valuemax");
|
|
17884
|
+
if (!this.#hasInput("max") && max !== null) this.maxValue = max;
|
|
17885
|
+
const value = this.#finiteAttribute("aria-valuenow");
|
|
17886
|
+
if (!this.#hasInput("value") && value !== null) this.valueValue = value;
|
|
17887
|
+
}
|
|
17888
|
+
/** Whether the author supplied the namespaced Value instead of using its default. */
|
|
17889
|
+
#hasInput(name) {
|
|
17890
|
+
return this.element.hasAttribute(`data-${this.identifier}-${name}-value`);
|
|
17891
|
+
}
|
|
17892
|
+
/** Strict finite-number parser for the one-time progressive-enhancement bridge. */
|
|
17893
|
+
#finiteAttribute(name) {
|
|
17894
|
+
const raw = this.element.getAttribute(name);
|
|
17895
|
+
if (raw === null || raw.trim() === "") return null;
|
|
17896
|
+
const value = Number(raw);
|
|
17897
|
+
return Number.isFinite(value) ? value : null;
|
|
16511
17898
|
}
|
|
16512
|
-
|
|
16513
|
-
|
|
16514
|
-
|
|
16515
|
-
|
|
16516
|
-
|
|
16517
|
-
return
|
|
16518
|
-
|
|
16519
|
-
return
|
|
17899
|
+
/** Returns attributes without overwriting values a consumer changed after render. */
|
|
17900
|
+
#returnAttributes() {
|
|
17901
|
+
this.#role.return(this.element);
|
|
17902
|
+
this.#tabindex.return(this.element);
|
|
17903
|
+
this.#orientation.return(this.element);
|
|
17904
|
+
this.#minimum.return(this.element);
|
|
17905
|
+
this.#maximum.return(this.element);
|
|
17906
|
+
this.#current.return(this.element);
|
|
16520
17907
|
}
|
|
16521
|
-
|
|
16522
|
-
|
|
16523
|
-
|
|
16524
|
-
|
|
16525
|
-
} catch {
|
|
17908
|
+
/** Prevents a queued Value repaint from racing Turbo's restored snapshot. */
|
|
17909
|
+
#rewindForCache() {
|
|
17910
|
+
this.#repaint.cancel();
|
|
17911
|
+
this.#returnAttributes();
|
|
16526
17912
|
}
|
|
16527
|
-
}
|
|
16528
|
-
|
|
16529
|
-
// src/controllers/sidebar_controller.ts
|
|
17913
|
+
};
|
|
16530
17914
|
var SidebarController = class extends Controller {
|
|
16531
17915
|
static targets = ["trigger", "panel", "backdrop"];
|
|
16532
17916
|
static values = {
|
|
@@ -16787,8 +18171,8 @@ var SidebarController = class extends Controller {
|
|
|
16787
18171
|
#restoreCollapsed() {
|
|
16788
18172
|
const key = this.#storageKey;
|
|
16789
18173
|
if (key) {
|
|
16790
|
-
const
|
|
16791
|
-
if (
|
|
18174
|
+
const result = readLocalStorage(key);
|
|
18175
|
+
if (result.ok && result.value !== null) return result.value === "1";
|
|
16792
18176
|
}
|
|
16793
18177
|
const domState = this.hasPanelTarget ? this.panelTarget.getAttribute("data-state") : null;
|
|
16794
18178
|
if (domState === "collapsed") return true;
|
|
@@ -17890,172 +19274,314 @@ var StickyObserverController = class extends Controller {
|
|
|
17890
19274
|
this.dispatch("change", { detail: { stuck: next } });
|
|
17891
19275
|
}
|
|
17892
19276
|
};
|
|
17893
|
-
var ORIGINAL_LABEL = "data-submit-once-original-label";
|
|
17894
|
-
var DISABLED_MARKER2 = "data-submit-once-disabled";
|
|
17895
19277
|
var BUTTON_BUSY_LABEL = "data-submit-once-busy-label";
|
|
17896
19278
|
var SubmitOnceController = class extends Controller {
|
|
17897
|
-
static targets = ["submit"];
|
|
19279
|
+
static targets = ["submit", "idle", "busy"];
|
|
17898
19280
|
static values = {
|
|
19281
|
+
announceText: { type: String, default: "" },
|
|
19282
|
+
announceReadyText: { type: String, default: "" },
|
|
17899
19283
|
busyLabel: { type: String, default: "" },
|
|
17900
19284
|
timeout: { type: Number, default: 0 },
|
|
17901
19285
|
restoreFocus: { type: Boolean, default: false }
|
|
17902
19286
|
};
|
|
17903
|
-
static actions = ["start"];
|
|
19287
|
+
static actions = ["cancel", "finish", "start"];
|
|
17904
19288
|
static events = ["start", "end"];
|
|
17905
|
-
#
|
|
17906
|
-
#
|
|
17907
|
-
#
|
|
17908
|
-
#
|
|
19289
|
+
#timers = new SafeTimeout();
|
|
19290
|
+
#gate = new DetachGate();
|
|
19291
|
+
#beforeCache = new BeforeCacheReset(() => this.#rewindForCache());
|
|
19292
|
+
#sessions = /* @__PURE__ */ new Map();
|
|
19293
|
+
#disabled = new AttributeLease("disabled");
|
|
19294
|
+
#controlBusy = new AttributeLease("aria-busy");
|
|
19295
|
+
#formBusy = new AttributeLease("aria-busy");
|
|
19296
|
+
#submitting = new AttributeLease("data-submitting");
|
|
19297
|
+
#ariaLabel = new AttributeLease("aria-label");
|
|
19298
|
+
#hidden = new AttributeLease("hidden");
|
|
17909
19299
|
#onSubmitStart = (event) => {
|
|
17910
19300
|
this.start(event);
|
|
17911
19301
|
};
|
|
17912
19302
|
#onSubmitEnd = (event) => {
|
|
17913
|
-
|
|
17914
|
-
|
|
19303
|
+
this.#complete(this.#eventForm(event), "turbo", this.#eventSuccess(event));
|
|
19304
|
+
};
|
|
19305
|
+
/** Blocks a later native submit while one for the same form is still active. */
|
|
19306
|
+
#onNativeSubmit = (event) => {
|
|
19307
|
+
const session = this.#sessionFor(event.target);
|
|
19308
|
+
if (!session) return;
|
|
19309
|
+
this.#syncControls(session);
|
|
19310
|
+
event.preventDefault();
|
|
17915
19311
|
};
|
|
17916
19312
|
connect() {
|
|
19313
|
+
this.#gate.cancel();
|
|
19314
|
+
this.#beforeCache.activate();
|
|
19315
|
+
this.element.addEventListener("submit", this.#onNativeSubmit, true);
|
|
17917
19316
|
this.element.addEventListener("turbo:submit-start", this.#onSubmitStart);
|
|
17918
19317
|
this.element.addEventListener("turbo:submit-end", this.#onSubmitEnd);
|
|
17919
|
-
this.#clearStaleBusy();
|
|
17920
19318
|
}
|
|
17921
19319
|
disconnect() {
|
|
19320
|
+
this.element.removeEventListener("submit", this.#onNativeSubmit, true);
|
|
17922
19321
|
this.element.removeEventListener("turbo:submit-start", this.#onSubmitStart);
|
|
17923
19322
|
this.element.removeEventListener("turbo:submit-end", this.#onSubmitEnd);
|
|
17924
|
-
this.#
|
|
19323
|
+
this.#beforeCache.deactivate();
|
|
19324
|
+
this.#gate.disconnected(this, () => this.#teardown());
|
|
17925
19325
|
}
|
|
17926
19326
|
/**
|
|
17927
|
-
*
|
|
17928
|
-
*
|
|
17929
|
-
* `
|
|
19327
|
+
* Begins the submission represented by `event`.
|
|
19328
|
+
*
|
|
19329
|
+
* Native `submit` events are intended for non-Turbo async forms. Turbo calls
|
|
19330
|
+
* this path through its auto-subscribed `turbo:submit-start` event. An event
|
|
19331
|
+
* whose default an earlier listener already cancelled carries no submission,
|
|
19332
|
+
* so it starts nothing.
|
|
17930
19333
|
*/
|
|
17931
19334
|
start(event) {
|
|
17932
|
-
if (
|
|
17933
|
-
|
|
17934
|
-
|
|
17935
|
-
const
|
|
17936
|
-
|
|
17937
|
-
|
|
17938
|
-
|
|
17939
|
-
|
|
17940
|
-
|
|
17941
|
-
|
|
17942
|
-
|
|
19335
|
+
if (event.defaultPrevented) return;
|
|
19336
|
+
const form = this.#eventForm(event);
|
|
19337
|
+
if (!form || this.#sessions.has(form)) return;
|
|
19338
|
+
const controls = this.#submitControls(form);
|
|
19339
|
+
const submitter = this.#resolveSubmitter(event, controls);
|
|
19340
|
+
const session = {
|
|
19341
|
+
form,
|
|
19342
|
+
submitter,
|
|
19343
|
+
submitterHadFocus: submitter !== null && document.activeElement === submitter,
|
|
19344
|
+
controls: /* @__PURE__ */ new Set(),
|
|
19345
|
+
timeoutId: null,
|
|
19346
|
+
labelWrite: null,
|
|
19347
|
+
idlePart: null,
|
|
19348
|
+
busyPart: null
|
|
19349
|
+
};
|
|
19350
|
+
this.#sessions.set(form, session);
|
|
19351
|
+
this.#formBusy.write(form, "true");
|
|
19352
|
+
this.#submitting.write(form, "true");
|
|
19353
|
+
this.#syncControls(session, controls);
|
|
19354
|
+
this.#enterLabel(session);
|
|
19355
|
+
this.dispatch("start", { detail: { form, submitter } });
|
|
17943
19356
|
if (this.timeoutValue > 0) {
|
|
17944
|
-
|
|
19357
|
+
session.timeoutId = this.#timers.set(
|
|
19358
|
+
() => this.#complete(form, "timeout", false),
|
|
19359
|
+
this.timeoutValue
|
|
19360
|
+
);
|
|
17945
19361
|
}
|
|
19362
|
+
announce(this.announceTextValue);
|
|
19363
|
+
}
|
|
19364
|
+
/**
|
|
19365
|
+
* Completes a non-Turbo async submission for the form associated with
|
|
19366
|
+
* `event`. When called directly with no event, it completes the controller's
|
|
19367
|
+
* sole active form (or the controller element itself when it is a form).
|
|
19368
|
+
*/
|
|
19369
|
+
finish(event) {
|
|
19370
|
+
const form = event ? this.#eventForm(event) : this.#defaultForm();
|
|
19371
|
+
this.#complete(form, "manual", event ? this.#eventSuccess(event) : void 0);
|
|
17946
19372
|
}
|
|
17947
|
-
/**
|
|
17948
|
-
|
|
17949
|
-
|
|
17950
|
-
|
|
17951
|
-
|
|
17952
|
-
|
|
17953
|
-
|
|
19373
|
+
/**
|
|
19374
|
+
* Abandons the submission for the form associated with `event` because it
|
|
19375
|
+
* never reached the network — a veto from validation, or a request the
|
|
19376
|
+
* consumer gave up before sending. With no event it targets the controller's
|
|
19377
|
+
* sole active form, exactly like {@link finish}.
|
|
19378
|
+
*
|
|
19379
|
+
* The distinction from `finish` is observable: a request that ran and failed
|
|
19380
|
+
* is `finish` with `success: false` and still announces completion, while a
|
|
19381
|
+
* submission that never happened announces nothing.
|
|
19382
|
+
*/
|
|
19383
|
+
cancel(event) {
|
|
19384
|
+
const form = event ? this.#eventForm(event) : this.#defaultForm();
|
|
19385
|
+
this.#complete(form, "canceled", false);
|
|
19386
|
+
}
|
|
19387
|
+
/** Disables a newly connected explicit submit target during its form's session. */
|
|
19388
|
+
submitTargetConnected(target) {
|
|
19389
|
+
const session = this.#sessionFor(target.form);
|
|
19390
|
+
if (!session || !this.#isNativeSubmitControl(target)) return;
|
|
19391
|
+
this.#enterControl(session, target);
|
|
19392
|
+
}
|
|
19393
|
+
/** Ends one session, restores owned state, and reports the observable completion. */
|
|
19394
|
+
#complete(form, reason, success) {
|
|
19395
|
+
const session = this.#sessionFor(form);
|
|
19396
|
+
if (!session) return;
|
|
19397
|
+
this.#sessions.delete(session.form);
|
|
19398
|
+
this.#returnSessionState(session);
|
|
19399
|
+
const detail = success === void 0 ? { form: session.form, submitter: session.submitter, reason } : { form: session.form, submitter: session.submitter, reason, success };
|
|
19400
|
+
this.dispatch("end", { detail });
|
|
19401
|
+
this.#restoreFocus(session);
|
|
19402
|
+
if (reason !== "canceled") announce(this.announceReadyTextValue);
|
|
19403
|
+
}
|
|
19404
|
+
/** Applies the busy state to every current native/explicit control exactly once. */
|
|
19405
|
+
#syncControls(session, controls = this.#submitControls(session.form)) {
|
|
19406
|
+
for (const control of controls) this.#enterControl(session, control);
|
|
19407
|
+
}
|
|
19408
|
+
/** Temporarily disables one enabled submit control while retaining authored state. */
|
|
19409
|
+
#enterControl(session, control) {
|
|
19410
|
+
if (session.controls.has(control) || control.matches(":disabled")) return;
|
|
19411
|
+
session.controls.add(control);
|
|
19412
|
+
this.#disabled.write(control, "");
|
|
19413
|
+
this.#controlBusy.write(control, "true");
|
|
19414
|
+
}
|
|
19415
|
+
/** Swaps only the triggering control's safe label channel. */
|
|
19416
|
+
#enterLabel(session) {
|
|
19417
|
+
const control = session.submitter;
|
|
19418
|
+
if (!control) return;
|
|
19419
|
+
const idlePart = this.#partInside(control, this.idleTargets);
|
|
19420
|
+
const busyPart = this.#partInside(control, this.busyTargets);
|
|
19421
|
+
if (idlePart && busyPart) {
|
|
19422
|
+
session.idlePart = idlePart;
|
|
19423
|
+
session.busyPart = busyPart;
|
|
19424
|
+
this.#hidden.write(idlePart, "");
|
|
19425
|
+
this.#hidden.write(busyPart, null);
|
|
19426
|
+
}
|
|
19427
|
+
const label = control.getAttribute(BUTTON_BUSY_LABEL) ?? this.busyLabelValue;
|
|
19428
|
+
if (label.length === 0) return;
|
|
19429
|
+
if (control instanceof HTMLInputElement) {
|
|
19430
|
+
if (control.type !== "submit") return;
|
|
19431
|
+
session.labelWrite = {
|
|
19432
|
+
channel: "value",
|
|
19433
|
+
control,
|
|
19434
|
+
original: control.value,
|
|
19435
|
+
written: label
|
|
19436
|
+
};
|
|
19437
|
+
control.value = label;
|
|
19438
|
+
return;
|
|
17954
19439
|
}
|
|
17955
|
-
|
|
17956
|
-
|
|
17957
|
-
|
|
17958
|
-
if (this.restoreFocusValue && this.#submitter) {
|
|
17959
|
-
this.#submitter.focus();
|
|
19440
|
+
if (control.hasAttribute("aria-label")) {
|
|
19441
|
+
this.#ariaLabel.write(control, label);
|
|
19442
|
+
return;
|
|
17960
19443
|
}
|
|
17961
|
-
|
|
17962
|
-
|
|
17963
|
-
|
|
17964
|
-
|
|
17965
|
-
|
|
17966
|
-
|
|
17967
|
-
|
|
17968
|
-
|
|
19444
|
+
if (control.childElementCount > 0) return;
|
|
19445
|
+
session.labelWrite = {
|
|
19446
|
+
channel: "text",
|
|
19447
|
+
control,
|
|
19448
|
+
original: control.textContent ?? "",
|
|
19449
|
+
written: label
|
|
19450
|
+
};
|
|
19451
|
+
control.textContent = label;
|
|
19452
|
+
}
|
|
19453
|
+
/** Returns labels, controls, and form hooks, respecting intervening consumer writes. */
|
|
19454
|
+
#returnSessionState(session) {
|
|
19455
|
+
if (session.timeoutId !== null) {
|
|
19456
|
+
this.#timers.clear(session.timeoutId);
|
|
19457
|
+
session.timeoutId = null;
|
|
19458
|
+
}
|
|
19459
|
+
if (session.labelWrite?.channel === "value") {
|
|
19460
|
+
const { control, original, written } = session.labelWrite;
|
|
19461
|
+
if (control.value === written) control.value = original;
|
|
19462
|
+
} else if (session.labelWrite?.channel === "text") {
|
|
19463
|
+
const { control, original, written } = session.labelWrite;
|
|
19464
|
+
if (control.childElementCount === 0 && control.textContent === written) {
|
|
19465
|
+
control.textContent = original;
|
|
19466
|
+
}
|
|
17969
19467
|
}
|
|
17970
|
-
|
|
17971
|
-
|
|
17972
|
-
|
|
17973
|
-
|
|
17974
|
-
|
|
19468
|
+
session.labelWrite = null;
|
|
19469
|
+
if (session.submitter instanceof HTMLButtonElement) {
|
|
19470
|
+
this.#ariaLabel.return(session.submitter);
|
|
19471
|
+
}
|
|
19472
|
+
if (session.idlePart) this.#hidden.return(session.idlePart);
|
|
19473
|
+
if (session.busyPart) this.#hidden.return(session.busyPart);
|
|
19474
|
+
session.idlePart = null;
|
|
19475
|
+
session.busyPart = null;
|
|
19476
|
+
for (const control of session.controls) {
|
|
19477
|
+
this.#controlBusy.return(control);
|
|
19478
|
+
this.#disabled.return(control);
|
|
19479
|
+
}
|
|
19480
|
+
session.controls.clear();
|
|
19481
|
+
this.#submitting.return(session.form);
|
|
19482
|
+
this.#formBusy.return(session.form);
|
|
19483
|
+
}
|
|
19484
|
+
/** Restores focus only when disabling the focused submitter actually stranded it. */
|
|
19485
|
+
#restoreFocus(session) {
|
|
19486
|
+
const submitter = session.submitter;
|
|
19487
|
+
if (!this.restoreFocusValue || !session.submitterHadFocus || !submitter?.isConnected) return;
|
|
19488
|
+
const active = document.activeElement;
|
|
19489
|
+
if (active === null || active === document.body || active === document.documentElement) {
|
|
19490
|
+
submitter.focus();
|
|
17975
19491
|
}
|
|
17976
|
-
this.#setLabel(button, label);
|
|
17977
19492
|
}
|
|
17978
|
-
/**
|
|
17979
|
-
#
|
|
17980
|
-
|
|
17981
|
-
|
|
17982
|
-
|
|
17983
|
-
|
|
17984
|
-
}
|
|
17985
|
-
const original = button.getAttribute(ORIGINAL_LABEL);
|
|
17986
|
-
if (original !== null) {
|
|
17987
|
-
this.#setLabel(button, original);
|
|
17988
|
-
button.removeAttribute(ORIGINAL_LABEL);
|
|
19493
|
+
/** Silently abandons every session before caching or on a real detach. */
|
|
19494
|
+
#abandonSessions() {
|
|
19495
|
+
this.#timers.clearAll();
|
|
19496
|
+
for (const session of this.#sessions.values()) {
|
|
19497
|
+
session.timeoutId = null;
|
|
19498
|
+
this.#returnSessionState(session);
|
|
17989
19499
|
}
|
|
19500
|
+
this.#sessions.clear();
|
|
19501
|
+
}
|
|
19502
|
+
/** Keeps Turbo's cached snapshot idle, without events, announcements, or focus moves. */
|
|
19503
|
+
#rewindForCache() {
|
|
19504
|
+
this.#abandonSessions();
|
|
19505
|
+
}
|
|
19506
|
+
/** Tears down a true detach; an in-page move is canceled by the next `connect()`. */
|
|
19507
|
+
#teardown() {
|
|
19508
|
+
this.#gate.cancel();
|
|
19509
|
+
this.#abandonSessions();
|
|
17990
19510
|
}
|
|
17991
|
-
/**
|
|
17992
|
-
#
|
|
17993
|
-
|
|
17994
|
-
|
|
17995
|
-
this
|
|
17996
|
-
this.element.removeAttribute("aria-busy");
|
|
19511
|
+
/** All native controls associated with `form`, plus valid explicit targets. */
|
|
19512
|
+
#submitControls(form) {
|
|
19513
|
+
const controls = /* @__PURE__ */ new Set();
|
|
19514
|
+
for (const element of Array.from(form.elements)) {
|
|
19515
|
+
if (this.#isNativeSubmitControl(element)) controls.add(element);
|
|
17997
19516
|
}
|
|
17998
|
-
|
|
17999
|
-
|
|
19517
|
+
const root = form.getRootNode();
|
|
19518
|
+
for (const image of root.querySelectorAll('input[type="image"]')) {
|
|
19519
|
+
if (image.form === form) controls.add(image);
|
|
18000
19520
|
}
|
|
19521
|
+
return Array.from(controls);
|
|
18001
19522
|
}
|
|
18002
|
-
/**
|
|
18003
|
-
#resolveSubmitter(event,
|
|
19523
|
+
/** Resolves the real submitter, falling back to the first enabled submit control. */
|
|
19524
|
+
#resolveSubmitter(event, controls) {
|
|
18004
19525
|
const submitter = this.#eventSubmitter(event);
|
|
18005
|
-
if (submitter &&
|
|
18006
|
-
|
|
18007
|
-
}
|
|
18008
|
-
return buttons[0] ?? null;
|
|
19526
|
+
if (submitter && controls.includes(submitter)) return submitter;
|
|
19527
|
+
return controls.find((control) => !control.matches(":disabled")) ?? controls[0] ?? null;
|
|
18009
19528
|
}
|
|
18010
|
-
/**
|
|
18011
|
-
* Reads the submitter from a native `SubmitEvent` (`event.submitter`) or from
|
|
18012
|
-
* Turbo's `turbo:submit-start` detail (`detail.formSubmission.submitter`), so
|
|
18013
|
-
* the busy label swaps onto the right button under either path.
|
|
18014
|
-
*/
|
|
19529
|
+
/** Reads a native or Turbo submitter without assuming `SubmitEvent` exists. */
|
|
18015
19530
|
#eventSubmitter(event) {
|
|
18016
19531
|
if (typeof SubmitEvent !== "undefined" && event instanceof SubmitEvent) {
|
|
18017
|
-
return event.submitter;
|
|
19532
|
+
return this.#isNativeSubmitControl(event.submitter) ? event.submitter : null;
|
|
18018
19533
|
}
|
|
18019
19534
|
const detail = event.detail;
|
|
18020
|
-
|
|
19535
|
+
const submitter = detail?.formSubmission?.submitter;
|
|
19536
|
+
return this.#isNativeSubmitControl(submitter) ? submitter : null;
|
|
18021
19537
|
}
|
|
18022
|
-
/**
|
|
18023
|
-
|
|
18024
|
-
|
|
18025
|
-
|
|
18026
|
-
|
|
18027
|
-
);
|
|
19538
|
+
/** Finds the form represented by an action/Turbo event and verifies this scope owns it. */
|
|
19539
|
+
#eventForm(event) {
|
|
19540
|
+
const detail = event.detail;
|
|
19541
|
+
const target = event.target;
|
|
19542
|
+
const candidate = (target instanceof HTMLFormElement ? target : null) ?? (target instanceof Element ? target.closest("form") : null) ?? detail?.formSubmission?.formElement ?? detail?.form ?? this.#eventSubmitter(event)?.form ?? (this.element instanceof HTMLFormElement ? this.element : null);
|
|
19543
|
+
return candidate && this.#ownsForm(candidate) ? candidate : null;
|
|
19544
|
+
}
|
|
19545
|
+
/** The only unambiguous form for a direct `finish()` call. */
|
|
19546
|
+
#defaultForm() {
|
|
19547
|
+
if (this.element instanceof HTMLFormElement && this.#ownsForm(this.element))
|
|
19548
|
+
return this.element;
|
|
19549
|
+
if (this.#sessions.size === 1) return this.#sessions.keys().next().value ?? null;
|
|
19550
|
+
return null;
|
|
18028
19551
|
}
|
|
18029
|
-
|
|
18030
|
-
|
|
19552
|
+
/** Prevents an ancestor controller from also taking over a nested instance's form. */
|
|
19553
|
+
#ownsForm(form) {
|
|
19554
|
+
let candidate = form;
|
|
19555
|
+
while (candidate) {
|
|
19556
|
+
const identifiers = (candidate.getAttribute("data-controller") ?? "").split(/\s+/);
|
|
19557
|
+
if (identifiers.includes(this.identifier)) return candidate === this.element;
|
|
19558
|
+
candidate = candidate.parentElement;
|
|
19559
|
+
}
|
|
19560
|
+
return false;
|
|
18031
19561
|
}
|
|
18032
|
-
/**
|
|
18033
|
-
#
|
|
18034
|
-
if (
|
|
18035
|
-
|
|
18036
|
-
if (aria !== null) return aria;
|
|
18037
|
-
return button.textContent ?? "";
|
|
19562
|
+
/** Returns the active session for a form-shaped external event value. */
|
|
19563
|
+
#sessionFor(candidate) {
|
|
19564
|
+
if (!(candidate instanceof HTMLFormElement)) return void 0;
|
|
19565
|
+
return this.#sessions.get(candidate);
|
|
18038
19566
|
}
|
|
18039
|
-
/**
|
|
18040
|
-
#
|
|
18041
|
-
if (
|
|
18042
|
-
|
|
18043
|
-
return;
|
|
18044
|
-
}
|
|
18045
|
-
if (button.hasAttribute("aria-label")) {
|
|
18046
|
-
button.setAttribute("aria-label", text);
|
|
18047
|
-
return;
|
|
18048
|
-
}
|
|
18049
|
-
button.textContent = text;
|
|
19567
|
+
/** Effective native submit controls, including implicit buttons and image inputs. */
|
|
19568
|
+
#isNativeSubmitControl(element) {
|
|
19569
|
+
if (element instanceof HTMLButtonElement) return element.type === "submit";
|
|
19570
|
+
return element instanceof HTMLInputElement && (element.type === "submit" || element.type === "image");
|
|
18050
19571
|
}
|
|
18051
|
-
|
|
18052
|
-
|
|
18053
|
-
|
|
18054
|
-
|
|
18055
|
-
|
|
19572
|
+
/** Finds a structured label part belonging to this button, not a nested control. */
|
|
19573
|
+
#partInside(control, parts) {
|
|
19574
|
+
return parts.find(
|
|
19575
|
+
(part) => control.contains(part) && part.closest("button, input[type=submit], input[type=image]") === control
|
|
19576
|
+
) ?? null;
|
|
19577
|
+
}
|
|
19578
|
+
/** Reads an optional boolean `success` from Turbo or a manual completion event. */
|
|
19579
|
+
#eventSuccess(event) {
|
|
19580
|
+
const success = event.detail?.success;
|
|
19581
|
+
return typeof success === "boolean" ? success : void 0;
|
|
18056
19582
|
}
|
|
18057
19583
|
};
|
|
18058
|
-
var
|
|
19584
|
+
var OBSERVED_ATTRIBUTES4 = [
|
|
18059
19585
|
"role",
|
|
18060
19586
|
"aria-checked",
|
|
18061
19587
|
"tabindex",
|
|
@@ -18168,7 +19694,7 @@ var SwitchController = class extends Controller {
|
|
|
18168
19694
|
#observeAttributes() {
|
|
18169
19695
|
this.#attributeObserver.observe(this.element, {
|
|
18170
19696
|
attributes: true,
|
|
18171
|
-
attributeFilter:
|
|
19697
|
+
attributeFilter: OBSERVED_ATTRIBUTES4
|
|
18172
19698
|
});
|
|
18173
19699
|
let ancestor = this.element.parentElement;
|
|
18174
19700
|
while (ancestor) {
|
|
@@ -18708,8 +20234,8 @@ var ThemeController = class extends Controller {
|
|
|
18708
20234
|
}
|
|
18709
20235
|
/** Reads a persisted, validated mode from `localStorage` (null when absent/blocked). */
|
|
18710
20236
|
#readStored() {
|
|
18711
|
-
const
|
|
18712
|
-
return isMode(value) ? value : null;
|
|
20237
|
+
const result = readLocalStorage(this.storageKeyValue);
|
|
20238
|
+
return result.ok && isMode(result.value) ? result.value : null;
|
|
18713
20239
|
}
|
|
18714
20240
|
/** Persists the mode, swallowing storage errors (private mode / quota). */
|
|
18715
20241
|
#writeStored(mode) {
|