stimeo-ui 0.5.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 +178 -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/aspect_ratio_controller.d.ts +1 -3
- package/dist/controllers/aspect_ratio_controller.js +19 -11
- package/dist/controllers/aspect_ratio_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.d.ts +36 -15
- package/dist/controllers/avatar_controller.js +237 -40
- 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/carousel_controller.d.ts +12 -3
- package/dist/controllers/carousel_controller.js +85 -9
- package/dist/controllers/carousel_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 +34 -5
- package/dist/controllers/checkbox_controller.js +136 -25
- package/dist/controllers/checkbox_controller.js.map +1 -1
- package/dist/controllers/color_picker_controller.d.ts +10 -1
- package/dist/controllers/color_picker_controller.js +35 -9
- 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/date_range_picker_controller.d.ts +23 -4
- package/dist/controllers/date_range_picker_controller.js +157 -30
- package/dist/controllers/date_range_picker_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 +9 -1
- package/dist/controllers/file_dropzone_controller.js +26 -3
- 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 +4 -3
- 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/hover_card_controller.d.ts +2 -2
- package/dist/controllers/hover_card_controller.js.map +1 -1
- package/dist/controllers/idle_controller.d.ts +5 -3
- package/dist/controllers/idle_controller.js +27 -5
- 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/menubar_controller.js +5 -3
- package/dist/controllers/menubar_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 +48 -12
- package/dist/controllers/multi_select_controller.js +460 -151
- 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 +26 -6
- package/dist/controllers/number_input_controller.js +317 -51
- 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 +6 -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 +35 -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 +5 -3
- package/dist/controllers/password_strength_controller.js +20 -2
- package/dist/controllers/password_strength_controller.js.map +1 -1
- package/dist/controllers/persist_controller.d.ts +33 -19
- package/dist/controllers/persist_controller.js +449 -120
- 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.d.ts +2 -2
- package/dist/controllers/popover_controller.js +77 -4
- package/dist/controllers/popover_controller.js.map +1 -1
- package/dist/controllers/portal_controller.d.ts +3 -2
- 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 +44 -15
- package/dist/controllers/radio_group_controller.js +540 -56
- package/dist/controllers/radio_group_controller.js.map +1 -1
- package/dist/controllers/rating_controller.d.ts +38 -31
- package/dist/controllers/rating_controller.js +276 -89
- 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 +33 -0
- package/dist/controllers/resizable_controller.js.map +1 -1
- package/dist/controllers/roving_controller.d.ts +6 -0
- package/dist/controllers/roving_controller.js +60 -5
- package/dist/controllers/roving_controller.js.map +1 -1
- package/dist/controllers/scroll_area_controller.d.ts +25 -11
- package/dist/controllers/scroll_area_controller.js +557 -125
- 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 +33 -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 +43 -11
- package/dist/controllers/tags_input_controller.js +356 -120
- 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 +44 -13
- package/dist/controllers/time_picker_controller.js +296 -107
- 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 +41 -13
- package/dist/controllers/toggle_group_controller.js +378 -55
- package/dist/controllers/toggle_group_controller.js.map +1 -1
- package/dist/controllers/toolbar_controller.js +5 -3
- package/dist/controllers/toolbar_controller.js.map +1 -1
- package/dist/controllers/tooltip_controller.d.ts +2 -2
- package/dist/controllers/tooltip_controller.js.map +1 -1
- package/dist/controllers/transition_controller.d.ts +4 -3
- 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 +7 -4
- package/dist/controllers/tree_view_controller.js.map +1 -1
- package/dist/index.js +4963 -1581
- package/dist/index.js.map +1 -1
- package/dist/inspector/cli.d.ts +123 -6
- package/dist/inspector/cli.js +212 -18
- package/dist/inspector/cli.js.map +1 -1
- package/dist/inspector/cli_bin.js +274 -50
- package/dist/inspector/cli_bin.js.map +1 -1
- package/dist/inspector/examples.json +15 -15
- package/dist/inspector/manifest.json +602 -80
- package/dist/positioning/index.d.ts +4 -2
- package/dist/positioning/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -12,11 +12,17 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
12
12
|
* Snapshots the form's field values on connect, marks the form `data-dirty` once a
|
|
13
13
|
* value changes, and — while dirty — guards both a full unload (`beforeunload`) and
|
|
14
14
|
* a Turbo visit (`turbo:before-visit`). On a Turbo visit it dispatches a cancelable
|
|
15
|
-
* `guard` event; if a consumer cancels
|
|
16
|
-
* blocked, otherwise
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
15
|
+
* `guard` event for each eligible form; if a consumer cancels one (or any form uses
|
|
16
|
+
* `confirmBridge`) the visit is blocked, otherwise the document falls back to one
|
|
17
|
+
* native `confirm(message)`, using the first eligible form in live DOM order. While a
|
|
18
|
+
* submit is in flight the guard is suppressed until another edit occurs (so a
|
|
19
|
+
* legitimate submit never prompts); the browser's `formdata` event captures the exact
|
|
20
|
+
* revision Turbo materializes, and a successful `turbo:submit-end` accepts only that
|
|
21
|
+
* revision, preserving any later edits as dirty. A failed submit re-arms the guard;
|
|
22
|
+
* `markClean` clears it manually (e.g. after a custom save), while `acceptRestore`
|
|
23
|
+
* adopts a restored draft only while no user edit is already dirty.
|
|
24
|
+
*
|
|
25
|
+
* `dirty` dispatches `{ dirty }`; `guard` dispatches `{ event }`.
|
|
20
26
|
*
|
|
21
27
|
* @remarks
|
|
22
28
|
* Behavior only — it renders no confirmation UI (pair with a Confirm Bridge) and
|
|
@@ -39,7 +45,7 @@ declare class DirtyFormController extends Controller<HTMLFormElement> {
|
|
|
39
45
|
default: boolean;
|
|
40
46
|
};
|
|
41
47
|
};
|
|
42
|
-
static actions: readonly ["markClean"];
|
|
48
|
+
static actions: readonly ["markClean", "acceptRestore"];
|
|
43
49
|
static events: readonly ["dirty", "guard"];
|
|
44
50
|
messageValue: string;
|
|
45
51
|
confirmBridgeValue: boolean;
|
|
@@ -47,6 +53,8 @@ declare class DirtyFormController extends Controller<HTMLFormElement> {
|
|
|
47
53
|
disconnect(): void;
|
|
48
54
|
/** Re-baselines to the current values and clears the dirty state (e.g. after a save). */
|
|
49
55
|
markClean(): void;
|
|
56
|
+
/** Adopts restored values without erasing an already-dirty user revision. */
|
|
57
|
+
acceptRestore(): void;
|
|
50
58
|
}
|
|
51
59
|
|
|
52
60
|
export { DirtyFormController };
|
|
@@ -1,60 +1,220 @@
|
|
|
1
1
|
import { Controller } from '@hotwired/stimulus';
|
|
2
2
|
|
|
3
3
|
// src/controllers/dirty_form_controller.ts
|
|
4
|
+
|
|
5
|
+
// src/utils/safe_timeout.ts
|
|
6
|
+
var TimerRegistry = class {
|
|
7
|
+
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
8
|
+
ids = /* @__PURE__ */ new Set();
|
|
9
|
+
/**
|
|
10
|
+
* Cancels a single tracked timer.
|
|
11
|
+
*
|
|
12
|
+
* No-ops if the id is unknown (already cleared, fired, or never owned by this
|
|
13
|
+
* registry), so callers can clear defensively without guarding.
|
|
14
|
+
*/
|
|
15
|
+
clear(id) {
|
|
16
|
+
if (this.ids.delete(id)) {
|
|
17
|
+
this.cancel(id);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Cancels every tracked timer. Call this from a controller's `disconnect()`
|
|
22
|
+
* to guarantee no timer outlives the element.
|
|
23
|
+
*/
|
|
24
|
+
clearAll() {
|
|
25
|
+
for (const id of this.ids) {
|
|
26
|
+
this.cancel(id);
|
|
27
|
+
}
|
|
28
|
+
this.ids.clear();
|
|
29
|
+
}
|
|
30
|
+
/** Number of timers currently tracked (pending). */
|
|
31
|
+
get size() {
|
|
32
|
+
return this.ids.size;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
var SafeTimeout = class extends TimerRegistry {
|
|
36
|
+
/**
|
|
37
|
+
* Schedules `callback` after `delay` ms and returns the timer id.
|
|
38
|
+
*
|
|
39
|
+
* The id is removed from the registry automatically when the timeout fires,
|
|
40
|
+
* so {@link TimerRegistry.size | size} reflects only still-pending timers.
|
|
41
|
+
*/
|
|
42
|
+
set(callback, delay) {
|
|
43
|
+
const id = this.schedule(() => {
|
|
44
|
+
this.ids.delete(id);
|
|
45
|
+
callback();
|
|
46
|
+
}, delay);
|
|
47
|
+
this.ids.add(id);
|
|
48
|
+
return id;
|
|
49
|
+
}
|
|
50
|
+
schedule(callback, delay) {
|
|
51
|
+
return window.setTimeout(callback, delay);
|
|
52
|
+
}
|
|
53
|
+
cancel(id) {
|
|
54
|
+
window.clearTimeout(id);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// src/controllers/dirty_form_controller.ts
|
|
59
|
+
var visitCoordinators = /* @__PURE__ */ new WeakMap();
|
|
60
|
+
var TurboVisitCoordinator = class {
|
|
61
|
+
#document;
|
|
62
|
+
#participants = /* @__PURE__ */ new Set();
|
|
63
|
+
#onBeforeVisit = (event) => {
|
|
64
|
+
let blocked = event.defaultPrevented;
|
|
65
|
+
let message;
|
|
66
|
+
for (const participant of this.#participantsInDomOrder()) {
|
|
67
|
+
if (!participant.form.isConnected) continue;
|
|
68
|
+
const decision = participant.evaluate(event);
|
|
69
|
+
blocked ||= event.defaultPrevented;
|
|
70
|
+
if (decision === null) continue;
|
|
71
|
+
if (decision.kind === "block") {
|
|
72
|
+
blocked = true;
|
|
73
|
+
} else {
|
|
74
|
+
message ??= decision.message;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (blocked) {
|
|
78
|
+
event.preventDefault();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (message === void 0) return;
|
|
82
|
+
if (!this.#document.defaultView?.confirm(message)) event.preventDefault();
|
|
83
|
+
};
|
|
84
|
+
constructor(document) {
|
|
85
|
+
this.#document = document;
|
|
86
|
+
}
|
|
87
|
+
add(participant) {
|
|
88
|
+
if (this.#participants.size === 0) {
|
|
89
|
+
this.#document.addEventListener("turbo:before-visit", this.#onBeforeVisit);
|
|
90
|
+
}
|
|
91
|
+
this.#participants.add(participant);
|
|
92
|
+
}
|
|
93
|
+
/** Removes a participant and reports whether the coordinator became empty. */
|
|
94
|
+
remove(participant) {
|
|
95
|
+
this.#participants.delete(participant);
|
|
96
|
+
if (this.#participants.size > 0) return false;
|
|
97
|
+
this.#document.removeEventListener("turbo:before-visit", this.#onBeforeVisit);
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
#participantsInDomOrder() {
|
|
101
|
+
return Array.from(this.#participants).filter(
|
|
102
|
+
(participant) => participant.form.isConnected && participant.form.ownerDocument === this.#document
|
|
103
|
+
).sort((left, right) => {
|
|
104
|
+
const position = left.form.compareDocumentPosition(right.form);
|
|
105
|
+
return Number(Boolean(position & Node.DOCUMENT_POSITION_PRECEDING)) - Number(Boolean(position & Node.DOCUMENT_POSITION_FOLLOWING));
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
function registerVisitParticipant(document, participant) {
|
|
110
|
+
const coordinator = visitCoordinators.get(document) ?? new TurboVisitCoordinator(document);
|
|
111
|
+
visitCoordinators.set(document, coordinator);
|
|
112
|
+
coordinator.add(participant);
|
|
113
|
+
return () => {
|
|
114
|
+
if (coordinator.remove(participant)) visitCoordinators.delete(document);
|
|
115
|
+
};
|
|
116
|
+
}
|
|
4
117
|
var DirtyFormController = class extends Controller {
|
|
5
118
|
static values = {
|
|
6
119
|
message: { type: String, default: "You have unsaved changes that will be lost." },
|
|
7
120
|
confirmBridge: { type: Boolean, default: false }
|
|
8
121
|
};
|
|
9
|
-
static actions = ["markClean"];
|
|
122
|
+
static actions = ["markClean", "acceptRestore"];
|
|
10
123
|
static events = ["dirty", "guard"];
|
|
11
124
|
#baseline = "";
|
|
12
125
|
#dirty = false;
|
|
13
126
|
#beforeunloadBound = false;
|
|
14
|
-
|
|
127
|
+
#timeouts = new SafeTimeout();
|
|
128
|
+
/** Whether the active submission still represents the form's current values. */
|
|
15
129
|
#submitting = false;
|
|
130
|
+
#submitAttempt = null;
|
|
131
|
+
#submittedBaseline = null;
|
|
132
|
+
#activeSubmission = null;
|
|
133
|
+
#unregisterVisit = null;
|
|
16
134
|
#onFieldChange = () => {
|
|
17
135
|
this.#submitting = false;
|
|
18
136
|
this.#evaluate();
|
|
19
137
|
};
|
|
20
|
-
#onSubmit = () => {
|
|
138
|
+
#onSubmit = (event) => {
|
|
139
|
+
const attempt = { event, snapshot: this.#serialize() };
|
|
140
|
+
this.#submitAttempt = attempt;
|
|
21
141
|
this.#submitting = true;
|
|
142
|
+
this.#timeouts.set(() => {
|
|
143
|
+
if (this.#submitAttempt !== attempt) return;
|
|
144
|
+
this.#submitting = false;
|
|
145
|
+
}, 0);
|
|
22
146
|
};
|
|
23
|
-
#
|
|
24
|
-
this.#
|
|
147
|
+
#onFormData = () => {
|
|
148
|
+
if (this.#submitAttempt === null) return;
|
|
149
|
+
this.#submitAttempt.snapshot = this.#serialize();
|
|
150
|
+
};
|
|
151
|
+
#onSubmitStart = (event) => {
|
|
152
|
+
const detail = event.detail;
|
|
153
|
+
this.#activeSubmission = detail?.formSubmission ?? event;
|
|
154
|
+
this.#submittedBaseline = this.#submitAttempt?.snapshot ?? this.#serialize();
|
|
155
|
+
this.#submitAttempt = null;
|
|
156
|
+
this.#submitting = this.#serialize() === this.#submittedBaseline;
|
|
25
157
|
};
|
|
26
158
|
#onSubmitEnd = (event) => {
|
|
159
|
+
const detail = event.detail;
|
|
160
|
+
if (detail?.formSubmission !== void 0 && (this.#activeSubmission === null || detail.formSubmission !== this.#activeSubmission)) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const submittedBaseline = this.#submittedBaseline;
|
|
27
164
|
this.#submitting = false;
|
|
28
|
-
|
|
29
|
-
|
|
165
|
+
this.#submittedBaseline = null;
|
|
166
|
+
this.#activeSubmission = null;
|
|
167
|
+
if (detail?.success === false) return;
|
|
168
|
+
if (submittedBaseline === null) {
|
|
169
|
+
this.markClean();
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
this.#baseline = submittedBaseline;
|
|
173
|
+
this.#evaluate();
|
|
30
174
|
};
|
|
31
175
|
#onBeforeUnload = (event) => {
|
|
32
|
-
if (!this.#dirty || this.#
|
|
176
|
+
if (!this.#dirty || this.#guardSuppressed()) return;
|
|
33
177
|
event.preventDefault();
|
|
34
178
|
event.returnValue = this.messageValue;
|
|
35
179
|
};
|
|
36
180
|
connect() {
|
|
181
|
+
this.#resetSubmission();
|
|
37
182
|
this.#baseline = this.#serialize();
|
|
183
|
+
this.#setDirty(false);
|
|
38
184
|
this.element.removeAttribute("data-dirty");
|
|
39
185
|
this.element.addEventListener("input", this.#onFieldChange);
|
|
40
186
|
this.element.addEventListener("change", this.#onFieldChange);
|
|
41
187
|
this.element.addEventListener("submit", this.#onSubmit);
|
|
188
|
+
this.element.addEventListener("formdata", this.#onFormData);
|
|
189
|
+
this.element.addEventListener("turbo:submit-start", this.#onSubmitStart);
|
|
42
190
|
this.element.addEventListener("turbo:submit-end", this.#onSubmitEnd);
|
|
43
|
-
|
|
191
|
+
this.#unregisterVisit = registerVisitParticipant(this.element.ownerDocument, {
|
|
192
|
+
form: this.element,
|
|
193
|
+
evaluate: (event) => this.#evaluateVisit(event)
|
|
194
|
+
});
|
|
44
195
|
}
|
|
45
196
|
disconnect() {
|
|
46
197
|
this.element.removeEventListener("input", this.#onFieldChange);
|
|
47
198
|
this.element.removeEventListener("change", this.#onFieldChange);
|
|
48
199
|
this.element.removeEventListener("submit", this.#onSubmit);
|
|
200
|
+
this.element.removeEventListener("formdata", this.#onFormData);
|
|
201
|
+
this.element.removeEventListener("turbo:submit-start", this.#onSubmitStart);
|
|
49
202
|
this.element.removeEventListener("turbo:submit-end", this.#onSubmitEnd);
|
|
50
|
-
|
|
203
|
+
this.#unregisterVisit?.();
|
|
204
|
+
this.#unregisterVisit = null;
|
|
51
205
|
this.#unbindBeforeUnload();
|
|
206
|
+
this.#resetSubmission();
|
|
52
207
|
}
|
|
53
208
|
/** Re-baselines to the current values and clears the dirty state (e.g. after a save). */
|
|
54
209
|
markClean() {
|
|
55
210
|
this.#baseline = this.#serialize();
|
|
56
211
|
this.#setDirty(false);
|
|
57
212
|
}
|
|
213
|
+
/** Adopts restored values without erasing an already-dirty user revision. */
|
|
214
|
+
acceptRestore() {
|
|
215
|
+
if (this.#dirty) return;
|
|
216
|
+
this.markClean();
|
|
217
|
+
}
|
|
58
218
|
/** Recomputes dirty against the baseline and flips state when it changes. */
|
|
59
219
|
#evaluate() {
|
|
60
220
|
this.#setDirty(this.#serialize() !== this.#baseline);
|
|
@@ -71,21 +231,14 @@ var DirtyFormController = class extends Controller {
|
|
|
71
231
|
}
|
|
72
232
|
this.dispatch("dirty", { detail: { dirty } });
|
|
73
233
|
}
|
|
74
|
-
/**
|
|
75
|
-
#
|
|
76
|
-
if (!this.#dirty || this.#
|
|
234
|
+
/** Evaluates this form for the document-level Turbo visit coordinator. */
|
|
235
|
+
#evaluateVisit(event) {
|
|
236
|
+
if (!this.#dirty || this.#guardSuppressed()) return null;
|
|
77
237
|
const guard = this.dispatch("guard", { detail: { event }, cancelable: true });
|
|
78
|
-
if (guard.defaultPrevented) {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
82
|
-
if (this.confirmBridgeValue) {
|
|
83
|
-
event.preventDefault();
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
if (!window.confirm(this.messageValue)) {
|
|
87
|
-
event.preventDefault();
|
|
88
|
-
}
|
|
238
|
+
if (guard.defaultPrevented) return { kind: "block" };
|
|
239
|
+
if (!this.element.isConnected || !this.#dirty || this.#guardSuppressed()) return null;
|
|
240
|
+
if (this.confirmBridgeValue) return { kind: "block" };
|
|
241
|
+
return { kind: "confirm", message: this.messageValue };
|
|
89
242
|
}
|
|
90
243
|
#bindBeforeUnload() {
|
|
91
244
|
if (this.#beforeunloadBound) return;
|
|
@@ -97,6 +250,16 @@ var DirtyFormController = class extends Controller {
|
|
|
97
250
|
window.removeEventListener("beforeunload", this.#onBeforeUnload);
|
|
98
251
|
this.#beforeunloadBound = false;
|
|
99
252
|
}
|
|
253
|
+
#guardSuppressed() {
|
|
254
|
+
return this.#submitting && (this.#submitAttempt === null || !this.#submitAttempt.event.defaultPrevented);
|
|
255
|
+
}
|
|
256
|
+
#resetSubmission() {
|
|
257
|
+
this.#timeouts.clearAll();
|
|
258
|
+
this.#submitting = false;
|
|
259
|
+
this.#submitAttempt = null;
|
|
260
|
+
this.#submittedBaseline = null;
|
|
261
|
+
this.#activeSubmission = null;
|
|
262
|
+
}
|
|
100
263
|
/** Stable serialization of the form's controls for change detection. */
|
|
101
264
|
#serialize() {
|
|
102
265
|
const parts = [];
|
|
@@ -104,15 +267,15 @@ var DirtyFormController = class extends Controller {
|
|
|
104
267
|
const name = this.#nameOf(el);
|
|
105
268
|
if (name === null) continue;
|
|
106
269
|
if (el instanceof HTMLInputElement && (el.type === "checkbox" || el.type === "radio")) {
|
|
107
|
-
parts.push(
|
|
270
|
+
parts.push([el.type, name, el.value, el.checked ? 1 : 0]);
|
|
108
271
|
} else if (el instanceof HTMLSelectElement) {
|
|
109
|
-
const value = el.multiple ? Array.from(el.selectedOptions).map((
|
|
110
|
-
parts.push(
|
|
272
|
+
const value = el.multiple ? Array.from(el.selectedOptions).map((option) => option.value) : el.value;
|
|
273
|
+
parts.push([el.type, name, value]);
|
|
111
274
|
} else if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
|
|
112
|
-
parts.push(
|
|
275
|
+
parts.push([el.type, name, el.value]);
|
|
113
276
|
}
|
|
114
277
|
}
|
|
115
|
-
return
|
|
278
|
+
return JSON.stringify(parts);
|
|
116
279
|
}
|
|
117
280
|
/** A stable key for a control, or null for elements without value semantics. */
|
|
118
281
|
#nameOf(el) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/controllers/dirty_form_controller.ts"],"names":[],"mappings":"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","file":"dirty_form_controller.js","sourcesContent":["import { Controller } from \"@hotwired/stimulus\";\n\n/**\n * Headless \"unsaved changes\" guard for a form (no dedicated APG pattern; supports\n * WCAG 2.2 error-prevention 3.3.4 / 3.3.6 by preventing accidental data loss).\n *\n * Markup contract (identifier: `stimeo--dirty-form`):\n * <form data-controller=\"stimeo--dirty-form\">\n * …fields…\n * </form>\n *\n * Snapshots the form's field values on connect, marks the form `data-dirty` once a\n * value changes, and — while dirty — guards both a full unload (`beforeunload`) and\n * a Turbo visit (`turbo:before-visit`). On a Turbo visit it dispatches a cancelable\n * `guard` event; if a consumer cancels it (or `confirmBridge` is set) the visit is\n * blocked, otherwise it falls back to a native `confirm(message)`. While a submit is\n * in flight the guard is suppressed (so a legitimate submit never prompts); the dirty\n * state is cleared only on a successful `turbo:submit-end`, and a failed submit\n * re-arms the guard. `markClean` clears it manually (e.g. after a custom save).\n *\n * @remarks\n * Behavior only — it renders no confirmation UI (pair with a Confirm Bridge) and\n * does not persist input (pair with Persist). The dirty baseline is read from the\n * DOM on `connect()` (no module-scope state) — which also clears a stale\n * `data-dirty` left in a Turbo cache snapshot, since the restored values are the\n * new baseline — `beforeunload` is wired only while dirty, and every listener is\n * removed on `disconnect()` (Turbo navigation included) so a stale guard never\n * outlives the form.\n */\nexport class DirtyFormController extends Controller<HTMLFormElement> {\n static override values = {\n message: { type: String, default: \"You have unsaved changes that will be lost.\" },\n confirmBridge: { type: Boolean, default: false },\n };\n static actions = [\"markClean\"] as const;\n static events = [\"dirty\", \"guard\"] as const;\n\n declare messageValue: string;\n declare confirmBridgeValue: boolean;\n\n #baseline = \"\";\n #dirty = false;\n #beforeunloadBound = false;\n /** True between a form `submit` and its `turbo:submit-end`, suppressing the guard. */\n #submitting = false;\n\n readonly #onFieldChange = (): void => {\n // Any edit means the user is actively working again, so a prior submit that\n // never resolved (e.g. one cancelled client-side, with no turbo:submit-end)\n // must not keep the guard suppressed.\n this.#submitting = false;\n this.#evaluate();\n };\n\n readonly #onSubmit = (): void => {\n // A real submit is leaving on purpose: suppress the guard for the in-flight\n // request. We do NOT clear the baseline yet — a failed submit must stay dirty.\n this.#submitting = true;\n };\n\n readonly #onBeforeVisit = (event: Event): void => {\n this.#guardVisit(event);\n };\n\n readonly #onSubmitEnd = (event: Event): void => {\n this.#submitting = false;\n const success = (event as CustomEvent<{ success?: boolean }>).detail?.success;\n // Only a successful submit clears the dirty state; a failure re-arms the guard.\n if (success !== false) this.markClean();\n };\n\n readonly #onBeforeUnload = (event: BeforeUnloadEvent): void => {\n if (!this.#dirty || this.#submitting) return;\n event.preventDefault();\n // Legacy requirement: a non-empty returnValue triggers the native prompt.\n event.returnValue = this.messageValue;\n };\n\n override connect(): void {\n this.#baseline = this.#serialize();\n // Re-baselining means the restored values ARE the clean state, so a stale\n // data-dirty captured in a Turbo cache snapshot mid-edit must not linger\n // (the guard would not fire, but consumer CSS would keep claiming \"unsaved\").\n this.element.removeAttribute(\"data-dirty\");\n this.element.addEventListener(\"input\", this.#onFieldChange);\n this.element.addEventListener(\"change\", this.#onFieldChange);\n this.element.addEventListener(\"submit\", this.#onSubmit);\n this.element.addEventListener(\"turbo:submit-end\", this.#onSubmitEnd);\n document.addEventListener(\"turbo:before-visit\", this.#onBeforeVisit);\n }\n\n override disconnect(): void {\n this.element.removeEventListener(\"input\", this.#onFieldChange);\n this.element.removeEventListener(\"change\", this.#onFieldChange);\n this.element.removeEventListener(\"submit\", this.#onSubmit);\n this.element.removeEventListener(\"turbo:submit-end\", this.#onSubmitEnd);\n document.removeEventListener(\"turbo:before-visit\", this.#onBeforeVisit);\n this.#unbindBeforeUnload();\n }\n\n /** Re-baselines to the current values and clears the dirty state (e.g. after a save). */\n markClean(): void {\n this.#baseline = this.#serialize();\n this.#setDirty(false);\n }\n\n /** Recomputes dirty against the baseline and flips state when it changes. */\n #evaluate(): void {\n this.#setDirty(this.#serialize() !== this.#baseline);\n }\n\n #setDirty(dirty: boolean): void {\n if (dirty === this.#dirty) return;\n this.#dirty = dirty;\n if (dirty) {\n this.element.setAttribute(\"data-dirty\", \"true\");\n this.#bindBeforeUnload();\n } else {\n this.element.removeAttribute(\"data-dirty\");\n this.#unbindBeforeUnload();\n }\n this.dispatch(\"dirty\", { detail: { dirty } });\n }\n\n /** Guards a Turbo visit while dirty: consumer cancel → confirmBridge → confirm. */\n #guardVisit(event: Event): void {\n if (!this.#dirty || this.#submitting) return;\n const guard = this.dispatch(\"guard\", { detail: { event }, cancelable: true });\n if (guard.defaultPrevented) {\n event.preventDefault();\n return;\n }\n if (this.confirmBridgeValue) {\n // Defer the actual prompt to a consumer-wired Confirm Bridge (via the guard\n // event); block the visit here so nothing navigates before it decides.\n event.preventDefault();\n return;\n }\n if (!window.confirm(this.messageValue)) {\n event.preventDefault();\n }\n }\n\n #bindBeforeUnload(): void {\n if (this.#beforeunloadBound) return;\n window.addEventListener(\"beforeunload\", this.#onBeforeUnload);\n this.#beforeunloadBound = true;\n }\n\n #unbindBeforeUnload(): void {\n if (!this.#beforeunloadBound) return;\n window.removeEventListener(\"beforeunload\", this.#onBeforeUnload);\n this.#beforeunloadBound = false;\n }\n\n /** Stable serialization of the form's controls for change detection. */\n #serialize(): string {\n const parts: string[] = [];\n for (const el of Array.from(this.element.elements)) {\n const name = this.#nameOf(el);\n if (name === null) continue;\n if (el instanceof HTMLInputElement && (el.type === \"checkbox\" || el.type === \"radio\")) {\n parts.push(`${name}:${el.checked ? 1 : 0}`);\n } else if (el instanceof HTMLSelectElement) {\n // Single select: read `value` (robust); multi-select: join the selected values.\n const value = el.multiple\n ? Array.from(el.selectedOptions)\n .map((o) => o.value)\n .join(\",\")\n : el.value;\n parts.push(`${name}:${value}`);\n } else if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {\n parts.push(`${name}:${el.value}`);\n }\n }\n return parts.join(\"|\");\n }\n\n /** A stable key for a control, or null for elements without value semantics. */\n #nameOf(el: Element): string | null {\n if (\n el instanceof HTMLInputElement ||\n el instanceof HTMLTextAreaElement ||\n el instanceof HTMLSelectElement\n ) {\n return el.name || el.id || \"\";\n }\n return null;\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/utils/safe_timeout.ts","../../src/controllers/dirty_form_controller.ts"],"names":[],"mappings":";;;;;AAwBA,IAAe,gBAAf,MAA6B;AAAA;AAAA,EAER,GAAA,uBAAU,GAAA,EAAY;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAczC,MAAM,EAAA,EAAkB;AACtB,IAAA,IAAI,IAAA,CAAK,GAAA,CAAI,MAAA,CAAO,EAAE,CAAA,EAAG;AACvB,MAAA,IAAA,CAAK,OAAO,EAAE,CAAA;AAAA,IAChB;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,QAAA,GAAiB;AACf,IAAA,KAAA,MAAW,EAAA,IAAM,KAAK,GAAA,EAAK;AACzB,MAAA,IAAA,CAAK,OAAO,EAAE,CAAA;AAAA,IAChB;AACA,IAAA,IAAA,CAAK,IAAI,KAAA,EAAM;AAAA,EACjB;AAAA;AAAA,EAGA,IAAI,IAAA,GAAe;AACjB,IAAA,OAAO,KAAK,GAAA,CAAI,IAAA;AAAA,EAClB;AACF,CAAA;AAmBO,IAAM,WAAA,GAAN,cAA0B,aAAA,CAAc;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAO7C,GAAA,CAAI,UAAsB,KAAA,EAAuB;AAC/C,IAAA,MAAM,EAAA,GAAK,IAAA,CAAK,QAAA,CAAS,MAAM;AAC7B,MAAA,IAAA,CAAK,GAAA,CAAI,OAAO,EAAE,CAAA;AAClB,MAAA,QAAA,EAAS;AAAA,IACX,GAAG,KAAK,CAAA;AACR,IAAA,IAAA,CAAK,GAAA,CAAI,IAAI,EAAE,CAAA;AACf,IAAA,OAAO,EAAA;AAAA,EACT;AAAA,EAEU,QAAA,CAAS,UAAsB,KAAA,EAAuB;AAC9D,IAAA,OAAO,MAAA,CAAO,UAAA,CAAW,QAAA,EAAU,KAAK,CAAA;AAAA,EAC1C;AAAA,EAEU,OAAO,EAAA,EAAkB;AACjC,IAAA,MAAA,CAAO,aAAa,EAAE,CAAA;AAAA,EACxB;AACF,CAAA;;;ACjEA,IAAM,iBAAA,uBAAwB,OAAA,EAAyC;AAEvE,IAAM,wBAAN,MAA4B;AAAA,EACjB,SAAA;AAAA,EACA,aAAA,uBAAoB,GAAA,EAAsB;AAAA,EAE1C,cAAA,GAAiB,CAAC,KAAA,KAAuB;AAChD,IAAA,IAAI,UAAU,KAAA,CAAM,gBAAA;AACpB,IAAA,IAAI,OAAA;AAKJ,IAAA,KAAA,MAAW,WAAA,IAAe,IAAA,CAAK,uBAAA,EAAwB,EAAG;AACxD,MAAA,IAAI,CAAC,WAAA,CAAY,IAAA,CAAK,WAAA,EAAa;AACnC,MAAA,MAAM,QAAA,GAAW,WAAA,CAAY,QAAA,CAAS,KAAK,CAAA;AAC3C,MAAA,OAAA,KAAY,KAAA,CAAM,gBAAA;AAClB,MAAA,IAAI,aAAa,IAAA,EAAM;AACvB,MAAA,IAAI,QAAA,CAAS,SAAS,OAAA,EAAS;AAC7B,QAAA,OAAA,GAAU,IAAA;AAAA,MACZ,CAAA,MAAO;AACL,QAAA,OAAA,KAAY,QAAA,CAAS,OAAA;AAAA,MACvB;AAAA,IACF;AAEA,IAAA,IAAI,OAAA,EAAS;AACX,MAAA,KAAA,CAAM,cAAA,EAAe;AACrB,MAAA;AAAA,IACF;AACA,IAAA,IAAI,YAAY,MAAA,EAAW;AAC3B,IAAA,IAAI,CAAC,KAAK,SAAA,CAAU,WAAA,EAAa,QAAQ,OAAO,CAAA,QAAS,cAAA,EAAe;AAAA,EAC1E,CAAA;AAAA,EAEA,YAAY,QAAA,EAAoB;AAC9B,IAAA,IAAA,CAAK,SAAA,GAAY,QAAA;AAAA,EACnB;AAAA,EAEA,IAAI,WAAA,EAAqC;AACvC,IAAA,IAAI,IAAA,CAAK,aAAA,CAAc,IAAA,KAAS,CAAA,EAAG;AACjC,MAAA,IAAA,CAAK,SAAA,CAAU,gBAAA,CAAiB,oBAAA,EAAsB,IAAA,CAAK,cAAc,CAAA;AAAA,IAC3E;AACA,IAAA,IAAA,CAAK,aAAA,CAAc,IAAI,WAAW,CAAA;AAAA,EACpC;AAAA;AAAA,EAGA,OAAO,WAAA,EAAwC;AAC7C,IAAA,IAAA,CAAK,aAAA,CAAc,OAAO,WAAW,CAAA;AACrC,IAAA,IAAI,IAAA,CAAK,aAAA,CAAc,IAAA,GAAO,CAAA,EAAG,OAAO,KAAA;AACxC,IAAA,IAAA,CAAK,SAAA,CAAU,mBAAA,CAAoB,oBAAA,EAAsB,IAAA,CAAK,cAAc,CAAA;AAC5E,IAAA,OAAO,IAAA;AAAA,EACT;AAAA,EAEA,uBAAA,GAA8C;AAC5C,IAAA,OAAO,KAAA,CAAM,IAAA,CAAK,IAAA,CAAK,aAAa,CAAA,CACjC,MAAA;AAAA,MACC,CAAC,gBACC,WAAA,CAAY,IAAA,CAAK,eAAe,WAAA,CAAY,IAAA,CAAK,kBAAkB,IAAA,CAAK;AAAA,KAC5E,CACC,IAAA,CAAK,CAAC,IAAA,EAAM,KAAA,KAAU;AACrB,MAAA,MAAM,QAAA,GAAW,IAAA,CAAK,IAAA,CAAK,uBAAA,CAAwB,MAAM,IAAI,CAAA;AAC7D,MAAA,OACE,MAAA,CAAO,OAAA,CAAQ,QAAA,GAAW,IAAA,CAAK,2BAA2B,CAAC,CAAA,GAC3D,MAAA,CAAO,OAAA,CAAQ,QAAA,GAAW,IAAA,CAAK,2BAA2B,CAAC,CAAA;AAAA,IAE/D,CAAC,CAAA;AAAA,EACL;AACF,CAAA;AAEA,SAAS,wBAAA,CAAyB,UAAoB,WAAA,EAA2C;AAC/F,EAAA,MAAM,cAAc,iBAAA,CAAkB,GAAA,CAAI,QAAQ,CAAA,IAAK,IAAI,sBAAsB,QAAQ,CAAA;AACzF,EAAA,iBAAA,CAAkB,GAAA,CAAI,UAAU,WAAW,CAAA;AAC3C,EAAA,WAAA,CAAY,IAAI,WAAW,CAAA;AAE3B,EAAA,OAAO,MAAM;AACX,IAAA,IAAI,YAAY,MAAA,CAAO,WAAW,CAAA,EAAG,iBAAA,CAAkB,OAAO,QAAQ,CAAA;AAAA,EACxE,CAAA;AACF;AAmCO,IAAM,mBAAA,GAAN,cAAkC,UAAA,CAA4B;AAAA,EACnE,OAAgB,MAAA,GAAS;AAAA,IACvB,OAAA,EAAS,EAAE,IAAA,EAAM,MAAA,EAAQ,SAAS,6CAAA,EAA8C;AAAA,IAChF,aAAA,EAAe,EAAE,IAAA,EAAM,OAAA,EAAS,SAAS,KAAA;AAAM,GACjD;AAAA,EACA,OAAO,OAAA,GAAU,CAAC,WAAA,EAAa,eAAe,CAAA;AAAA,EAC9C,OAAO,MAAA,GAAS,CAAC,OAAA,EAAS,OAAO,CAAA;AAAA,EAKjC,SAAA,GAAY,EAAA;AAAA,EACZ,MAAA,GAAS,KAAA;AAAA,EACT,kBAAA,GAAqB,KAAA;AAAA,EACZ,SAAA,GAAY,IAAI,WAAA,EAAY;AAAA;AAAA,EAErC,WAAA,GAAc,KAAA;AAAA,EACd,cAAA,GAAuC,IAAA;AAAA,EACvC,kBAAA,GAAoC,IAAA;AAAA,EACpC,iBAAA,GAAoC,IAAA;AAAA,EACpC,gBAAA,GAAwC,IAAA;AAAA,EAE/B,iBAAiB,MAAY;AAGpC,IAAA,IAAA,CAAK,WAAA,GAAc,KAAA;AACnB,IAAA,IAAA,CAAK,SAAA,EAAU;AAAA,EACjB,CAAA;AAAA,EAES,SAAA,GAAY,CAAC,KAAA,KAAuB;AAC3C,IAAA,MAAM,UAAyB,EAAE,KAAA,EAAO,QAAA,EAAU,IAAA,CAAK,YAAW,EAAE;AACpE,IAAA,IAAA,CAAK,cAAA,GAAiB,OAAA;AACtB,IAAA,IAAA,CAAK,WAAA,GAAc,IAAA;AAGnB,IAAA,IAAA,CAAK,SAAA,CAAU,IAAI,MAAM;AACvB,MAAA,IAAI,IAAA,CAAK,mBAAmB,OAAA,EAAS;AACrC,MAAA,IAAA,CAAK,WAAA,GAAc,KAAA;AAAA,IACrB,GAAG,CAAC,CAAA;AAAA,EACN,CAAA;AAAA,EAES,cAAc,MAAY;AACjC,IAAA,IAAI,IAAA,CAAK,mBAAmB,IAAA,EAAM;AAIlC,IAAA,IAAA,CAAK,cAAA,CAAe,QAAA,GAAW,IAAA,CAAK,UAAA,EAAW;AAAA,EACjD,CAAA;AAAA,EAES,cAAA,GAAiB,CAAC,KAAA,KAAuB;AAChD,IAAA,MAAM,SAAU,KAAA,CAAyC,MAAA;AACzD,IAAA,IAAA,CAAK,iBAAA,GAAoB,QAAQ,cAAA,IAAkB,KAAA;AACnD,IAAA,IAAA,CAAK,kBAAA,GAAqB,IAAA,CAAK,cAAA,EAAgB,QAAA,IAAY,KAAK,UAAA,EAAW;AAC3E,IAAA,IAAA,CAAK,cAAA,GAAiB,IAAA;AACtB,IAAA,IAAA,CAAK,WAAA,GAAc,IAAA,CAAK,UAAA,EAAW,KAAM,IAAA,CAAK,kBAAA;AAAA,EAChD,CAAA;AAAA,EAES,YAAA,GAAe,CAAC,KAAA,KAAuB;AAC9C,IAAA,MAAM,SAAU,KAAA,CAAyC,MAAA;AACzD,IAAA,IACE,MAAA,EAAQ,mBAAmB,MAAA,KAC1B,IAAA,CAAK,sBAAsB,IAAA,IAAQ,MAAA,CAAO,cAAA,KAAmB,IAAA,CAAK,iBAAA,CAAA,EACnE;AACA,MAAA;AAAA,IACF;AACA,IAAA,MAAM,oBAAoB,IAAA,CAAK,kBAAA;AAC/B,IAAA,IAAA,CAAK,WAAA,GAAc,KAAA;AACnB,IAAA,IAAA,CAAK,kBAAA,GAAqB,IAAA;AAC1B,IAAA,IAAA,CAAK,iBAAA,GAAoB,IAAA;AACzB,IAAA,IAAI,MAAA,EAAQ,YAAY,KAAA,EAAO;AAC/B,IAAA,IAAI,sBAAsB,IAAA,EAAM;AAC9B,MAAA,IAAA,CAAK,SAAA,EAAU;AACf,MAAA;AAAA,IACF;AAEA,IAAA,IAAA,CAAK,SAAA,GAAY,iBAAA;AACjB,IAAA,IAAA,CAAK,SAAA,EAAU;AAAA,EACjB,CAAA;AAAA,EAES,eAAA,GAAkB,CAAC,KAAA,KAAmC;AAC7D,IAAA,IAAI,CAAC,IAAA,CAAK,MAAA,IAAU,IAAA,CAAK,kBAAiB,EAAG;AAC7C,IAAA,KAAA,CAAM,cAAA,EAAe;AAErB,IAAA,KAAA,CAAM,cAAc,IAAA,CAAK,YAAA;AAAA,EAC3B,CAAA;AAAA,EAES,OAAA,GAAgB;AACvB,IAAA,IAAA,CAAK,gBAAA,EAAiB;AACtB,IAAA,IAAA,CAAK,SAAA,GAAY,KAAK,UAAA,EAAW;AACjC,IAAA,IAAA,CAAK,UAAU,KAAK,CAAA;AAIpB,IAAA,IAAA,CAAK,OAAA,CAAQ,gBAAgB,YAAY,CAAA;AACzC,IAAA,IAAA,CAAK,OAAA,CAAQ,gBAAA,CAAiB,OAAA,EAAS,IAAA,CAAK,cAAc,CAAA;AAC1D,IAAA,IAAA,CAAK,OAAA,CAAQ,gBAAA,CAAiB,QAAA,EAAU,IAAA,CAAK,cAAc,CAAA;AAC3D,IAAA,IAAA,CAAK,OAAA,CAAQ,gBAAA,CAAiB,QAAA,EAAU,IAAA,CAAK,SAAS,CAAA;AACtD,IAAA,IAAA,CAAK,OAAA,CAAQ,gBAAA,CAAiB,UAAA,EAAY,IAAA,CAAK,WAAW,CAAA;AAC1D,IAAA,IAAA,CAAK,OAAA,CAAQ,gBAAA,CAAiB,oBAAA,EAAsB,IAAA,CAAK,cAAc,CAAA;AACvE,IAAA,IAAA,CAAK,OAAA,CAAQ,gBAAA,CAAiB,kBAAA,EAAoB,IAAA,CAAK,YAAY,CAAA;AACnE,IAAA,IAAA,CAAK,gBAAA,GAAmB,wBAAA,CAAyB,IAAA,CAAK,OAAA,CAAQ,aAAA,EAAe;AAAA,MAC3E,MAAM,IAAA,CAAK,OAAA;AAAA,MACX,QAAA,EAAU,CAAC,KAAA,KAAU,IAAA,CAAK,eAAe,KAAK;AAAA,KAC/C,CAAA;AAAA,EACH;AAAA,EAES,UAAA,GAAmB;AAC1B,IAAA,IAAA,CAAK,OAAA,CAAQ,mBAAA,CAAoB,OAAA,EAAS,IAAA,CAAK,cAAc,CAAA;AAC7D,IAAA,IAAA,CAAK,OAAA,CAAQ,mBAAA,CAAoB,QAAA,EAAU,IAAA,CAAK,cAAc,CAAA;AAC9D,IAAA,IAAA,CAAK,OAAA,CAAQ,mBAAA,CAAoB,QAAA,EAAU,IAAA,CAAK,SAAS,CAAA;AACzD,IAAA,IAAA,CAAK,OAAA,CAAQ,mBAAA,CAAoB,UAAA,EAAY,IAAA,CAAK,WAAW,CAAA;AAC7D,IAAA,IAAA,CAAK,OAAA,CAAQ,mBAAA,CAAoB,oBAAA,EAAsB,IAAA,CAAK,cAAc,CAAA;AAC1E,IAAA,IAAA,CAAK,OAAA,CAAQ,mBAAA,CAAoB,kBAAA,EAAoB,IAAA,CAAK,YAAY,CAAA;AACtE,IAAA,IAAA,CAAK,gBAAA,IAAmB;AACxB,IAAA,IAAA,CAAK,gBAAA,GAAmB,IAAA;AACxB,IAAA,IAAA,CAAK,mBAAA,EAAoB;AACzB,IAAA,IAAA,CAAK,gBAAA,EAAiB;AAAA,EACxB;AAAA;AAAA,EAGA,SAAA,GAAkB;AAChB,IAAA,IAAA,CAAK,SAAA,GAAY,KAAK,UAAA,EAAW;AACjC,IAAA,IAAA,CAAK,UAAU,KAAK,CAAA;AAAA,EACtB;AAAA;AAAA,EAGA,aAAA,GAAsB;AACpB,IAAA,IAAI,KAAK,MAAA,EAAQ;AACjB,IAAA,IAAA,CAAK,SAAA,EAAU;AAAA,EACjB;AAAA;AAAA,EAGA,SAAA,GAAkB;AAChB,IAAA,IAAA,CAAK,SAAA,CAAU,IAAA,CAAK,UAAA,EAAW,KAAM,KAAK,SAAS,CAAA;AAAA,EACrD;AAAA,EAEA,UAAU,KAAA,EAAsB;AAC9B,IAAA,IAAI,KAAA,KAAU,KAAK,MAAA,EAAQ;AAC3B,IAAA,IAAA,CAAK,MAAA,GAAS,KAAA;AACd,IAAA,IAAI,KAAA,EAAO;AACT,MAAA,IAAA,CAAK,OAAA,CAAQ,YAAA,CAAa,YAAA,EAAc,MAAM,CAAA;AAC9C,MAAA,IAAA,CAAK,iBAAA,EAAkB;AAAA,IACzB,CAAA,MAAO;AACL,MAAA,IAAA,CAAK,OAAA,CAAQ,gBAAgB,YAAY,CAAA;AACzC,MAAA,IAAA,CAAK,mBAAA,EAAoB;AAAA,IAC3B;AACA,IAAA,IAAA,CAAK,SAAS,OAAA,EAAS,EAAE,QAAQ,EAAE,KAAA,IAAS,CAAA;AAAA,EAC9C;AAAA;AAAA,EAGA,eAAe,KAAA,EAA6B;AAC1C,IAAA,IAAI,CAAC,IAAA,CAAK,MAAA,IAAU,IAAA,CAAK,gBAAA,IAAoB,OAAO,IAAA;AACpD,IAAA,MAAM,KAAA,GAAQ,IAAA,CAAK,QAAA,CAAS,OAAA,EAAS,EAAE,MAAA,EAAQ,EAAE,KAAA,EAAM,EAAG,UAAA,EAAY,IAAA,EAAM,CAAA;AAC5E,IAAA,IAAI,KAAA,CAAM,gBAAA,EAAkB,OAAO,EAAE,MAAM,OAAA,EAAQ;AAEnD,IAAA,IAAI,CAAC,IAAA,CAAK,OAAA,CAAQ,WAAA,IAAe,CAAC,KAAK,MAAA,IAAU,IAAA,CAAK,gBAAA,EAAiB,EAAG,OAAO,IAAA;AACjF,IAAA,IAAI,IAAA,CAAK,kBAAA,EAAoB,OAAO,EAAE,MAAM,OAAA,EAAQ;AACpD,IAAA,OAAO,EAAE,IAAA,EAAM,SAAA,EAAW,OAAA,EAAS,KAAK,YAAA,EAAa;AAAA,EACvD;AAAA,EAEA,iBAAA,GAA0B;AACxB,IAAA,IAAI,KAAK,kBAAA,EAAoB;AAC7B,IAAA,MAAA,CAAO,gBAAA,CAAiB,cAAA,EAAgB,IAAA,CAAK,eAAe,CAAA;AAC5D,IAAA,IAAA,CAAK,kBAAA,GAAqB,IAAA;AAAA,EAC5B;AAAA,EAEA,mBAAA,GAA4B;AAC1B,IAAA,IAAI,CAAC,KAAK,kBAAA,EAAoB;AAC9B,IAAA,MAAA,CAAO,mBAAA,CAAoB,cAAA,EAAgB,IAAA,CAAK,eAAe,CAAA;AAC/D,IAAA,IAAA,CAAK,kBAAA,GAAqB,KAAA;AAAA,EAC5B;AAAA,EAEA,gBAAA,GAA4B;AAC1B,IAAA,OACE,IAAA,CAAK,gBACJ,IAAA,CAAK,cAAA,KAAmB,QAAQ,CAAC,IAAA,CAAK,eAAe,KAAA,CAAM,gBAAA,CAAA;AAAA,EAEhE;AAAA,EAEA,gBAAA,GAAyB;AACvB,IAAA,IAAA,CAAK,UAAU,QAAA,EAAS;AACxB,IAAA,IAAA,CAAK,WAAA,GAAc,KAAA;AACnB,IAAA,IAAA,CAAK,cAAA,GAAiB,IAAA;AACtB,IAAA,IAAA,CAAK,kBAAA,GAAqB,IAAA;AAC1B,IAAA,IAAA,CAAK,iBAAA,GAAoB,IAAA;AAAA,EAC3B;AAAA;AAAA,EAGA,UAAA,GAAqB;AACnB,IAAA,MAAM,QAA6B,EAAC;AACpC,IAAA,KAAA,MAAW,MAAM,KAAA,CAAM,IAAA,CAAK,IAAA,CAAK,OAAA,CAAQ,QAAQ,CAAA,EAAG;AAClD,MAAA,MAAM,IAAA,GAAO,IAAA,CAAK,OAAA,CAAQ,EAAE,CAAA;AAC5B,MAAA,IAAI,SAAS,IAAA,EAAM;AACnB,MAAA,IAAI,cAAc,gBAAA,KAAqB,EAAA,CAAG,SAAS,UAAA,IAAc,EAAA,CAAG,SAAS,OAAA,CAAA,EAAU;AACrF,QAAA,KAAA,CAAM,IAAA,CAAK,CAAC,EAAA,CAAG,IAAA,EAAM,IAAA,EAAM,EAAA,CAAG,KAAA,EAAO,EAAA,CAAG,OAAA,GAAU,CAAA,GAAI,CAAC,CAAC,CAAA;AAAA,MAC1D,CAAA,MAAA,IAAW,cAAc,iBAAA,EAAmB;AAE1C,QAAA,MAAM,KAAA,GAAQ,EAAA,CAAG,QAAA,GACb,KAAA,CAAM,KAAK,EAAA,CAAG,eAAe,CAAA,CAAE,GAAA,CAAI,CAAC,MAAA,KAAW,MAAA,CAAO,KAAK,IAC3D,EAAA,CAAG,KAAA;AACP,QAAA,KAAA,CAAM,KAAK,CAAC,EAAA,CAAG,IAAA,EAAM,IAAA,EAAM,KAAK,CAAC,CAAA;AAAA,MACnC,CAAA,MAAA,IAAW,EAAA,YAAc,gBAAA,IAAoB,EAAA,YAAc,mBAAA,EAAqB;AAC9E,QAAA,KAAA,CAAM,KAAK,CAAC,EAAA,CAAG,MAAM,IAAA,EAAM,EAAA,CAAG,KAAK,CAAC,CAAA;AAAA,MACtC;AAAA,IACF;AACA,IAAA,OAAO,IAAA,CAAK,UAAU,KAAK,CAAA;AAAA,EAC7B;AAAA;AAAA,EAGA,QAAQ,EAAA,EAA4B;AAClC,IAAA,IACE,EAAA,YAAc,gBAAA,IACd,EAAA,YAAc,mBAAA,IACd,cAAc,iBAAA,EACd;AACA,MAAA,OAAO,EAAA,CAAG,IAAA,IAAQ,EAAA,CAAG,EAAA,IAAM,EAAA;AAAA,IAC7B;AACA,IAAA,OAAO,IAAA;AAAA,EACT;AACF","file":"dirty_form_controller.js","sourcesContent":["/**\n * Self-cleaning timer registries shared by Stimeo controllers.\n *\n * Stimulus controllers frequently schedule `setTimeout` / `setInterval` work\n * (auto-dismiss, debouncing, polling). When the element leaves the DOM — a\n * Turbo Drive navigation, a Turbo Stream replacement, or any `disconnect()` —\n * orphaned timers keep firing against a detached controller, leaking memory and\n * mutating stale state. {@link SafeTimeout} and {@link SafeInterval} track every\n * timer they create so a single {@link TimerRegistry.clearAll | clearAll()} call\n * in `disconnect()` tears them all down.\n *\n * These are intentionally low-level primitives: they own *registration and\n * cleanup only*. Higher-level policy (pause/resume, remaining-time accounting)\n * stays in the individual controllers so per-widget semantics are not flattened\n * into a lowest-common-denominator helper.\n */\n\n/**\n * Shared registry bookkeeping for the timeout/interval variants.\n *\n * Subclasses provide the scheduling primitive ({@link schedule}) and its matching\n * canceller ({@link cancel}); this base owns the set of live ids plus the\n * per-id and bulk teardown shared by both.\n */\nabstract class TimerRegistry {\n /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */\n protected readonly ids = new Set<number>();\n\n /** Schedules the underlying platform timer and returns its id. */\n protected abstract schedule(callback: () => void, delay: number): number;\n\n /** Cancels the underlying platform timer for `id`. */\n protected abstract cancel(id: number): void;\n\n /**\n * Cancels a single tracked timer.\n *\n * No-ops if the id is unknown (already cleared, fired, or never owned by this\n * registry), so callers can clear defensively without guarding.\n */\n clear(id: number): void {\n if (this.ids.delete(id)) {\n this.cancel(id);\n }\n }\n\n /**\n * Cancels every tracked timer. Call this from a controller's `disconnect()`\n * to guarantee no timer outlives the element.\n */\n clearAll(): void {\n for (const id of this.ids) {\n this.cancel(id);\n }\n this.ids.clear();\n }\n\n /** Number of timers currently tracked (pending). */\n get size(): number {\n return this.ids.size;\n }\n}\n\n/**\n * `setTimeout` wrapper that auto-forgets each timer once it fires and supports\n * bulk teardown on disconnect.\n *\n * @example\n * ```ts\n * #timers = new SafeTimeout();\n *\n * connect() {\n * this.#timers.set(() => this.dismiss(), 5000);\n * }\n *\n * disconnect() {\n * this.#timers.clearAll();\n * }\n * ```\n */\nexport class SafeTimeout extends TimerRegistry {\n /**\n * Schedules `callback` after `delay` ms and returns the timer id.\n *\n * The id is removed from the registry automatically when the timeout fires,\n * so {@link TimerRegistry.size | size} reflects only still-pending timers.\n */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(() => {\n this.ids.delete(id);\n callback();\n }, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setTimeout(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearTimeout(id);\n }\n}\n\n/**\n * `setInterval` wrapper that tracks every interval for bulk teardown on\n * disconnect. Unlike {@link SafeTimeout}, intervals are retained until they are\n * explicitly cleared because they fire repeatedly.\n *\n * @example\n * ```ts\n * #intervals = new SafeInterval();\n *\n * connect() {\n * this.#intervals.set(() => this.tick(), 1000);\n * }\n *\n * disconnect() {\n * this.#intervals.clearAll();\n * }\n * ```\n */\nexport class SafeInterval extends TimerRegistry {\n /** Schedules a repeating `callback` every `delay` ms and returns the timer id. */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(callback, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setInterval(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearInterval(id);\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { SafeTimeout } from \"../utils/safe_timeout\";\n\ntype VisitDecision =\n | { readonly kind: \"block\" }\n | { readonly kind: \"confirm\"; readonly message: string }\n | null;\n\ninterface VisitParticipant {\n readonly form: HTMLFormElement;\n readonly evaluate: (event: Event) => VisitDecision;\n}\n\ninterface SubmitAttempt {\n readonly event: Event;\n snapshot: string;\n}\n\ninterface TurboSubmitDetail {\n readonly formSubmission?: unknown;\n readonly success?: boolean;\n}\n\ntype SerializedControl = readonly [\n type: string,\n name: string,\n value: string | readonly string[],\n checked?: 0 | 1,\n];\n\n/**\n * Coordinates native Turbo confirmations for every dirty form in one document.\n *\n * The weak document registry owns no dirty baseline or durable form state. Each\n * controller remains the source of truth for its own state and unregisters on\n * disconnect; the shared coordinator only gives one navigation event a single,\n * deterministic decision point.\n */\nconst visitCoordinators = new WeakMap<Document, TurboVisitCoordinator>();\n\nclass TurboVisitCoordinator {\n readonly #document: Document;\n readonly #participants = new Set<VisitParticipant>();\n\n readonly #onBeforeVisit = (event: Event): void => {\n let blocked = event.defaultPrevented;\n let message: string | undefined;\n\n // Every eligible form gets its public guard event before a native prompt.\n // A later consumer can therefore block the visit without an earlier form\n // opening a redundant confirm first.\n for (const participant of this.#participantsInDomOrder()) {\n if (!participant.form.isConnected) continue;\n const decision = participant.evaluate(event);\n blocked ||= event.defaultPrevented;\n if (decision === null) continue;\n if (decision.kind === \"block\") {\n blocked = true;\n } else {\n message ??= decision.message;\n }\n }\n\n if (blocked) {\n event.preventDefault();\n return;\n }\n if (message === undefined) return;\n if (!this.#document.defaultView?.confirm(message)) event.preventDefault();\n };\n\n constructor(document: Document) {\n this.#document = document;\n }\n\n add(participant: VisitParticipant): void {\n if (this.#participants.size === 0) {\n this.#document.addEventListener(\"turbo:before-visit\", this.#onBeforeVisit);\n }\n this.#participants.add(participant);\n }\n\n /** Removes a participant and reports whether the coordinator became empty. */\n remove(participant: VisitParticipant): boolean {\n this.#participants.delete(participant);\n if (this.#participants.size > 0) return false;\n this.#document.removeEventListener(\"turbo:before-visit\", this.#onBeforeVisit);\n return true;\n }\n\n #participantsInDomOrder(): VisitParticipant[] {\n return Array.from(this.#participants)\n .filter(\n (participant) =>\n participant.form.isConnected && participant.form.ownerDocument === this.#document,\n )\n .sort((left, right) => {\n const position = left.form.compareDocumentPosition(right.form);\n return (\n Number(Boolean(position & Node.DOCUMENT_POSITION_PRECEDING)) -\n Number(Boolean(position & Node.DOCUMENT_POSITION_FOLLOWING))\n );\n });\n }\n}\n\nfunction registerVisitParticipant(document: Document, participant: VisitParticipant): () => void {\n const coordinator = visitCoordinators.get(document) ?? new TurboVisitCoordinator(document);\n visitCoordinators.set(document, coordinator);\n coordinator.add(participant);\n\n return () => {\n if (coordinator.remove(participant)) visitCoordinators.delete(document);\n };\n}\n\n/**\n * Headless \"unsaved changes\" guard for a form (no dedicated APG pattern; supports\n * WCAG 2.2 error-prevention 3.3.4 / 3.3.6 by preventing accidental data loss).\n *\n * Markup contract (identifier: `stimeo--dirty-form`):\n * <form data-controller=\"stimeo--dirty-form\">\n * …fields…\n * </form>\n *\n * Snapshots the form's field values on connect, marks the form `data-dirty` once a\n * value changes, and — while dirty — guards both a full unload (`beforeunload`) and\n * a Turbo visit (`turbo:before-visit`). On a Turbo visit it dispatches a cancelable\n * `guard` event for each eligible form; if a consumer cancels one (or any form uses\n * `confirmBridge`) the visit is blocked, otherwise the document falls back to one\n * native `confirm(message)`, using the first eligible form in live DOM order. While a\n * submit is in flight the guard is suppressed until another edit occurs (so a\n * legitimate submit never prompts); the browser's `formdata` event captures the exact\n * revision Turbo materializes, and a successful `turbo:submit-end` accepts only that\n * revision, preserving any later edits as dirty. A failed submit re-arms the guard;\n * `markClean` clears it manually (e.g. after a custom save), while `acceptRestore`\n * adopts a restored draft only while no user edit is already dirty.\n *\n * `dirty` dispatches `{ dirty }`; `guard` dispatches `{ event }`.\n *\n * @remarks\n * Behavior only — it renders no confirmation UI (pair with a Confirm Bridge) and\n * does not persist input (pair with Persist). The dirty baseline is read from the\n * DOM on `connect()` (no module-scope state) — which also clears a stale\n * `data-dirty` left in a Turbo cache snapshot, since the restored values are the\n * new baseline — `beforeunload` is wired only while dirty, and every listener is\n * removed on `disconnect()` (Turbo navigation included) so a stale guard never\n * outlives the form.\n */\nexport class DirtyFormController extends Controller<HTMLFormElement> {\n static override values = {\n message: { type: String, default: \"You have unsaved changes that will be lost.\" },\n confirmBridge: { type: Boolean, default: false },\n };\n static actions = [\"markClean\", \"acceptRestore\"] as const;\n static events = [\"dirty\", \"guard\"] as const;\n\n declare messageValue: string;\n declare confirmBridgeValue: boolean;\n\n #baseline = \"\";\n #dirty = false;\n #beforeunloadBound = false;\n readonly #timeouts = new SafeTimeout();\n /** Whether the active submission still represents the form's current values. */\n #submitting = false;\n #submitAttempt: SubmitAttempt | null = null;\n #submittedBaseline: string | null = null;\n #activeSubmission: unknown | null = null;\n #unregisterVisit: (() => void) | null = null;\n\n readonly #onFieldChange = (): void => {\n // An edit after submission belongs to a new unsaved revision. Keep the\n // submitted snapshot for submit-end, but stop suppressing navigation now.\n this.#submitting = false;\n this.#evaluate();\n };\n\n readonly #onSubmit = (event: Event): void => {\n const attempt: SubmitAttempt = { event, snapshot: this.#serialize() };\n this.#submitAttempt = attempt;\n this.#submitting = true;\n // Native navigation runs before the next task. If it does not navigate and\n // Turbo never claims the attempt, the form must not remain suppressed.\n this.#timeouts.set(() => {\n if (this.#submitAttempt !== attempt) return;\n this.#submitting = false;\n }, 0);\n };\n\n readonly #onFormData = (): void => {\n if (this.#submitAttempt === null) return;\n // Turbo constructs FormData after target-level submit consumers have run.\n // Capturing here reflects exactly that revision, even when an asynchronous\n // confirmation delays turbo:submit-start and the user edits in the meantime.\n this.#submitAttempt.snapshot = this.#serialize();\n };\n\n readonly #onSubmitStart = (event: Event): void => {\n const detail = (event as CustomEvent<TurboSubmitDetail>).detail;\n this.#activeSubmission = detail?.formSubmission ?? event;\n this.#submittedBaseline = this.#submitAttempt?.snapshot ?? this.#serialize();\n this.#submitAttempt = null;\n this.#submitting = this.#serialize() === this.#submittedBaseline;\n };\n\n readonly #onSubmitEnd = (event: Event): void => {\n const detail = (event as CustomEvent<TurboSubmitDetail>).detail;\n if (\n detail?.formSubmission !== undefined &&\n (this.#activeSubmission === null || detail.formSubmission !== this.#activeSubmission)\n ) {\n return;\n }\n const submittedBaseline = this.#submittedBaseline;\n this.#submitting = false;\n this.#submittedBaseline = null;\n this.#activeSubmission = null;\n if (detail?.success === false) return;\n if (submittedBaseline === null) {\n this.markClean();\n return;\n }\n // The server accepted the submitted revision, not edits made after it left.\n this.#baseline = submittedBaseline;\n this.#evaluate();\n };\n\n readonly #onBeforeUnload = (event: BeforeUnloadEvent): void => {\n if (!this.#dirty || this.#guardSuppressed()) return;\n event.preventDefault();\n // Legacy requirement: a non-empty returnValue triggers the native prompt.\n event.returnValue = this.messageValue;\n };\n\n override connect(): void {\n this.#resetSubmission();\n this.#baseline = this.#serialize();\n this.#setDirty(false);\n // Re-baselining means the restored values ARE the clean state, so a stale\n // data-dirty captured in a Turbo cache snapshot mid-edit must not linger\n // (the guard would not fire, but consumer CSS would keep claiming \"unsaved\").\n this.element.removeAttribute(\"data-dirty\");\n this.element.addEventListener(\"input\", this.#onFieldChange);\n this.element.addEventListener(\"change\", this.#onFieldChange);\n this.element.addEventListener(\"submit\", this.#onSubmit);\n this.element.addEventListener(\"formdata\", this.#onFormData);\n this.element.addEventListener(\"turbo:submit-start\", this.#onSubmitStart);\n this.element.addEventListener(\"turbo:submit-end\", this.#onSubmitEnd);\n this.#unregisterVisit = registerVisitParticipant(this.element.ownerDocument, {\n form: this.element,\n evaluate: (event) => this.#evaluateVisit(event),\n });\n }\n\n override disconnect(): void {\n this.element.removeEventListener(\"input\", this.#onFieldChange);\n this.element.removeEventListener(\"change\", this.#onFieldChange);\n this.element.removeEventListener(\"submit\", this.#onSubmit);\n this.element.removeEventListener(\"formdata\", this.#onFormData);\n this.element.removeEventListener(\"turbo:submit-start\", this.#onSubmitStart);\n this.element.removeEventListener(\"turbo:submit-end\", this.#onSubmitEnd);\n this.#unregisterVisit?.();\n this.#unregisterVisit = null;\n this.#unbindBeforeUnload();\n this.#resetSubmission();\n }\n\n /** Re-baselines to the current values and clears the dirty state (e.g. after a save). */\n markClean(): void {\n this.#baseline = this.#serialize();\n this.#setDirty(false);\n }\n\n /** Adopts restored values without erasing an already-dirty user revision. */\n acceptRestore(): void {\n if (this.#dirty) return;\n this.markClean();\n }\n\n /** Recomputes dirty against the baseline and flips state when it changes. */\n #evaluate(): void {\n this.#setDirty(this.#serialize() !== this.#baseline);\n }\n\n #setDirty(dirty: boolean): void {\n if (dirty === this.#dirty) return;\n this.#dirty = dirty;\n if (dirty) {\n this.element.setAttribute(\"data-dirty\", \"true\");\n this.#bindBeforeUnload();\n } else {\n this.element.removeAttribute(\"data-dirty\");\n this.#unbindBeforeUnload();\n }\n this.dispatch(\"dirty\", { detail: { dirty } });\n }\n\n /** Evaluates this form for the document-level Turbo visit coordinator. */\n #evaluateVisit(event: Event): VisitDecision {\n if (!this.#dirty || this.#guardSuppressed()) return null;\n const guard = this.dispatch(\"guard\", { detail: { event }, cancelable: true });\n if (guard.defaultPrevented) return { kind: \"block\" };\n // A guard consumer may have saved, submitted, or removed the form synchronously.\n if (!this.element.isConnected || !this.#dirty || this.#guardSuppressed()) return null;\n if (this.confirmBridgeValue) return { kind: \"block\" };\n return { kind: \"confirm\", message: this.messageValue };\n }\n\n #bindBeforeUnload(): void {\n if (this.#beforeunloadBound) return;\n window.addEventListener(\"beforeunload\", this.#onBeforeUnload);\n this.#beforeunloadBound = true;\n }\n\n #unbindBeforeUnload(): void {\n if (!this.#beforeunloadBound) return;\n window.removeEventListener(\"beforeunload\", this.#onBeforeUnload);\n this.#beforeunloadBound = false;\n }\n\n #guardSuppressed(): boolean {\n return (\n this.#submitting &&\n (this.#submitAttempt === null || !this.#submitAttempt.event.defaultPrevented)\n );\n }\n\n #resetSubmission(): void {\n this.#timeouts.clearAll();\n this.#submitting = false;\n this.#submitAttempt = null;\n this.#submittedBaseline = null;\n this.#activeSubmission = null;\n }\n\n /** Stable serialization of the form's controls for change detection. */\n #serialize(): string {\n const parts: SerializedControl[] = [];\n for (const el of Array.from(this.element.elements)) {\n const name = this.#nameOf(el);\n if (name === null) continue;\n if (el instanceof HTMLInputElement && (el.type === \"checkbox\" || el.type === \"radio\")) {\n parts.push([el.type, name, el.value, el.checked ? 1 : 0]);\n } else if (el instanceof HTMLSelectElement) {\n // Keep multi-select values structural so option values containing commas cannot collide.\n const value = el.multiple\n ? Array.from(el.selectedOptions).map((option) => option.value)\n : el.value;\n parts.push([el.type, name, value]);\n } else if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {\n parts.push([el.type, name, el.value]);\n }\n }\n return JSON.stringify(parts);\n }\n\n /** A stable key for a control, or null for elements without value semantics. */\n #nameOf(el: Element): string | null {\n if (\n el instanceof HTMLInputElement ||\n el instanceof HTMLTextAreaElement ||\n el instanceof HTMLSelectElement\n ) {\n return el.name || el.id || \"\";\n }\n return null;\n }\n}\n"]}
|
|
@@ -18,6 +18,8 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
18
18
|
* it is moved to a safe place first so the close button vanishing never strands
|
|
19
19
|
* the user (WCAG 2.4.3).
|
|
20
20
|
*
|
|
21
|
+
* `dismiss` dispatches `{ mode: "remove" | "hide" }`.
|
|
22
|
+
*
|
|
21
23
|
* @remarks
|
|
22
24
|
* Behavior only — the consumer owns any exit transition (use `hide` mode, which
|
|
23
25
|
* adds `hidden`, and animate off `data-state`) and any semantics like
|
|
@@ -2,8 +2,79 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/dismissible_controller.ts
|
|
4
4
|
|
|
5
|
-
// src/utils/
|
|
6
|
-
|
|
5
|
+
// src/utils/focus_candidate.ts
|
|
6
|
+
function inheritsFieldsetDisabled(control) {
|
|
7
|
+
let fieldset = control.closest("fieldset[disabled]");
|
|
8
|
+
while (fieldset) {
|
|
9
|
+
const legend = Array.from(fieldset.children).find((child) => child.tagName === "LEGEND");
|
|
10
|
+
if (!legend?.contains(control)) return true;
|
|
11
|
+
fieldset = fieldset.parentElement?.closest("fieldset[disabled]") ?? null;
|
|
12
|
+
}
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
function canTakeFocus(element) {
|
|
16
|
+
if (element.closest("[hidden], [inert]")) return false;
|
|
17
|
+
if (element instanceof HTMLInputElement && element.type === "hidden") return false;
|
|
18
|
+
if (!("disabled" in element)) return true;
|
|
19
|
+
if (element.disabled) return false;
|
|
20
|
+
return !inheritsFieldsetDisabled(element);
|
|
21
|
+
}
|
|
22
|
+
var TAB_STOP_CANDIDATE_SELECTOR = [
|
|
23
|
+
"a[href]",
|
|
24
|
+
"area[href]",
|
|
25
|
+
"button",
|
|
26
|
+
"input",
|
|
27
|
+
"select",
|
|
28
|
+
"textarea",
|
|
29
|
+
"summary",
|
|
30
|
+
"iframe",
|
|
31
|
+
"audio[controls]",
|
|
32
|
+
"video[controls]",
|
|
33
|
+
"[tabindex]",
|
|
34
|
+
"[contenteditable]"
|
|
35
|
+
].join(",");
|
|
36
|
+
function isRenderedForFocus(element) {
|
|
37
|
+
const check = element.checkVisibility;
|
|
38
|
+
return typeof check === "function" ? check.call(element, { visibilityProperty: true }) : true;
|
|
39
|
+
}
|
|
40
|
+
function authoredTabindex(element) {
|
|
41
|
+
const value = element.getAttribute("tabindex");
|
|
42
|
+
if (value === null || !/^[+-]?\d+$/.test(value.trim())) return null;
|
|
43
|
+
return Number(value);
|
|
44
|
+
}
|
|
45
|
+
function hasNativeTabStop(element) {
|
|
46
|
+
if (element instanceof HTMLAnchorElement || element instanceof HTMLAreaElement) {
|
|
47
|
+
return element.hasAttribute("href");
|
|
48
|
+
}
|
|
49
|
+
if (element instanceof HTMLButtonElement || element instanceof HTMLSelectElement || element instanceof HTMLTextAreaElement) {
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
if (element instanceof HTMLInputElement) return element.type !== "hidden";
|
|
53
|
+
if (element instanceof HTMLIFrameElement) return true;
|
|
54
|
+
if (element.tagName === "AUDIO" || element.tagName === "VIDEO") {
|
|
55
|
+
return element.hasAttribute("controls");
|
|
56
|
+
}
|
|
57
|
+
if (element instanceof HTMLElement && element.tagName === "SUMMARY") {
|
|
58
|
+
const details = element.parentElement;
|
|
59
|
+
return details instanceof HTMLDetailsElement && Array.from(details.children).find((child) => child.tagName === "SUMMARY") === element;
|
|
60
|
+
}
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
function hasEditableTabStop(element) {
|
|
64
|
+
const value = element.getAttribute("contenteditable")?.toLowerCase();
|
|
65
|
+
return value === "" || value === "true" || value === "plaintext-only";
|
|
66
|
+
}
|
|
67
|
+
function isTabStop(element) {
|
|
68
|
+
if (!canTakeFocus(element) || !isRenderedForFocus(element)) return false;
|
|
69
|
+
const tabindex = authoredTabindex(element);
|
|
70
|
+
if (tabindex !== null) return tabindex >= 0;
|
|
71
|
+
return hasNativeTabStop(element) || hasEditableTabStop(element);
|
|
72
|
+
}
|
|
73
|
+
function tabStopsWithin(root) {
|
|
74
|
+
return Array.from(root.querySelectorAll(TAB_STOP_CANDIDATE_SELECTOR)).filter(
|
|
75
|
+
isTabStop
|
|
76
|
+
);
|
|
77
|
+
}
|
|
7
78
|
|
|
8
79
|
// src/controllers/dismissible_controller.ts
|
|
9
80
|
var DismissibleController = class extends Controller {
|
|
@@ -80,35 +151,29 @@ var DismissibleController = class extends Controller {
|
|
|
80
151
|
/** Yields available focus fallbacks in precedence order, stopping after focus succeeds. */
|
|
81
152
|
*#focusFallbacks(root) {
|
|
82
153
|
const explicitFallback = this.hasFallbackTarget ? this.fallbackTarget : null;
|
|
83
|
-
if (explicitFallback && !root.contains(explicitFallback) && this.#
|
|
154
|
+
if (explicitFallback && !root.contains(explicitFallback) && this.#isExplicitFocusAvailable(explicitFallback)) {
|
|
84
155
|
yield explicitFallback;
|
|
85
156
|
}
|
|
86
|
-
const candidates = document
|
|
157
|
+
const candidates = tabStopsWithin(document);
|
|
87
158
|
for (const element of candidates) {
|
|
88
159
|
if (element === explicitFallback || root.contains(element) || !(root.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_FOLLOWING)) {
|
|
89
160
|
continue;
|
|
90
161
|
}
|
|
91
|
-
|
|
162
|
+
yield element;
|
|
92
163
|
}
|
|
93
164
|
for (let index = candidates.length - 1; index >= 0; index -= 1) {
|
|
94
|
-
const element = candidates
|
|
165
|
+
const element = candidates[index];
|
|
166
|
+
if (!element) continue;
|
|
95
167
|
if (element === explicitFallback || root.contains(element) || !(root.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_PRECEDING)) {
|
|
96
168
|
continue;
|
|
97
169
|
}
|
|
98
|
-
|
|
170
|
+
yield element;
|
|
99
171
|
}
|
|
100
172
|
}
|
|
101
|
-
/** Whether
|
|
102
|
-
#
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
106
|
-
if (element.matches(":disabled")) return false;
|
|
107
|
-
if (element instanceof HTMLInputElement && element.type === "hidden") return false;
|
|
108
|
-
for (let current = element; current; current = current.parentElement) {
|
|
109
|
-
if (current.hidden || current.inert) return false;
|
|
110
|
-
}
|
|
111
|
-
return true;
|
|
173
|
+
/** Whether an explicit fallback can receive programmatic focus right now. */
|
|
174
|
+
#isExplicitFocusAvailable(element) {
|
|
175
|
+
const programmaticallyFocusable = element.hasAttribute("tabindex") || isTabStop(element);
|
|
176
|
+
return programmaticallyFocusable && canTakeFocus(element) && isRenderedForFocus(element);
|
|
112
177
|
}
|
|
113
178
|
};
|
|
114
179
|
|