stimeo-ui 0.14.0 → 0.16.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 +212 -0
- package/README.md +120 -0
- package/dist/cable/index.js +123 -29
- package/dist/cable/index.js.map +1 -1
- package/dist/controllers/accordion_controller.d.ts +30 -3
- package/dist/controllers/accordion_controller.js +98 -9
- package/dist/controllers/accordion_controller.js.map +1 -1
- package/dist/controllers/alert_dialog_controller.d.ts +8 -7
- package/dist/controllers/alert_dialog_controller.js.map +1 -1
- package/dist/controllers/announcer_controller.d.ts +3 -2
- package/dist/controllers/announcer_controller.js +96 -62
- package/dist/controllers/announcer_controller.js.map +1 -1
- package/dist/controllers/auto_submit_controller.js +83 -7
- package/dist/controllers/auto_submit_controller.js.map +1 -1
- package/dist/controllers/avatar_controller.js +1 -1
- package/dist/controllers/avatar_controller.js.map +1 -1
- package/dist/controllers/breadcrumb_controller.d.ts +10 -7
- package/dist/controllers/breadcrumb_controller.js +38 -11
- package/dist/controllers/breadcrumb_controller.js.map +1 -1
- package/dist/controllers/bulk_select_controller.d.ts +4 -4
- package/dist/controllers/bulk_select_controller.js +7 -6
- package/dist/controllers/bulk_select_controller.js.map +1 -1
- package/dist/controllers/calendar_controller.d.ts +101 -23
- package/dist/controllers/calendar_controller.js +340 -123
- package/dist/controllers/calendar_controller.js.map +1 -1
- package/dist/controllers/carousel_controller.d.ts +67 -22
- package/dist/controllers/carousel_controller.js +263 -38
- package/dist/controllers/carousel_controller.js.map +1 -1
- package/dist/controllers/character_counter_controller.d.ts +1 -1
- package/dist/controllers/character_counter_controller.js +40 -2
- package/dist/controllers/character_counter_controller.js.map +1 -1
- package/dist/controllers/checkbox_controller.js +81 -12
- package/dist/controllers/checkbox_controller.js.map +1 -1
- package/dist/controllers/clipboard_controller.d.ts +28 -2
- package/dist/controllers/clipboard_controller.js +63 -5
- package/dist/controllers/clipboard_controller.js.map +1 -1
- package/dist/controllers/collapsible_controller.d.ts +25 -1
- package/dist/controllers/collapsible_controller.js +99 -14
- package/dist/controllers/collapsible_controller.js.map +1 -1
- package/dist/controllers/color_picker_controller.d.ts +29 -8
- package/dist/controllers/color_picker_controller.js +80 -34
- package/dist/controllers/color_picker_controller.js.map +1 -1
- package/dist/controllers/combobox_controller.d.ts +10 -1
- package/dist/controllers/combobox_controller.js +106 -16
- package/dist/controllers/combobox_controller.js.map +1 -1
- package/dist/controllers/command_palette_controller.d.ts +3 -3
- package/dist/controllers/command_palette_controller.js +35 -3
- package/dist/controllers/command_palette_controller.js.map +1 -1
- package/dist/controllers/conditional_fields_controller.js +85 -17
- package/dist/controllers/conditional_fields_controller.js.map +1 -1
- package/dist/controllers/confirm_controller.js +3 -0
- package/dist/controllers/confirm_controller.js.map +1 -1
- package/dist/controllers/context_menu_controller.d.ts +6 -0
- package/dist/controllers/context_menu_controller.js +32 -12
- package/dist/controllers/context_menu_controller.js.map +1 -1
- package/dist/controllers/count_up_controller.js.map +1 -1
- package/dist/controllers/countdown_controller.d.ts +30 -1
- package/dist/controllers/countdown_controller.js +129 -26
- package/dist/controllers/countdown_controller.js.map +1 -1
- package/dist/controllers/currency_input_controller.d.ts +77 -16
- package/dist/controllers/currency_input_controller.js +221 -67
- package/dist/controllers/currency_input_controller.js.map +1 -1
- package/dist/controllers/data_grid_controller.d.ts +63 -18
- package/dist/controllers/data_grid_controller.js +195 -29
- package/dist/controllers/data_grid_controller.js.map +1 -1
- package/dist/controllers/date_range_picker_controller.d.ts +41 -6
- package/dist/controllers/date_range_picker_controller.js +151 -30
- package/dist/controllers/date_range_picker_controller.js.map +1 -1
- package/dist/controllers/dialog_controller.d.ts +10 -3
- package/dist/controllers/dialog_controller.js +35 -8
- package/dist/controllers/dialog_controller.js.map +1 -1
- package/dist/controllers/direct_upload_controller.js +22 -4
- package/dist/controllers/direct_upload_controller.js.map +1 -1
- package/dist/controllers/dirty_form_controller.d.ts +2 -2
- package/dist/controllers/dirty_form_controller.js +46 -13
- package/dist/controllers/dirty_form_controller.js.map +1 -1
- package/dist/controllers/dismissible_controller.js +1 -0
- package/dist/controllers/dismissible_controller.js.map +1 -1
- package/dist/controllers/drawer_controller.d.ts +18 -10
- package/dist/controllers/drawer_controller.js +54 -19
- package/dist/controllers/drawer_controller.js.map +1 -1
- package/dist/controllers/dropdown_controller.d.ts +9 -3
- package/dist/controllers/dropdown_controller.js +36 -9
- package/dist/controllers/dropdown_controller.js.map +1 -1
- package/dist/controllers/editable_controller.js +34 -0
- package/dist/controllers/editable_controller.js.map +1 -1
- package/dist/controllers/file_dropzone_controller.js +144 -51
- package/dist/controllers/file_dropzone_controller.js.map +1 -1
- package/dist/controllers/filter_controller.d.ts +10 -4
- package/dist/controllers/filter_controller.js +20 -6
- package/dist/controllers/filter_controller.js.map +1 -1
- package/dist/controllers/flash_controller.d.ts +26 -6
- package/dist/controllers/flash_controller.js +432 -71
- package/dist/controllers/flash_controller.js.map +1 -1
- package/dist/controllers/focus_controller.js +1 -0
- package/dist/controllers/focus_controller.js.map +1 -1
- package/dist/controllers/form_field_controller.js +7 -5
- package/dist/controllers/form_field_controller.js.map +1 -1
- package/dist/controllers/form_validation_controller.js +19 -13
- package/dist/controllers/form_validation_controller.js.map +1 -1
- package/dist/controllers/frame_loading_controller.js +45 -8
- package/dist/controllers/frame_loading_controller.js.map +1 -1
- package/dist/controllers/highlight_controller.js +82 -25
- package/dist/controllers/highlight_controller.js.map +1 -1
- package/dist/controllers/hover_card_controller.d.ts +10 -2
- package/dist/controllers/hover_card_controller.js +40 -14
- package/dist/controllers/hover_card_controller.js.map +1 -1
- package/dist/controllers/idle_controller.d.ts +16 -3
- package/dist/controllers/idle_controller.js +90 -5
- package/dist/controllers/idle_controller.js.map +1 -1
- package/dist/controllers/input_mask_controller.d.ts +5 -2
- package/dist/controllers/input_mask_controller.js +65 -9
- package/dist/controllers/input_mask_controller.js.map +1 -1
- package/dist/controllers/intersection_controller.js +3 -0
- package/dist/controllers/intersection_controller.js.map +1 -1
- package/dist/controllers/lazy_frame_controller.js +11 -2
- package/dist/controllers/lazy_frame_controller.js.map +1 -1
- package/dist/controllers/listbox_controller.d.ts +50 -8
- package/dist/controllers/listbox_controller.js +203 -45
- package/dist/controllers/listbox_controller.js.map +1 -1
- package/dist/controllers/local_time_controller.js +10 -5
- package/dist/controllers/local_time_controller.js.map +1 -1
- package/dist/controllers/masonry_controller.d.ts +3 -3
- package/dist/controllers/masonry_controller.js +31 -15
- package/dist/controllers/masonry_controller.js.map +1 -1
- package/dist/controllers/menu_controller.d.ts +9 -3
- package/dist/controllers/menu_controller.js +45 -16
- package/dist/controllers/menu_controller.js.map +1 -1
- package/dist/controllers/menubar_controller.d.ts +11 -0
- package/dist/controllers/menubar_controller.js +58 -24
- package/dist/controllers/menubar_controller.js.map +1 -1
- package/dist/controllers/meter_controller.js +9 -5
- package/dist/controllers/meter_controller.js.map +1 -1
- package/dist/controllers/multi_select_controller.d.ts +18 -3
- package/dist/controllers/multi_select_controller.js +278 -104
- package/dist/controllers/multi_select_controller.js.map +1 -1
- package/dist/controllers/navigation_menu_controller.d.ts +11 -0
- package/dist/controllers/navigation_menu_controller.js +48 -15
- package/dist/controllers/navigation_menu_controller.js.map +1 -1
- package/dist/controllers/nested_form_controller.js +37 -8
- package/dist/controllers/nested_form_controller.js.map +1 -1
- package/dist/controllers/network_status_controller.js +9 -1
- package/dist/controllers/network_status_controller.js.map +1 -1
- package/dist/controllers/number_input_controller.d.ts +36 -8
- package/dist/controllers/number_input_controller.js +124 -21
- package/dist/controllers/number_input_controller.js.map +1 -1
- package/dist/controllers/optimistic_controller.js +42 -5
- package/dist/controllers/optimistic_controller.js.map +1 -1
- package/dist/controllers/otp_controller.d.ts +22 -7
- package/dist/controllers/otp_controller.js +198 -55
- package/dist/controllers/otp_controller.js.map +1 -1
- package/dist/controllers/overflow_indicator_controller.d.ts +8 -12
- package/dist/controllers/overflow_indicator_controller.js +115 -21
- package/dist/controllers/overflow_indicator_controller.js.map +1 -1
- package/dist/controllers/overflow_menu_controller.d.ts +26 -6
- package/dist/controllers/overflow_menu_controller.js +141 -44
- package/dist/controllers/overflow_menu_controller.js.map +1 -1
- package/dist/controllers/pagination_controller.d.ts +19 -11
- package/dist/controllers/pagination_controller.js +74 -28
- package/dist/controllers/pagination_controller.js.map +1 -1
- package/dist/controllers/password_reveal_controller.d.ts +15 -1
- package/dist/controllers/password_reveal_controller.js +59 -2
- package/dist/controllers/password_reveal_controller.js.map +1 -1
- package/dist/controllers/persist_controller.js +30 -8
- package/dist/controllers/persist_controller.js.map +1 -1
- package/dist/controllers/pointer_drag_controller.js +131 -52
- package/dist/controllers/pointer_drag_controller.js.map +1 -1
- package/dist/controllers/popover_controller.d.ts +9 -3
- package/dist/controllers/popover_controller.js +45 -11
- package/dist/controllers/popover_controller.js.map +1 -1
- package/dist/controllers/portal_controller.d.ts +1 -1
- package/dist/controllers/portal_controller.js +6 -2
- package/dist/controllers/portal_controller.js.map +1 -1
- package/dist/controllers/preview_guard_controller.js +16 -1
- package/dist/controllers/preview_guard_controller.js.map +1 -1
- package/dist/controllers/progress_controller.js +8 -4
- package/dist/controllers/progress_controller.js.map +1 -1
- package/dist/controllers/radio_group_controller.d.ts +6 -4
- package/dist/controllers/radio_group_controller.js +42 -17
- package/dist/controllers/radio_group_controller.js.map +1 -1
- package/dist/controllers/range_slider_controller.d.ts +49 -1
- package/dist/controllers/range_slider_controller.js +88 -42
- package/dist/controllers/range_slider_controller.js.map +1 -1
- package/dist/controllers/rating_controller.d.ts +14 -3
- package/dist/controllers/rating_controller.js +39 -15
- package/dist/controllers/rating_controller.js.map +1 -1
- package/dist/controllers/read_more_controller.d.ts +24 -2
- package/dist/controllers/read_more_controller.js +100 -7
- package/dist/controllers/read_more_controller.js.map +1 -1
- package/dist/controllers/reading_progress_controller.js +65 -19
- package/dist/controllers/reading_progress_controller.js.map +1 -1
- package/dist/controllers/relative_time_controller.js +10 -5
- package/dist/controllers/relative_time_controller.js.map +1 -1
- package/dist/controllers/resizable_controller.d.ts +16 -2
- package/dist/controllers/resizable_controller.js +82 -22
- package/dist/controllers/resizable_controller.js.map +1 -1
- package/dist/controllers/scroll_area_controller.js +75 -27
- package/dist/controllers/scroll_area_controller.js.map +1 -1
- package/dist/controllers/scroll_restore_controller.js +37 -16
- package/dist/controllers/scroll_restore_controller.js.map +1 -1
- package/dist/controllers/scroll_visibility_controller.js +49 -30
- package/dist/controllers/scroll_visibility_controller.js.map +1 -1
- package/dist/controllers/scrollspy_controller.d.ts +3 -2
- package/dist/controllers/scrollspy_controller.js +71 -26
- package/dist/controllers/scrollspy_controller.js.map +1 -1
- package/dist/controllers/separator_controller.d.ts +41 -14
- package/dist/controllers/separator_controller.js +66 -37
- package/dist/controllers/separator_controller.js.map +1 -1
- package/dist/controllers/sidebar_controller.d.ts +20 -3
- package/dist/controllers/sidebar_controller.js +77 -18
- package/dist/controllers/sidebar_controller.js.map +1 -1
- package/dist/controllers/skeleton_controller.js +6 -1
- package/dist/controllers/skeleton_controller.js.map +1 -1
- package/dist/controllers/slider_controller.d.ts +45 -7
- package/dist/controllers/slider_controller.js +82 -47
- package/dist/controllers/slider_controller.js.map +1 -1
- package/dist/controllers/smart_sticky_header_controller.js +60 -26
- package/dist/controllers/smart_sticky_header_controller.js.map +1 -1
- package/dist/controllers/sortable_controller.js +17 -2
- package/dist/controllers/sortable_controller.js.map +1 -1
- package/dist/controllers/spinner_controller.js +10 -2
- package/dist/controllers/spinner_controller.js.map +1 -1
- package/dist/controllers/step_indicator_controller.d.ts +19 -17
- package/dist/controllers/step_indicator_controller.js +18 -17
- package/dist/controllers/step_indicator_controller.js.map +1 -1
- package/dist/controllers/stepper_controller.d.ts +34 -9
- package/dist/controllers/stepper_controller.js +101 -19
- package/dist/controllers/stepper_controller.js.map +1 -1
- package/dist/controllers/stick_to_bottom_controller.d.ts +19 -0
- package/dist/controllers/stick_to_bottom_controller.js +104 -8
- package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
- package/dist/controllers/submit_once_controller.d.ts +3 -2
- package/dist/controllers/submit_once_controller.js +45 -9
- package/dist/controllers/submit_once_controller.js.map +1 -1
- package/dist/controllers/switch_controller.d.ts +29 -7
- package/dist/controllers/switch_controller.js +101 -10
- package/dist/controllers/switch_controller.js.map +1 -1
- package/dist/controllers/tabs_controller.d.ts +12 -0
- package/dist/controllers/tabs_controller.js +21 -2
- package/dist/controllers/tabs_controller.js.map +1 -1
- package/dist/controllers/tags_input_controller.d.ts +15 -3
- package/dist/controllers/tags_input_controller.js +209 -59
- package/dist/controllers/tags_input_controller.js.map +1 -1
- package/dist/controllers/textarea_autosize_controller.js +29 -3
- package/dist/controllers/textarea_autosize_controller.js.map +1 -1
- package/dist/controllers/theme_controller.d.ts +20 -3
- package/dist/controllers/theme_controller.js +64 -14
- package/dist/controllers/theme_controller.js.map +1 -1
- package/dist/controllers/time_picker_controller.js +23 -8
- package/dist/controllers/time_picker_controller.js.map +1 -1
- package/dist/controllers/toast_controller.d.ts +55 -15
- package/dist/controllers/toast_controller.js +451 -105
- package/dist/controllers/toast_controller.js.map +1 -1
- package/dist/controllers/toggle_group_controller.d.ts +49 -7
- package/dist/controllers/toggle_group_controller.js +159 -23
- package/dist/controllers/toggle_group_controller.js.map +1 -1
- package/dist/controllers/toolbar_controller.js +32 -0
- package/dist/controllers/toolbar_controller.js.map +1 -1
- package/dist/controllers/tooltip_controller.d.ts +8 -0
- package/dist/controllers/tooltip_controller.js +39 -13
- package/dist/controllers/tooltip_controller.js.map +1 -1
- package/dist/controllers/transition_controller.js +4 -0
- package/dist/controllers/transition_controller.js.map +1 -1
- package/dist/controllers/tree_view_controller.d.ts +39 -8
- package/dist/controllers/tree_view_controller.js +169 -16
- package/dist/controllers/tree_view_controller.js.map +1 -1
- package/dist/index.d.ts +28 -1
- package/dist/index.js +5002 -1911
- package/dist/index.js.map +1 -1
- package/dist/inspector/cli.d.ts +72 -6
- package/dist/inspector/cli.js +262 -51
- package/dist/inspector/cli.js.map +1 -1
- package/dist/inspector/cli_bin.js +309 -51
- package/dist/inspector/cli_bin.js.map +1 -1
- package/dist/inspector/examples.json +40 -40
- package/dist/inspector/manifest.json +529 -61
- package/dist/positioning/index.js +2 -0
- package/dist/positioning/index.js.map +1 -1
- package/package.json +2 -2
|
@@ -2,6 +2,30 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
2
2
|
|
|
3
3
|
// src/controllers/dirty_form_controller.ts
|
|
4
4
|
|
|
5
|
+
// src/utils/listener_set.ts
|
|
6
|
+
var ListenerSet = class {
|
|
7
|
+
/** The generation every `add` joins until the next `dispose()`. */
|
|
8
|
+
#abort = new AbortController();
|
|
9
|
+
/**
|
|
10
|
+
* Attaches `handler` to the open generation, exactly as the caller spelled it.
|
|
11
|
+
*
|
|
12
|
+
* The set supplies the signal, so `options` carries everything else the DOM
|
|
13
|
+
* accepts — `capture` included, which has to match at release time and no
|
|
14
|
+
* longer has a second place to drift from.
|
|
15
|
+
*/
|
|
16
|
+
add(target, type, handler, options) {
|
|
17
|
+
target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Releases every listener of the open generation, synchronously, and opens the
|
|
21
|
+
* next one. Idempotent, and safe before anything has been added.
|
|
22
|
+
*/
|
|
23
|
+
dispose() {
|
|
24
|
+
this.#abort.abort();
|
|
25
|
+
this.#abort = new AbortController();
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
5
29
|
// src/utils/safe_timeout.ts
|
|
6
30
|
var TimerRegistry = class {
|
|
7
31
|
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
@@ -55,7 +79,20 @@ var SafeTimeout = class extends TimerRegistry {
|
|
|
55
79
|
}
|
|
56
80
|
};
|
|
57
81
|
|
|
82
|
+
// src/utils/transient_hooks.ts
|
|
83
|
+
var TransientHooks = class {
|
|
84
|
+
#attributes;
|
|
85
|
+
constructor(options) {
|
|
86
|
+
this.#attributes = options.attributes;
|
|
87
|
+
}
|
|
88
|
+
/** Drops the declared hooks from `element`; what it carries besides stays. */
|
|
89
|
+
reset(element) {
|
|
90
|
+
for (const attribute of this.#attributes) element.removeAttribute(attribute);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
58
94
|
// src/controllers/dirty_form_controller.ts
|
|
95
|
+
var TRANSIENT = new TransientHooks({ attributes: ["data-dirty"] });
|
|
59
96
|
var visitCoordinators = /* @__PURE__ */ new WeakMap();
|
|
60
97
|
var TurboVisitCoordinator = class {
|
|
61
98
|
#document;
|
|
@@ -125,6 +162,7 @@ var DirtyFormController = class extends Controller {
|
|
|
125
162
|
#dirty = false;
|
|
126
163
|
#beforeunloadBound = false;
|
|
127
164
|
#timeouts = new SafeTimeout();
|
|
165
|
+
#listeners = new ListenerSet();
|
|
128
166
|
/** Whether the active submission still represents the form's current values. */
|
|
129
167
|
#submitting = false;
|
|
130
168
|
#submitAttempt = null;
|
|
@@ -181,25 +219,20 @@ var DirtyFormController = class extends Controller {
|
|
|
181
219
|
this.#resetSubmission();
|
|
182
220
|
this.#baseline = this.#serialize();
|
|
183
221
|
this.#setDirty(false);
|
|
184
|
-
this.element
|
|
185
|
-
this.element
|
|
186
|
-
this.element
|
|
187
|
-
this.element
|
|
188
|
-
this.element
|
|
189
|
-
this.element
|
|
190
|
-
this.element
|
|
222
|
+
TRANSIENT.reset(this.element);
|
|
223
|
+
this.#listeners.add(this.element, "input", this.#onFieldChange);
|
|
224
|
+
this.#listeners.add(this.element, "change", this.#onFieldChange);
|
|
225
|
+
this.#listeners.add(this.element, "submit", this.#onSubmit);
|
|
226
|
+
this.#listeners.add(this.element, "formdata", this.#onFormData);
|
|
227
|
+
this.#listeners.add(this.element, "turbo:submit-start", this.#onSubmitStart);
|
|
228
|
+
this.#listeners.add(this.element, "turbo:submit-end", this.#onSubmitEnd);
|
|
191
229
|
this.#unregisterVisit = registerVisitParticipant(this.element.ownerDocument, {
|
|
192
230
|
form: this.element,
|
|
193
231
|
evaluate: (event) => this.#evaluateVisit(event)
|
|
194
232
|
});
|
|
195
233
|
}
|
|
196
234
|
disconnect() {
|
|
197
|
-
this.
|
|
198
|
-
this.element.removeEventListener("change", this.#onFieldChange);
|
|
199
|
-
this.element.removeEventListener("submit", this.#onSubmit);
|
|
200
|
-
this.element.removeEventListener("formdata", this.#onFormData);
|
|
201
|
-
this.element.removeEventListener("turbo:submit-start", this.#onSubmitStart);
|
|
202
|
-
this.element.removeEventListener("turbo:submit-end", this.#onSubmitEnd);
|
|
235
|
+
this.#listeners.dispose();
|
|
203
236
|
this.#unregisterVisit?.();
|
|
204
237
|
this.#unregisterVisit = null;
|
|
205
238
|
this.#unbindBeforeUnload();
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/utils/safe_timeout.ts","../../src/controllers/dirty_form_controller.ts"],"names":[],"mappings":"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","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 * Largest delay a timer can hold: the platform stores it in a 32-bit signed\n * integer, and anything larger overflows to `1`, so a delay meant to be far in\n * the future fires almost immediately. A declared delay above this bound names\n * no delay at all, and a controller reading one falls back to its default.\n */\nexport const MAX_TIMER_DELAY_MS = 2_147_483_647;\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"]}
|
|
1
|
+
{"version":3,"sources":["../../src/utils/listener_set.ts","../../src/utils/safe_timeout.ts","../../src/utils/transient_hooks.ts","../../src/controllers/dirty_form_controller.ts"],"names":[],"mappings":"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","file":"dirty_form_controller.js","sourcesContent":["/**\n * Owns every listener a controller attaches for one connected lifetime and\n * releases them together, so an event name is spelled once and the release\n * cannot drift from the registration.\n *\n * Detaching by hand repeats three things at the release site — the type, the\n * handler reference and the capture flag — and any one of them drifting leaves\n * a listener alive on an element the controller no longer drives. Aborting the\n * signal a registration was created with releases it whatever it was made of,\n * so the release site names nothing.\n *\n * One set per lifetime, held as a field and never rebuilt. {@link\n * ListenerSet.dispose} is the only thing that closes a generation, and it opens\n * the next one in the same step, so the lifecycle hooks can call `add` and\n * `dispose` in any order any number of times, and no `add` ever meets an\n * already aborted signal — a DOM that registers a listener on such a signal\n * would keep it forever. Rebuilding the set instead would leak:\n * `addEventListener` matches an existing registration on target, type, callback\n * and capture alone, so re-adding the same handler while the first registration\n * is live is discarded, and that live registration keeps the signal it was\n * attached with — a set built by a second `connect()` can never release what it\n * appears to own.\n *\n * Scope is the registration only. Timers, observers, leases and the order they\n * are unwound in stay with the controller, so `dispose()` belongs exactly where\n * the `removeEventListener` block stood. A receiver that changes at runtime (a\n * swapped target) is rebound by calling `dispose()` and then `add()` again from\n * the rebind, not by a second set.\n *\n * The set must own every registration of a tuple it holds: a listener the same\n * target already carries for the same type, callback and capture flag keeps the\n * right to remove it, and this set's `add` for that tuple is discarded. A\n * subscription shared between instances behind a participant count, or one that\n * lives for less than the connection, keeps its own pair of calls.\n *\n * This file's own doc block is dropped from `dist`, but every member comment is\n * inlined into each consumer entry (`tsup` builds with `splitting: false`), so\n * rationale belongs here and only the contract belongs on the members.\n *\n * @example\n * ```ts\n * readonly #listeners = new ListenerSet();\n *\n * connect(): void {\n * this.#listeners.add(this.element, \"submit\", this.#onNativeSubmit, { capture: true });\n * this.#listeners.add(this.element, \"turbo:submit-start\", this.#onSubmitStart);\n * this.#listeners.add(this.element, \"turbo:submit-end\", this.#onSubmitEnd);\n * }\n *\n * disconnect(): void {\n * this.#listeners.dispose();\n * }\n * ```\n */\nexport class ListenerSet {\n /** The generation every `add` joins until the next `dispose()`. */\n #abort = new AbortController();\n\n /**\n * Attaches `handler` to the open generation, exactly as the caller spelled it.\n *\n * The set supplies the signal, so `options` carries everything else the DOM\n * accepts — `capture` included, which has to match at release time and no\n * longer has a second place to drift from.\n */\n add(\n target: EventTarget,\n type: string,\n handler: EventListener,\n options?: Omit<AddEventListenerOptions, \"signal\">,\n ): void {\n target.addEventListener(type, handler, { ...options, signal: this.#abort.signal });\n }\n\n /**\n * Releases every listener of the open generation, synchronously, and opens the\n * next one. Idempotent, and safe before anything has been added.\n */\n dispose(): void {\n this.#abort.abort();\n this.#abort = new AbortController();\n }\n}\n","/**\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 * Largest delay a timer can hold: the platform stores it in a 32-bit signed\n * integer, and anything larger overflows to `1`, so a delay meant to be far in\n * the future fires almost immediately. A declared delay above this bound names\n * no delay at all, and a controller reading one falls back to its default.\n */\nexport const MAX_TIMER_DELAY_MS = 2_147_483_647;\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","/**\n * Drops the transient state hooks a connection inherited, so the cycle it opens\n * starts from a DOM that claims nothing.\n *\n * A transient hook is an attribute a controller writes to say \"this is happening\n * right now\" — a drag in progress, a form with unsaved edits, a refused\n * subscription. Its lifetime belongs to the interaction, not to the element, and\n * the two come apart whenever the element outlives the instance that wrote it: a\n * Turbo cache snapshot taken mid-interaction restores the hook with the markup,\n * and an element re-inserted elsewhere in the page carries it along. Nothing\n * takes such a hook off, because the instance that owned it is gone; consumer\n * CSS then keeps claiming a state no one is in.\n *\n * **`turbo:before-cache` cannot do this job**, which is why the pass belongs in\n * `connect()`: that event fires for a navigation only, so an in-page move — the\n * other half of the population — never reaches it, and a hook that arrived with\n * a moved element would survive. `connect()` covers both, because both end in a\n * connection.\n *\n * Scope is the **drop** only. What the fresh cycle should say instead is the\n * consumer's: a value re-read from the live DOM, a roster refilled by the\n * stream, a measurement retaken from the current scroll position, a slot emptied\n * of the last result. Those answers differ per consumer and none of them is a\n * hook removal, so the pass stops where every consumer agrees.\n *\n * A declaration names one group of hooks and carries no per-instance state, so\n * it belongs at module scope, shared by every instance the page connects. A\n * consumer writes as many as it has groups — hooks it drops under different\n * conditions cannot share one. The exception is a hook whose name is only known\n * at runtime, such as one built from the registered identifier: that\n * declaration is a field, because the name is.\n *\n * **A drop is not always right.** The population splits on one question: can the\n * state a hook describes outlive the instance that wrote it? Where it cannot — a\n * drag ends with the pointer, a subscription dies with its socket — dropping on\n * every connection is correct. Where it can — a submission in flight is not\n * interrupted by an in-page move — only a connection that follows a real restore\n * may drop, and the consumer answers \"did a session survive?\" with the signal it\n * already holds for its own teardown (a live-session field, a `DetachGate`\n * probe). The drop then sits behind that answer, as the second example shows.\n *\n * @example Unconditional — the state cannot outlive the instance.\n * ```ts\n * const TRANSIENT = new TransientHooks({ attributes: [\"data-sortable-dragging\"] });\n *\n * connect(): void {\n * TRANSIENT.reset(this.element);\n * }\n * ```\n *\n * @example Gated — an in-page move must not drop a live state.\n * ```ts\n * const TRANSIENT = new TransientHooks({ attributes: [\"data-optimistic\"] });\n *\n * connect(): void {\n * const restored = !this.#connected && !this.#gate.pending;\n * this.#gate.cancel();\n * this.#connected = true;\n * if (restored) TRANSIENT.reset(this.element);\n * }\n * ```\n */\n\n/** What a consumer declares once about the hooks its interaction owns. */\nexport interface TransientHooksOptions {\n /**\n * The attribute names a connection may find written by an earlier one. Hooks\n * a consumer drops under different conditions belong to different\n * declarations, so each one can be reset on its own.\n */\n readonly attributes: readonly string[];\n}\n\n/**\n * One consumer's declaration of the hooks its interaction owns, dropped from an\n * element by {@link TransientHooks.reset}.\n *\n * Every member comment inside this class body is inlined into each consumer\n * bundle (`tsup` builds with `splitting: false`), so rationale belongs in this\n * file's own block, which `dist` drops, and only the contract belongs inside.\n */\nexport class TransientHooks {\n readonly #attributes: readonly string[];\n\n constructor(options: TransientHooksOptions) {\n this.#attributes = options.attributes;\n }\n\n /** Drops the declared hooks from `element`; what it carries besides stays. */\n reset(element: Element): void {\n for (const attribute of this.#attributes) element.removeAttribute(attribute);\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { ListenerSet } from \"../utils/listener_set\";\nimport { SafeTimeout } from \"../utils/safe_timeout\";\nimport { TransientHooks } from \"../utils/transient_hooks\";\n\n/** The hook a connection may find written by an earlier, now-gone one. */\nconst TRANSIENT = new TransientHooks({ attributes: [\"data-dirty\"] });\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/** The coordinator of each document, dropped once its last participant leaves. */\nconst visitCoordinators = new WeakMap<Document, TurboVisitCoordinator>();\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 */\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 `stimeo--confirm`) and\n * does not persist input (pair with `stimeo--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 readonly #listeners = new ListenerSet();\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 hook a\n // snapshot preserved mid-edit must not linger: the guard would not fire, but\n // consumer CSS would keep claiming \"unsaved\".\n TRANSIENT.reset(this.element);\n this.#listeners.add(this.element, \"input\", this.#onFieldChange);\n this.#listeners.add(this.element, \"change\", this.#onFieldChange);\n this.#listeners.add(this.element, \"submit\", this.#onSubmit);\n this.#listeners.add(this.element, \"formdata\", this.#onFormData);\n this.#listeners.add(this.element, \"turbo:submit-start\", this.#onSubmitStart);\n this.#listeners.add(this.element, \"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.#listeners.dispose();\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"]}
|
|
@@ -123,6 +123,7 @@ var DismissibleController = class extends Controller {
|
|
|
123
123
|
get #root() {
|
|
124
124
|
return this.hasRootTarget ? this.rootTarget : this.element;
|
|
125
125
|
}
|
|
126
|
+
/** @stimeoRuntimeOnly `mode` decides how this one dismissal takes the element away. */
|
|
126
127
|
#performDismiss() {
|
|
127
128
|
const root = this.#root;
|
|
128
129
|
const mode = this.modeValue === "hide" ? "hide" : "remove";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/utils/focus_candidate.ts","../../src/controllers/dismissible_controller.ts"],"names":[],"mappings":"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","file":"dismissible_controller.js","sourcesContent":["/**\n * Whether a disabled `<fieldset>` ancestor actually reaches `control`.\n *\n * HTML exempts the contents of a fieldset's **first direct-child `<legend>`**, and\n * the exemption is per fieldset — a control legal in one legend can still be\n * disabled by a second, outer fieldset, so the walk continues upward.\n *\n * Exported on its own for callers that need the disabled-inheritance rule without\n * the rest of {@link canTakeFocus} — a control's availability check, or a `hidden`\n * walk bounded at a widget root, which is a different rule to compose with.\n */\nexport function inheritsFieldsetDisabled(control: HTMLElement): boolean {\n let fieldset: Element | null = control.closest(\"fieldset[disabled]\");\n while (fieldset) {\n const legend = Array.from(fieldset.children).find((child) => child.tagName === \"LEGEND\");\n if (!legend?.contains(control)) return true;\n fieldset = fieldset.parentElement?.closest(\"fieldset[disabled]\") ?? null;\n }\n return false;\n}\n\n/**\n * Whether an element can actually take focus, checked **before** `focus()` runs.\n *\n * A controller that must move focus off a control it is about to disable or hide\n * picks a destination and calls `focus()` on it. If that destination cannot take\n * focus, the call **fails silently**: `hidden` and natively `disabled` elements\n * swallow it, so the caret stays in the subtree that is disappearing and lands on\n * `<body>` a frame later — the exact outcome the rescue exists to prevent, minus\n * any signal that it happened.\n *\n * Testing after the fact is the obvious alternative and is deliberately not the\n * rule. Reading `document.activeElement` back only works in a real browser, and\n * looping over candidates that way performs a real focus move per failure —\n * observable to assistive technology. Checking first costs nothing and catches\n * the cases that actually occur.\n *\n * **`aria-disabled` is not disqualifying.** It is the attribute an author uses\n * for a control that must stay *discoverable*, and the roving contract keeps\n * such items reachable. Only the four conditions that make the platform refuse\n * focus are checked: `hidden` / `inert` (an ancestor's counts),\n * `input[type=\"hidden\"]`, the native `disabled` property, and `disabled`\n * inherited from an ancestor `fieldset`. CSS-only invisibility is handled by\n * {@link isRenderedForFocus} when a consumer needs sequential-focus semantics.\n *\n * Reading `:disabled` instead of walking the fieldset chain would be shorter, but\n * that pseudo-class is not evaluated consistently outside real browsers and this\n * has to be right headlessly too. happy-dom in particular focuses a `<button>`\n * inside a disabled fieldset where a real engine refuses, so the inheritance is\n * spelled out rather than delegated.\n *\n * What a consumer does when nothing survives is its own call: some fall back to\n * their landmark, while a widget whose caret already sits somewhere legitimate\n * refuses the move outright rather than relocating it.\n *\n * @example\n * ```ts\n * const target = candidates.find(canTakeFocus);\n * if (target) target.focus();\n * else {\n * this.#tabindex.lend(this.element); // nothing left: fall back to the landmark\n * this.element.focus();\n * }\n * ```\n *\n * @param element - the candidate destination\n */\nexport function canTakeFocus(element: HTMLElement): boolean {\n if (element.closest(\"[hidden], [inert]\")) return false;\n if (element instanceof HTMLInputElement && element.type === \"hidden\") return false;\n if (!(\"disabled\" in element)) return true;\n if ((element as HTMLElement & { disabled: boolean }).disabled) return false;\n return !inheritsFieldsetDisabled(element);\n}\n\n/** Elements whose semantics or authored attributes can place them in sequential focus order. */\nexport const TAB_STOP_CANDIDATE_SELECTOR = [\n \"a[href]\",\n \"area[href]\",\n \"button\",\n \"input\",\n \"select\",\n \"textarea\",\n \"summary\",\n \"iframe\",\n \"audio[controls]\",\n \"video[controls]\",\n \"[tabindex]\",\n \"[contenteditable]\",\n].join(\",\");\n\n/** Optional browser visibility API used to exclude CSS-hidden candidates. */\ninterface VisibilityCheckable {\n checkVisibility?: (options?: { visibilityProperty?: boolean }) => boolean;\n}\n\n/** Whether CSS visibility allows an otherwise eligible element to participate in focus order. */\nexport function isRenderedForFocus(element: HTMLElement): boolean {\n const check = (element as HTMLElement & VisibilityCheckable).checkVisibility;\n return typeof check === \"function\" ? check.call(element, { visibilityProperty: true }) : true;\n}\n\n/** Parses an authored `tabindex`; invalid syntax has no explicit focus-order meaning. */\nfunction authoredTabindex(element: HTMLElement): number | null {\n const value = element.getAttribute(\"tabindex\");\n if (value === null || !/^[+-]?\\d+$/.test(value.trim())) return null;\n return Number(value);\n}\n\n/** Whether the element's native semantics place it in sequential focus order. */\nfunction hasNativeTabStop(element: HTMLElement): boolean {\n if (element instanceof HTMLAnchorElement || element instanceof HTMLAreaElement) {\n return element.hasAttribute(\"href\");\n }\n if (\n element instanceof HTMLButtonElement ||\n element instanceof HTMLSelectElement ||\n element instanceof HTMLTextAreaElement\n ) {\n return true;\n }\n if (element instanceof HTMLInputElement) return element.type !== \"hidden\";\n if (element instanceof HTMLIFrameElement) return true;\n if (element.tagName === \"AUDIO\" || element.tagName === \"VIDEO\") {\n return element.hasAttribute(\"controls\");\n }\n if (element instanceof HTMLElement && element.tagName === \"SUMMARY\") {\n const details = element.parentElement;\n return (\n details instanceof HTMLDetailsElement &&\n Array.from(details.children).find((child) => child.tagName === \"SUMMARY\") === element\n );\n }\n return false;\n}\n\n/** Whether an explicit `contenteditable` value creates an editable tab stop. */\nfunction hasEditableTabStop(element: HTMLElement): boolean {\n const value = element.getAttribute(\"contenteditable\")?.toLowerCase();\n return value === \"\" || value === \"true\" || value === \"plaintext-only\";\n}\n\n/**\n * Whether an element is a usable sequential Tab stop right now.\n *\n * Native semantics, authored `tabindex`, editable hosts, inherited disabled state,\n * HTML `hidden`/`inert`, and CSS visibility are evaluated together. `aria-disabled`\n * remains focusable because it communicates unavailability without removing the\n * control from discovery order.\n */\nexport function isTabStop(element: HTMLElement): boolean {\n if (!canTakeFocus(element) || !isRenderedForFocus(element)) return false;\n\n const tabindex = authoredTabindex(element);\n if (tabindex !== null) return tabindex >= 0;\n return hasNativeTabStop(element) || hasEditableTabStop(element);\n}\n\n/** Returns every usable sequential Tab stop below `root` in document order. */\nexport function tabStopsWithin(root: ParentNode): HTMLElement[] {\n return Array.from(root.querySelectorAll<HTMLElement>(TAB_STOP_CANDIDATE_SELECTOR)).filter(\n isTabStop,\n );\n}\n\n/** Returns the first usable sequential Tab stop below `root`, if one exists. */\nexport function firstTabStop(root: ParentNode): HTMLElement | null {\n for (const candidate of root.querySelectorAll<HTMLElement>(TAB_STOP_CANDIDATE_SELECTOR)) {\n if (isTabStop(candidate)) return candidate;\n }\n return null;\n}\n\n/** Whether `root` contains at least one usable sequential Tab stop. */\nexport function hasTabStop(root: ParentNode): boolean {\n return firstTabStop(root) !== null;\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport {\n canTakeFocus,\n isRenderedForFocus,\n isTabStop,\n tabStopsWithin,\n} from \"../utils/focus_candidate\";\n\n/**\n * Headless \"dismissible\" behavior for banners, notices, and inline alerts.\n *\n * Markup contract (identifier: `stimeo--dismissible`):\n * <div data-controller=\"stimeo--dismissible\"\n * data-stimeo--dismissible-mode-value=\"remove\">\n * <div data-stimeo--dismissible-target=\"root\" role=\"status\">\n * <p>Saved.</p>\n * <button type=\"button\" aria-label=\"Close\"\n * data-action=\"click->stimeo--dismissible#dismiss\">×</button>\n * </div>\n * </div>\n *\n * A general utility with no dedicated APG pattern. Its accessibility job is to\n * keep focus from being orphaned: if focus is inside the element being removed,\n * it is moved to a safe place first so the close button vanishing never strands\n * the user (WCAG 2.4.3).\n *\n * `dismiss` dispatches `{ mode: \"remove\" | \"hide\" }`.\n *\n * @remarks\n * Behavior only — the consumer owns any exit transition (use `hide` mode, which\n * adds `hidden`, and animate off `data-state`) and any semantics like\n * `role=\"alert\"`. Focus retreats to, in order: the `fallback` target → the next\n * focusable element after the root → the previous one → `document.body` as a\n * last resort (weak focus; prefer providing a `fallback`).\n */\nexport class DismissibleController extends Controller<HTMLElement> {\n static override targets = [\"root\", \"fallback\"];\n static override values = {\n mode: { type: String, default: \"remove\" },\n closeOnEscape: { type: Boolean, default: false },\n };\n static actions = [\"dismiss\"] as const;\n static events = [\"dismiss\"] as const;\n\n declare readonly rootTarget: HTMLElement;\n declare readonly fallbackTarget: HTMLElement;\n declare readonly hasRootTarget: boolean;\n declare readonly hasFallbackTarget: boolean;\n\n declare modeValue: string;\n declare closeOnEscapeValue: boolean;\n\n override connect(): void {\n const root = this.#root;\n if (!root.hasAttribute(\"data-state\")) {\n root.setAttribute(\"data-state\", \"open\");\n }\n this.#syncEscapeListener();\n }\n\n override disconnect(): void {\n this.element.removeEventListener(\"keydown\", this.#onKeydown);\n }\n\n /** Keeps the Escape listener aligned with a live `closeOnEscape` Value. */\n closeOnEscapeValueChanged(): void {\n this.#syncEscapeListener();\n }\n\n /** Dismisses the element. Bound via `data-action` (click on the close button). */\n dismiss(): void {\n this.#performDismiss();\n }\n\n /** Dismisses on Escape when `closeOnEscape` is set and focus is inside. */\n readonly #onKeydown = (event: KeyboardEvent): void => {\n // A press during IME composition cancels the conversion (e.g. in a text\n // field inside this element), never the element itself.\n if (event.key !== \"Escape\" || event.defaultPrevented || event.isComposing) return;\n if (!this.closeOnEscapeValue) return;\n const active = document.activeElement;\n if (!active || !this.element.contains(active)) return;\n event.preventDefault();\n this.#performDismiss();\n };\n\n /** Adds or removes the one stable listener reference without duplicating it. */\n #syncEscapeListener(): void {\n this.element.removeEventListener(\"keydown\", this.#onKeydown);\n if (this.closeOnEscapeValue) {\n this.element.addEventListener(\"keydown\", this.#onKeydown);\n }\n }\n\n /** The element to dismiss: the explicit `root` target, or the host element. */\n get #root(): HTMLElement {\n return this.hasRootTarget ? this.rootTarget : this.element;\n }\n\n #performDismiss(): void {\n const root = this.#root;\n const mode = this.modeValue === \"hide\" ? \"hide\" : \"remove\";\n\n this.#retreatFocus(root);\n root.setAttribute(\"data-state\", \"closing\");\n // Dispatch before removal so a listener on the (about-to-leave) element still\n // runs; `dispatch` fires synchronously on `this.element`.\n this.dispatch(\"dismiss\", { detail: { mode } });\n\n if (mode === \"hide\") {\n root.hidden = true;\n } else {\n root.remove();\n }\n }\n\n /**\n * Moves focus out of `root` *before* it is removed, but only when focus is\n * actually inside it — otherwise the user's place elsewhere is left undisturbed.\n */\n #retreatFocus(root: HTMLElement): void {\n const active = document.activeElement;\n if (!(active instanceof HTMLElement) || !root.contains(active)) return;\n\n for (const candidate of this.#focusFallbacks(root)) {\n candidate.focus();\n if (document.activeElement === candidate) return;\n }\n document.body.focus();\n }\n\n /** Yields available focus fallbacks in precedence order, stopping after focus succeeds. */\n *#focusFallbacks(root: HTMLElement): Generator<HTMLElement> {\n const explicitFallback = this.hasFallbackTarget ? this.fallbackTarget : null;\n if (\n explicitFallback &&\n !root.contains(explicitFallback) &&\n this.#isExplicitFocusAvailable(explicitFallback)\n ) {\n yield explicitFallback;\n }\n\n const candidates = tabStopsWithin(document);\n for (const element of candidates) {\n if (\n element === explicitFallback ||\n root.contains(element) ||\n !(root.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_FOLLOWING)\n ) {\n continue;\n }\n yield element;\n }\n\n for (let index = candidates.length - 1; index >= 0; index -= 1) {\n const element = candidates[index];\n if (!element) continue;\n if (\n element === explicitFallback ||\n root.contains(element) ||\n !(root.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_PRECEDING)\n ) {\n continue;\n }\n yield element;\n }\n }\n\n /** Whether an explicit fallback can receive programmatic focus right now. */\n #isExplicitFocusAvailable(element: HTMLElement): boolean {\n const programmaticallyFocusable = element.hasAttribute(\"tabindex\") || isTabStop(element);\n return programmaticallyFocusable && canTakeFocus(element) && isRenderedForFocus(element);\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/utils/focus_candidate.ts","../../src/controllers/dismissible_controller.ts"],"names":[],"mappings":"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","file":"dismissible_controller.js","sourcesContent":["/**\n * Whether a disabled `<fieldset>` ancestor actually reaches `control`.\n *\n * HTML exempts the contents of a fieldset's **first direct-child `<legend>`**, and\n * the exemption is per fieldset — a control legal in one legend can still be\n * disabled by a second, outer fieldset, so the walk continues upward.\n *\n * Exported on its own for callers that need the disabled-inheritance rule without\n * the rest of {@link canTakeFocus} — a control's availability check, or a `hidden`\n * walk bounded at a widget root, which is a different rule to compose with.\n */\nexport function inheritsFieldsetDisabled(control: HTMLElement): boolean {\n let fieldset: Element | null = control.closest(\"fieldset[disabled]\");\n while (fieldset) {\n const legend = Array.from(fieldset.children).find((child) => child.tagName === \"LEGEND\");\n if (!legend?.contains(control)) return true;\n fieldset = fieldset.parentElement?.closest(\"fieldset[disabled]\") ?? null;\n }\n return false;\n}\n\n/**\n * Whether an element can actually take focus, checked **before** `focus()` runs.\n *\n * A controller that must move focus off a control it is about to disable or hide\n * picks a destination and calls `focus()` on it. If that destination cannot take\n * focus, the call **fails silently**: `hidden` and natively `disabled` elements\n * swallow it, so the caret stays in the subtree that is disappearing and lands on\n * `<body>` a frame later — the exact outcome the rescue exists to prevent, minus\n * any signal that it happened.\n *\n * Testing after the fact is the obvious alternative and is deliberately not the\n * rule. Reading `document.activeElement` back only works in a real browser, and\n * looping over candidates that way performs a real focus move per failure —\n * observable to assistive technology. Checking first costs nothing and catches\n * the cases that actually occur.\n *\n * **`aria-disabled` is not disqualifying.** It is the attribute an author uses\n * for a control that must stay *discoverable*, and the roving contract keeps\n * such items reachable. Only the four conditions that make the platform refuse\n * focus are checked: `hidden` / `inert` (an ancestor's counts),\n * `input[type=\"hidden\"]`, the native `disabled` property, and `disabled`\n * inherited from an ancestor `fieldset`. CSS-only invisibility is handled by\n * {@link isRenderedForFocus} when a consumer needs sequential-focus semantics.\n *\n * Reading `:disabled` instead of walking the fieldset chain would be shorter, but\n * that pseudo-class is not evaluated consistently outside real browsers and this\n * has to be right headlessly too. happy-dom in particular focuses a `<button>`\n * inside a disabled fieldset where a real engine refuses, so the inheritance is\n * spelled out rather than delegated.\n *\n * What a consumer does when nothing survives is its own call: some fall back to\n * their landmark, while a widget whose caret already sits somewhere legitimate\n * refuses the move outright rather than relocating it.\n *\n * @example\n * ```ts\n * const target = candidates.find(canTakeFocus);\n * if (target) target.focus();\n * else {\n * this.#tabindex.lend(this.element); // nothing left: fall back to the landmark\n * this.element.focus();\n * }\n * ```\n *\n * @param element - the candidate destination\n */\nexport function canTakeFocus(element: HTMLElement): boolean {\n if (element.closest(\"[hidden], [inert]\")) return false;\n if (element instanceof HTMLInputElement && element.type === \"hidden\") return false;\n if (!(\"disabled\" in element)) return true;\n if ((element as HTMLElement & { disabled: boolean }).disabled) return false;\n return !inheritsFieldsetDisabled(element);\n}\n\n/** Elements whose semantics or authored attributes can place them in sequential focus order. */\nexport const TAB_STOP_CANDIDATE_SELECTOR = [\n \"a[href]\",\n \"area[href]\",\n \"button\",\n \"input\",\n \"select\",\n \"textarea\",\n \"summary\",\n \"iframe\",\n \"audio[controls]\",\n \"video[controls]\",\n \"[tabindex]\",\n \"[contenteditable]\",\n].join(\",\");\n\n/** Optional browser visibility API used to exclude CSS-hidden candidates. */\ninterface VisibilityCheckable {\n checkVisibility?: (options?: { visibilityProperty?: boolean }) => boolean;\n}\n\n/** Whether CSS visibility allows an otherwise eligible element to participate in focus order. */\nexport function isRenderedForFocus(element: HTMLElement): boolean {\n const check = (element as HTMLElement & VisibilityCheckable).checkVisibility;\n return typeof check === \"function\" ? check.call(element, { visibilityProperty: true }) : true;\n}\n\n/** Parses an authored `tabindex`; invalid syntax has no explicit focus-order meaning. */\nfunction authoredTabindex(element: HTMLElement): number | null {\n const value = element.getAttribute(\"tabindex\");\n if (value === null || !/^[+-]?\\d+$/.test(value.trim())) return null;\n return Number(value);\n}\n\n/** Whether the element's native semantics place it in sequential focus order. */\nfunction hasNativeTabStop(element: HTMLElement): boolean {\n if (element instanceof HTMLAnchorElement || element instanceof HTMLAreaElement) {\n return element.hasAttribute(\"href\");\n }\n if (\n element instanceof HTMLButtonElement ||\n element instanceof HTMLSelectElement ||\n element instanceof HTMLTextAreaElement\n ) {\n return true;\n }\n if (element instanceof HTMLInputElement) return element.type !== \"hidden\";\n if (element instanceof HTMLIFrameElement) return true;\n if (element.tagName === \"AUDIO\" || element.tagName === \"VIDEO\") {\n return element.hasAttribute(\"controls\");\n }\n if (element instanceof HTMLElement && element.tagName === \"SUMMARY\") {\n const details = element.parentElement;\n return (\n details instanceof HTMLDetailsElement &&\n Array.from(details.children).find((child) => child.tagName === \"SUMMARY\") === element\n );\n }\n return false;\n}\n\n/** Whether an explicit `contenteditable` value creates an editable tab stop. */\nfunction hasEditableTabStop(element: HTMLElement): boolean {\n const value = element.getAttribute(\"contenteditable\")?.toLowerCase();\n return value === \"\" || value === \"true\" || value === \"plaintext-only\";\n}\n\n/**\n * Whether an element is a usable sequential Tab stop right now.\n *\n * Native semantics, authored `tabindex`, editable hosts, inherited disabled state,\n * HTML `hidden`/`inert`, and CSS visibility are evaluated together. `aria-disabled`\n * remains focusable because it communicates unavailability without removing the\n * control from discovery order.\n */\nexport function isTabStop(element: HTMLElement): boolean {\n if (!canTakeFocus(element) || !isRenderedForFocus(element)) return false;\n\n const tabindex = authoredTabindex(element);\n if (tabindex !== null) return tabindex >= 0;\n return hasNativeTabStop(element) || hasEditableTabStop(element);\n}\n\n/** Returns every usable sequential Tab stop below `root` in document order. */\nexport function tabStopsWithin(root: ParentNode): HTMLElement[] {\n return Array.from(root.querySelectorAll<HTMLElement>(TAB_STOP_CANDIDATE_SELECTOR)).filter(\n isTabStop,\n );\n}\n\n/** Returns the first usable sequential Tab stop below `root`, if one exists. */\nexport function firstTabStop(root: ParentNode): HTMLElement | null {\n for (const candidate of root.querySelectorAll<HTMLElement>(TAB_STOP_CANDIDATE_SELECTOR)) {\n if (isTabStop(candidate)) return candidate;\n }\n return null;\n}\n\n/** Whether `root` contains at least one usable sequential Tab stop. */\nexport function hasTabStop(root: ParentNode): boolean {\n return firstTabStop(root) !== null;\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport {\n canTakeFocus,\n isRenderedForFocus,\n isTabStop,\n tabStopsWithin,\n} from \"../utils/focus_candidate\";\n\n/**\n * Headless \"dismissible\" behavior for banners, notices, and inline alerts.\n *\n * Markup contract (identifier: `stimeo--dismissible`):\n * <div data-controller=\"stimeo--dismissible\"\n * data-stimeo--dismissible-mode-value=\"remove\">\n * <div data-stimeo--dismissible-target=\"root\" role=\"status\">\n * <p>Saved.</p>\n * <button type=\"button\" aria-label=\"Close\"\n * data-action=\"click->stimeo--dismissible#dismiss\">×</button>\n * </div>\n * </div>\n *\n * A general utility with no dedicated APG pattern. Its accessibility job is to\n * keep focus from being orphaned: if focus is inside the element being removed,\n * it is moved to a safe place first so the close button vanishing never strands\n * the user (WCAG 2.4.3).\n *\n * `dismiss` dispatches `{ mode: \"remove\" | \"hide\" }`.\n *\n * @remarks\n * Behavior only — the consumer owns any exit transition (use `hide` mode, which\n * adds `hidden`, and animate off `data-state`) and any semantics like\n * `role=\"alert\"`. Focus retreats to, in order: the `fallback` target → the next\n * focusable element after the root → the previous one → `document.body` as a\n * last resort (weak focus; prefer providing a `fallback`).\n */\nexport class DismissibleController extends Controller<HTMLElement> {\n static override targets = [\"root\", \"fallback\"];\n static override values = {\n mode: { type: String, default: \"remove\" },\n closeOnEscape: { type: Boolean, default: false },\n };\n static actions = [\"dismiss\"] as const;\n static events = [\"dismiss\"] as const;\n\n declare readonly rootTarget: HTMLElement;\n declare readonly fallbackTarget: HTMLElement;\n declare readonly hasRootTarget: boolean;\n declare readonly hasFallbackTarget: boolean;\n\n declare modeValue: string;\n declare closeOnEscapeValue: boolean;\n\n override connect(): void {\n const root = this.#root;\n if (!root.hasAttribute(\"data-state\")) {\n root.setAttribute(\"data-state\", \"open\");\n }\n this.#syncEscapeListener();\n }\n\n override disconnect(): void {\n this.element.removeEventListener(\"keydown\", this.#onKeydown);\n }\n\n /** Keeps the Escape listener aligned with a live `closeOnEscape` Value. */\n closeOnEscapeValueChanged(): void {\n this.#syncEscapeListener();\n }\n\n /** Dismisses the element. Bound via `data-action` (click on the close button). */\n dismiss(): void {\n this.#performDismiss();\n }\n\n /** Dismisses on Escape when `closeOnEscape` is set and focus is inside. */\n readonly #onKeydown = (event: KeyboardEvent): void => {\n // A press during IME composition cancels the conversion (e.g. in a text\n // field inside this element), never the element itself.\n if (event.key !== \"Escape\" || event.defaultPrevented || event.isComposing) return;\n if (!this.closeOnEscapeValue) return;\n const active = document.activeElement;\n if (!active || !this.element.contains(active)) return;\n event.preventDefault();\n this.#performDismiss();\n };\n\n /** Adds or removes the one stable listener reference without duplicating it. */\n #syncEscapeListener(): void {\n this.element.removeEventListener(\"keydown\", this.#onKeydown);\n if (this.closeOnEscapeValue) {\n this.element.addEventListener(\"keydown\", this.#onKeydown);\n }\n }\n\n /** The element to dismiss: the explicit `root` target, or the host element. */\n get #root(): HTMLElement {\n return this.hasRootTarget ? this.rootTarget : this.element;\n }\n\n /** @stimeoRuntimeOnly `mode` decides how this one dismissal takes the element away. */\n #performDismiss(): void {\n const root = this.#root;\n const mode = this.modeValue === \"hide\" ? \"hide\" : \"remove\";\n\n this.#retreatFocus(root);\n root.setAttribute(\"data-state\", \"closing\");\n // Dispatch before removal so a listener on the (about-to-leave) element still\n // runs; `dispatch` fires synchronously on `this.element`.\n this.dispatch(\"dismiss\", { detail: { mode } });\n\n if (mode === \"hide\") {\n root.hidden = true;\n } else {\n root.remove();\n }\n }\n\n /**\n * Moves focus out of `root` *before* it is removed, but only when focus is\n * actually inside it — otherwise the user's place elsewhere is left undisturbed.\n */\n #retreatFocus(root: HTMLElement): void {\n const active = document.activeElement;\n if (!(active instanceof HTMLElement) || !root.contains(active)) return;\n\n for (const candidate of this.#focusFallbacks(root)) {\n candidate.focus();\n if (document.activeElement === candidate) return;\n }\n document.body.focus();\n }\n\n /** Yields available focus fallbacks in precedence order, stopping after focus succeeds. */\n *#focusFallbacks(root: HTMLElement): Generator<HTMLElement> {\n const explicitFallback = this.hasFallbackTarget ? this.fallbackTarget : null;\n if (\n explicitFallback &&\n !root.contains(explicitFallback) &&\n this.#isExplicitFocusAvailable(explicitFallback)\n ) {\n yield explicitFallback;\n }\n\n const candidates = tabStopsWithin(document);\n for (const element of candidates) {\n if (\n element === explicitFallback ||\n root.contains(element) ||\n !(root.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_FOLLOWING)\n ) {\n continue;\n }\n yield element;\n }\n\n for (let index = candidates.length - 1; index >= 0; index -= 1) {\n const element = candidates[index];\n if (!element) continue;\n if (\n element === explicitFallback ||\n root.contains(element) ||\n !(root.compareDocumentPosition(element) & Node.DOCUMENT_POSITION_PRECEDING)\n ) {\n continue;\n }\n yield element;\n }\n }\n\n /** Whether an explicit fallback can receive programmatic focus right now. */\n #isExplicitFocusAvailable(element: HTMLElement): boolean {\n const programmaticallyFocusable = element.hasAttribute(\"tabindex\") || isTabStop(element);\n return programmaticallyFocusable && canTakeFocus(element) && isRenderedForFocus(element);\n }\n}\n"]}
|
|
@@ -31,10 +31,18 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
31
31
|
* consumer's CSS — `data-placement` is merely a flag.
|
|
32
32
|
*
|
|
33
33
|
* Behavior provided:
|
|
34
|
-
* - {@link open}/{@link close
|
|
34
|
+
* - {@link DrawerController.open | open}/{@link DrawerController.close | close}
|
|
35
|
+
* toggle `data-state` and (deferred) `hidden`.
|
|
35
36
|
* - On open, focus moves to the first focusable element in the panel.
|
|
36
37
|
* - `Tab`/`Shift+Tab` cycle focus within the panel; `Escape` closes.
|
|
37
38
|
* - {@link closeOnBackdrop} closes only when the overlay *itself* is clicked.
|
|
39
|
+
* - Each move of the open state is reported: `stimeo--drawer:open` and
|
|
40
|
+
* `stimeo--drawer:close` dispatch `{ reason: StateReason }`, as soon as
|
|
41
|
+
* `data-state` is written — the deferred `hidden` and the exit transition are
|
|
42
|
+
* not waited for. Both are informational, so neither is cancelable. A call
|
|
43
|
+
* that leaves the state where it already was, the normalization in
|
|
44
|
+
* {@link connect}, the reconciliation that follows panel churn, and
|
|
45
|
+
* {@link disconnect} are all silent.
|
|
38
46
|
*/
|
|
39
47
|
declare class DrawerController extends Controller<HTMLElement> {
|
|
40
48
|
#private;
|
|
@@ -50,6 +58,7 @@ declare class DrawerController extends Controller<HTMLElement> {
|
|
|
50
58
|
};
|
|
51
59
|
};
|
|
52
60
|
static actions: readonly ["close", "closeOnBackdrop", "open"];
|
|
61
|
+
static events: readonly ["close", "open"];
|
|
53
62
|
readonly triggerTarget: HTMLElement;
|
|
54
63
|
readonly overlayTarget: HTMLElement;
|
|
55
64
|
readonly panelTarget: HTMLElement;
|
|
@@ -65,9 +74,9 @@ declare class DrawerController extends Controller<HTMLElement> {
|
|
|
65
74
|
* restored snapshot whose panel is already `data-state="open"` stays open
|
|
66
75
|
* rather than being re-derived from the declarative `open` Value (which would
|
|
67
76
|
* close a user-opened drawer). The `open` Value only seeds a genuinely fresh
|
|
68
|
-
* render. We normalize to a clean closed baseline first so
|
|
69
|
-
* full reveal + trap activation —
|
|
70
|
-
* disconnect and must be re-activated.
|
|
77
|
+
* render. We normalize to a clean closed baseline first so
|
|
78
|
+
* {@link DrawerController.open | open} runs its full reveal + trap activation —
|
|
79
|
+
* the `FocusTrap` is inactive after a disconnect and must be re-activated.
|
|
71
80
|
*/
|
|
72
81
|
connect(): void;
|
|
73
82
|
/** Reverts the modal side effects and pending hide if torn down while open. */
|
|
@@ -79,16 +88,15 @@ declare class DrawerController extends Controller<HTMLElement> {
|
|
|
79
88
|
/** Keeps `data-placement` in sync if the value changes at runtime. */
|
|
80
89
|
placementValueChanged(): void;
|
|
81
90
|
/** Opens the drawer: reveals it, syncs `data-state`, traps focus. */
|
|
82
|
-
open(): void;
|
|
91
|
+
open(event?: Event): void;
|
|
83
92
|
/**
|
|
84
93
|
* Closes the drawer: syncs `data-state` to start the exit transition, then
|
|
85
94
|
* defers both `hidden` *and* the modal teardown (scroll lock / background
|
|
86
|
-
* `inert` / focus restore) until the transition finishes
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
* the exit animation.
|
|
95
|
+
* `inert` / focus restore) until the transition finishes. This keeps the
|
|
96
|
+
* background inert and focus trapped while the drawer is still visually on
|
|
97
|
+
* screen, preserving the modal contract during the exit animation.
|
|
90
98
|
*/
|
|
91
|
-
close(): void;
|
|
99
|
+
close(event?: Event): void;
|
|
92
100
|
/** Closes only when the overlay itself (not its contents) is clicked. */
|
|
93
101
|
closeOnBackdrop(event: MouseEvent): void;
|
|
94
102
|
}
|
|
@@ -365,6 +365,16 @@ var FocusTrap = class {
|
|
|
365
365
|
}
|
|
366
366
|
};
|
|
367
367
|
|
|
368
|
+
// src/utils/state_reason.ts
|
|
369
|
+
var FOCUS_EVENTS = /* @__PURE__ */ new Set(["blur", "focus", "focusin", "focusout"]);
|
|
370
|
+
var POINTER_EVENTS = /* @__PURE__ */ new Set(["mouseenter", "mouseleave", "pointerenter", "pointerleave"]);
|
|
371
|
+
function stateReasonFor(event) {
|
|
372
|
+
if (!event) return "api";
|
|
373
|
+
if (FOCUS_EVENTS.has(event.type)) return "focus";
|
|
374
|
+
if (POINTER_EVENTS.has(event.type)) return "pointer";
|
|
375
|
+
return "user";
|
|
376
|
+
}
|
|
377
|
+
|
|
368
378
|
// src/utils/safe_timeout.ts
|
|
369
379
|
var TimerRegistry = class {
|
|
370
380
|
/** Live timer ids that have not yet been cleared (or, for timeouts, fired). */
|
|
@@ -567,17 +577,20 @@ var DrawerController = class extends Controller {
|
|
|
567
577
|
open: { type: Boolean, default: false }
|
|
568
578
|
};
|
|
569
579
|
static actions = ["close", "closeOnBackdrop", "open"];
|
|
580
|
+
static events = ["close", "open"];
|
|
570
581
|
/** Exact panel currently owned by the modal lifecycle (survives target churn safely). */
|
|
571
582
|
#activePanel = null;
|
|
572
583
|
/** Owns the modal side effects; Escape closes, focus falls back to the trigger. */
|
|
573
584
|
#trap = new FocusTrap(() => this.#activePanel ?? this.panelTarget, {
|
|
574
|
-
onEscape: () => this
|
|
585
|
+
onEscape: () => this.#close("escape"),
|
|
575
586
|
fallbackFocus: () => this.hasTriggerTarget ? this.triggerTarget : null
|
|
576
587
|
});
|
|
577
588
|
/** Owns the cancellable close-transition wait and its bounded fallback. */
|
|
578
589
|
#transition = new TransitionCompletion();
|
|
579
590
|
/** Distinguishes dynamic target churn from callbacks around controller teardown. */
|
|
580
591
|
#connected = false;
|
|
592
|
+
/** Whether state moves are reported: set once `connect()` settled the baseline. */
|
|
593
|
+
#reporting = false;
|
|
581
594
|
/**
|
|
582
595
|
* Reflects placement and establishes the initial open/closed state.
|
|
583
596
|
*
|
|
@@ -585,9 +598,9 @@ var DrawerController = class extends Controller {
|
|
|
585
598
|
* restored snapshot whose panel is already `data-state="open"` stays open
|
|
586
599
|
* rather than being re-derived from the declarative `open` Value (which would
|
|
587
600
|
* close a user-opened drawer). The `open` Value only seeds a genuinely fresh
|
|
588
|
-
* render. We normalize to a clean closed baseline first so
|
|
589
|
-
* full reveal + trap activation —
|
|
590
|
-
* disconnect and must be re-activated.
|
|
601
|
+
* render. We normalize to a clean closed baseline first so
|
|
602
|
+
* {@link DrawerController.open | open} runs its full reveal + trap activation —
|
|
603
|
+
* the `FocusTrap` is inactive after a disconnect and must be re-activated.
|
|
591
604
|
*/
|
|
592
605
|
connect() {
|
|
593
606
|
this.#connected = true;
|
|
@@ -595,11 +608,13 @@ var DrawerController = class extends Controller {
|
|
|
595
608
|
this.#reflectPlacement();
|
|
596
609
|
const shouldOpen = this.#isOpen || this.openValue;
|
|
597
610
|
this.#applyClosedState();
|
|
598
|
-
if (shouldOpen) this
|
|
611
|
+
if (shouldOpen) this.#open("api");
|
|
612
|
+
this.#reporting = true;
|
|
599
613
|
}
|
|
600
614
|
/** Reverts the modal side effects and pending hide if torn down while open. */
|
|
601
615
|
disconnect() {
|
|
602
616
|
this.#connected = false;
|
|
617
|
+
this.#reporting = false;
|
|
603
618
|
this.#transition.cancel();
|
|
604
619
|
this.#trap.deactivate({ restoreFocus: false });
|
|
605
620
|
this.#activePanel = null;
|
|
@@ -630,7 +645,11 @@ var DrawerController = class extends Controller {
|
|
|
630
645
|
this.#reflectPlacement();
|
|
631
646
|
}
|
|
632
647
|
/** Opens the drawer: reveals it, syncs `data-state`, traps focus. */
|
|
633
|
-
open() {
|
|
648
|
+
open(event) {
|
|
649
|
+
this.#open(stateReasonFor(event));
|
|
650
|
+
}
|
|
651
|
+
/** Reveals the drawer, syncs `data-state`, reports a move, then traps focus. */
|
|
652
|
+
#open(reason) {
|
|
634
653
|
if (!this.hasPanelTarget || this.#isOpen) return;
|
|
635
654
|
this.#transition.cancel();
|
|
636
655
|
this.#activePanel = this.panelTarget;
|
|
@@ -639,31 +658,46 @@ var DrawerController = class extends Controller {
|
|
|
639
658
|
void this.panelTarget.offsetWidth;
|
|
640
659
|
this.#setState("open");
|
|
641
660
|
this.openValue = true;
|
|
661
|
+
if (this.#reporting) this.dispatch("open", { detail: { reason }, cancelable: false });
|
|
662
|
+
if (!this.#isOpen) return;
|
|
642
663
|
this.#trap.activate();
|
|
643
664
|
}
|
|
644
665
|
/**
|
|
645
666
|
* Closes the drawer: syncs `data-state` to start the exit transition, then
|
|
646
667
|
* defers both `hidden` *and* the modal teardown (scroll lock / background
|
|
647
|
-
* `inert` / focus restore) until the transition finishes
|
|
648
|
-
*
|
|
649
|
-
*
|
|
650
|
-
* the exit animation.
|
|
668
|
+
* `inert` / focus restore) until the transition finishes. This keeps the
|
|
669
|
+
* background inert and focus trapped while the drawer is still visually on
|
|
670
|
+
* screen, preserving the modal contract during the exit animation.
|
|
651
671
|
*/
|
|
652
|
-
close() {
|
|
672
|
+
close(event) {
|
|
673
|
+
this.#close(stateReasonFor(event));
|
|
674
|
+
}
|
|
675
|
+
/** Closes only when the overlay itself (not its contents) is clicked. */
|
|
676
|
+
closeOnBackdrop(event) {
|
|
677
|
+
if (this.hasOverlayTarget && event.target === this.overlayTarget) this.#close("outside");
|
|
678
|
+
}
|
|
679
|
+
/** Starts the exit transition, reports a move, then defers `hidden` and teardown. */
|
|
680
|
+
#close(reason) {
|
|
653
681
|
if (!this.hasPanelTarget || !this.#isOpen) return;
|
|
654
682
|
this.openValue = false;
|
|
655
683
|
this.#setState("closed");
|
|
684
|
+
if (this.#reporting) this.dispatch("close", { detail: { reason }, cancelable: false });
|
|
685
|
+
if (this.#isOpen) return;
|
|
656
686
|
this.#hideAfterTransition();
|
|
657
687
|
}
|
|
658
|
-
/**
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
688
|
+
/**
|
|
689
|
+
* Writes `data-placement` from the current `placement` value.
|
|
690
|
+
*
|
|
691
|
+
* @stimeoRenderRoot
|
|
692
|
+
*/
|
|
663
693
|
#reflectPlacement() {
|
|
664
694
|
if (this.hasPanelTarget) this.panelTarget.setAttribute("data-placement", this.#placement);
|
|
665
695
|
}
|
|
666
|
-
/**
|
|
696
|
+
/**
|
|
697
|
+
* Reconciles a replacement panel and companion overlay from its explicit DOM state.
|
|
698
|
+
*
|
|
699
|
+
* @stimeoRenderRoot
|
|
700
|
+
*/
|
|
667
701
|
#adoptPanel(panel) {
|
|
668
702
|
this.#transition.cancel();
|
|
669
703
|
const trapWasActive = this.#trap.active;
|
|
@@ -719,8 +753,9 @@ var DrawerController = class extends Controller {
|
|
|
719
753
|
* Runs once the close transition has finished: applies `hidden` to the panel
|
|
720
754
|
* and overlay, then reverts the modal side effects (scroll lock, background
|
|
721
755
|
* `inert`, keydown listener) and restores focus to the opener. Deferring the
|
|
722
|
-
* `FocusTrap` teardown to here — rather than at
|
|
723
|
-
*
|
|
756
|
+
* `FocusTrap` teardown to here — rather than at
|
|
757
|
+
* {@link DrawerController.close | close} time — keeps the background unreachable
|
|
758
|
+
* and focus trapped for the whole exit animation.
|
|
724
759
|
*/
|
|
725
760
|
#applyHidden(panel) {
|
|
726
761
|
panel.hidden = true;
|