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
|
@@ -192,6 +192,16 @@ function isScrollable(element) {
|
|
|
192
192
|
return /auto|scroll|overlay/.test(style.overflow + style.overflowX + style.overflowY);
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
+
// src/utils/state_reason.ts
|
|
196
|
+
var FOCUS_EVENTS = /* @__PURE__ */ new Set(["blur", "focus", "focusin", "focusout"]);
|
|
197
|
+
var POINTER_EVENTS = /* @__PURE__ */ new Set(["mouseenter", "mouseleave", "pointerenter", "pointerleave"]);
|
|
198
|
+
function stateReasonFor(event) {
|
|
199
|
+
if (!event) return "api";
|
|
200
|
+
if (FOCUS_EVENTS.has(event.type)) return "focus";
|
|
201
|
+
if (POINTER_EVENTS.has(event.type)) return "pointer";
|
|
202
|
+
return "user";
|
|
203
|
+
}
|
|
204
|
+
|
|
195
205
|
// src/controllers/popover_controller.ts
|
|
196
206
|
var PopoverController = class extends Controller {
|
|
197
207
|
static targets = ["trigger", "panel"];
|
|
@@ -199,15 +209,19 @@ var PopoverController = class extends Controller {
|
|
|
199
209
|
closeOnScroll: { type: Boolean, default: false }
|
|
200
210
|
};
|
|
201
211
|
static actions = ["close", "open", "toggle"];
|
|
212
|
+
static events = ["close", "open"];
|
|
202
213
|
/** Cleanup for the dismiss-on-scroll listeners while open, or `null`. */
|
|
203
214
|
#stopScrollDismiss = null;
|
|
204
215
|
/** Escape-stack membership while open; the shared resolver dismisses via it. */
|
|
205
216
|
#escapeLayer = new EscapeLayer();
|
|
217
|
+
/** Whether state moves are reported: set once `connect()` settled the baseline. */
|
|
218
|
+
#reporting = false;
|
|
206
219
|
/** Starts closed and registers the standing dismissal listeners. */
|
|
207
220
|
connect() {
|
|
208
|
-
this
|
|
221
|
+
this.#close("api");
|
|
209
222
|
document.addEventListener("click", this.#onOutsideClick, true);
|
|
210
223
|
this.element.addEventListener("focusout", this.#onFocusOut);
|
|
224
|
+
this.#reporting = true;
|
|
211
225
|
}
|
|
212
226
|
/**
|
|
213
227
|
* Removes every standing listener registered in {@link connect} plus any active
|
|
@@ -216,6 +230,7 @@ var PopoverController = class extends Controller {
|
|
|
216
230
|
* the element after a Turbo navigation.
|
|
217
231
|
*/
|
|
218
232
|
disconnect() {
|
|
233
|
+
this.#reporting = false;
|
|
219
234
|
this.#escapeLayer.deactivate();
|
|
220
235
|
document.removeEventListener("click", this.#onOutsideClick, true);
|
|
221
236
|
this.element.removeEventListener("focusout", this.#onFocusOut);
|
|
@@ -223,34 +238,51 @@ var PopoverController = class extends Controller {
|
|
|
223
238
|
this.#stopScrollDismiss = null;
|
|
224
239
|
}
|
|
225
240
|
/** Toggles the popover. Bound via `data-action` (click on the trigger). */
|
|
226
|
-
toggle() {
|
|
241
|
+
toggle(event) {
|
|
227
242
|
if (this.#isOpen) {
|
|
228
|
-
this.close();
|
|
243
|
+
this.close(event);
|
|
229
244
|
} else {
|
|
230
|
-
this.open();
|
|
245
|
+
this.open(event);
|
|
231
246
|
}
|
|
232
247
|
}
|
|
233
248
|
/** Opens the panel, reflects state, and moves focus inside it. */
|
|
234
|
-
open() {
|
|
249
|
+
open(event) {
|
|
250
|
+
this.#open(stateReasonFor(event));
|
|
251
|
+
}
|
|
252
|
+
/** Closes the panel and reflects the collapsed state. Bound via `data-action`. */
|
|
253
|
+
close(event) {
|
|
254
|
+
this.#close(stateReasonFor(event));
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Opens the panel, reflects state, reports a move, and moves focus inside it.
|
|
258
|
+
*
|
|
259
|
+
* @stimeoRuntimeOnly `closeOnScroll` decides whether this opening wires the scroll dismissal;
|
|
260
|
+
* what is shown does not depend on it.
|
|
261
|
+
*/
|
|
262
|
+
#open(reason) {
|
|
235
263
|
if (!this.hasPanelTarget || this.#isOpen) return;
|
|
236
264
|
this.panelTarget.hidden = false;
|
|
237
265
|
if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "true");
|
|
266
|
+
if (this.#reporting) this.dispatch("open", { detail: { reason }, cancelable: false });
|
|
267
|
+
if (!this.#isOpen) return;
|
|
238
268
|
this.#escapeLayer.activate(document, {
|
|
239
269
|
onDismiss: () => this.#closeAndRestore(),
|
|
240
270
|
claims: claimsWhileFocusWithin(this.element)
|
|
241
271
|
});
|
|
242
272
|
if (this.closeOnScrollValue && !this.#stopScrollDismiss) {
|
|
243
|
-
this.#stopScrollDismiss = observeScrollDismiss(this.element, () => this
|
|
273
|
+
this.#stopScrollDismiss = observeScrollDismiss(this.element, () => this.#close("scroll"));
|
|
244
274
|
}
|
|
245
275
|
this.#focusFirst();
|
|
246
276
|
}
|
|
247
|
-
/** Closes the panel
|
|
248
|
-
close() {
|
|
277
|
+
/** Closes the panel, reflects the collapsed state, and reports a move. */
|
|
278
|
+
#close(reason) {
|
|
279
|
+
const was = this.#isOpen;
|
|
249
280
|
this.#escapeLayer.deactivate();
|
|
250
281
|
this.#stopScrollDismiss?.();
|
|
251
282
|
this.#stopScrollDismiss = null;
|
|
252
283
|
if (this.hasPanelTarget) this.panelTarget.hidden = true;
|
|
253
284
|
if (this.hasTriggerTarget) this.triggerTarget.setAttribute("aria-expanded", "false");
|
|
285
|
+
if (was && this.#reporting) this.dispatch("close", { detail: { reason }, cancelable: false });
|
|
254
286
|
}
|
|
255
287
|
/** Moves focus to the first focusable element in the panel, or the panel itself. */
|
|
256
288
|
#focusFirst() {
|
|
@@ -264,13 +296,15 @@ var PopoverController = class extends Controller {
|
|
|
264
296
|
}
|
|
265
297
|
/** Closes and restores focus to the trigger for explicit keyboard dismissal. */
|
|
266
298
|
#closeAndRestore() {
|
|
267
|
-
this
|
|
299
|
+
this.#close("escape");
|
|
268
300
|
if (this.hasTriggerTarget) this.triggerTarget.focus();
|
|
269
301
|
}
|
|
270
302
|
/** Closes without moving focus when a click lands outside the controller element. */
|
|
271
303
|
#onOutsideClick = (event) => {
|
|
272
304
|
const target = event.target;
|
|
273
|
-
if (this.#isOpen && target instanceof Node && !this.element.contains(target))
|
|
305
|
+
if (this.#isOpen && target instanceof Node && !this.element.contains(target)) {
|
|
306
|
+
this.#close("outside");
|
|
307
|
+
}
|
|
274
308
|
};
|
|
275
309
|
/**
|
|
276
310
|
* Closes when focus leaves the controller for a known external destination
|
|
@@ -284,7 +318,7 @@ var PopoverController = class extends Controller {
|
|
|
284
318
|
if (!this.#isOpen) return;
|
|
285
319
|
const next = event.relatedTarget;
|
|
286
320
|
if (!(next instanceof Node) || this.element.contains(next)) return;
|
|
287
|
-
this
|
|
321
|
+
this.#close("focus");
|
|
288
322
|
};
|
|
289
323
|
/** Whether the panel is currently visible. */
|
|
290
324
|
get #isOpen() {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/utils/escape_layer.ts","../../src/utils/focus_candidate.ts","../../src/utils/scroll_dismiss.ts","../../src/controllers/popover_controller.ts"],"names":[],"mappings":";;;;;AAgEO,SAAS,uBAAuB,OAAA,EAAiC;AACtE,EAAA,OAAO,MAAM;AACX,IAAA,MAAM,MAAA,GAAS,QAAQ,aAAA,CAAc,aAAA;AACrC,IAAA,OAAO,MAAA,KAAW,QAAQ,MAAA,KAAW,OAAA,CAAQ,cAAc,IAAA,IAAQ,OAAA,CAAQ,SAAS,MAAM,CAAA;AAAA,EAC5F,CAAA;AACF;AAEO,IAAM,WAAA,GAAN,MAAM,YAAA,CAAY;AAAA,EACvB,OAAgB,WAAA,mBAAc,IAAI,OAAA,EAAuC;AAAA,EAEzE,cAAA,GAAkC,IAAA;AAAA;AAAA,EAElC,UAAA,GAAkC,IAAA;AAAA;AAAA,EAElC,OAAA,GAAkC,IAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOlC,QAAA,CAAS,aAAA,GAA0B,QAAA,EAAU,OAAA,EAAmC;AAC9E,IAAA,IAAA,CAAK,UAAA,EAAW;AAChB,IAAA,IAAI,QAAA,GAAW,YAAA,CAAY,WAAA,CAAY,GAAA,CAAI,aAAa,CAAA;AACxD,IAAA,IAAI,CAAC,QAAA,EAAU;AACb,MAAA,QAAA,GAAW,aAAY,eAAA,EAAgB;AACvC,MAAA,YAAA,CAAY,WAAA,CAAY,GAAA,CAAI,aAAA,EAAe,QAAQ,CAAA;AACnD,MAAA,aAAA,CAAc,gBAAA,CAAiB,SAAA,EAAW,QAAA,CAAS,SAAS,CAAA;AAAA,IAC9D;AACA,IAAA,QAAA,CAAS,KAAA,CAAM,KAAK,IAAI,CAAA;AACxB,IAAA,IAAA,CAAK,cAAA,GAAiB,aAAA;AACtB,IAAA,IAAA,CAAK,aAAa,OAAA,CAAQ,SAAA;AAC1B,IAAA,IAAA,CAAK,OAAA,GAAU,QAAQ,MAAA,IAAU,IAAA;AAAA,EACnC;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,UAAA,GAAmB;AACjB,IAAA,MAAM,gBAAgB,IAAA,CAAK,cAAA;AAC3B,IAAA,IAAI,CAAC,aAAA,EAAe;AAEpB,IAAA,MAAM,QAAA,GAAW,YAAA,CAAY,WAAA,CAAY,GAAA,CAAI,aAAa,CAAA;AAC1D,IAAA,IAAI,QAAA,EAAU;AACZ,MAAA,MAAM,KAAA,GAAQ,QAAA,CAAS,KAAA,CAAM,WAAA,CAAY,IAAI,CAAA;AAC7C,MAAA,IAAI,SAAS,CAAA,EAAG,QAAA,CAAS,KAAA,CAAM,MAAA,CAAO,OAAO,CAAC,CAAA;AAC9C,MAAA,IAAI,QAAA,CAAS,KAAA,CAAM,MAAA,KAAW,CAAA,EAAG;AAC/B,QAAA,aAAA,CAAc,mBAAA,CAAoB,SAAA,EAAW,QAAA,CAAS,SAAS,CAAA;AAC/D,QAAA,YAAA,CAAY,WAAA,CAAY,OAAO,aAAa,CAAA;AAAA,MAC9C;AAAA,IACF;AACA,IAAA,IAAA,CAAK,cAAA,GAAiB,IAAA;AACtB,IAAA,IAAA,CAAK,UAAA,GAAa,IAAA;AAClB,IAAA,IAAA,CAAK,OAAA,GAAU,IAAA;AAAA,EACjB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,IAAI,UAAA,GAAsB;AACxB,IAAA,MAAM,gBAAgB,IAAA,CAAK,cAAA;AAC3B,IAAA,IAAI,CAAC,eAAe,OAAO,KAAA;AAC3B,IAAA,MAAM,QAAA,GAAW,YAAA,CAAY,WAAA,CAAY,GAAA,CAAI,aAAa,CAAA;AAC1D,IAAA,IAAI,CAAC,UAAU,OAAO,KAAA;AACtB,IAAA,OAAO,YAAA,CAAY,aAAA,CAAc,QAAA,CAAS,KAAK,CAAA,KAAM,IAAA;AAAA,EACvD;AAAA;AAAA,EAGA,OAAO,eAAA,GAAuC;AAC5C,IAAA,MAAM,QAAA,GAAgC;AAAA,MACpC,OAAO,EAAC;AAAA,MACR,SAAA,EAAW,CAAC,KAAA,KAA+B;AACzC,QAAA,IAAI,MAAM,GAAA,KAAQ,QAAA,IAAY,KAAA,CAAM,gBAAA,IAAoB,MAAM,WAAA,EAAa;AAC3E,QAAA,MAAM,KAAA,GAAQ,YAAA,CAAY,aAAA,CAAc,QAAA,CAAS,KAAK,CAAA;AACtD,QAAA,IAAI,CAAC,KAAA,EAAO;AACZ,QAAA,KAAA,CAAM,cAAA,EAAe;AACrB,QAAA,KAAA,CAAM,UAAA,IAAa;AAAA,MACrB;AAAA,KACF;AACA,IAAA,OAAO,QAAA;AAAA,EACT;AAAA;AAAA,EAGA,OAAO,cAAc,KAAA,EAA0C;AAC7D,IAAA,KAAA,IAAS,QAAQ,KAAA,CAAM,MAAA,GAAS,CAAA,EAAG,KAAA,IAAS,GAAG,KAAA,EAAA,EAAS;AACtD,MAAA,MAAM,KAAA,GAAQ,MAAM,KAAK,CAAA;AACzB,MAAA,IAAI,CAAC,KAAA,EAAO;AACZ,MAAA,IAAI,KAAA,CAAM,OAAA,IAAW,CAAC,KAAA,CAAM,SAAQ,EAAG;AACvC,MAAA,OAAO,KAAA;AAAA,IACT;AACA,IAAA,OAAO,IAAA;AAAA,EACT;AACF,CAAA;;;ACpJO,SAAS,yBAAyB,OAAA,EAA+B;AACtE,EAAA,IAAI,QAAA,GAA2B,OAAA,CAAQ,OAAA,CAAQ,oBAAoB,CAAA;AACnE,EAAA,OAAO,QAAA,EAAU;AACf,IAAA,MAAM,MAAA,GAAS,KAAA,CAAM,IAAA,CAAK,QAAA,CAAS,QAAQ,CAAA,CAAE,IAAA,CAAK,CAAC,KAAA,KAAU,KAAA,CAAM,OAAA,KAAY,QAAQ,CAAA;AACvF,IAAA,IAAI,CAAC,MAAA,EAAQ,QAAA,CAAS,OAAO,GAAG,OAAO,IAAA;AACvC,IAAA,QAAA,GAAW,QAAA,CAAS,aAAA,EAAe,OAAA,CAAQ,oBAAoB,CAAA,IAAK,IAAA;AAAA,EACtE;AACA,EAAA,OAAO,KAAA;AACT;AAgDO,SAAS,aAAa,OAAA,EAA+B;AAC1D,EAAA,IAAI,OAAA,CAAQ,OAAA,CAAQ,mBAAmB,CAAA,EAAG,OAAO,KAAA;AACjD,EAAA,IAAI,OAAA,YAAmB,gBAAA,IAAoB,OAAA,CAAQ,IAAA,KAAS,UAAU,OAAO,KAAA;AAC7E,EAAA,IAAI,EAAE,UAAA,IAAc,OAAA,CAAA,EAAU,OAAO,IAAA;AACrC,EAAA,IAAK,OAAA,CAAgD,UAAU,OAAO,KAAA;AACtE,EAAA,OAAO,CAAC,yBAAyB,OAAO,CAAA;AAC1C;AAGO,IAAM,2BAAA,GAA8B;AAAA,EACzC,SAAA;AAAA,EACA,YAAA;AAAA,EACA,QAAA;AAAA,EACA,OAAA;AAAA,EACA,QAAA;AAAA,EACA,UAAA;AAAA,EACA,SAAA;AAAA,EACA,QAAA;AAAA,EACA,iBAAA;AAAA,EACA,iBAAA;AAAA,EACA,YAAA;AAAA,EACA;AACF,CAAA,CAAE,KAAK,GAAG,CAAA;AAQH,SAAS,mBAAmB,OAAA,EAA+B;AAChE,EAAA,MAAM,QAAS,OAAA,CAA8C,eAAA;AAC7D,EAAA,OAAO,OAAO,KAAA,KAAU,UAAA,GAAa,KAAA,CAAM,IAAA,CAAK,SAAS,EAAE,kBAAA,EAAoB,IAAA,EAAM,CAAA,GAAI,IAAA;AAC3F;AAGA,SAAS,iBAAiB,OAAA,EAAqC;AAC7D,EAAA,MAAM,KAAA,GAAQ,OAAA,CAAQ,YAAA,CAAa,UAAU,CAAA;AAC7C,EAAA,IAAI,KAAA,KAAU,QAAQ,CAAC,YAAA,CAAa,KAAK,KAAA,CAAM,IAAA,EAAM,CAAA,EAAG,OAAO,IAAA;AAC/D,EAAA,OAAO,OAAO,KAAK,CAAA;AACrB;AAGA,SAAS,iBAAiB,OAAA,EAA+B;AACvD,EAAA,IAAI,OAAA,YAAmB,iBAAA,IAAqB,OAAA,YAAmB,eAAA,EAAiB;AAC9E,IAAA,OAAO,OAAA,CAAQ,aAAa,MAAM,CAAA;AAAA,EACpC;AACA,EAAA,IACE,OAAA,YAAmB,iBAAA,IACnB,OAAA,YAAmB,iBAAA,IACnB,mBAAmB,mBAAA,EACnB;AACA,IAAA,OAAO,IAAA;AAAA,EACT;AACA,EAAA,IAAI,OAAA,YAAmB,gBAAA,EAAkB,OAAO,OAAA,CAAQ,IAAA,KAAS,QAAA;AACjE,EAAA,IAAI,OAAA,YAAmB,mBAAmB,OAAO,IAAA;AACjD,EAAA,IAAI,OAAA,CAAQ,OAAA,KAAY,OAAA,IAAW,OAAA,CAAQ,YAAY,OAAA,EAAS;AAC9D,IAAA,OAAO,OAAA,CAAQ,aAAa,UAAU,CAAA;AAAA,EACxC;AACA,EAAA,IAAI,OAAA,YAAmB,WAAA,IAAe,OAAA,CAAQ,OAAA,KAAY,SAAA,EAAW;AACnE,IAAA,MAAM,UAAU,OAAA,CAAQ,aAAA;AACxB,IAAA,OACE,OAAA,YAAmB,kBAAA,IACnB,KAAA,CAAM,IAAA,CAAK,OAAA,CAAQ,QAAQ,CAAA,CAAE,IAAA,CAAK,CAAC,KAAA,KAAU,KAAA,CAAM,OAAA,KAAY,SAAS,CAAA,KAAM,OAAA;AAAA,EAElF;AACA,EAAA,OAAO,KAAA;AACT;AAGA,SAAS,mBAAmB,OAAA,EAA+B;AACzD,EAAA,MAAM,KAAA,GAAQ,OAAA,CAAQ,YAAA,CAAa,iBAAiB,GAAG,WAAA,EAAY;AACnE,EAAA,OAAO,KAAA,KAAU,EAAA,IAAM,KAAA,KAAU,MAAA,IAAU,KAAA,KAAU,gBAAA;AACvD;AAUO,SAAS,UAAU,OAAA,EAA+B;AACvD,EAAA,IAAI,CAAC,aAAa,OAAO,CAAA,IAAK,CAAC,kBAAA,CAAmB,OAAO,GAAG,OAAO,KAAA;AAEnE,EAAA,MAAM,QAAA,GAAW,iBAAiB,OAAO,CAAA;AACzC,EAAA,IAAI,QAAA,KAAa,IAAA,EAAM,OAAO,QAAA,IAAY,CAAA;AAC1C,EAAA,OAAO,gBAAA,CAAiB,OAAO,CAAA,IAAK,kBAAA,CAAmB,OAAO,CAAA;AAChE;AAUO,SAAS,aAAa,IAAA,EAAsC;AACjE,EAAA,KAAA,MAAW,SAAA,IAAa,IAAA,CAAK,gBAAA,CAA8B,2BAA2B,CAAA,EAAG;AACvF,IAAA,IAAI,SAAA,CAAU,SAAS,CAAA,EAAG,OAAO,SAAA;AAAA,EACnC;AACA,EAAA,OAAO,IAAA;AACT;;;AC1JO,SAAS,oBAAA,CAAqB,SAAkB,QAAA,EAAkC;AACvF,EAAA,MAAM,UAAmC,CAAC,GAAG,aAAA,CAAc,OAAO,GAAG,MAAM,CAAA;AAC3E,EAAA,MAAM,OAAA,GAAU,MAAY,QAAA,EAAS;AACrC,EAAA,KAAA,MAAW,UAAU,OAAA,EAAS;AAC5B,IAAA,MAAA,CAAO,iBAAiB,QAAA,EAAU,OAAA,EAAS,EAAE,OAAA,EAAS,MAAM,CAAA;AAAA,EAC9D;AACA,EAAA,OAAO,MAAM;AACX,IAAA,KAAA,MAAW,MAAA,IAAU,OAAA,EAAS,MAAA,CAAO,mBAAA,CAAoB,UAAU,OAAO,CAAA;AAAA,EAC5E,CAAA;AACF;AAGA,SAAS,cAAc,OAAA,EAA6B;AAClD,EAAA,MAAM,UAAqB,EAAC;AAC5B,EAAA,IAAI,OAAO,OAAA,CAAQ,aAAA;AACnB,EAAA,OAAO,IAAA,EAAM;AACX,IAAA,IAAI,YAAA,CAAa,IAAI,CAAA,EAAG,OAAA,CAAQ,KAAK,IAAI,CAAA;AACzC,IAAA,IAAA,GAAO,IAAA,CAAK,aAAA;AAAA,EACd;AACA,EAAA,OAAO,OAAA;AACT;AAGA,SAAS,aAAa,OAAA,EAA2B;AAC/C,EAAA,MAAM,KAAA,GAAQ,iBAAiB,OAAO,CAAA;AACtC,EAAA,OAAO,sBAAsB,IAAA,CAAK,KAAA,CAAM,WAAW,KAAA,CAAM,SAAA,GAAY,MAAM,SAAS,CAAA;AACtF;;;ACQO,IAAM,iBAAA,GAAN,cAAgC,UAAA,CAAwB;AAAA,EAC7D,OAAgB,OAAA,GAAU,CAAC,SAAA,EAAW,OAAO,CAAA;AAAA,EAC7C,OAAgB,MAAA,GAAS;AAAA,IACvB,aAAA,EAAe,EAAE,IAAA,EAAM,OAAA,EAAS,SAAS,KAAA;AAAM,GACjD;AAAA,EACA,OAAO,OAAA,GAAU,CAAC,OAAA,EAAS,QAAQ,QAAQ,CAAA;AAAA;AAAA,EAS3C,kBAAA,GAA0C,IAAA;AAAA;AAAA,EAEjC,YAAA,GAAe,IAAI,WAAA,EAAY;AAAA;AAAA,EAG/B,OAAA,GAAgB;AACvB,IAAA,IAAA,CAAK,KAAA,EAAM;AACX,IAAA,QAAA,CAAS,gBAAA,CAAiB,OAAA,EAAS,IAAA,CAAK,eAAA,EAAiB,IAAI,CAAA;AAC7D,IAAA,IAAA,CAAK,OAAA,CAAQ,gBAAA,CAAiB,UAAA,EAAY,IAAA,CAAK,WAAW,CAAA;AAAA,EAC5D;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQS,UAAA,GAAmB;AAC1B,IAAA,IAAA,CAAK,aAAa,UAAA,EAAW;AAC7B,IAAA,QAAA,CAAS,mBAAA,CAAoB,OAAA,EAAS,IAAA,CAAK,eAAA,EAAiB,IAAI,CAAA;AAChE,IAAA,IAAA,CAAK,OAAA,CAAQ,mBAAA,CAAoB,UAAA,EAAY,IAAA,CAAK,WAAW,CAAA;AAC7D,IAAA,IAAA,CAAK,kBAAA,IAAqB;AAC1B,IAAA,IAAA,CAAK,kBAAA,GAAqB,IAAA;AAAA,EAC5B;AAAA;AAAA,EAGA,MAAA,GAAe;AACb,IAAA,IAAI,KAAK,OAAA,EAAS;AAChB,MAAA,IAAA,CAAK,KAAA,EAAM;AAAA,IACb,CAAA,MAAO;AACL,MAAA,IAAA,CAAK,IAAA,EAAK;AAAA,IACZ;AAAA,EACF;AAAA;AAAA,EAGA,IAAA,GAAa;AACX,IAAA,IAAI,CAAC,IAAA,CAAK,cAAA,IAAkB,IAAA,CAAK,OAAA,EAAS;AAC1C,IAAA,IAAA,CAAK,YAAY,MAAA,GAAS,KAAA;AAC1B,IAAA,IAAI,KAAK,gBAAA,EAAkB,IAAA,CAAK,aAAA,CAAc,YAAA,CAAa,iBAAiB,MAAM,CAAA;AAClF,IAAA,IAAA,CAAK,YAAA,CAAa,SAAS,QAAA,EAAU;AAAA,MACnC,SAAA,EAAW,MAAM,IAAA,CAAK,gBAAA,EAAiB;AAAA,MACvC,MAAA,EAAQ,sBAAA,CAAuB,IAAA,CAAK,OAAO;AAAA,KAC5C,CAAA;AACD,IAAA,IAAI,IAAA,CAAK,kBAAA,IAAsB,CAAC,IAAA,CAAK,kBAAA,EAAoB;AAEvD,MAAA,IAAA,CAAK,qBAAqB,oBAAA,CAAqB,IAAA,CAAK,SAAS,MAAM,IAAA,CAAK,OAAO,CAAA;AAAA,IACjF;AACA,IAAA,IAAA,CAAK,WAAA,EAAY;AAAA,EACnB;AAAA;AAAA,EAGA,KAAA,GAAc;AAGZ,IAAA,IAAA,CAAK,aAAa,UAAA,EAAW;AAC7B,IAAA,IAAA,CAAK,kBAAA,IAAqB;AAC1B,IAAA,IAAA,CAAK,kBAAA,GAAqB,IAAA;AAC1B,IAAA,IAAI,IAAA,CAAK,cAAA,EAAgB,IAAA,CAAK,WAAA,CAAY,MAAA,GAAS,IAAA;AACnD,IAAA,IAAI,KAAK,gBAAA,EAAkB,IAAA,CAAK,aAAA,CAAc,YAAA,CAAa,iBAAiB,OAAO,CAAA;AAAA,EACrF;AAAA;AAAA,EAGA,WAAA,GAAoB;AAClB,IAAA,MAAM,KAAA,GAAQ,YAAA,CAAa,IAAA,CAAK,WAAW,CAAA;AAC3C,IAAA,IAAI,KAAA,EAAO;AACT,MAAA,KAAA,CAAM,KAAA,EAAM;AACZ,MAAA;AAAA,IACF;AACA,IAAA,IAAI,CAAC,KAAK,WAAA,CAAY,YAAA,CAAa,UAAU,CAAA,EAAG,IAAA,CAAK,YAAY,QAAA,GAAW,EAAA;AAC5E,IAAA,IAAA,CAAK,YAAY,KAAA,EAAM;AAAA,EACzB;AAAA;AAAA,EAGA,gBAAA,GAAyB;AACvB,IAAA,IAAA,CAAK,KAAA,EAAM;AACX,IAAA,IAAI,IAAA,CAAK,gBAAA,EAAkB,IAAA,CAAK,aAAA,CAAc,KAAA,EAAM;AAAA,EACtD;AAAA;AAAA,EAGS,eAAA,GAAkB,CAAC,KAAA,KAA4B;AACtD,IAAA,MAAM,SAAS,KAAA,CAAM,MAAA;AACrB,IAAA,IAAI,IAAA,CAAK,OAAA,IAAW,MAAA,YAAkB,IAAA,IAAQ,CAAC,IAAA,CAAK,OAAA,CAAQ,QAAA,CAAS,MAAM,CAAA,EAAG,IAAA,CAAK,KAAA,EAAM;AAAA,EAC3F,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAUS,WAAA,GAAc,CAAC,KAAA,KAA4B;AAClD,IAAA,IAAI,CAAC,KAAK,OAAA,EAAS;AACnB,IAAA,MAAM,OAAO,KAAA,CAAM,aAAA;AACnB,IAAA,IAAI,EAAE,IAAA,YAAgB,IAAA,CAAA,IAAS,KAAK,OAAA,CAAQ,QAAA,CAAS,IAAI,CAAA,EAAG;AAC5D,IAAA,IAAA,CAAK,KAAA,EAAM;AAAA,EACb,CAAA;AAAA;AAAA,EAGA,IAAI,OAAA,GAAmB;AACrB,IAAA,OAAO,IAAA,CAAK,cAAA,IAAkB,CAAC,IAAA,CAAK,WAAA,CAAY,MAAA;AAAA,EAClD;AACF","file":"popover_controller.js","sourcesContent":["/**\n * Single resolver for layered Escape dismissal.\n *\n * Every Escape-dismissable overlay layer (modal focus traps, disclosure\n * overlays like dropdown/popover/menu, and hover-triggered transient layers)\n * registers here while it is open. One document-level listener per document\n * resolves each press to exactly one owner and invokes that layer's\n * {@link EscapeLayerOptions.onDismiss} — controllers never listen for a\n * dismissing Escape themselves.\n *\n * @remarks\n * Each document owns an activation-ordered stack. The owner of a press is the\n * **topmost layer whose {@link EscapeLayerOptions.claims} passes**; a layer\n * that declines is transparent, so a background overlay opened behind a modal\n * never blocks it. Because a layer opened from within another is necessarily\n * activated later, LIFO order is also inner-first for nested layers — no DOM\n * inspection is needed.\n *\n * The shared listener runs in the document bubble phase and honors\n * `event.defaultPrevented`, so an element-level widget handler that consumes\n * Escape first (an editor cancelling its edit, a combobox closing its list)\n * always wins over every registered layer — the deepest handler resolves the\n * press. A keydown that is part of an IME composition (`event.isComposing`)\n * cancels the composition, never a layer, and is ignored here for every layer\n * at once.\n *\n * A WeakMap keeps documents collectible; the listener is installed only while\n * a document's stack is non-empty, and controller lifecycle hooks guarantee\n * that disconnected layers never remain registered.\n */\n\n/** Behavior a layer registers when it activates. */\nexport interface EscapeLayerOptions {\n /**\n * Dismisses the layer. Called by the shared resolver when this layer owns a\n * press; the resolver has already consumed the event (`preventDefault()`),\n * so the callback only needs to close and place focus per the widget's\n * contract.\n */\n onDismiss: () => void;\n /**\n * Whether the layer claims the current press. Evaluated per press, so it can\n * depend on live state (e.g. \"focus is inside me or fell to the body\"). A\n * declining layer is skipped and the next layer down is consulted; omitting\n * it means the layer always claims while active.\n */\n claims?: () => boolean;\n}\n\n/** A document's stack plus the one shared listener bound to it. */\ninterface EscapeLayerRegistry {\n stack: EscapeLayer[];\n onKeydown: (event: KeyboardEvent) => void;\n}\n\n/**\n * Claims predicate shared by the click-opened disclosure overlays (dropdown /\n * popover / navigation-menu / menu / context-menu / menubar): the layer claims\n * a press while focus is inside `element`, or after focus fell to the body —\n * a click on non-focusable overlay content blurs to `<body>`, and Escape must\n * still close the overlay (the \"body-focus rescue\"). A press made after focus\n * moved to another interactive element is declined, so closing never yanks\n * focus away from where the user deliberately went.\n */\nexport function claimsWhileFocusWithin(element: Element): () => boolean {\n return () => {\n const active = element.ownerDocument.activeElement;\n return active === null || active === element.ownerDocument.body || element.contains(active);\n };\n}\n\nexport class EscapeLayer {\n static readonly #registries = new WeakMap<Document, EscapeLayerRegistry>();\n\n #ownerDocument: Document | null = null;\n /** Dismissal callback while active; `null` when inactive. */\n #onDismiss: (() => void) | null = null;\n /** Live predicate deciding whether the layer claims a press; `null` = always. */\n #claims: (() => boolean) | null = null;\n\n /**\n * Activates this layer at the top of its document's Escape stack, installing\n * the document's shared resolver listener if this is its first layer.\n * Re-activating an already-active layer moves it to the top.\n */\n activate(ownerDocument: Document = document, options: EscapeLayerOptions): void {\n this.deactivate();\n let registry = EscapeLayer.#registries.get(ownerDocument);\n if (!registry) {\n registry = EscapeLayer.#createRegistry();\n EscapeLayer.#registries.set(ownerDocument, registry);\n ownerDocument.addEventListener(\"keydown\", registry.onKeydown);\n }\n registry.stack.push(this);\n this.#ownerDocument = ownerDocument;\n this.#onDismiss = options.onDismiss;\n this.#claims = options.claims ?? null;\n }\n\n /**\n * Removes this layer from its document's Escape stack, uninstalling the\n * shared listener when the stack empties. Safe to call when inactive.\n */\n deactivate(): void {\n const ownerDocument = this.#ownerDocument;\n if (!ownerDocument) return;\n\n const registry = EscapeLayer.#registries.get(ownerDocument);\n if (registry) {\n const index = registry.stack.lastIndexOf(this);\n if (index >= 0) registry.stack.splice(index, 1);\n if (registry.stack.length === 0) {\n ownerDocument.removeEventListener(\"keydown\", registry.onKeydown);\n EscapeLayer.#registries.delete(ownerDocument);\n }\n }\n this.#ownerDocument = null;\n this.#onDismiss = null;\n this.#claims = null;\n }\n\n /**\n * Whether this active layer would own a press right now: it is the topmost\n * layer whose {@link EscapeLayerOptions.claims} passes. Exposed for tests\n * and diagnostics — production dismissal goes through the shared listener.\n */\n get ownsEscape(): boolean {\n const ownerDocument = this.#ownerDocument;\n if (!ownerDocument) return false;\n const registry = EscapeLayer.#registries.get(ownerDocument);\n if (!registry) return false;\n return EscapeLayer.#resolveOwner(registry.stack) === this;\n }\n\n /** Builds a document's registry with its shared resolver listener. */\n static #createRegistry(): EscapeLayerRegistry {\n const registry: EscapeLayerRegistry = {\n stack: [],\n onKeydown: (event: KeyboardEvent): void => {\n if (event.key !== \"Escape\" || event.defaultPrevented || event.isComposing) return;\n const owner = EscapeLayer.#resolveOwner(registry.stack);\n if (!owner) return;\n event.preventDefault();\n owner.#onDismiss?.();\n },\n };\n return registry;\n }\n\n /** The topmost stack layer whose claims predicate passes, or `null`. */\n static #resolveOwner(stack: EscapeLayer[]): EscapeLayer | null {\n for (let index = stack.length - 1; index >= 0; index--) {\n const layer = stack[index];\n if (!layer) continue;\n if (layer.#claims && !layer.#claims()) continue;\n return layer;\n }\n return null;\n }\n}\n","/**\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","/**\n * Invokes `onScroll` when the page — or any scrollable ancestor of `element` —\n * scrolls, returning a cleanup that detaches every listener.\n *\n * `scroll` events do not bubble, and a single capture-phase listener on\n * `window`/`document` is unreliable for descendant scroll containers — so this\n * attaches a `scroll` listener directly to each scroll-parent ancestor (walking\n * `parentElement` and checking `overflow`) plus the window. That mirrors the\n * *intent* of `@floating-ui/dom`'s `autoUpdate` scroll tracking; it is a\n * deliberately simple resolver and does not cross shadow roots. This is precisely\n * the case a consumer cannot cover with `data-action` alone, which is why the\n * surface controllers own it.\n *\n * The listeners are `passive` (the handler never calls `preventDefault`), so they\n * never delay scrolling. Pass the surface's anchor/root so the relevant scroll\n * parents are found even when the floating element is portaled elsewhere.\n */\nexport function observeScrollDismiss(element: Element, onScroll: () => void): () => void {\n const targets: Array<Element | Window> = [...scrollParents(element), window];\n const handler = (): void => onScroll();\n for (const target of targets) {\n target.addEventListener(\"scroll\", handler, { passive: true });\n }\n return () => {\n for (const target of targets) target.removeEventListener(\"scroll\", handler);\n };\n}\n\n/** The scrollable ancestors of `element`, nearest first (window excluded). */\nfunction scrollParents(element: Element): Element[] {\n const parents: Element[] = [];\n let node = element.parentElement;\n while (node) {\n if (isScrollable(node)) parents.push(node);\n node = node.parentElement;\n }\n return parents;\n}\n\n/** Whether `element` is a scroll container (any axis `auto`/`scroll`/`overlay`). */\nfunction isScrollable(element: Element): boolean {\n const style = getComputedStyle(element);\n return /auto|scroll|overlay/.test(style.overflow + style.overflowX + style.overflowY);\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { claimsWhileFocusWithin, EscapeLayer } from \"../utils/escape_layer\";\nimport { firstTabStop } from \"../utils/focus_candidate\";\nimport { observeScrollDismiss } from \"../utils/scroll_dismiss\";\n\n/**\n * Headless, accessible **non-modal popover** behavior.\n *\n * Markup contract (identifier: `stimeo--popover`):\n * <div data-controller=\"stimeo--popover\">\n * <button data-stimeo--popover-target=\"trigger\"\n * aria-haspopup=\"dialog\" aria-expanded=\"false\" aria-controls=\"pop\"\n * data-action=\"click->stimeo--popover#toggle\">Edit profile</button>\n * <div id=\"pop\" data-stimeo--popover-target=\"panel\"\n * role=\"dialog\" aria-label=\"Edit profile\" hidden>…</div>\n * </div>\n *\n * Implements the WAI-ARIA APG **Dialog** pattern run *non-modally* (no\n * `aria-modal`, no focus trap, no `inert`/scroll lock). The background stays\n * fully interactive; this is the modeless counterpart to `stimeo--dialog`. For\n * a roving `role=\"menu\"` of commands use `stimeo--menu`; for decorative-only\n * text use `stimeo--tooltip`.\n *\n * @remarks\n * Behavior only — static placement is the consumer's CSS, and dynamic\n * edge-collision avoidance is delegated to the opt-in `stimeo-ui/positioning`\n * module (this controller never imports it, preserving the zero-dep core). State\n * is exposed via the trigger's `aria-expanded` and the panel's `hidden`.\n *\n * Behavior provided:\n * - Click the trigger to toggle (`aria-expanded` + `hidden` reflect state).\n * - On open, focus moves to the first focusable element inside the panel (or the\n * panel itself if it has none).\n * - `Escape` closes and restores focus to the trigger. While open the panel is a\n * layer on the shared `EscapeLayer` stack; it claims a press only while\n * focus is inside the controller or fell to the body (a click on non-focusable\n * panel content), so a press aimed at another layer is never consumed here,\n * and one keypress closes exactly one layer.\n * - An outside click (anywhere off the controller element) closes without moving\n * focus. Focus stays at the clicked element, or falls back to the document body\n * for a non-focusable destination.\n * - Because it is modeless, focus is *not* trapped: when `Tab` moves focus out of\n * the controller it closes (detected via bubbling `focusout`) without yanking\n * focus back, so forward and reverse traversal preserve their natural destination.\n * A `focusout` with no destination is ignored because it also occurs for clicks\n * on non-focusable content and when the browser window loses focus.\n * - Opt-in **dismiss on scroll** (`closeOnScroll`): while open, scrolling a tracked\n * scroll-parent ancestor (or the window) closes the panel, the usual convention for\n * anchored popups. Closes without restoring focus (like the modeless `focusout` path)\n * so the close never fights the user's scroll. Off by default.\n */\nexport class PopoverController extends Controller<HTMLElement> {\n static override targets = [\"trigger\", \"panel\"];\n static override values = {\n closeOnScroll: { type: Boolean, default: false },\n };\n static actions = [\"close\", \"open\", \"toggle\"] as const;\n\n declare readonly triggerTarget: HTMLButtonElement;\n declare readonly panelTarget: HTMLElement;\n declare readonly hasTriggerTarget: boolean;\n declare readonly hasPanelTarget: boolean;\n declare readonly closeOnScrollValue: boolean;\n\n /** Cleanup for the dismiss-on-scroll listeners while open, or `null`. */\n #stopScrollDismiss: (() => void) | null = null;\n /** Escape-stack membership while open; the shared resolver dismisses via it. */\n readonly #escapeLayer = new EscapeLayer();\n\n /** Starts closed and registers the standing dismissal listeners. */\n override connect(): void {\n this.close();\n document.addEventListener(\"click\", this.#onOutsideClick, true);\n this.element.addEventListener(\"focusout\", this.#onFocusOut);\n }\n\n /**\n * Removes every standing listener registered in {@link connect} plus any active\n * dismiss-on-scroll observers. `removeEventListener` is a no-op when it was\n * never added, so this is safe in the closed state too — no listener outlives\n * the element after a Turbo navigation.\n */\n override disconnect(): void {\n this.#escapeLayer.deactivate();\n document.removeEventListener(\"click\", this.#onOutsideClick, true);\n this.element.removeEventListener(\"focusout\", this.#onFocusOut);\n this.#stopScrollDismiss?.();\n this.#stopScrollDismiss = null;\n }\n\n /** Toggles the popover. Bound via `data-action` (click on the trigger). */\n toggle(): void {\n if (this.#isOpen) {\n this.close();\n } else {\n this.open();\n }\n }\n\n /** Opens the panel, reflects state, and moves focus inside it. */\n open(): void {\n if (!this.hasPanelTarget || this.#isOpen) return;\n this.panelTarget.hidden = false;\n if (this.hasTriggerTarget) this.triggerTarget.setAttribute(\"aria-expanded\", \"true\");\n this.#escapeLayer.activate(document, {\n onDismiss: () => this.#closeAndRestore(),\n claims: claimsWhileFocusWithin(this.element),\n });\n if (this.closeOnScrollValue && !this.#stopScrollDismiss) {\n // Close (no focus restore) so dismissing never fights the user's scroll.\n this.#stopScrollDismiss = observeScrollDismiss(this.element, () => this.close());\n }\n this.#focusFirst();\n }\n\n /** Closes the panel and reflects the collapsed state. Bound via `data-action`. */\n close(): void {\n // Release listeners first, unconditionally: a consumer may remove the panel\n // target while it is open, and an early return would leak scroll observers.\n this.#escapeLayer.deactivate();\n this.#stopScrollDismiss?.();\n this.#stopScrollDismiss = null;\n if (this.hasPanelTarget) this.panelTarget.hidden = true;\n if (this.hasTriggerTarget) this.triggerTarget.setAttribute(\"aria-expanded\", \"false\");\n }\n\n /** Moves focus to the first focusable element in the panel, or the panel itself. */\n #focusFirst(): void {\n const first = firstTabStop(this.panelTarget);\n if (first) {\n first.focus();\n return;\n }\n if (!this.panelTarget.hasAttribute(\"tabindex\")) this.panelTarget.tabIndex = -1;\n this.panelTarget.focus();\n }\n\n /** Closes and restores focus to the trigger for explicit keyboard dismissal. */\n #closeAndRestore(): void {\n this.close();\n if (this.hasTriggerTarget) this.triggerTarget.focus();\n }\n\n /** Closes without moving focus when a click lands outside the controller element. */\n readonly #onOutsideClick = (event: MouseEvent): void => {\n const target = event.target;\n if (this.#isOpen && target instanceof Node && !this.element.contains(target)) this.close();\n };\n\n /**\n * Closes when focus leaves the controller for a known external destination\n * (e.g. forward Tab past the panel or reverse Tab past the trigger). Focus is\n * not restored — the natural destination is kept, which is the modeless\n * contract. A null/non-Node destination is indeterminate: browsers use it for\n * clicks on non-focusable content and window deactivation, so the later outside\n * click handler decides pointer dismissal instead.\n */\n readonly #onFocusOut = (event: FocusEvent): void => {\n if (!this.#isOpen) return;\n const next = event.relatedTarget;\n if (!(next instanceof Node) || this.element.contains(next)) return;\n this.close();\n };\n\n /** Whether the panel is currently visible. */\n get #isOpen(): boolean {\n return this.hasPanelTarget && !this.panelTarget.hidden;\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/utils/escape_layer.ts","../../src/utils/focus_candidate.ts","../../src/utils/scroll_dismiss.ts","../../src/utils/state_reason.ts","../../src/controllers/popover_controller.ts"],"names":[],"mappings":"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","file":"popover_controller.js","sourcesContent":["/**\n * Single resolver for layered Escape dismissal.\n *\n * Every Escape-dismissable overlay layer (modal focus traps, disclosure\n * overlays like dropdown/popover/menu, and hover-triggered transient layers)\n * registers here while it is open. One document-level listener per document\n * resolves each press to exactly one owner and invokes that layer's\n * {@link EscapeLayerOptions.onDismiss} — controllers never listen for a\n * dismissing Escape themselves.\n *\n * @remarks\n * Each document owns an activation-ordered stack. The owner of a press is the\n * **topmost layer whose {@link EscapeLayerOptions.claims} passes**; a layer\n * that declines is transparent, so a background overlay opened behind a modal\n * never blocks it. Because a layer opened from within another is necessarily\n * activated later, LIFO order is also inner-first for nested layers — no DOM\n * inspection is needed.\n *\n * The shared listener runs in the document bubble phase and honors\n * `event.defaultPrevented`, so an element-level widget handler that consumes\n * Escape first (an editor cancelling its edit, a combobox closing its list)\n * always wins over every registered layer — the deepest handler resolves the\n * press. A keydown that is part of an IME composition (`event.isComposing`)\n * cancels the composition, never a layer, and is ignored here for every layer\n * at once.\n *\n * A WeakMap keeps documents collectible; the listener is installed only while\n * a document's stack is non-empty, and controller lifecycle hooks guarantee\n * that disconnected layers never remain registered.\n */\n\n/** Behavior a layer registers when it activates. */\nexport interface EscapeLayerOptions {\n /**\n * Dismisses the layer. Called by the shared resolver when this layer owns a\n * press; the resolver has already consumed the event (`preventDefault()`),\n * so the callback only needs to close and place focus per the widget's\n * contract.\n */\n onDismiss: () => void;\n /**\n * Whether the layer claims the current press. Evaluated per press, so it can\n * depend on live state (e.g. \"focus is inside me or fell to the body\"). A\n * declining layer is skipped and the next layer down is consulted; omitting\n * it means the layer always claims while active.\n */\n claims?: () => boolean;\n}\n\n/** A document's stack plus the one shared listener bound to it. */\ninterface EscapeLayerRegistry {\n stack: EscapeLayer[];\n onKeydown: (event: KeyboardEvent) => void;\n}\n\n/**\n * Claims predicate shared by the click-opened disclosure overlays (dropdown /\n * popover / navigation-menu / menu / context-menu / menubar): the layer claims\n * a press while focus is inside `element`, or after focus fell to the body —\n * a click on non-focusable overlay content blurs to `<body>`, and Escape must\n * still close the overlay (the \"body-focus rescue\"). A press made after focus\n * moved to another interactive element is declined, so closing never yanks\n * focus away from where the user deliberately went.\n */\nexport function claimsWhileFocusWithin(element: Element): () => boolean {\n return () => {\n const active = element.ownerDocument.activeElement;\n return active === null || active === element.ownerDocument.body || element.contains(active);\n };\n}\n\nexport class EscapeLayer {\n static readonly #registries = new WeakMap<Document, EscapeLayerRegistry>();\n\n #ownerDocument: Document | null = null;\n /** Dismissal callback while active; `null` when inactive. */\n #onDismiss: (() => void) | null = null;\n /** Live predicate deciding whether the layer claims a press; `null` = always. */\n #claims: (() => boolean) | null = null;\n\n /**\n * Activates this layer at the top of its document's Escape stack, installing\n * the document's shared resolver listener if this is its first layer.\n * Re-activating an already-active layer moves it to the top.\n */\n activate(ownerDocument: Document = document, options: EscapeLayerOptions): void {\n this.deactivate();\n let registry = EscapeLayer.#registries.get(ownerDocument);\n if (!registry) {\n registry = EscapeLayer.#createRegistry();\n EscapeLayer.#registries.set(ownerDocument, registry);\n ownerDocument.addEventListener(\"keydown\", registry.onKeydown);\n }\n registry.stack.push(this);\n this.#ownerDocument = ownerDocument;\n this.#onDismiss = options.onDismiss;\n this.#claims = options.claims ?? null;\n }\n\n /**\n * Removes this layer from its document's Escape stack, uninstalling the\n * shared listener when the stack empties. Safe to call when inactive.\n */\n deactivate(): void {\n const ownerDocument = this.#ownerDocument;\n if (!ownerDocument) return;\n\n const registry = EscapeLayer.#registries.get(ownerDocument);\n if (registry) {\n const index = registry.stack.lastIndexOf(this);\n if (index >= 0) registry.stack.splice(index, 1);\n if (registry.stack.length === 0) {\n ownerDocument.removeEventListener(\"keydown\", registry.onKeydown);\n EscapeLayer.#registries.delete(ownerDocument);\n }\n }\n this.#ownerDocument = null;\n this.#onDismiss = null;\n this.#claims = null;\n }\n\n /**\n * Whether this active layer would own a press right now: it is the topmost\n * layer whose {@link EscapeLayerOptions.claims} passes. Exposed for tests\n * and diagnostics — production dismissal goes through the shared listener.\n */\n get ownsEscape(): boolean {\n const ownerDocument = this.#ownerDocument;\n if (!ownerDocument) return false;\n const registry = EscapeLayer.#registries.get(ownerDocument);\n if (!registry) return false;\n return EscapeLayer.#resolveOwner(registry.stack) === this;\n }\n\n /** Builds a document's registry with its shared resolver listener. */\n static #createRegistry(): EscapeLayerRegistry {\n const registry: EscapeLayerRegistry = {\n stack: [],\n onKeydown: (event: KeyboardEvent): void => {\n if (event.key !== \"Escape\" || event.defaultPrevented || event.isComposing) return;\n const owner = EscapeLayer.#resolveOwner(registry.stack);\n if (!owner) return;\n event.preventDefault();\n owner.#onDismiss?.();\n },\n };\n return registry;\n }\n\n /** The topmost stack layer whose claims predicate passes, or `null`. */\n static #resolveOwner(stack: EscapeLayer[]): EscapeLayer | null {\n for (let index = stack.length - 1; index >= 0; index--) {\n const layer = stack[index];\n if (!layer) continue;\n if (layer.#claims && !layer.#claims()) continue;\n return layer;\n }\n return null;\n }\n}\n","/**\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","/**\n * Invokes `onScroll` when the page — or any scrollable ancestor of `element` —\n * scrolls, returning a cleanup that detaches every listener.\n *\n * `scroll` events do not bubble, and a single capture-phase listener on\n * `window`/`document` is unreliable for descendant scroll containers — so this\n * attaches a `scroll` listener directly to each scroll-parent ancestor (walking\n * `parentElement` and checking `overflow`) plus the window. That mirrors the\n * *intent* of `@floating-ui/dom`'s `autoUpdate` scroll tracking; it is a\n * deliberately simple resolver and does not cross shadow roots. This is precisely\n * the case a consumer cannot cover with `data-action` alone, which is why the\n * surface controllers own it.\n *\n * The listeners are `passive` (the handler never calls `preventDefault`), so they\n * never delay scrolling. Pass the surface's anchor/root so the relevant scroll\n * parents are found even when the floating element is portaled elsewhere.\n */\nexport function observeScrollDismiss(element: Element, onScroll: () => void): () => void {\n const targets: Array<Element | Window> = [...scrollParents(element), window];\n const handler = (): void => onScroll();\n for (const target of targets) {\n target.addEventListener(\"scroll\", handler, { passive: true });\n }\n return () => {\n for (const target of targets) target.removeEventListener(\"scroll\", handler);\n };\n}\n\n/** The scrollable ancestors of `element`, nearest first (window excluded). */\nfunction scrollParents(element: Element): Element[] {\n const parents: Element[] = [];\n let node = element.parentElement;\n while (node) {\n if (isScrollable(node)) parents.push(node);\n node = node.parentElement;\n }\n return parents;\n}\n\n/** Whether `element` is a scroll container (any axis `auto`/`scroll`/`overlay`). */\nfunction isScrollable(element: Element): boolean {\n const style = getComputedStyle(element);\n return /auto|scroll|overlay/.test(style.overflow + style.overflowX + style.overflowY);\n}\n","/**\n * Why a component's public open/closed state moved, carried as `detail.reason`\n * on the state event that reports the move.\n *\n * The attribute a component publishes (`aria-expanded`, `hidden`, `data-state`)\n * only says *what* the state is now. A subscriber that mirrors the state\n * elsewhere, or reports it, needs *why*: closing on `Escape` and closing because\n * the consumer called the action are the same attribute write and different\n * events to the page around it.\n *\n * `\"api\"` is the one every subscriber has to look at. A close the consumer asked\n * for arrives back at their own listener, so a listener that closes something\n * else on `close` loops unless it ignores its own calls.\n *\n * | Reason | The state moved because |\n * | --- | --- |\n * | `\"user\"` | a control of this component was operated |\n * | `\"select\"` | an item inside it was activated |\n * | `\"escape\"` | `Escape` was pressed and this layer owned it |\n * | `\"outside\"` | a pointer landed outside it, or on its backdrop |\n * | `\"focus\"` | focus entered or left it |\n * | `\"pointer\"` | the pointer entered or left it |\n * | `\"scroll\"` | a tracked scroll container scrolled |\n * | `\"api\"` | a public action was called with no DOM event |\n */\nexport type StateReason =\n | \"user\"\n | \"select\"\n | \"escape\"\n | \"outside\"\n | \"focus\"\n | \"pointer\"\n | \"scroll\"\n | \"api\";\n\n/** Focus modality: the events a focus move delivers to an action. */\nconst FOCUS_EVENTS = new Set([\"blur\", \"focus\", \"focusin\", \"focusout\"]);\n\n/** Pointer modality: the crossing events an action is bound to for hover. */\nconst POINTER_EVENTS = new Set([\"mouseenter\", \"mouseleave\", \"pointerenter\", \"pointerleave\"]);\n\n/**\n * Reads the interaction behind a **public action** from the DOM event it was\n * handed.\n *\n * Stimulus always passes the event to an action it invoked from `data-action`,\n * and a consumer calling the method themselves passes nothing — which is what\n * separates `\"api\"` from the rest.\n *\n * Only an action entry may resolve a reason this way. Inside the component the\n * event type no longer identifies the interaction: one `click` is the trigger\n * being pressed, the page outside being pressed, and an item being activated,\n * and those paths pass their own reason instead.\n *\n * @param event - The event the action received, if any.\n * @returns `\"api\"` with no event, else the modality the event type names, else\n * `\"user\"`.\n *\n * @example\n * ```ts\n * toggle(event?: Event): void {\n * this.#apply(!this.#isOpen, stateReasonFor(event));\n * }\n * ```\n */\nexport function stateReasonFor(event?: Event | null): \"user\" | \"focus\" | \"pointer\" | \"api\" {\n if (!event) return \"api\";\n if (FOCUS_EVENTS.has(event.type)) return \"focus\";\n if (POINTER_EVENTS.has(event.type)) return \"pointer\";\n return \"user\";\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { claimsWhileFocusWithin, EscapeLayer } from \"../utils/escape_layer\";\nimport { firstTabStop } from \"../utils/focus_candidate\";\nimport { observeScrollDismiss } from \"../utils/scroll_dismiss\";\nimport { type StateReason, stateReasonFor } from \"../utils/state_reason\";\n\n/**\n * Headless, accessible **non-modal popover** behavior.\n *\n * Markup contract (identifier: `stimeo--popover`):\n * <div data-controller=\"stimeo--popover\">\n * <button data-stimeo--popover-target=\"trigger\"\n * aria-haspopup=\"dialog\" aria-expanded=\"false\" aria-controls=\"pop\"\n * data-action=\"click->stimeo--popover#toggle\">Edit profile</button>\n * <div id=\"pop\" data-stimeo--popover-target=\"panel\"\n * role=\"dialog\" aria-label=\"Edit profile\" hidden>…</div>\n * </div>\n *\n * Implements the WAI-ARIA APG **Dialog** pattern run *non-modally* (no\n * `aria-modal`, no focus trap, no `inert`/scroll lock). The background stays\n * fully interactive; this is the modeless counterpart to `stimeo--dialog`. For\n * a roving `role=\"menu\"` of commands use `stimeo--menu`; for decorative-only\n * text use `stimeo--tooltip`.\n *\n * @remarks\n * Behavior only — static placement is the consumer's CSS, and dynamic\n * edge-collision avoidance is delegated to the opt-in `stimeo-ui/positioning`\n * module (this controller never imports it, preserving the zero-dep core). State\n * is exposed via the trigger's `aria-expanded` and the panel's `hidden`.\n *\n * Behavior provided:\n * - Click the trigger to toggle (`aria-expanded` + `hidden` reflect state).\n * - On open, focus moves to the first focusable element inside the panel (or the\n * panel itself if it has none).\n * - `Escape` closes and restores focus to the trigger. While open the panel is a\n * layer on the shared `EscapeLayer` stack; it claims a press only while\n * focus is inside the controller or fell to the body (a click on non-focusable\n * panel content), so a press aimed at another layer is never consumed here,\n * and one keypress closes exactly one layer.\n * - An outside click (anywhere off the controller element) closes without moving\n * focus. Focus stays at the clicked element, or falls back to the document body\n * for a non-focusable destination.\n * - Because it is modeless, focus is *not* trapped: when `Tab` moves focus out of\n * the controller it closes (detected via bubbling `focusout`) without yanking\n * focus back, so forward and reverse traversal preserve their natural destination.\n * A `focusout` with no destination is ignored because it also occurs for clicks\n * on non-focusable content and when the browser window loses focus.\n * - Opt-in **dismiss on scroll** (`closeOnScroll`): while open, scrolling a tracked\n * scroll-parent ancestor (or the window) closes the panel, the usual convention for\n * anchored popups. Closes without restoring focus (like the modeless `focusout` path)\n * so the close never fights the user's scroll. Off by default.\n * - Each move of the open state is reported: `stimeo--popover:open` and\n * `stimeo--popover:close` dispatch `{ reason: StateReason }`, after the state\n * attributes are written. Both are informational, so neither is cancelable. A\n * call that leaves the state where it already was, the normalization in\n * {@link connect}, and {@link disconnect} are all silent.\n */\nexport class PopoverController extends Controller<HTMLElement> {\n static override targets = [\"trigger\", \"panel\"];\n static override values = {\n closeOnScroll: { type: Boolean, default: false },\n };\n static actions = [\"close\", \"open\", \"toggle\"] as const;\n static events = [\"close\", \"open\"] as const;\n\n declare readonly triggerTarget: HTMLButtonElement;\n declare readonly panelTarget: HTMLElement;\n declare readonly hasTriggerTarget: boolean;\n declare readonly hasPanelTarget: boolean;\n declare readonly closeOnScrollValue: boolean;\n\n /** Cleanup for the dismiss-on-scroll listeners while open, or `null`. */\n #stopScrollDismiss: (() => void) | null = null;\n /** Escape-stack membership while open; the shared resolver dismisses via it. */\n readonly #escapeLayer = new EscapeLayer();\n\n /** Whether state moves are reported: set once `connect()` settled the baseline. */\n #reporting = false;\n\n /** Starts closed and registers the standing dismissal listeners. */\n override connect(): void {\n this.#close(\"api\");\n document.addEventListener(\"click\", this.#onOutsideClick, true);\n this.element.addEventListener(\"focusout\", this.#onFocusOut);\n this.#reporting = true;\n }\n\n /**\n * Removes every standing listener registered in {@link connect} plus any active\n * dismiss-on-scroll observers. `removeEventListener` is a no-op when it was\n * never added, so this is safe in the closed state too — no listener outlives\n * the element after a Turbo navigation.\n */\n override disconnect(): void {\n this.#reporting = false;\n this.#escapeLayer.deactivate();\n document.removeEventListener(\"click\", this.#onOutsideClick, true);\n this.element.removeEventListener(\"focusout\", this.#onFocusOut);\n this.#stopScrollDismiss?.();\n this.#stopScrollDismiss = null;\n }\n\n /** Toggles the popover. Bound via `data-action` (click on the trigger). */\n toggle(event?: Event): void {\n if (this.#isOpen) {\n this.close(event);\n } else {\n this.open(event);\n }\n }\n\n /** Opens the panel, reflects state, and moves focus inside it. */\n open(event?: Event): void {\n this.#open(stateReasonFor(event));\n }\n\n /** Closes the panel and reflects the collapsed state. Bound via `data-action`. */\n close(event?: Event): void {\n this.#close(stateReasonFor(event));\n }\n\n /**\n * Opens the panel, reflects state, reports a move, and moves focus inside it.\n *\n * @stimeoRuntimeOnly `closeOnScroll` decides whether this opening wires the scroll dismissal;\n * what is shown does not depend on it.\n */\n #open(reason: StateReason): void {\n if (!this.hasPanelTarget || this.#isOpen) return;\n this.panelTarget.hidden = false;\n if (this.hasTriggerTarget) this.triggerTarget.setAttribute(\"aria-expanded\", \"true\");\n if (this.#reporting) this.dispatch(\"open\", { detail: { reason }, cancelable: false });\n // A subscriber may close it again from the handler above. Everything below\n // applies to an element that is open; run against a closed one, the Escape\n // layer and scroll dismissal it arms would outlive the panel, and focus would\n // land inside a hidden one.\n if (!this.#isOpen) return;\n this.#escapeLayer.activate(document, {\n onDismiss: () => this.#closeAndRestore(),\n claims: claimsWhileFocusWithin(this.element),\n });\n if (this.closeOnScrollValue && !this.#stopScrollDismiss) {\n // Close (no focus restore) so dismissing never fights the user's scroll.\n this.#stopScrollDismiss = observeScrollDismiss(this.element, () => this.#close(\"scroll\"));\n }\n this.#focusFirst();\n }\n\n /** Closes the panel, reflects the collapsed state, and reports a move. */\n #close(reason: StateReason): void {\n // Release listeners first, unconditionally: a consumer may remove the panel\n // target while it is open, and an early return would leak scroll observers.\n const was = this.#isOpen;\n this.#escapeLayer.deactivate();\n this.#stopScrollDismiss?.();\n this.#stopScrollDismiss = null;\n if (this.hasPanelTarget) this.panelTarget.hidden = true;\n if (this.hasTriggerTarget) this.triggerTarget.setAttribute(\"aria-expanded\", \"false\");\n if (was && this.#reporting) this.dispatch(\"close\", { detail: { reason }, cancelable: false });\n }\n\n /** Moves focus to the first focusable element in the panel, or the panel itself. */\n #focusFirst(): void {\n const first = firstTabStop(this.panelTarget);\n if (first) {\n first.focus();\n return;\n }\n if (!this.panelTarget.hasAttribute(\"tabindex\")) this.panelTarget.tabIndex = -1;\n this.panelTarget.focus();\n }\n\n /** Closes and restores focus to the trigger for explicit keyboard dismissal. */\n #closeAndRestore(): void {\n this.#close(\"escape\");\n if (this.hasTriggerTarget) this.triggerTarget.focus();\n }\n\n /** Closes without moving focus when a click lands outside the controller element. */\n readonly #onOutsideClick = (event: MouseEvent): void => {\n const target = event.target;\n if (this.#isOpen && target instanceof Node && !this.element.contains(target)) {\n this.#close(\"outside\");\n }\n };\n\n /**\n * Closes when focus leaves the controller for a known external destination\n * (e.g. forward Tab past the panel or reverse Tab past the trigger). Focus is\n * not restored — the natural destination is kept, which is the modeless\n * contract. A null/non-Node destination is indeterminate: browsers use it for\n * clicks on non-focusable content and window deactivation, so the later outside\n * click handler decides pointer dismissal instead.\n */\n readonly #onFocusOut = (event: FocusEvent): void => {\n if (!this.#isOpen) return;\n const next = event.relatedTarget;\n if (!(next instanceof Node) || this.element.contains(next)) return;\n this.#close(\"focus\");\n };\n\n /** Whether the panel is currently visible. */\n get #isOpen(): boolean {\n return this.hasPanelTarget && !this.panelTarget.hidden;\n }\n}\n"]}
|
|
@@ -18,7 +18,7 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
18
18
|
* `data-portaled`. A `to` the engine cannot read — malformed or empty — falls back to
|
|
19
19
|
* the default; a well-formed selector matching nothing moves nothing.
|
|
20
20
|
*
|
|
21
|
-
* `mount` dispatches `{
|
|
21
|
+
* `mount` dispatches `{ destination }`; `unmount` dispatches `{}`.
|
|
22
22
|
*
|
|
23
23
|
* @remarks
|
|
24
24
|
* Behavior only — no positioning (pair with `stimeo-ui/positioning`) and no focus
|
|
@@ -122,7 +122,7 @@ var PortalController = class extends Controller {
|
|
|
122
122
|
destination.appendChild(node);
|
|
123
123
|
}
|
|
124
124
|
node.setAttribute("data-portaled", "true");
|
|
125
|
-
this.dispatch("mount", { detail: {
|
|
125
|
+
this.dispatch("mount", { detail: { destination } });
|
|
126
126
|
}
|
|
127
127
|
disconnect() {
|
|
128
128
|
const state = portalState.get(this.element);
|
|
@@ -137,7 +137,11 @@ var PortalController = class extends Controller {
|
|
|
137
137
|
if (current?.owner === this) this.#restore(current);
|
|
138
138
|
});
|
|
139
139
|
}
|
|
140
|
-
/**
|
|
140
|
+
/**
|
|
141
|
+
* Returns the node to its placeholder (or removes it) and clears the bookkeeping.
|
|
142
|
+
*
|
|
143
|
+
* @stimeoRuntimeOnly `restore` decides whether this one unmount puts the node back or drops it.
|
|
144
|
+
*/
|
|
141
145
|
#restore(state) {
|
|
142
146
|
portalState.delete(this.element);
|
|
143
147
|
const { node, placeholder } = state;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/utils/declared_value.ts","../../src/utils/detach_gate.ts","../../src/controllers/portal_controller.ts"],"names":[],"mappings":"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","file":"portal_controller.js","sourcesContent":["/**\n * Readers for the String Values whose text can only be checked by handing it to\n * a parser: a CSS selector, a regular-expression source, a JSON object.\n *\n * Stimulus reads each of them as an ordinary string, so the controller connects\n * and the flaw surfaces later, inside the first handler that consumes the value\n * (a `SyntaxError` from `new RegExp`, a `DOMException` from the selector engine),\n * where it takes the whole handler down on every event. Parsing here, once, in\n * the `<name>ValueChanged` callback keeps the failure local to the declaration:\n * an unreadable value falls back to its default, the element stays alive, and\n * the hot path only ever receives a value that parsed.\n *\n * Which default a broken declaration falls back to is the caller's contract, so\n * every reader takes (or returns) the fallback rather than choosing one.\n */\n\n/**\n * The result of `parse(raw)`, or `fallback` when `parse` throws.\n *\n * Any exception counts as \"does not parse\": the parsers these Values feed\n * (`RegExp`, `JSON.parse`, the selector engine) all report a malformed input by\n * throwing, and none of them throws for another reason.\n */\nexport function parseDeclared<T>(raw: string, parse: (raw: string) => T, fallback: T): T {\n try {\n return parse(raw);\n } catch {\n return fallback;\n }\n}\n\n/** What {@link validSelector} needs of the element it probes with. */\nexport interface SelectorProbe {\n matches(selector: string): boolean;\n}\n\n/**\n * `raw` when it is a non-empty selector the DOM accepts, otherwise `fallback`.\n *\n * `element.matches` is the probe: the engine parses the selector and throws for\n * one it cannot read. Whether the selector actually matches `element` plays no\n * part, so a selector aimed at another element still passes.\n *\n * The parameter names the one method the probe uses rather than `Element`, so\n * this module carries no DOM type and the readers below stay importable from\n * the Node-side Inspector, which checks the same declarations statically.\n */\nexport function validSelector(element: SelectorProbe, raw: string, fallback: string): string {\n if (raw.length === 0) return fallback;\n return parseDeclared(\n raw,\n (selector) => {\n element.matches(selector);\n return selector;\n },\n fallback,\n );\n}\n\n/** How `compileRegExp` wraps a source before compiling it. */\nexport type RegExpAnchor = \"none\" | \"exact\";\n\n/**\n * `source` compiled as a `RegExp`, or `null` when it does not compile.\n *\n * `\"exact\"` wraps the source as `^(?:source)$`, so the whole input has to match\n * and an alternation inside the source cannot escape the anchors. The source is\n * compiled on its own first, because an unbalanced source can be made to parse\n * by the wrapper's own parentheses — `0)|(1` becomes `^(?:0)|(1)$` — which would\n * accept a declaration that is not a regular expression and leave half of it\n * outside the anchors. The default that replaces a broken source is the\n * caller's, so `null` is returned rather than a fallback pattern.\n */\nexport function compileRegExp(source: string, anchor: RegExpAnchor = \"none\"): RegExp | null {\n return parseDeclared(\n source,\n (text) => {\n const bare = new RegExp(text);\n return anchor === \"exact\" ? new RegExp(`^(?:${text})$`) : bare;\n },\n null,\n );\n}\n\n/**\n * The JSON object `raw` declares, or `null` when the text does not parse or\n * parses to something other than a plain object (`null`, an array, a scalar).\n * Values are returned as parsed; narrowing them is the caller's contract.\n */\nexport function parseJsonObject(raw: string): Record<string, unknown> | null {\n const parsed = parseDeclared<unknown>(raw, (text) => JSON.parse(text), null);\n if (parsed === null || typeof parsed !== \"object\" || Array.isArray(parsed)) return null;\n return parsed as Record<string, unknown>;\n}\n","/**\n * Shared \"in-page move vs real detach\" discriminator for `disconnect()`.\n *\n * A mid-session `disconnect()` whose element is still in the document is\n * ambiguous. It can be:\n *\n * - an **in-page move** — a consumer (sortable, a teleport) re-inserted the\n * element; Stimulus reconnects the SAME instance in the same mutation batch\n * and the controller's runtime state must survive;\n * - a **detach that keeps the element** — `data-controller` no longer lists the\n * identifier (a Turbo 8 morph), or the element moved outside the observed\n * root (a scoped `Application.start(root)`, a shadow root); no reconnect will\n * ever come and the controller must tear down or it leaks its sessions,\n * document listeners, and moved nodes.\n *\n * Neither available signal decides this alone. A synchronous `data-controller`\n * token check catches the morph case but misses an observed-root exit (the\n * token stays, the reconnect never comes). Deferring teardown one microtask and\n * letting `connect()` cancel it catches the root exit, but leans on Stimulus\n * reconnecting moved elements within the same mutation batch — internal\n * behavior, not a public contract. {@link DetachGate} composes both: the token\n * check is the synchronous fast path (no probe, no reliance on batching), the\n * microtask probe is the fallback for the genuinely ambiguous remainder.\n *\n * Policy stays with the consumer; this util owns the discrimination only:\n *\n * - **Probe** ({@link disconnected}) when the teardown must eventually happen —\n * live drag sessions, document listeners, teleported *content*. Call\n * {@link cancel} from `connect()` (a reconnect means in-page move) and from\n * the head of any direct teardown path (see below).\n * - **Keep** (bare {@link DetachGate.isDetached}) when undoing the ambiguous\n * case would fight the controller's own effect: a controller that moves its\n * OWN element (portal's no-`content` form) exits a scoped observed root as\n * its normal job — a probe-driven teardown would restore the element into\n * the root, reconnect, re-teleport, and disconnect again, forever. Such\n * controllers keep their state on an ambiguous disconnect and tear down only\n * on {@link DetachGate.isDetached}.\n *\n * Event convention for probe consumers: teardown into a dead (detached) tree is\n * silent — consumers restore from their own `connect()`; a detach that keeps the\n * tree alive must end an in-flight session with a `cancel` event, or composing\n * consumers strand their session bookkeeping.\n */\n\n/** The slice of a Stimulus controller the gate inspects. */\nexport interface DetachGateHost {\n readonly element: Element;\n readonly identifier: string;\n}\n\n/**\n * Per-controller-instance gate deciding whether a `disconnect()` is a real\n * detach. Hold one per controller (`#gate = new DetachGate()`) — Stimulus\n * reuses the instance across reconnects, so the pending probe state carries\n * over exactly as the protocol needs.\n *\n * @example\n * ```ts\n * #gate = new DetachGate();\n *\n * connect(): void {\n * this.#gate.cancel(); // a reconnect: the element moved in-page\n * // …\n * }\n *\n * disconnect(): void {\n * this.#gate.disconnected(this, () => this.#teardown());\n * }\n *\n * #teardown(): void {\n * this.#gate.cancel(); // disarm a still-queued probe (double-run guard)\n * // …\n * }\n * ```\n */\nexport class DetachGate {\n /** Set while a probe is queued, waiting for a reconnect to cancel it. */\n #pending = false;\n\n /**\n * True while a probe is queued — the last disconnect was ambiguous and no\n * reconnect has cancelled it yet. Read it from `connect()` to tell the\n * reconnect half of an in-page move from a first connect: a controller whose\n * initialisation restarts a measurement (a min-duration floor, an elapsed\n * counter) must skip it for the move, where nothing actually restarted.\n */\n get pending(): boolean {\n return this.#pending;\n }\n\n /**\n * True when the disconnect is definitely a real detach — the element left\n * the document, or `data-controller` no longer lists the identifier. False\n * means ambiguous (in-page move or observed-root exit), NOT \"alive\".\n */\n static isDetached(host: DetachGateHost): boolean {\n if (!host.element.isConnected) return true;\n const tokens = (host.element.getAttribute(\"data-controller\") ?? \"\").split(/\\s+/);\n return !tokens.includes(host.identifier);\n }\n\n /**\n * Call from `disconnect()`: runs `teardown` synchronously on a definite\n * detach (fast path), otherwise defers it one microtask — a reconnect\n * ({@link cancel} from `connect()`) keeps the state, no reconnect runs it.\n * One microtask is the whole probe window: Stimulus reconnects a moved\n * element within the same mutation batch, before the checkpoint drains.\n */\n disconnected(host: DetachGateHost, teardown: () => void): void {\n if (DetachGate.isDetached(host)) {\n // Also disarms a probe a previous ambiguous disconnect left queued\n // (defer, element removed, disconnect again) — exactly one teardown.\n this.#pending = false;\n teardown();\n return;\n }\n this.#pending = true;\n queueMicrotask(() => {\n if (!this.#pending) return;\n this.#pending = false;\n teardown();\n });\n }\n\n /**\n * Disarms a pending probe. Call from `connect()` (the reconnect that proves\n * an in-page move) and from the head of any teardown path not routed through\n * {@link disconnected} (disabled-toggle, Escape), so an orphaned probe can\n * never run the teardown a second time.\n */\n cancel(): void {\n this.#pending = false;\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { validSelector } from \"../utils/declared_value\";\nimport { DetachGate } from \"../utils/detach_gate\";\n\n/**\n * Teleport bookkeeping keyed by the controller element (stable across the\n * connect/disconnect churn some DOM runtimes emit when an observed element is moved).\n * Holds the moved node, its placeholder, and the owner — the instance that connected\n * last. Only the owner finishes a probe-driven teardown, so an instance the element has\n * already replaced cannot rewind a teleport the live one holds. The no-`content` form\n * needs no owner check: it restores only on a definite detach, where no successor comes.\n */\nconst portalState = new WeakMap<\n Element,\n { node: HTMLElement; placeholder: Comment; owner: PortalController }\n>();\n\n/**\n * Headless **portal / teleport**: moves an element to another place in the DOM (e.g.\n * directly under `body`) on connect and cleans up on disconnect — the shared substrate\n * for overlays that must escape an ancestor's `overflow: hidden`, `transform`, or\n * stacking context (no APG pattern; a DOM utility).\n *\n * Markup contract (identifier: `stimeo--portal`):\n * <div data-controller=\"stimeo--portal\" data-stimeo--portal-to-value=\"body\">\n * <div data-stimeo--portal-target=\"content\">Teleported content</div>\n * </div>\n *\n * Moves `content` (or `this.element` when no `content` target) into the first element\n * matching `to` (default `body`), `append`ed or `prepend`ed per `position`. A comment\n * placeholder records the original spot so `disconnect()` can return the node there\n * (when `restore`) — or remove it — leaving no orphan behind. The moved node carries\n * `data-portaled`. A `to` the engine cannot read — malformed or empty — falls back to\n * the default; a well-formed selector matching nothing moves nothing.\n *\n * `mount` dispatches `{ target }`; `unmount` dispatches `{}`.\n *\n * @remarks\n * Behavior only — no positioning (pair with `stimeo-ui/positioning`) and no focus\n * trapping (pair with `stimeo--focus` or the overlay). Moving a Stimulus element within\n * the same document re-fires connect/disconnect; `DetachGate` reads that pair as an\n * in-page move rather than a detach, which is what keeps the move safe. For Turbo\n * compatibility prefer the `content`-target form: the controller then stays on the\n * in-place source, so its `disconnect()` fires when the original container is replaced\n * and the teleported node is restored/removed rather than orphaned under `body`. The\n * move is idempotent (guarded by the element-keyed bookkeeping) and reversed on\n * `disconnect()` (Turbo\n * navigation included). The \"in-page move vs real detach\" split on `disconnect()` is\n * `DetachGate`; in the `content` form the source element may even leave a\n * scoped application's observed root and the content is still restored, while the\n * no-`content` form teleporting itself out of the observed root is fire-and-forget\n * by design (see `disconnect()`).\n */\nexport class PortalController extends Controller<HTMLElement> {\n static override targets = [\"content\"];\n static override values = {\n to: { type: String, default: \"body\" },\n position: { type: String, default: \"append\" },\n restore: { type: Boolean, default: true },\n };\n static events = [\"mount\", \"unmount\"] as const;\n\n declare readonly contentTarget: HTMLElement;\n declare readonly hasContentTarget: boolean;\n\n declare toValue: string;\n declare positionValue: string;\n declare restoreValue: boolean;\n\n /** Decides whether a `disconnect()` is an in-page move or a real detach. */\n readonly #gate = new DetachGate();\n\n /** The `to` declaration after validation; the default when it cannot be parsed. */\n #toSelector = \"body\";\n\n /**\n * Validates the destination declaration once, keeping only a selector the engine can\n * read.\n *\n * A selector reads back as an ordinary string, so a malformed one survives until it is\n * handed to the DOM — where it would take the whole teleport down and leave the part\n * silently doing nothing. Falling back to the default puts the node somewhere visible\n * instead, which reads as a mistake and can be traced back to the declaration.\n */\n toValueChanged(): void {\n this.#toSelector = validSelector(this.element, this.toValue, \"body\");\n }\n\n override connect(): void {\n // A reconnect proves an in-page move of the source element: disarm the\n // probe the mid-move disconnect() deferred (see disconnect).\n this.#gate.cancel();\n // Already portaled here (idempotent). A later instance for the same element takes\n // ownership, so the teardown belongs to whichever one is still connected.\n const existing = portalState.get(this.element);\n if (existing) {\n existing.owner = this;\n return;\n }\n const node = this.hasContentTarget ? this.contentTarget : this.element;\n const destination = this.#destination();\n if (!destination || destination === node || node.contains(destination)) return;\n\n const placeholder = document.createComment(\"stimeo--portal\");\n node.parentNode?.insertBefore(placeholder, node);\n portalState.set(this.element, { node, placeholder, owner: this });\n\n if (this.positionValue === \"prepend\") {\n destination.prepend(node);\n } else {\n destination.appendChild(node);\n }\n node.setAttribute(\"data-portaled\", \"true\");\n this.dispatch(\"mount\", { detail: { target: destination } });\n }\n\n override disconnect(): void {\n const state = portalState.get(this.element);\n if (!state) return;\n if (state.node === this.element) {\n // No-`content` form: the teleported node IS the controller element, so the\n // teleport itself may exit a scoped application's observed root — the controller\n // doing its job, not a detach. A probe-driven restore would re-enter the root,\n // reconnect, re-teleport and disconnect again, forever. So an ambiguous\n // disconnect (in the document, identifier still listed — also the churn a\n // self-move emits in some runtimes) KEEPS the teleport, and only a definite\n // detach — the element left the DOM, or `data-controller` no longer lists us\n // (a Turbo 8 morph) — restores. The cost, by design: a teleport that left the\n // observed root is fire-and-forget (Stimulus never fires for it again).\n if (!DetachGate.isDetached(this)) return;\n this.#restore(state);\n return;\n }\n // `content` form: the controller element stays put, so its own teleport emits no\n // churn — an ambiguous disconnect means the SOURCE element moved. In-page, the\n // same-batch reconnect cancels the probe and the teleport survives; out of the\n // observed root, no reconnect comes and the probe restores, so the content is\n // never stranded at the destination with a dead owner.\n this.#gate.disconnected(this, () => {\n const current = portalState.get(this.element);\n if (current?.owner === this) this.#restore(current);\n });\n }\n\n /** Returns the node to its placeholder (or removes it) and clears the bookkeeping. */\n #restore(state: { node: HTMLElement; placeholder: Comment }): void {\n portalState.delete(this.element);\n const { node, placeholder } = state;\n\n node.removeAttribute(\"data-portaled\");\n if (this.restoreValue && placeholder.parentNode) {\n placeholder.parentNode.insertBefore(node, placeholder);\n } else {\n node.remove();\n }\n placeholder.remove();\n this.dispatch(\"unmount\", { detail: {} });\n }\n\n /** Resolves the destination from the validated `to` selector. */\n #destination(): Element | null {\n return document.querySelector(this.#toSelector);\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/utils/declared_value.ts","../../src/utils/detach_gate.ts","../../src/controllers/portal_controller.ts"],"names":[],"mappings":"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","file":"portal_controller.js","sourcesContent":["/**\n * Readers for the String Values whose text can only be checked by handing it to\n * a parser: a CSS selector, a regular-expression source, a JSON object.\n *\n * Stimulus reads each of them as an ordinary string, so the controller connects\n * and the flaw surfaces later, inside the first handler that consumes the value\n * (a `SyntaxError` from `new RegExp`, a `DOMException` from the selector engine),\n * where it takes the whole handler down on every event. Parsing here, once, in\n * the `<name>ValueChanged` callback keeps the failure local to the declaration:\n * an unreadable value falls back to its default, the element stays alive, and\n * the hot path only ever receives a value that parsed.\n *\n * Which default a broken declaration falls back to is the caller's contract, so\n * every reader takes (or returns) the fallback rather than choosing one.\n */\n\n/**\n * The result of `parse(raw)`, or `fallback` when `parse` throws.\n *\n * Any exception counts as \"does not parse\": the parsers these Values feed\n * (`RegExp`, `JSON.parse`, the selector engine) all report a malformed input by\n * throwing, and none of them throws for another reason.\n */\nexport function parseDeclared<T>(raw: string, parse: (raw: string) => T, fallback: T): T {\n try {\n return parse(raw);\n } catch {\n return fallback;\n }\n}\n\n/** What {@link validSelector} needs of the element it probes with. */\nexport interface SelectorProbe {\n matches(selector: string): boolean;\n}\n\n/**\n * `raw` when it is a non-empty selector the DOM accepts, otherwise `fallback`.\n *\n * `element.matches` is the probe: the engine parses the selector and throws for\n * one it cannot read. Whether the selector actually matches `element` plays no\n * part, so a selector aimed at another element still passes.\n *\n * The parameter names the one method the probe uses rather than `Element`, so\n * this module carries no DOM type and the readers below stay importable from\n * the Node-side Inspector, which checks the same declarations statically.\n */\nexport function validSelector(element: SelectorProbe, raw: string, fallback: string): string {\n if (raw.length === 0) return fallback;\n return parseDeclared(\n raw,\n (selector) => {\n element.matches(selector);\n return selector;\n },\n fallback,\n );\n}\n\n/** How `compileRegExp` wraps a source before compiling it. */\nexport type RegExpAnchor = \"none\" | \"exact\";\n\n/**\n * `source` compiled as a `RegExp`, or `null` when it does not compile.\n *\n * `\"exact\"` wraps the source as `^(?:source)$`, so the whole input has to match\n * and an alternation inside the source cannot escape the anchors. The source is\n * compiled on its own first, because an unbalanced source can be made to parse\n * by the wrapper's own parentheses — `0)|(1` becomes `^(?:0)|(1)$` — which would\n * accept a declaration that is not a regular expression and leave half of it\n * outside the anchors. The default that replaces a broken source is the\n * caller's, so `null` is returned rather than a fallback pattern.\n */\nexport function compileRegExp(source: string, anchor: RegExpAnchor = \"none\"): RegExp | null {\n return parseDeclared(\n source,\n (text) => {\n const bare = new RegExp(text);\n return anchor === \"exact\" ? new RegExp(`^(?:${text})$`) : bare;\n },\n null,\n );\n}\n\n/**\n * The JSON object `raw` declares, or `null` when the text does not parse or\n * parses to something other than a plain object (`null`, an array, a scalar).\n * Values are returned as parsed; narrowing them is the caller's contract.\n */\nexport function parseJsonObject(raw: string): Record<string, unknown> | null {\n const parsed = parseDeclared<unknown>(raw, (text) => JSON.parse(text), null);\n if (parsed === null || typeof parsed !== \"object\" || Array.isArray(parsed)) return null;\n return parsed as Record<string, unknown>;\n}\n","/**\n * Shared \"in-page move vs real detach\" discriminator for `disconnect()`.\n *\n * A mid-session `disconnect()` whose element is still in the document is\n * ambiguous. It can be:\n *\n * - an **in-page move** — a consumer (sortable, a teleport) re-inserted the\n * element; Stimulus reconnects the SAME instance in the same mutation batch\n * and the controller's runtime state must survive;\n * - a **detach that keeps the element** — `data-controller` no longer lists the\n * identifier (a Turbo 8 morph), or the element moved outside the observed\n * root (a scoped `Application.start(root)`, a shadow root); no reconnect will\n * ever come and the controller must tear down or it leaks its sessions,\n * document listeners, and moved nodes.\n *\n * Neither available signal decides this alone. A synchronous `data-controller`\n * token check catches the morph case but misses an observed-root exit (the\n * token stays, the reconnect never comes). Deferring teardown one microtask and\n * letting `connect()` cancel it catches the root exit, but leans on Stimulus\n * reconnecting moved elements within the same mutation batch — internal\n * behavior, not a public contract. {@link DetachGate} composes both: the token\n * check is the synchronous fast path (no probe, no reliance on batching), the\n * microtask probe is the fallback for the genuinely ambiguous remainder.\n *\n * Policy stays with the consumer; this util owns the discrimination only:\n *\n * - **Probe** ({@link disconnected}) when the teardown must eventually happen —\n * live drag sessions, document listeners, teleported *content*. Call\n * {@link cancel} from `connect()` (a reconnect means in-page move) and from\n * the head of any direct teardown path (see below).\n * - **Keep** (bare {@link DetachGate.isDetached}) when undoing the ambiguous\n * case would fight the controller's own effect: a controller that moves its\n * OWN element (portal's no-`content` form) exits a scoped observed root as\n * its normal job — a probe-driven teardown would restore the element into\n * the root, reconnect, re-teleport, and disconnect again, forever. Such\n * controllers keep their state on an ambiguous disconnect and tear down only\n * on {@link DetachGate.isDetached}.\n *\n * Event convention for probe consumers: teardown into a dead (detached) tree is\n * silent — consumers restore from their own `connect()`; a detach that keeps the\n * tree alive must end an in-flight session with a `cancel` event, or composing\n * consumers strand their session bookkeeping.\n */\n\n/** The slice of a Stimulus controller the gate inspects. */\nexport interface DetachGateHost {\n readonly element: Element;\n readonly identifier: string;\n}\n\n/**\n * Per-controller-instance gate deciding whether a `disconnect()` is a real\n * detach. Hold one per controller (`#gate = new DetachGate()`) — Stimulus\n * reuses the instance across reconnects, so the pending probe state carries\n * over exactly as the protocol needs.\n *\n * @example\n * ```ts\n * #gate = new DetachGate();\n *\n * connect(): void {\n * this.#gate.cancel(); // a reconnect: the element moved in-page\n * // …\n * }\n *\n * disconnect(): void {\n * this.#gate.disconnected(this, () => this.#teardown());\n * }\n *\n * #teardown(): void {\n * this.#gate.cancel(); // disarm a still-queued probe (double-run guard)\n * // …\n * }\n * ```\n */\nexport class DetachGate {\n /** Set while a probe is queued, waiting for a reconnect to cancel it. */\n #pending = false;\n\n /**\n * True while a probe is queued — the last disconnect was ambiguous and no\n * reconnect has cancelled it yet. Read it from `connect()` to tell the\n * reconnect half of an in-page move from a first connect: a controller whose\n * initialisation restarts a measurement (a min-duration floor, an elapsed\n * counter) must skip it for the move, where nothing actually restarted.\n */\n get pending(): boolean {\n return this.#pending;\n }\n\n /**\n * True when the disconnect is definitely a real detach — the element left\n * the document, or `data-controller` no longer lists the identifier. False\n * means ambiguous (in-page move or observed-root exit), NOT \"alive\".\n */\n static isDetached(host: DetachGateHost): boolean {\n if (!host.element.isConnected) return true;\n const tokens = (host.element.getAttribute(\"data-controller\") ?? \"\").split(/\\s+/);\n return !tokens.includes(host.identifier);\n }\n\n /**\n * Call from `disconnect()`: runs `teardown` synchronously on a definite\n * detach (fast path), otherwise defers it one microtask — a reconnect\n * ({@link cancel} from `connect()`) keeps the state, no reconnect runs it.\n * One microtask is the whole probe window: Stimulus reconnects a moved\n * element within the same mutation batch, before the checkpoint drains.\n */\n disconnected(host: DetachGateHost, teardown: () => void): void {\n if (DetachGate.isDetached(host)) {\n // Also disarms a probe a previous ambiguous disconnect left queued\n // (defer, element removed, disconnect again) — exactly one teardown.\n this.#pending = false;\n teardown();\n return;\n }\n this.#pending = true;\n queueMicrotask(() => {\n if (!this.#pending) return;\n this.#pending = false;\n teardown();\n });\n }\n\n /**\n * Disarms a pending probe. Call from `connect()` (the reconnect that proves\n * an in-page move) and from the head of any teardown path not routed through\n * {@link disconnected} (disabled-toggle, Escape), so an orphaned probe can\n * never run the teardown a second time.\n */\n cancel(): void {\n this.#pending = false;\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { validSelector } from \"../utils/declared_value\";\nimport { DetachGate } from \"../utils/detach_gate\";\n\n/**\n * Teleport bookkeeping keyed by the controller element (stable across the\n * connect/disconnect churn some DOM runtimes emit when an observed element is moved).\n * Holds the moved node, its placeholder, and the owner — the instance that connected\n * last. Only the owner finishes a probe-driven teardown, so an instance the element has\n * already replaced cannot rewind a teleport the live one holds. The no-`content` form\n * needs no owner check: it restores only on a definite detach, where no successor comes.\n */\nconst portalState = new WeakMap<\n Element,\n { node: HTMLElement; placeholder: Comment; owner: PortalController }\n>();\n\n/**\n * Headless **portal / teleport**: moves an element to another place in the DOM (e.g.\n * directly under `body`) on connect and cleans up on disconnect — the shared substrate\n * for overlays that must escape an ancestor's `overflow: hidden`, `transform`, or\n * stacking context (no APG pattern; a DOM utility).\n *\n * Markup contract (identifier: `stimeo--portal`):\n * <div data-controller=\"stimeo--portal\" data-stimeo--portal-to-value=\"body\">\n * <div data-stimeo--portal-target=\"content\">Teleported content</div>\n * </div>\n *\n * Moves `content` (or `this.element` when no `content` target) into the first element\n * matching `to` (default `body`), `append`ed or `prepend`ed per `position`. A comment\n * placeholder records the original spot so `disconnect()` can return the node there\n * (when `restore`) — or remove it — leaving no orphan behind. The moved node carries\n * `data-portaled`. A `to` the engine cannot read — malformed or empty — falls back to\n * the default; a well-formed selector matching nothing moves nothing.\n *\n * `mount` dispatches `{ destination }`; `unmount` dispatches `{}`.\n *\n * @remarks\n * Behavior only — no positioning (pair with `stimeo-ui/positioning`) and no focus\n * trapping (pair with `stimeo--focus` or the overlay). Moving a Stimulus element within\n * the same document re-fires connect/disconnect; `DetachGate` reads that pair as an\n * in-page move rather than a detach, which is what keeps the move safe. For Turbo\n * compatibility prefer the `content`-target form: the controller then stays on the\n * in-place source, so its `disconnect()` fires when the original container is replaced\n * and the teleported node is restored/removed rather than orphaned under `body`. The\n * move is idempotent (guarded by the element-keyed bookkeeping) and reversed on\n * `disconnect()` (Turbo\n * navigation included). The \"in-page move vs real detach\" split on `disconnect()` is\n * `DetachGate`; in the `content` form the source element may even leave a\n * scoped application's observed root and the content is still restored, while the\n * no-`content` form teleporting itself out of the observed root is fire-and-forget\n * by design (see `disconnect()`).\n */\nexport class PortalController extends Controller<HTMLElement> {\n static override targets = [\"content\"];\n static override values = {\n to: { type: String, default: \"body\" },\n position: { type: String, default: \"append\" },\n restore: { type: Boolean, default: true },\n };\n static events = [\"mount\", \"unmount\"] as const;\n\n declare readonly contentTarget: HTMLElement;\n declare readonly hasContentTarget: boolean;\n\n declare toValue: string;\n declare positionValue: string;\n declare restoreValue: boolean;\n\n /** Decides whether a `disconnect()` is an in-page move or a real detach. */\n readonly #gate = new DetachGate();\n\n /** The `to` declaration after validation; the default when it cannot be parsed. */\n #toSelector = \"body\";\n\n /**\n * Validates the destination declaration once, keeping only a selector the engine can\n * read.\n *\n * A selector reads back as an ordinary string, so a malformed one survives until it is\n * handed to the DOM — where it would take the whole teleport down and leave the part\n * silently doing nothing. Falling back to the default puts the node somewhere visible\n * instead, which reads as a mistake and can be traced back to the declaration.\n */\n toValueChanged(): void {\n this.#toSelector = validSelector(this.element, this.toValue, \"body\");\n }\n\n override connect(): void {\n // A reconnect proves an in-page move of the source element: disarm the\n // probe the mid-move disconnect() deferred (see disconnect).\n this.#gate.cancel();\n // Already portaled here (idempotent). A later instance for the same element takes\n // ownership, so the teardown belongs to whichever one is still connected.\n const existing = portalState.get(this.element);\n if (existing) {\n existing.owner = this;\n return;\n }\n const node = this.hasContentTarget ? this.contentTarget : this.element;\n const destination = this.#destination();\n if (!destination || destination === node || node.contains(destination)) return;\n\n const placeholder = document.createComment(\"stimeo--portal\");\n node.parentNode?.insertBefore(placeholder, node);\n portalState.set(this.element, { node, placeholder, owner: this });\n\n if (this.positionValue === \"prepend\") {\n destination.prepend(node);\n } else {\n destination.appendChild(node);\n }\n node.setAttribute(\"data-portaled\", \"true\");\n this.dispatch(\"mount\", { detail: { destination } });\n }\n\n override disconnect(): void {\n const state = portalState.get(this.element);\n if (!state) return;\n if (state.node === this.element) {\n // No-`content` form: the teleported node IS the controller element, so the\n // teleport itself may exit a scoped application's observed root — the controller\n // doing its job, not a detach. A probe-driven restore would re-enter the root,\n // reconnect, re-teleport and disconnect again, forever. So an ambiguous\n // disconnect (in the document, identifier still listed — also the churn a\n // self-move emits in some runtimes) KEEPS the teleport, and only a definite\n // detach — the element left the DOM, or `data-controller` no longer lists us\n // (a Turbo 8 morph) — restores. The cost, by design: a teleport that left the\n // observed root is fire-and-forget (Stimulus never fires for it again).\n if (!DetachGate.isDetached(this)) return;\n this.#restore(state);\n return;\n }\n // `content` form: the controller element stays put, so its own teleport emits no\n // churn — an ambiguous disconnect means the SOURCE element moved. In-page, the\n // same-batch reconnect cancels the probe and the teleport survives; out of the\n // observed root, no reconnect comes and the probe restores, so the content is\n // never stranded at the destination with a dead owner.\n this.#gate.disconnected(this, () => {\n const current = portalState.get(this.element);\n if (current?.owner === this) this.#restore(current);\n });\n }\n\n /**\n * Returns the node to its placeholder (or removes it) and clears the bookkeeping.\n *\n * @stimeoRuntimeOnly `restore` decides whether this one unmount puts the node back or drops it.\n */\n #restore(state: { node: HTMLElement; placeholder: Comment }): void {\n portalState.delete(this.element);\n const { node, placeholder } = state;\n\n node.removeAttribute(\"data-portaled\");\n if (this.restoreValue && placeholder.parentNode) {\n placeholder.parentNode.insertBefore(node, placeholder);\n } else {\n node.remove();\n }\n placeholder.remove();\n this.dispatch(\"unmount\", { detail: {} });\n }\n\n /** Resolves the destination from the validated `to` selector. */\n #destination(): Element | null {\n return document.querySelector(this.#toSelector);\n }\n}\n"]}
|
|
@@ -83,7 +83,20 @@ var StylePropertyLease = class {
|
|
|
83
83
|
}
|
|
84
84
|
};
|
|
85
85
|
|
|
86
|
+
// src/utils/transient_hooks.ts
|
|
87
|
+
var TransientHooks = class {
|
|
88
|
+
#attributes;
|
|
89
|
+
constructor(options) {
|
|
90
|
+
this.#attributes = options.attributes;
|
|
91
|
+
}
|
|
92
|
+
/** Drops the declared hooks from `element`; what it carries besides stays. */
|
|
93
|
+
reset(element) {
|
|
94
|
+
for (const attribute of this.#attributes) element.removeAttribute(attribute);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
86
98
|
// src/controllers/preview_guard_controller.ts
|
|
99
|
+
var TRANSIENT = new TransientHooks({ attributes: ["data-preview-hidden"] });
|
|
87
100
|
var PreviewGuardController = class extends Controller {
|
|
88
101
|
static values = {
|
|
89
102
|
placeholder: { type: String, default: "" }
|
|
@@ -101,7 +114,7 @@ var PreviewGuardController = class extends Controller {
|
|
|
101
114
|
connect() {
|
|
102
115
|
this.#connected = true;
|
|
103
116
|
if (this.#hidden) this.#reguard();
|
|
104
|
-
else this.element
|
|
117
|
+
else TRANSIENT.reset(this.element);
|
|
105
118
|
this.#beforeCache.activate();
|
|
106
119
|
if (typeof MutationObserver !== "undefined") {
|
|
107
120
|
this.#observer = new MutationObserver(() => this.#sync());
|
|
@@ -161,6 +174,8 @@ var PreviewGuardController = class extends Controller {
|
|
|
161
174
|
* first would reconnect the whole subtree for an instant. Only a change of *form* —
|
|
162
175
|
* to or from the empty placeholder — has to revert, and the focus the guard is holding
|
|
163
176
|
* carries across it rather than being handed back and taken again.
|
|
177
|
+
*
|
|
178
|
+
* @stimeoRenderRoot
|
|
164
179
|
*/
|
|
165
180
|
#reguard() {
|
|
166
181
|
if (this.#savedNodes && this.placeholderValue !== "") {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/utils/before_cache_reset.ts","../../src/utils/style_property_lease.ts","../../src/controllers/preview_guard_controller.ts"],"names":[],"mappings":"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","file":"preview_guard_controller.js","sourcesContent":["/**\n * Runs a controller's \"return to the initial state\" pass just before Turbo\n * caches the page.\n *\n * **`disconnect()` cannot do this job, for two independent reasons.** Turbo\n * queues the clone from this event rather than taking it here, and the body swap\n * that runs the controller's `disconnect()` is queued separately — so which of\n * the two lands first is not something a controller can rely on, and a rewind\n * written in `disconnect()` may reach only the DOM being thrown away. In the\n * other direction, `disconnect()` also fires on an in-page move (Stimulus tears\n * down and reconnects the same element), where rewinding would wipe a\n * legitimately in-progress interaction — a spinner mid-load would vanish. One\n * timing is unreliable, the other is too eager; `turbo:before-cache` is the only\n * point that is exactly \"the page is about to be frozen\".\n *\n * Scope is the subscription only: registering on `activate()`, unregistering on\n * `deactivate()`, and one shared document listener no matter how many instances\n * are live. *What* to return to its initial state — which `data-state`, which\n * `hidden`, which `aria-busy` — stays in the controller, because no two\n * consumers answer it the same way.\n *\n * **Rewind state, not appearance.** The pass writes attributes the controller\n * itself owns; the visual result of those attributes is the consumer's CSS, and\n * a library that reached for style or class names would be guessing at markup\n * it does not own.\n *\n * Both entry points are idempotent, so the lifecycle hooks can call them\n * unconditionally: a second `activate()` does not double-subscribe and does not\n * make the callback run twice, and `deactivate()` on an instance that never\n * subscribed is a no-op.\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 #beforeCache = new BeforeCacheReset(() => this.#rewind());\n *\n * connect() { this.#beforeCache.activate(); }\n * disconnect() { this.#beforeCache.deactivate(); }\n * ```\n */\nexport class BeforeCacheReset {\n /** Every subscribed instance, iterated by the one shared document listener. */\n static readonly #subscribers = new Set<BeforeCacheReset>();\n\n /** The shared listener; installed while at least one instance is subscribed. */\n static readonly #onBeforeCache = (): void => {\n for (const subscriber of BeforeCacheReset.#subscribers) subscriber.#rewind();\n };\n\n readonly #rewind: () => void;\n\n /** @param rewind - the pass that returns this controller's state to its initial form. */\n constructor(rewind: () => void) {\n this.#rewind = rewind;\n }\n\n /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */\n activate(): void {\n const first = BeforeCacheReset.#subscribers.size === 0;\n BeforeCacheReset.#subscribers.add(this);\n if (first) {\n document.addEventListener(\"turbo:before-cache\", BeforeCacheReset.#onBeforeCache);\n }\n }\n\n /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */\n deactivate(): void {\n BeforeCacheReset.#subscribers.delete(this);\n if (BeforeCacheReset.#subscribers.size > 0) return;\n document.removeEventListener(\"turbo:before-cache\", BeforeCacheReset.#onBeforeCache);\n }\n}\n","/** One temporarily controlled style property and the authored declaration it displaced. */\ninterface StylePropertyLeaseRecord {\n readonly originalValue: string;\n readonly originalPriority: string;\n writtenValue: string | null;\n writtenPriority: string;\n}\n\n/**\n * Temporarily controls one inline CSS property across a changing set of elements.\n *\n * Authored value and priority are restored only while the declaration still matches\n * the last leased write. A later consumer write therefore wins.\n *\n * The lease has no lifecycle of its own, so it never subscribes to document events:\n * a consumer returns its leases from its own `turbo:before-cache` rewind.\n */\nexport class StylePropertyLease<T extends HTMLElement = HTMLElement> {\n readonly #property: string;\n readonly #records = new Map<T, StylePropertyLeaseRecord>();\n\n /** @param property - The CSS property whose temporary values this lease owns. */\n constructor(property: string) {\n this.#property = property;\n }\n\n /** Writes or removes the leased declaration while preserving its authored value. */\n write(element: T, value: string | null, priority = \"\"): void {\n const existing = this.#records.get(element);\n if (existing) {\n existing.writtenValue = value;\n existing.writtenPriority = value === null ? \"\" : priority;\n } else {\n this.#records.set(element, {\n originalValue: element.style.getPropertyValue(this.#property),\n originalPriority: element.style.getPropertyPriority(this.#property),\n writtenValue: value,\n writtenPriority: value === null ? \"\" : priority,\n });\n }\n\n this.#reflect(element, value, priority);\n }\n\n /** Returns one lease without overwriting a later consumer declaration. */\n return(element: T): void {\n const record = this.#records.get(element);\n if (!record) return;\n this.#records.delete(element);\n\n const style = element.style;\n const stillOwned =\n style.getPropertyValue(this.#property) === (record.writtenValue ?? \"\") &&\n style.getPropertyPriority(this.#property) === record.writtenPriority;\n if (stillOwned) {\n this.#reflect(element, record.originalValue, record.originalPriority);\n }\n }\n\n /** Returns every outstanding declaration lease. */\n returnAll(): void {\n for (const element of Array.from(this.#records.keys())) this.return(element);\n }\n\n /** Reflects only a real declaration transition. */\n #reflect(element: T, value: string | null, priority: string): void {\n const style = element.style;\n const nextValue = value ?? \"\";\n const nextPriority = value === null ? \"\" : priority;\n if (\n style.getPropertyValue(this.#property) === nextValue &&\n style.getPropertyPriority(this.#property) === nextPriority\n ) {\n return;\n }\n if (value === null) style.removeProperty(this.#property);\n else style.setProperty(this.#property, value, priority);\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { BeforeCacheReset } from \"../utils/before_cache_reset\";\nimport { StylePropertyLease } from \"../utils/style_property_lease\";\n\n/**\n * Headless **preview guard** (Hotwire-specific): hides or placeholders a volatile element\n * — a balance, a notification count, a live timestamp — *only while Turbo is showing a\n * preview* (`html[data-turbo-preview]`), so a stale cached snapshot does not briefly flash\n * the old value on a back/restore visit. No APG pattern; it keeps displayed information\n * accurate and hands focus back if guarding took it.\n *\n * Markup contract (identifier: `stimeo--preview-guard`):\n * <span data-controller=\"stimeo--preview-guard\"\n * data-stimeo--preview-guard-placeholder-value=\"—\">¥123,456</span>\n *\n * Watches `<html>` for the `data-turbo-preview` attribute with a `MutationObserver`.\n * While it is present the element is guarded, and `placeholder` alone decides how: empty\n * (the default) makes it `visibility: hidden` — its box is kept, so nothing shifts — and\n * any other value takes its place as the element's content. The element carries\n * `data-preview-hidden` and emits `hide`; when the preview clears it is restored and\n * `show` fires. A `placeholder` changed while guarded re-guards to match it.\n *\n * `hide` and `show` dispatch `{}`.\n *\n * @remarks\n * Behavior only — restoring the *fresh* value is the normal render's job, not this\n * controller's (it just un-hides what was there). State is derived from the DOM (no\n * module-scope state), and the guard is reversible in three ways that do not depend on\n * each other: the child nodes a placeholder displaced are held and put back intact, the\n * inline `visibility` is leased so an authored declaration — and one the consumer writes\n * while guarded — survives, and a fresh `connect()` clears a `data-preview-hidden` it\n * finds already on the element. The rewind Turbo's snapshot needs runs on\n * `turbo:before-cache` rather than `disconnect()`, so an in-page move keeps the guard\n * instead of flashing the stale value between the two lifecycle calls — and the instance\n * that comes back re-forms it rather than reading the hook as someone else's leftover.\n *\n * Focus is only ever handed back, never taken: an element that held focus when the guard\n * went up gets it back on show, and focus resting anywhere else is left alone.\n */\nexport class PreviewGuardController extends Controller<HTMLElement> {\n static override values = {\n placeholder: { type: String, default: \"\" },\n };\n static events = [\"hide\", \"show\"] as const;\n\n declare placeholderValue: string;\n\n readonly #visibility = new StylePropertyLease(\"visibility\");\n readonly #beforeCache = new BeforeCacheReset(() => this.#restore());\n\n #observer: MutationObserver | null = null;\n #connected = false;\n #hidden = false;\n /** Child nodes a placeholder displaced; non-null marks that content — not visibility — was swapped. */\n #savedNodes: DocumentFragment | null = null;\n /** The descendant that held focus when the guard went up, so show can hand it back. */\n #focused: HTMLElement | null = null;\n\n override connect(): void {\n this.#connected = true;\n // The hook means \"the connection now running has this element guarded\", and which of\n // the two connects is happening is only knowable from the instance. A restored\n // snapshot brings a fresh controller to an element that still carries the hook, with\n // nothing in the DOM to say what was underneath it, so that one drops it. An in-page\n // move brings the *same* instance back still holding the guard, so that one re-forms\n // instead — against the `placeholder` as it stands now, since a morph can swap it\n // while the element is detached, where the value callback has no connection to act on.\n if (this.#hidden) this.#reguard();\n else this.element.removeAttribute(\"data-preview-hidden\");\n this.#beforeCache.activate();\n if (typeof MutationObserver !== \"undefined\") {\n this.#observer = new MutationObserver(() => this.#sync());\n this.#observer.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"data-turbo-preview\"],\n });\n }\n // Sync once in case we connect while a preview is already on screen.\n this.#sync();\n }\n\n override disconnect(): void {\n this.#connected = false;\n this.#beforeCache.deactivate();\n this.#observer?.disconnect();\n this.#observer = null;\n }\n\n /**\n * Re-guards to match a `placeholder` changed at runtime — the value is the content on\n * display while the guard is up, so a morph that swaps it must not leave the old one.\n */\n placeholderValueChanged(): void {\n if (!this.#connected || !this.#hidden) return;\n this.#reguard();\n }\n\n /**\n * Reflects the current `data-turbo-preview` state onto the element.\n *\n * @stimeoRenderRoot\n */\n #sync(): void {\n const previewing = document.documentElement.hasAttribute(\"data-turbo-preview\");\n if (previewing && !this.#hidden) this.#hide();\n else if (!previewing && this.#hidden) this.#show();\n }\n\n #hide(): void {\n this.#focused = this.#focusedInside();\n this.#applyGuard();\n this.dispatch(\"hide\", { detail: {} });\n }\n\n /** Puts the guard up in the form the current `placeholder` calls for. */\n #applyGuard(): void {\n this.#hidden = true;\n if (this.placeholderValue === \"\") {\n this.#visibility.write(this.element, \"hidden\");\n } else {\n this.#savedNodes = document.createDocumentFragment();\n this.#savedNodes.append(...this.element.childNodes);\n this.element.textContent = this.placeholderValue;\n }\n this.element.setAttribute(\"data-preview-hidden\", \"true\");\n }\n\n /**\n * Re-forms a guard that is already up so it matches the current `placeholder`.\n *\n * No event: `hide` reports that the guard went up, and it has not come down. Swapping\n * one stand-in text for another writes only that text — putting the held content back\n * first would reconnect the whole subtree for an instant. Only a change of *form* —\n * to or from the empty placeholder — has to revert, and the focus the guard is holding\n * carries across it rather than being handed back and taken again.\n */\n #reguard(): void {\n if (this.#savedNodes && this.placeholderValue !== \"\") {\n this.element.textContent = this.placeholderValue;\n return;\n }\n const held = this.#focused;\n this.#revert();\n this.#applyGuard();\n this.#focused = held;\n }\n\n #show(): void {\n this.#restore();\n this.dispatch(\"show\", { detail: {} });\n }\n\n /** Reverts the guard and hands focus back. Used by show and by the snapshot rewind. */\n #restore(): void {\n this.#revert();\n this.#refocus();\n }\n\n /**\n * Puts the element back the way the guard found it.\n *\n * Every step is a no-op on an element this controller never guarded: the lease returns\n * only declarations it recorded, and the hook is removed whether or not it is there.\n */\n #revert(): void {\n this.#hidden = false;\n if (this.#savedNodes) {\n this.element.textContent = \"\";\n this.element.append(this.#savedNodes);\n this.#savedNodes = null;\n } else {\n this.#visibility.return(this.element);\n }\n this.element.removeAttribute(\"data-preview-hidden\");\n }\n\n /**\n * The focused element the guard is about to make unfocusable, if any.\n *\n * The element itself counts as well as its descendants: guarding takes focus either way\n * — a `visibility: hidden` subtree cannot hold it, and a placeholder displaces the nodes\n * outright — so the browser drops focus to `<body>` the moment the guard goes up.\n */\n #focusedInside(): HTMLElement | null {\n const active = document.activeElement;\n return active instanceof HTMLElement && this.element.contains(active) ? active : null;\n }\n\n /**\n * Hands focus back to the element the guard took it from.\n *\n * Only when that element is still in the document and focus has not moved on since —\n * anything else is the user's or another controller's, and putting it back would be\n * taking it.\n */\n #refocus(): void {\n const target = this.#focused;\n this.#focused = null;\n if (!target?.isConnected || document.activeElement !== document.body) return;\n target.focus();\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/utils/before_cache_reset.ts","../../src/utils/style_property_lease.ts","../../src/utils/transient_hooks.ts","../../src/controllers/preview_guard_controller.ts"],"names":[],"mappings":"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","file":"preview_guard_controller.js","sourcesContent":["/**\n * Runs a controller's \"return to the initial state\" pass just before Turbo\n * caches the page.\n *\n * **`disconnect()` cannot do this job, for two independent reasons.** Turbo\n * queues the clone from this event rather than taking it here, and the body swap\n * that runs the controller's `disconnect()` is queued separately — so which of\n * the two lands first is not something a controller can rely on, and a rewind\n * written in `disconnect()` may reach only the DOM being thrown away. In the\n * other direction, `disconnect()` also fires on an in-page move (Stimulus tears\n * down and reconnects the same element), where rewinding would wipe a\n * legitimately in-progress interaction — a spinner mid-load would vanish. One\n * timing is unreliable, the other is too eager; `turbo:before-cache` is the only\n * point that is exactly \"the page is about to be frozen\".\n *\n * Scope is the subscription only: registering on `activate()`, unregistering on\n * `deactivate()`, and one shared document listener no matter how many instances\n * are live. *What* to return to its initial state — which `data-state`, which\n * `hidden`, which `aria-busy` — stays in the controller, because no two\n * consumers answer it the same way.\n *\n * **Rewind state, not appearance.** The pass writes attributes the controller\n * itself owns; the visual result of those attributes is the consumer's CSS, and\n * a library that reached for style or class names would be guessing at markup\n * it does not own.\n *\n * Both entry points are idempotent, so the lifecycle hooks can call them\n * unconditionally: a second `activate()` does not double-subscribe and does not\n * make the callback run twice, and `deactivate()` on an instance that never\n * subscribed is a no-op.\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 #beforeCache = new BeforeCacheReset(() => this.#rewind());\n *\n * connect() { this.#beforeCache.activate(); }\n * disconnect() { this.#beforeCache.deactivate(); }\n * ```\n */\nexport class BeforeCacheReset {\n /** Every subscribed instance, iterated by the one shared document listener. */\n static readonly #subscribers = new Set<BeforeCacheReset>();\n\n /** The shared listener; installed while at least one instance is subscribed. */\n static readonly #onBeforeCache = (): void => {\n for (const subscriber of BeforeCacheReset.#subscribers) subscriber.#rewind();\n };\n\n readonly #rewind: () => void;\n\n /** @param rewind - the pass that returns this controller's state to its initial form. */\n constructor(rewind: () => void) {\n this.#rewind = rewind;\n }\n\n /** Subscribes to `turbo:before-cache`; call from `connect()`. Idempotent. */\n activate(): void {\n const first = BeforeCacheReset.#subscribers.size === 0;\n BeforeCacheReset.#subscribers.add(this);\n if (first) {\n document.addEventListener(\"turbo:before-cache\", BeforeCacheReset.#onBeforeCache);\n }\n }\n\n /** Unsubscribes; call from `disconnect()`. Safe when never subscribed. */\n deactivate(): void {\n BeforeCacheReset.#subscribers.delete(this);\n if (BeforeCacheReset.#subscribers.size > 0) return;\n document.removeEventListener(\"turbo:before-cache\", BeforeCacheReset.#onBeforeCache);\n }\n}\n","/** One temporarily controlled style property and the authored declaration it displaced. */\ninterface StylePropertyLeaseRecord {\n readonly originalValue: string;\n readonly originalPriority: string;\n writtenValue: string | null;\n writtenPriority: string;\n}\n\n/**\n * Temporarily controls one inline CSS property across a changing set of elements.\n *\n * Authored value and priority are restored only while the declaration still matches\n * the last leased write. A later consumer write therefore wins.\n *\n * The lease has no lifecycle of its own, so it never subscribes to document events:\n * a consumer returns its leases from its own `turbo:before-cache` rewind.\n */\nexport class StylePropertyLease<T extends HTMLElement = HTMLElement> {\n readonly #property: string;\n readonly #records = new Map<T, StylePropertyLeaseRecord>();\n\n /** @param property - The CSS property whose temporary values this lease owns. */\n constructor(property: string) {\n this.#property = property;\n }\n\n /** Writes or removes the leased declaration while preserving its authored value. */\n write(element: T, value: string | null, priority = \"\"): void {\n const existing = this.#records.get(element);\n if (existing) {\n existing.writtenValue = value;\n existing.writtenPriority = value === null ? \"\" : priority;\n } else {\n this.#records.set(element, {\n originalValue: element.style.getPropertyValue(this.#property),\n originalPriority: element.style.getPropertyPriority(this.#property),\n writtenValue: value,\n writtenPriority: value === null ? \"\" : priority,\n });\n }\n\n this.#reflect(element, value, priority);\n }\n\n /** Returns one lease without overwriting a later consumer declaration. */\n return(element: T): void {\n const record = this.#records.get(element);\n if (!record) return;\n this.#records.delete(element);\n\n const style = element.style;\n const stillOwned =\n style.getPropertyValue(this.#property) === (record.writtenValue ?? \"\") &&\n style.getPropertyPriority(this.#property) === record.writtenPriority;\n if (stillOwned) {\n this.#reflect(element, record.originalValue, record.originalPriority);\n }\n }\n\n /** Returns every outstanding declaration lease. */\n returnAll(): void {\n for (const element of Array.from(this.#records.keys())) this.return(element);\n }\n\n /** Reflects only a real declaration transition. */\n #reflect(element: T, value: string | null, priority: string): void {\n const style = element.style;\n const nextValue = value ?? \"\";\n const nextPriority = value === null ? \"\" : priority;\n if (\n style.getPropertyValue(this.#property) === nextValue &&\n style.getPropertyPriority(this.#property) === nextPriority\n ) {\n return;\n }\n if (value === null) style.removeProperty(this.#property);\n else style.setProperty(this.#property, value, priority);\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 { BeforeCacheReset } from \"../utils/before_cache_reset\";\nimport { StylePropertyLease } from \"../utils/style_property_lease\";\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-preview-hidden\"] });\n\n/**\n * Headless **preview guard** (Hotwire-specific): hides or placeholders a volatile element\n * — a balance, a notification count, a live timestamp — *only while Turbo is showing a\n * preview* (`html[data-turbo-preview]`), so a stale cached snapshot does not briefly flash\n * the old value on a back/restore visit. No APG pattern; it keeps displayed information\n * accurate and hands focus back if guarding took it.\n *\n * Markup contract (identifier: `stimeo--preview-guard`):\n * <span data-controller=\"stimeo--preview-guard\"\n * data-stimeo--preview-guard-placeholder-value=\"—\">¥123,456</span>\n *\n * Watches `<html>` for the `data-turbo-preview` attribute with a `MutationObserver`.\n * While it is present the element is guarded, and `placeholder` alone decides how: empty\n * (the default) makes it `visibility: hidden` — its box is kept, so nothing shifts — and\n * any other value takes its place as the element's content. The element carries\n * `data-preview-hidden` and emits `hide`; when the preview clears it is restored and\n * `show` fires. A `placeholder` changed while guarded re-guards to match it.\n *\n * `hide` and `show` dispatch `{}`.\n *\n * @remarks\n * Behavior only — restoring the *fresh* value is the normal render's job, not this\n * controller's (it just un-hides what was there). State is derived from the DOM (no\n * module-scope state), and the guard is reversible in three ways that do not depend on\n * each other: the child nodes a placeholder displaced are held and put back intact, the\n * inline `visibility` is leased so an authored declaration — and one the consumer writes\n * while guarded — survives, and a fresh `connect()` clears a `data-preview-hidden` it\n * finds already on the element. The rewind Turbo's snapshot needs runs on\n * `turbo:before-cache` rather than `disconnect()`, so an in-page move keeps the guard\n * instead of flashing the stale value between the two lifecycle calls — and the instance\n * that comes back re-forms it rather than reading the hook as someone else's leftover.\n *\n * Focus is only ever handed back, never taken: an element that held focus when the guard\n * went up gets it back on show, and focus resting anywhere else is left alone.\n */\nexport class PreviewGuardController extends Controller<HTMLElement> {\n static override values = {\n placeholder: { type: String, default: \"\" },\n };\n static events = [\"hide\", \"show\"] as const;\n\n declare placeholderValue: string;\n\n readonly #visibility = new StylePropertyLease(\"visibility\");\n readonly #beforeCache = new BeforeCacheReset(() => this.#restore());\n\n #observer: MutationObserver | null = null;\n #connected = false;\n #hidden = false;\n /** Child nodes a placeholder displaced; non-null marks that content — not visibility — was swapped. */\n #savedNodes: DocumentFragment | null = null;\n /** The descendant that held focus when the guard went up, so show can hand it back. */\n #focused: HTMLElement | null = null;\n\n override connect(): void {\n this.#connected = true;\n // The hook means \"the connection now running has this element guarded\", and which of\n // the two connects is happening is only knowable from the instance. A restored\n // snapshot brings a fresh controller to an element that still carries the hook, with\n // nothing in the DOM to say what was underneath it, so that one drops it. An in-page\n // move brings the *same* instance back still holding the guard, so that one re-forms\n // instead — against the `placeholder` as it stands now, since a morph can swap it\n // while the element is detached, where the value callback has no connection to act on.\n if (this.#hidden) this.#reguard();\n else TRANSIENT.reset(this.element);\n this.#beforeCache.activate();\n if (typeof MutationObserver !== \"undefined\") {\n this.#observer = new MutationObserver(() => this.#sync());\n this.#observer.observe(document.documentElement, {\n attributes: true,\n attributeFilter: [\"data-turbo-preview\"],\n });\n }\n // Sync once in case we connect while a preview is already on screen.\n this.#sync();\n }\n\n override disconnect(): void {\n this.#connected = false;\n this.#beforeCache.deactivate();\n this.#observer?.disconnect();\n this.#observer = null;\n }\n\n /**\n * Re-guards to match a `placeholder` changed at runtime — the value is the content on\n * display while the guard is up, so a morph that swaps it must not leave the old one.\n */\n placeholderValueChanged(): void {\n if (!this.#connected || !this.#hidden) return;\n this.#reguard();\n }\n\n /**\n * Reflects the current `data-turbo-preview` state onto the element.\n *\n * @stimeoRenderRoot\n */\n #sync(): void {\n const previewing = document.documentElement.hasAttribute(\"data-turbo-preview\");\n if (previewing && !this.#hidden) this.#hide();\n else if (!previewing && this.#hidden) this.#show();\n }\n\n #hide(): void {\n this.#focused = this.#focusedInside();\n this.#applyGuard();\n this.dispatch(\"hide\", { detail: {} });\n }\n\n /** Puts the guard up in the form the current `placeholder` calls for. */\n #applyGuard(): void {\n this.#hidden = true;\n if (this.placeholderValue === \"\") {\n this.#visibility.write(this.element, \"hidden\");\n } else {\n this.#savedNodes = document.createDocumentFragment();\n this.#savedNodes.append(...this.element.childNodes);\n this.element.textContent = this.placeholderValue;\n }\n this.element.setAttribute(\"data-preview-hidden\", \"true\");\n }\n\n /**\n * Re-forms a guard that is already up so it matches the current `placeholder`.\n *\n * No event: `hide` reports that the guard went up, and it has not come down. Swapping\n * one stand-in text for another writes only that text — putting the held content back\n * first would reconnect the whole subtree for an instant. Only a change of *form* —\n * to or from the empty placeholder — has to revert, and the focus the guard is holding\n * carries across it rather than being handed back and taken again.\n *\n * @stimeoRenderRoot\n */\n #reguard(): void {\n if (this.#savedNodes && this.placeholderValue !== \"\") {\n this.element.textContent = this.placeholderValue;\n return;\n }\n const held = this.#focused;\n this.#revert();\n this.#applyGuard();\n this.#focused = held;\n }\n\n #show(): void {\n this.#restore();\n this.dispatch(\"show\", { detail: {} });\n }\n\n /** Reverts the guard and hands focus back. Used by show and by the snapshot rewind. */\n #restore(): void {\n this.#revert();\n this.#refocus();\n }\n\n /**\n * Puts the element back the way the guard found it.\n *\n * Every step is a no-op on an element this controller never guarded: the lease returns\n * only declarations it recorded, and the hook is removed whether or not it is there.\n */\n #revert(): void {\n this.#hidden = false;\n if (this.#savedNodes) {\n this.element.textContent = \"\";\n this.element.append(this.#savedNodes);\n this.#savedNodes = null;\n } else {\n this.#visibility.return(this.element);\n }\n this.element.removeAttribute(\"data-preview-hidden\");\n }\n\n /**\n * The focused element the guard is about to make unfocusable, if any.\n *\n * The element itself counts as well as its descendants: guarding takes focus either way\n * — a `visibility: hidden` subtree cannot hold it, and a placeholder displaces the nodes\n * outright — so the browser drops focus to `<body>` the moment the guard goes up.\n */\n #focusedInside(): HTMLElement | null {\n const active = document.activeElement;\n return active instanceof HTMLElement && this.element.contains(active) ? active : null;\n }\n\n /**\n * Hands focus back to the element the guard took it from.\n *\n * Only when that element is still in the document and focus has not moved on since —\n * anything else is the user's or another controller's, and putting it back would be\n * taking it.\n */\n #refocus(): void {\n const target = this.#focused;\n this.#focused = null;\n if (!target?.isConnected || document.activeElement !== document.body) return;\n target.focus();\n }\n}\n"]}
|