stimeo-ui 0.2.0 → 0.3.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 +163 -0
- package/dist/cable/index.d.ts +16 -16
- package/dist/cable/index.js.map +1 -1
- package/dist/controllers/accordion_controller.d.ts +3 -2
- package/dist/controllers/accordion_controller.js +10 -0
- package/dist/controllers/accordion_controller.js.map +1 -1
- package/dist/controllers/alert_dialog_controller.d.ts +64 -0
- package/dist/controllers/alert_dialog_controller.js +318 -0
- package/dist/controllers/alert_dialog_controller.js.map +1 -0
- package/dist/controllers/breadcrumb_controller.d.ts +97 -10
- package/dist/controllers/breadcrumb_controller.js +225 -13
- package/dist/controllers/breadcrumb_controller.js.map +1 -1
- package/dist/controllers/calendar_controller.d.ts +11 -1
- package/dist/controllers/calendar_controller.js +89 -22
- package/dist/controllers/calendar_controller.js.map +1 -1
- package/dist/controllers/carousel_controller.d.ts +115 -0
- package/dist/controllers/carousel_controller.js +313 -0
- package/dist/controllers/carousel_controller.js.map +1 -0
- package/dist/controllers/clipboard_controller.d.ts +72 -0
- package/dist/controllers/clipboard_controller.js +144 -0
- package/dist/controllers/clipboard_controller.js.map +1 -0
- package/dist/controllers/collapsible_controller.d.ts +70 -0
- package/dist/controllers/collapsible_controller.js +327 -0
- package/dist/controllers/collapsible_controller.js.map +1 -0
- package/dist/controllers/color_picker_controller.d.ts +87 -0
- package/dist/controllers/color_picker_controller.js +252 -0
- package/dist/controllers/color_picker_controller.js.map +1 -0
- package/dist/controllers/combobox_controller.d.ts +14 -1
- package/dist/controllers/combobox_controller.js +162 -23
- package/dist/controllers/combobox_controller.js.map +1 -1
- package/dist/controllers/command_palette_controller.d.ts +2 -0
- package/dist/controllers/command_palette_controller.js +194 -17
- package/dist/controllers/command_palette_controller.js.map +1 -1
- package/dist/controllers/confirm_controller.js.map +1 -1
- package/dist/controllers/context_menu_controller.d.ts +7 -6
- package/dist/controllers/context_menu_controller.js +32 -10
- package/dist/controllers/context_menu_controller.js.map +1 -1
- package/dist/controllers/count_up_controller.js +8 -1
- package/dist/controllers/count_up_controller.js.map +1 -1
- package/dist/controllers/currency_input_controller.d.ts +72 -0
- package/dist/controllers/currency_input_controller.js +147 -0
- package/dist/controllers/currency_input_controller.js.map +1 -0
- package/dist/controllers/data_grid_controller.d.ts +89 -0
- package/dist/controllers/data_grid_controller.js +246 -0
- package/dist/controllers/data_grid_controller.js.map +1 -0
- package/dist/controllers/date_range_picker_controller.d.ts +88 -0
- package/dist/controllers/date_range_picker_controller.js +441 -0
- package/dist/controllers/date_range_picker_controller.js.map +1 -0
- package/dist/controllers/dialog_controller.js.map +1 -1
- package/dist/controllers/dismissible_controller.d.ts +57 -0
- package/dist/controllers/dismissible_controller.js +117 -0
- package/dist/controllers/dismissible_controller.js.map +1 -0
- package/dist/controllers/drawer_controller.d.ts +96 -0
- package/dist/controllers/drawer_controller.js +630 -0
- package/dist/controllers/drawer_controller.js.map +1 -0
- package/dist/controllers/dropdown_controller.d.ts +1 -2
- package/dist/controllers/dropdown_controller.js.map +1 -1
- package/dist/controllers/editable_controller.d.ts +73 -0
- package/dist/controllers/editable_controller.js +169 -0
- package/dist/controllers/editable_controller.js.map +1 -0
- package/dist/controllers/file_dropzone_controller.d.ts +92 -0
- package/dist/controllers/file_dropzone_controller.js +165 -0
- package/dist/controllers/file_dropzone_controller.js.map +1 -0
- package/dist/controllers/filter_controller.d.ts +60 -0
- package/dist/controllers/filter_controller.js +86 -0
- package/dist/controllers/filter_controller.js.map +1 -0
- package/dist/controllers/flash_controller.js +36 -5
- package/dist/controllers/flash_controller.js.map +1 -1
- package/dist/controllers/focus_controller.d.ts +1 -1
- package/dist/controllers/focus_controller.js.map +1 -1
- package/dist/controllers/form_field_controller.js.map +1 -1
- package/dist/controllers/form_validation_controller.js +1 -1
- package/dist/controllers/form_validation_controller.js.map +1 -1
- package/dist/controllers/highlight_controller.js +6 -4
- package/dist/controllers/highlight_controller.js.map +1 -1
- package/dist/controllers/hover_card_controller.d.ts +1 -1
- package/dist/controllers/hover_card_controller.js.map +1 -1
- package/dist/controllers/intersection_controller.js +67 -19
- package/dist/controllers/intersection_controller.js.map +1 -1
- package/dist/controllers/lazy_frame_controller.js +54 -11
- package/dist/controllers/lazy_frame_controller.js.map +1 -1
- package/dist/controllers/listbox_controller.d.ts +9 -2
- package/dist/controllers/listbox_controller.js +257 -53
- package/dist/controllers/listbox_controller.js.map +1 -1
- package/dist/controllers/local_time_controller.js +2 -2
- package/dist/controllers/local_time_controller.js.map +1 -1
- package/dist/controllers/masonry_controller.d.ts +54 -0
- package/dist/controllers/masonry_controller.js +142 -0
- package/dist/controllers/masonry_controller.js.map +1 -0
- package/dist/controllers/menu_controller.d.ts +29 -11
- package/dist/controllers/menu_controller.js +104 -17
- package/dist/controllers/menu_controller.js.map +1 -1
- package/dist/controllers/menubar_controller.d.ts +142 -0
- package/dist/controllers/menubar_controller.js +785 -0
- package/dist/controllers/menubar_controller.js.map +1 -0
- package/dist/controllers/meter_controller.js.map +1 -1
- package/dist/controllers/multi_select_controller.d.ts +141 -0
- package/dist/controllers/multi_select_controller.js +755 -0
- package/dist/controllers/multi_select_controller.js.map +1 -0
- package/dist/controllers/navigation_menu_controller.d.ts +146 -0
- package/dist/controllers/navigation_menu_controller.js +511 -0
- package/dist/controllers/navigation_menu_controller.js.map +1 -0
- package/dist/controllers/nested_form_controller.d.ts +4 -5
- package/dist/controllers/nested_form_controller.js.map +1 -1
- package/dist/controllers/number_input_controller.d.ts +1 -1
- package/dist/controllers/number_input_controller.js +7 -0
- package/dist/controllers/number_input_controller.js.map +1 -1
- package/dist/controllers/otp_controller.js +18 -1
- package/dist/controllers/otp_controller.js.map +1 -1
- package/dist/controllers/overflow_indicator_controller.d.ts +15 -6
- package/dist/controllers/overflow_indicator_controller.js +246 -27
- package/dist/controllers/overflow_indicator_controller.js.map +1 -1
- package/dist/controllers/overflow_menu_controller.d.ts +78 -19
- package/dist/controllers/overflow_menu_controller.js +381 -57
- package/dist/controllers/overflow_menu_controller.js.map +1 -1
- package/dist/controllers/pagination_controller.d.ts +36 -5
- package/dist/controllers/pagination_controller.js +163 -32
- package/dist/controllers/pagination_controller.js.map +1 -1
- package/dist/controllers/password_reveal_controller.d.ts +49 -0
- package/dist/controllers/password_reveal_controller.js +117 -0
- package/dist/controllers/password_reveal_controller.js.map +1 -0
- package/dist/controllers/persist_controller.js +6 -6
- package/dist/controllers/persist_controller.js.map +1 -1
- package/dist/controllers/pointer_drag_controller.d.ts +2 -3
- package/dist/controllers/pointer_drag_controller.js +9 -1
- package/dist/controllers/pointer_drag_controller.js.map +1 -1
- package/dist/controllers/popover_controller.d.ts +1 -2
- package/dist/controllers/popover_controller.js +2 -2
- package/dist/controllers/popover_controller.js.map +1 -1
- package/dist/controllers/portal_controller.d.ts +4 -4
- package/dist/controllers/portal_controller.js.map +1 -1
- package/dist/controllers/progress_controller.js.map +1 -1
- package/dist/controllers/radio_group_controller.js +22 -3
- package/dist/controllers/radio_group_controller.js.map +1 -1
- package/dist/controllers/range_slider_controller.d.ts +97 -0
- package/dist/controllers/range_slider_controller.js +192 -0
- package/dist/controllers/range_slider_controller.js.map +1 -0
- package/dist/controllers/rating_controller.js +16 -2
- package/dist/controllers/rating_controller.js.map +1 -1
- package/dist/controllers/read_more_controller.d.ts +52 -0
- package/dist/controllers/read_more_controller.js +238 -0
- package/dist/controllers/read_more_controller.js.map +1 -0
- package/dist/controllers/reading_progress_controller.js.map +1 -1
- package/dist/controllers/resizable_controller.d.ts +3 -1
- package/dist/controllers/resizable_controller.js +65 -1
- package/dist/controllers/resizable_controller.js.map +1 -1
- package/dist/controllers/roving_controller.js +17 -2
- package/dist/controllers/roving_controller.js.map +1 -1
- package/dist/controllers/scroll_area_controller.js +101 -14
- package/dist/controllers/scroll_area_controller.js.map +1 -1
- package/dist/controllers/scroll_restore_controller.d.ts +54 -0
- package/dist/controllers/scroll_restore_controller.js +93 -0
- package/dist/controllers/scroll_restore_controller.js.map +1 -0
- package/dist/controllers/scroll_visibility_controller.d.ts +6 -3
- package/dist/controllers/scroll_visibility_controller.js +40 -6
- package/dist/controllers/scroll_visibility_controller.js.map +1 -1
- package/dist/controllers/scrollspy_controller.d.ts +64 -15
- package/dist/controllers/scrollspy_controller.js +369 -74
- package/dist/controllers/scrollspy_controller.js.map +1 -1
- package/dist/controllers/separator_controller.d.ts +69 -0
- package/dist/controllers/separator_controller.js +96 -0
- package/dist/controllers/separator_controller.js.map +1 -0
- package/dist/controllers/sidebar_controller.d.ts +107 -0
- package/dist/controllers/sidebar_controller.js +761 -0
- package/dist/controllers/sidebar_controller.js.map +1 -0
- package/dist/controllers/skeleton_controller.js +1 -1
- package/dist/controllers/skeleton_controller.js.map +1 -1
- package/dist/controllers/slider_controller.d.ts +11 -1
- package/dist/controllers/slider_controller.js +32 -6
- package/dist/controllers/slider_controller.js.map +1 -1
- package/dist/controllers/sortable_controller.d.ts +3 -1
- package/dist/controllers/sortable_controller.js +34 -3
- package/dist/controllers/sortable_controller.js.map +1 -1
- package/dist/controllers/spinner_controller.js +1 -1
- package/dist/controllers/spinner_controller.js.map +1 -1
- package/dist/controllers/stepper_controller.d.ts +4 -0
- package/dist/controllers/stepper_controller.js +28 -12
- package/dist/controllers/stepper_controller.js.map +1 -1
- package/dist/controllers/stick_to_bottom_controller.d.ts +5 -6
- package/dist/controllers/stick_to_bottom_controller.js +9 -4
- package/dist/controllers/stick_to_bottom_controller.js.map +1 -1
- package/dist/controllers/sticky_observer_controller.d.ts +17 -4
- package/dist/controllers/sticky_observer_controller.js +109 -20
- package/dist/controllers/sticky_observer_controller.js.map +1 -1
- package/dist/controllers/submit_once_controller.d.ts +2 -2
- package/dist/controllers/submit_once_controller.js.map +1 -1
- package/dist/controllers/switch_controller.js +1 -0
- package/dist/controllers/switch_controller.js.map +1 -1
- package/dist/controllers/tabs_controller.d.ts +13 -4
- package/dist/controllers/tabs_controller.js +26 -3
- package/dist/controllers/tabs_controller.js.map +1 -1
- package/dist/controllers/tags_input_controller.d.ts +91 -0
- package/dist/controllers/tags_input_controller.js +295 -0
- package/dist/controllers/tags_input_controller.js.map +1 -0
- package/dist/controllers/theme_controller.d.ts +1 -1
- package/dist/controllers/theme_controller.js +42 -13
- package/dist/controllers/theme_controller.js.map +1 -1
- package/dist/controllers/time_picker_controller.d.ts +67 -0
- package/dist/controllers/time_picker_controller.js +231 -0
- package/dist/controllers/time_picker_controller.js.map +1 -0
- package/dist/controllers/toast_controller.d.ts +5 -14
- package/dist/controllers/toast_controller.js +40 -14
- package/dist/controllers/toast_controller.js.map +1 -1
- package/dist/controllers/toggle_group_controller.js +23 -2
- package/dist/controllers/toggle_group_controller.js.map +1 -1
- package/dist/controllers/toolbar_controller.d.ts +53 -13
- package/dist/controllers/toolbar_controller.js +230 -31
- package/dist/controllers/toolbar_controller.js.map +1 -1
- package/dist/controllers/tooltip_controller.d.ts +1 -1
- package/dist/controllers/tooltip_controller.js.map +1 -1
- package/dist/controllers/transition_controller.d.ts +14 -8
- package/dist/controllers/transition_controller.js +153 -38
- package/dist/controllers/transition_controller.js.map +1 -1
- package/dist/controllers/tree_view_controller.d.ts +135 -0
- package/dist/controllers/tree_view_controller.js +691 -0
- package/dist/controllers/tree_view_controller.js.map +1 -0
- package/dist/index.d.ts +27 -1818
- package/dist/index.js +4256 -915
- package/dist/index.js.map +1 -1
- package/dist/inspector/cli.d.ts +375 -39
- package/dist/inspector/cli.js +228 -14
- package/dist/inspector/cli.js.map +1 -1
- package/dist/inspector/cli_bin.js +419 -48
- package/dist/inspector/cli_bin.js.map +1 -1
- package/dist/inspector/examples.json +26 -26
- package/dist/inspector/manifest.json +1054 -127
- package/package.json +5 -6
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/utils/escape_layer.ts","../../src/utils/safe_timeout.ts","../../src/utils/scroll_dismiss.ts","../../src/controllers/hover_card_controller.ts"],"names":[],"mappings":"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","file":"hover_card_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-first half of the\n * shared layered-Escape contract. A keydown that is part of an IME composition\n * (`event.isComposing`) cancels the composition, never a layer, and is ignored\n * here for every layer 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 * Self-cleaning timer registries shared by Stimeo controllers.\n *\n * Stimulus controllers frequently schedule `setTimeout` / `setInterval` work\n * (auto-dismiss, debouncing, polling). When the element leaves the DOM — a\n * Turbo Drive navigation, a Turbo Stream replacement, or any `disconnect()` —\n * orphaned timers keep firing against a detached controller, leaking memory and\n * mutating stale state. {@link SafeTimeout} and {@link SafeInterval} track every\n * timer they create so a single {@link TimerRegistry.clearAll | clearAll()} call\n * in `disconnect()` tears them all down.\n *\n * These are intentionally low-level primitives: they own *registration and\n * cleanup only*. Higher-level policy (pause/resume, remaining-time accounting)\n * stays in the individual controllers so per-widget semantics are not flattened\n * into a lowest-common-denominator helper.\n */\n\n/**\n * Shared registry bookkeeping for the timeout/interval variants.\n *\n * Subclasses provide the scheduling primitive ({@link schedule}) and its matching\n * canceller ({@link cancel}); this base owns the set of live ids plus the\n * per-id and bulk teardown shared by both.\n */\nabstract class TimerRegistry {\n /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */\n protected readonly ids = new Set<number>();\n\n /** Schedules the underlying platform timer and returns its id. */\n protected abstract schedule(callback: () => void, delay: number): number;\n\n /** Cancels the underlying platform timer for `id`. */\n protected abstract cancel(id: number): void;\n\n /**\n * Cancels a single tracked timer.\n *\n * No-ops if the id is unknown (already cleared, fired, or never owned by this\n * registry), so callers can clear defensively without guarding.\n */\n clear(id: number): void {\n if (this.ids.delete(id)) {\n this.cancel(id);\n }\n }\n\n /**\n * Cancels every tracked timer. Call this from a controller's `disconnect()`\n * to guarantee no timer outlives the element.\n */\n clearAll(): void {\n for (const id of this.ids) {\n this.cancel(id);\n }\n this.ids.clear();\n }\n\n /** Number of timers currently tracked (pending). */\n get size(): number {\n return this.ids.size;\n }\n}\n\n/**\n * `setTimeout` wrapper that auto-forgets each timer once it fires and supports\n * bulk teardown on disconnect.\n *\n * @example\n * ```ts\n * #timers = new SafeTimeout();\n *\n * connect() {\n * this.#timers.set(() => this.dismiss(), 5000);\n * }\n *\n * disconnect() {\n * this.#timers.clearAll();\n * }\n * ```\n */\nexport class SafeTimeout extends TimerRegistry {\n /**\n * Schedules `callback` after `delay` ms and returns the timer id.\n *\n * The id is removed from the registry automatically when the timeout fires,\n * so {@link TimerRegistry.size | size} reflects only still-pending timers.\n */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(() => {\n this.ids.delete(id);\n callback();\n }, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setTimeout(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearTimeout(id);\n }\n}\n\n/**\n * `setInterval` wrapper that tracks every interval for bulk teardown on\n * disconnect. Unlike {@link SafeTimeout}, intervals are retained until they are\n * explicitly cleared because they fire repeatedly.\n *\n * @example\n * ```ts\n * #intervals = new SafeInterval();\n *\n * connect() {\n * this.#intervals.set(() => this.tick(), 1000);\n * }\n *\n * disconnect() {\n * this.#intervals.clearAll();\n * }\n * ```\n */\nexport class SafeInterval extends TimerRegistry {\n /** Schedules a repeating `callback` every `delay` ms and returns the timer id. */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(callback, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setInterval(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearInterval(id);\n }\n}\n","/**\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 { EscapeLayer } from \"../utils/escape_layer\";\nimport { SafeTimeout } from \"../utils/safe_timeout\";\nimport { observeScrollDismiss } from \"../utils/scroll_dismiss\";\n\n/**\n * Headless, accessible **hover card** behavior.\n *\n * Markup contract (identifier: `stimeo--hover-card`):\n * <span data-controller=\"stimeo--hover-card\">\n * <a href=\"/users/jane\" data-stimeo--hover-card-target=\"trigger\"\n * aria-expanded=\"false\" aria-controls=\"hc\"\n * data-action=\"mouseenter->stimeo--hover-card#open\n * mouseleave->stimeo--hover-card#close\n * focusin->stimeo--hover-card#open\n * focusout->stimeo--hover-card#close\">@jane</a>\n * <div id=\"hc\" data-stimeo--hover-card-target=\"card\"\n * data-action=\"mouseenter->stimeo--hover-card#open\n * mouseleave->stimeo--hover-card#close\n * focusin->stimeo--hover-card#open\n * focusout->stimeo--hover-card#close\" hidden>…</div>\n * </span>\n *\n * There is no dedicated APG pattern; this follows the **Disclosure** convention\n * (`aria-expanded`) for a hover/focus-opened, non-modal popover that *may* hold\n * interactive content (unlike a tooltip). The card is **not** a `role=\"dialog\"`:\n * it is supplementary, so its content must also be reachable from the trigger\n * itself. For a short text hint use `stimeo--tooltip`; for a click-opened\n * action panel use `stimeo--popover`.\n *\n * @remarks\n * Behavior only — placement is the consumer's CSS (static) or the opt-in\n * `stimeo-ui/positioning` module (dynamic); this controller never imports it.\n *\n * Behavior provided:\n * - Open on `mouseenter`/`focusin`, close on `mouseleave`/`focusout`, each gated by\n * `openDelay`/`closeDelay` to prevent accidental flicker.\n * - **Hoverable bridge**: binding open/close on the card cancels a pending close\n * when the pointer crosses into it. Matching focus actions on the card cancel\n * the trigger's pending close while focus is inside, then schedule close once\n * focus leaves the whole controller.\n * - **Dismissible**: while open, the card joins the shared {@link EscapeLayer}\n * stack, so `Escape` closes it regardless of where focus sits (card, trigger,\n * or elsewhere). The resolver ignores an Escape already consumed by an inner\n * handler and lets the most recently shown layer own the press, so one\n * keypress closes exactly one layer (the shared layered-Escape contract).\n * - Open/closed flips the trigger's `aria-expanded`, the card's `hidden`, and a\n * `data-state` (`open`/`closed`). Focus is never stolen on open.\n * - Opt-in **dismiss on scroll** (`closeOnScroll`): while open, scrolling a tracked\n * scroll-parent ancestor (or the window) closes the card — the Radix / floating-ui\n * convention. Covers keyboard/programmatic scroll and scrollbar-drag, which the\n * pointer-leave close cannot. Off by default.\n */\nexport class HoverCardController extends Controller<HTMLElement> {\n static override targets = [\"trigger\", \"card\"];\n static override values = {\n openDelay: { type: Number, default: 300 },\n closeDelay: { type: Number, default: 200 },\n closeOnScroll: { type: Boolean, default: false },\n };\n static actions = [\"close\", \"open\"] as const;\n\n declare readonly triggerTarget: HTMLElement;\n declare readonly cardTarget: HTMLElement;\n declare readonly hasTriggerTarget: boolean;\n declare readonly hasCardTarget: boolean;\n declare readonly openDelayValue: number;\n declare readonly closeDelayValue: number;\n declare readonly closeOnScrollValue: boolean;\n\n /** Pending open/close timers, with their IDs reset on every lifecycle boundary. */\n readonly #timers = new SafeTimeout();\n /** Escape-stack membership while open; the shared resolver dismisses via it. */\n readonly #escapeLayer = new EscapeLayer();\n #pendingOpen: number | null = null;\n #pendingClose: number | null = null;\n /** Cleanup for the dismiss-on-scroll listeners while open, or `null`. */\n #stopScrollDismiss: (() => void) | null = null;\n\n /** Starts closed and discards any stale pending state from a prior connection. */\n override connect(): void {\n this.#cancelOpen();\n this.#cancelClose();\n this.#conceal();\n }\n\n /** Clears timers, the Escape-stack membership, and scroll listeners so nothing outlives the element. */\n override disconnect(): void {\n this.#cancelOpen();\n this.#cancelClose();\n this.#escapeLayer.deactivate();\n this.#stopScrollDismiss?.();\n this.#stopScrollDismiss = null;\n }\n\n /** Opens the card, after `openDelay` ms (or immediately at 0). Cancels a pending close. */\n open(): void {\n this.#cancelClose();\n if (this.#isOpen || this.#pendingOpen !== null) return;\n if (this.openDelayValue <= 0) {\n this.#reveal();\n return;\n }\n this.#pendingOpen = this.#timers.set(() => {\n this.#pendingOpen = null;\n this.#reveal();\n }, this.openDelayValue);\n }\n\n /**\n * Schedules the card to close after `closeDelay`. Cancels a pending open. The\n * delayed callback re-checks whether focus has landed inside the controller\n * (e.g. a link in the card) and, if so, aborts the close — covering keyboard\n * traversal that the pointer-only hoverable bridge cannot.\n */\n close(): void {\n this.#cancelOpen();\n if (!this.#isOpen || this.#pendingClose !== null) return;\n this.#pendingClose = this.#timers.set(() => {\n this.#pendingClose = null;\n if (this.element.contains(document.activeElement)) return;\n this.#conceal();\n }, this.closeDelayValue);\n }\n\n /** Reveals the card, reflects state, and joins the Escape stack / scroll watcher. */\n #reveal(): void {\n if (!this.hasCardTarget) return;\n this.cardTarget.hidden = false;\n this.cardTarget.setAttribute(\"data-state\", \"open\");\n if (this.hasTriggerTarget) this.triggerTarget.setAttribute(\"aria-expanded\", \"true\");\n // No claims predicate: hover-revealed content is dismissible regardless of\n // where focus sits (WCAG 2.2 SC 1.4.13), so it always claims while open.\n this.#escapeLayer.activate(document, { onDismiss: () => this.#dismiss() });\n if (this.closeOnScrollValue && !this.#stopScrollDismiss) {\n this.#stopScrollDismiss = observeScrollDismiss(this.element, () => this.#dismiss());\n }\n }\n\n /** Hides the card, reflects state, and leaves the Escape stack / scroll watcher. */\n #conceal(): void {\n // Release the layer and observers first, unconditionally: if the card\n // target was removed from the DOM while open, an early return would leak\n // the stack entry and the scroll-dismiss listeners.\n this.#escapeLayer.deactivate();\n this.#stopScrollDismiss?.();\n this.#stopScrollDismiss = null;\n if (this.hasCardTarget) {\n this.cardTarget.hidden = true;\n this.cardTarget.setAttribute(\"data-state\", \"closed\");\n }\n if (this.hasTriggerTarget) this.triggerTarget.setAttribute(\"aria-expanded\", \"false\");\n }\n\n /** Cancels pending timers and conceals immediately (shared Escape path). */\n #dismiss(): void {\n this.#cancelOpen();\n this.#cancelClose();\n this.#conceal();\n }\n\n /** Cancels any pending open timer. */\n #cancelOpen(): void {\n if (this.#pendingOpen !== null) {\n this.#timers.clear(this.#pendingOpen);\n this.#pendingOpen = null;\n }\n }\n\n /** Cancels any pending close timer. */\n #cancelClose(): void {\n if (this.#pendingClose !== null) {\n this.#timers.clear(this.#pendingClose);\n this.#pendingClose = null;\n }\n }\n\n /** Whether the card is currently visible. */\n get #isOpen(): boolean {\n return this.hasCardTarget && !this.cardTarget.hidden;\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/utils/escape_layer.ts","../../src/utils/safe_timeout.ts","../../src/utils/scroll_dismiss.ts","../../src/controllers/hover_card_controller.ts"],"names":[],"mappings":"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","file":"hover_card_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 * Self-cleaning timer registries shared by Stimeo controllers.\n *\n * Stimulus controllers frequently schedule `setTimeout` / `setInterval` work\n * (auto-dismiss, debouncing, polling). When the element leaves the DOM — a\n * Turbo Drive navigation, a Turbo Stream replacement, or any `disconnect()` —\n * orphaned timers keep firing against a detached controller, leaking memory and\n * mutating stale state. {@link SafeTimeout} and {@link SafeInterval} track every\n * timer they create so a single {@link TimerRegistry.clearAll | clearAll()} call\n * in `disconnect()` tears them all down.\n *\n * These are intentionally low-level primitives: they own *registration and\n * cleanup only*. Higher-level policy (pause/resume, remaining-time accounting)\n * stays in the individual controllers so per-widget semantics are not flattened\n * into a lowest-common-denominator helper.\n */\n\n/**\n * Shared registry bookkeeping for the timeout/interval variants.\n *\n * Subclasses provide the scheduling primitive ({@link schedule}) and its matching\n * canceller ({@link cancel}); this base owns the set of live ids plus the\n * per-id and bulk teardown shared by both.\n */\nabstract class TimerRegistry {\n /** Live timer ids that have not yet been cleared (or, for timeouts, fired). */\n protected readonly ids = new Set<number>();\n\n /** Schedules the underlying platform timer and returns its id. */\n protected abstract schedule(callback: () => void, delay: number): number;\n\n /** Cancels the underlying platform timer for `id`. */\n protected abstract cancel(id: number): void;\n\n /**\n * Cancels a single tracked timer.\n *\n * No-ops if the id is unknown (already cleared, fired, or never owned by this\n * registry), so callers can clear defensively without guarding.\n */\n clear(id: number): void {\n if (this.ids.delete(id)) {\n this.cancel(id);\n }\n }\n\n /**\n * Cancels every tracked timer. Call this from a controller's `disconnect()`\n * to guarantee no timer outlives the element.\n */\n clearAll(): void {\n for (const id of this.ids) {\n this.cancel(id);\n }\n this.ids.clear();\n }\n\n /** Number of timers currently tracked (pending). */\n get size(): number {\n return this.ids.size;\n }\n}\n\n/**\n * `setTimeout` wrapper that auto-forgets each timer once it fires and supports\n * bulk teardown on disconnect.\n *\n * @example\n * ```ts\n * #timers = new SafeTimeout();\n *\n * connect() {\n * this.#timers.set(() => this.dismiss(), 5000);\n * }\n *\n * disconnect() {\n * this.#timers.clearAll();\n * }\n * ```\n */\nexport class SafeTimeout extends TimerRegistry {\n /**\n * Schedules `callback` after `delay` ms and returns the timer id.\n *\n * The id is removed from the registry automatically when the timeout fires,\n * so {@link TimerRegistry.size | size} reflects only still-pending timers.\n */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(() => {\n this.ids.delete(id);\n callback();\n }, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setTimeout(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearTimeout(id);\n }\n}\n\n/**\n * `setInterval` wrapper that tracks every interval for bulk teardown on\n * disconnect. Unlike {@link SafeTimeout}, intervals are retained until they are\n * explicitly cleared because they fire repeatedly.\n *\n * @example\n * ```ts\n * #intervals = new SafeInterval();\n *\n * connect() {\n * this.#intervals.set(() => this.tick(), 1000);\n * }\n *\n * disconnect() {\n * this.#intervals.clearAll();\n * }\n * ```\n */\nexport class SafeInterval extends TimerRegistry {\n /** Schedules a repeating `callback` every `delay` ms and returns the timer id. */\n set(callback: () => void, delay: number): number {\n const id = this.schedule(callback, delay);\n this.ids.add(id);\n return id;\n }\n\n protected schedule(callback: () => void, delay: number): number {\n return window.setInterval(callback, delay);\n }\n\n protected cancel(id: number): void {\n window.clearInterval(id);\n }\n}\n","/**\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 { EscapeLayer } from \"../utils/escape_layer\";\nimport { SafeTimeout } from \"../utils/safe_timeout\";\nimport { observeScrollDismiss } from \"../utils/scroll_dismiss\";\n\n/**\n * Headless, accessible **hover card** behavior.\n *\n * Markup contract (identifier: `stimeo--hover-card`):\n * <span data-controller=\"stimeo--hover-card\">\n * <a href=\"/users/jane\" data-stimeo--hover-card-target=\"trigger\"\n * aria-expanded=\"false\" aria-controls=\"hc\"\n * data-action=\"mouseenter->stimeo--hover-card#open\n * mouseleave->stimeo--hover-card#close\n * focusin->stimeo--hover-card#open\n * focusout->stimeo--hover-card#close\">@jane</a>\n * <div id=\"hc\" data-stimeo--hover-card-target=\"card\"\n * data-action=\"mouseenter->stimeo--hover-card#open\n * mouseleave->stimeo--hover-card#close\n * focusin->stimeo--hover-card#open\n * focusout->stimeo--hover-card#close\" hidden>…</div>\n * </span>\n *\n * There is no dedicated APG pattern; this follows the **Disclosure** convention\n * (`aria-expanded`) for a hover/focus-opened, non-modal popover that *may* hold\n * interactive content (unlike a tooltip). The card is **not** a `role=\"dialog\"`:\n * it is supplementary, so its content must also be reachable from the trigger\n * itself. For a short text hint use `stimeo--tooltip`; for a click-opened\n * action panel use `stimeo--popover`.\n *\n * @remarks\n * Behavior only — placement is the consumer's CSS (static) or the opt-in\n * `stimeo-ui/positioning` module (dynamic); this controller never imports it.\n *\n * Behavior provided:\n * - Open on `mouseenter`/`focusin`, close on `mouseleave`/`focusout`, each gated by\n * `openDelay`/`closeDelay` to prevent accidental flicker.\n * - **Hoverable bridge**: binding open/close on the card cancels a pending close\n * when the pointer crosses into it. Matching focus actions on the card cancel\n * the trigger's pending close while focus is inside, then schedule close once\n * focus leaves the whole controller.\n * - **Dismissible**: while open, the card joins the shared {@link EscapeLayer}\n * stack, so `Escape` closes it regardless of where focus sits (card, trigger,\n * or elsewhere). The resolver ignores an Escape already consumed by an inner\n * handler and lets the most recently shown layer own the press, so one\n * keypress closes exactly one layer.\n * - Open/closed flips the trigger's `aria-expanded`, the card's `hidden`, and a\n * `data-state` (`open`/`closed`). Focus is never stolen on open.\n * - Opt-in **dismiss on scroll** (`closeOnScroll`): while open, scrolling a tracked\n * scroll-parent ancestor (or the window) closes the card — the Radix / floating-ui\n * convention. Covers keyboard/programmatic scroll and scrollbar-drag, which the\n * pointer-leave close cannot. Off by default.\n */\nexport class HoverCardController extends Controller<HTMLElement> {\n static override targets = [\"trigger\", \"card\"];\n static override values = {\n openDelay: { type: Number, default: 300 },\n closeDelay: { type: Number, default: 200 },\n closeOnScroll: { type: Boolean, default: false },\n };\n static actions = [\"close\", \"open\"] as const;\n\n declare readonly triggerTarget: HTMLElement;\n declare readonly cardTarget: HTMLElement;\n declare readonly hasTriggerTarget: boolean;\n declare readonly hasCardTarget: boolean;\n declare readonly openDelayValue: number;\n declare readonly closeDelayValue: number;\n declare readonly closeOnScrollValue: boolean;\n\n /** Pending open/close timers, with their IDs reset on every lifecycle boundary. */\n readonly #timers = new SafeTimeout();\n /** Escape-stack membership while open; the shared resolver dismisses via it. */\n readonly #escapeLayer = new EscapeLayer();\n #pendingOpen: number | null = null;\n #pendingClose: number | null = null;\n /** Cleanup for the dismiss-on-scroll listeners while open, or `null`. */\n #stopScrollDismiss: (() => void) | null = null;\n\n /** Starts closed and discards any stale pending state from a prior connection. */\n override connect(): void {\n this.#cancelOpen();\n this.#cancelClose();\n this.#conceal();\n }\n\n /** Clears timers, the Escape-stack membership, and scroll listeners so nothing outlives the element. */\n override disconnect(): void {\n this.#cancelOpen();\n this.#cancelClose();\n this.#escapeLayer.deactivate();\n this.#stopScrollDismiss?.();\n this.#stopScrollDismiss = null;\n }\n\n /** Opens the card, after `openDelay` ms (or immediately at 0). Cancels a pending close. */\n open(): void {\n this.#cancelClose();\n if (this.#isOpen || this.#pendingOpen !== null) return;\n if (this.openDelayValue <= 0) {\n this.#reveal();\n return;\n }\n this.#pendingOpen = this.#timers.set(() => {\n this.#pendingOpen = null;\n this.#reveal();\n }, this.openDelayValue);\n }\n\n /**\n * Schedules the card to close after `closeDelay`. Cancels a pending open. The\n * delayed callback re-checks whether focus has landed inside the controller\n * (e.g. a link in the card) and, if so, aborts the close — covering keyboard\n * traversal that the pointer-only hoverable bridge cannot.\n */\n close(): void {\n this.#cancelOpen();\n if (!this.#isOpen || this.#pendingClose !== null) return;\n this.#pendingClose = this.#timers.set(() => {\n this.#pendingClose = null;\n if (this.element.contains(document.activeElement)) return;\n this.#conceal();\n }, this.closeDelayValue);\n }\n\n /** Reveals the card, reflects state, and joins the Escape stack / scroll watcher. */\n #reveal(): void {\n if (!this.hasCardTarget) return;\n this.cardTarget.hidden = false;\n this.cardTarget.setAttribute(\"data-state\", \"open\");\n if (this.hasTriggerTarget) this.triggerTarget.setAttribute(\"aria-expanded\", \"true\");\n // No claims predicate: hover-revealed content is dismissible regardless of\n // where focus sits (WCAG 2.2 SC 1.4.13), so it always claims while open.\n this.#escapeLayer.activate(document, { onDismiss: () => this.#dismiss() });\n if (this.closeOnScrollValue && !this.#stopScrollDismiss) {\n this.#stopScrollDismiss = observeScrollDismiss(this.element, () => this.#dismiss());\n }\n }\n\n /** Hides the card, reflects state, and leaves the Escape stack / scroll watcher. */\n #conceal(): void {\n // Release the layer and observers first, unconditionally: if the card\n // target was removed from the DOM while open, an early return would leak\n // the stack entry and the scroll-dismiss listeners.\n this.#escapeLayer.deactivate();\n this.#stopScrollDismiss?.();\n this.#stopScrollDismiss = null;\n if (this.hasCardTarget) {\n this.cardTarget.hidden = true;\n this.cardTarget.setAttribute(\"data-state\", \"closed\");\n }\n if (this.hasTriggerTarget) this.triggerTarget.setAttribute(\"aria-expanded\", \"false\");\n }\n\n /** Cancels pending timers and conceals immediately (shared Escape path). */\n #dismiss(): void {\n this.#cancelOpen();\n this.#cancelClose();\n this.#conceal();\n }\n\n /** Cancels any pending open timer. */\n #cancelOpen(): void {\n if (this.#pendingOpen !== null) {\n this.#timers.clear(this.#pendingOpen);\n this.#pendingOpen = null;\n }\n }\n\n /** Cancels any pending close timer. */\n #cancelClose(): void {\n if (this.#pendingClose !== null) {\n this.#timers.clear(this.#pendingClose);\n this.#pendingClose = null;\n }\n }\n\n /** Whether the card is currently visible. */\n get #isOpen(): boolean {\n return this.hasCardTarget && !this.cardTarget.hidden;\n }\n}\n"]}
|
|
@@ -3,10 +3,17 @@ import { Controller } from '@hotwired/stimulus';
|
|
|
3
3
|
// src/controllers/intersection_controller.ts
|
|
4
4
|
|
|
5
5
|
// src/utils/intersection_watcher.ts
|
|
6
|
+
function isBeforeRootStart(entry) {
|
|
7
|
+
const rect = entry.boundingClientRect;
|
|
8
|
+
if (rect.width === 0 && rect.height === 0) return false;
|
|
9
|
+
const rootTop = entry.rootBounds?.top ?? 0;
|
|
10
|
+
return rect.bottom <= rootTop;
|
|
11
|
+
}
|
|
6
12
|
var IntersectionWatcher = class {
|
|
7
13
|
#onEntries;
|
|
8
14
|
#observer = null;
|
|
9
15
|
#active = false;
|
|
16
|
+
#usingPlatformDefaults = false;
|
|
10
17
|
constructor(onEntries) {
|
|
11
18
|
this.#onEntries = onEntries;
|
|
12
19
|
}
|
|
@@ -14,10 +21,22 @@ var IntersectionWatcher = class {
|
|
|
14
21
|
get active() {
|
|
15
22
|
return this.#active;
|
|
16
23
|
}
|
|
24
|
+
/** Whether the live observer discarded configured options after construction failed. */
|
|
25
|
+
get usingPlatformDefaults() {
|
|
26
|
+
return this.#usingPlatformDefaults;
|
|
27
|
+
}
|
|
17
28
|
/**
|
|
18
29
|
* (Re)creates the observer and observes `targets`. Returns `false` — leaving
|
|
19
30
|
* the watcher inert — without `IntersectionObserver` support (very old
|
|
20
31
|
* browsers; the caller's no-JS fallback stays in charge) or with no targets.
|
|
32
|
+
* If initial construction with the configured options fails, the watcher
|
|
33
|
+
* warns and retries once with the same root and platform defaults.
|
|
34
|
+
*
|
|
35
|
+
* @throws The fallback constructor error if both construction attempts fail,
|
|
36
|
+
* or whatever the platform throws from `observe()`. The exception is passed
|
|
37
|
+
* through unchanged, but the watcher rolls back first: every target observed
|
|
38
|
+
* so far is released and `active` stays `false`, so a caller that retries
|
|
39
|
+
* starts from a clean slate.
|
|
21
40
|
*/
|
|
22
41
|
start(targets, options = {}) {
|
|
23
42
|
this.stop();
|
|
@@ -25,31 +44,61 @@ var IntersectionWatcher = class {
|
|
|
25
44
|
const list = Array.isArray(targets) ? targets : [targets];
|
|
26
45
|
if (list.length === 0) return false;
|
|
27
46
|
const root = "root" in options ? options.root ?? null : options.rootSelector ? document.querySelector(options.rootSelector) : null;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
(entries) => {
|
|
31
|
-
if (this.#active) this.#onEntries(entries);
|
|
32
|
-
}
|
|
33
|
-
{
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
47
|
+
let observer = null;
|
|
48
|
+
try {
|
|
49
|
+
const onEntries = (entries) => {
|
|
50
|
+
if (this.#active && this.#observer === observer) this.#onEntries(entries);
|
|
51
|
+
};
|
|
52
|
+
try {
|
|
53
|
+
observer = new IntersectionObserver(onEntries, {
|
|
54
|
+
root,
|
|
55
|
+
rootMargin: options.rootMargin,
|
|
56
|
+
threshold: options.threshold
|
|
57
|
+
});
|
|
58
|
+
} catch (error) {
|
|
59
|
+
console.warn(
|
|
60
|
+
"Stimeo UI: IntersectionObserver could not be constructed with the configured options; retrying with platform defaults.",
|
|
61
|
+
error
|
|
62
|
+
);
|
|
63
|
+
observer = new IntersectionObserver(onEntries, { root });
|
|
64
|
+
this.#usingPlatformDefaults = true;
|
|
65
|
+
}
|
|
66
|
+
for (const target of list) observer.observe(target);
|
|
67
|
+
this.#observer = observer;
|
|
68
|
+
this.#active = true;
|
|
69
|
+
return true;
|
|
70
|
+
} catch (error) {
|
|
71
|
+
observer?.disconnect();
|
|
72
|
+
this.#observer = null;
|
|
73
|
+
this.#active = false;
|
|
74
|
+
this.#usingPlatformDefaults = false;
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
37
77
|
}
|
|
38
78
|
/**
|
|
39
79
|
* Re-delivers `target`'s CURRENT intersection state: `IntersectionObserver`
|
|
40
80
|
* only reports *changes*, but `observe()` always reports the present state,
|
|
41
81
|
* so unobserve→observe turns "still intersecting" into a fresh callback.
|
|
82
|
+
*
|
|
83
|
+
* @throws Whatever `unobserve()`/`observe()` throws. The watcher is stopped
|
|
84
|
+
* first, so it never stays live with a half-rearmed target.
|
|
42
85
|
*/
|
|
43
86
|
rearm(target) {
|
|
44
87
|
if (!this.#observer) return;
|
|
45
|
-
|
|
46
|
-
|
|
88
|
+
try {
|
|
89
|
+
this.#observer.unobserve(target);
|
|
90
|
+
this.#observer.observe(target);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
this.stop();
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
47
95
|
}
|
|
48
96
|
/** Severs the observer; late queued callbacks become no-ops via the guard. */
|
|
49
97
|
stop() {
|
|
50
98
|
this.#active = false;
|
|
51
99
|
this.#observer?.disconnect();
|
|
52
100
|
this.#observer = null;
|
|
101
|
+
this.#usingPlatformDefaults = false;
|
|
53
102
|
}
|
|
54
103
|
};
|
|
55
104
|
|
|
@@ -68,6 +117,8 @@ var IntersectionController = class extends Controller {
|
|
|
68
117
|
static events = ["enter", "exit", "change", "passed"];
|
|
69
118
|
/** Shared IO plumbing (support guard, root resolution, active guard, re-arm). */
|
|
70
119
|
#watcher = new IntersectionWatcher((entries) => this.#onIntersect(entries));
|
|
120
|
+
/** Threshold actually installed in the live observer (0 after option fallback). */
|
|
121
|
+
#effectiveThreshold = 0;
|
|
71
122
|
/** Bumped by `refresh()`: an in-flight batch becomes stale and stops. */
|
|
72
123
|
#generation = 0;
|
|
73
124
|
#onIntersect(entries) {
|
|
@@ -75,21 +126,23 @@ var IntersectionController = class extends Controller {
|
|
|
75
126
|
for (const entry of entries) {
|
|
76
127
|
if (!this.#watcher.active || this.#generation !== generation) return;
|
|
77
128
|
const ratio = entry.intersectionRatio;
|
|
78
|
-
const threshold = this.#
|
|
129
|
+
const threshold = this.#effectiveThreshold;
|
|
79
130
|
const intersecting = threshold > 0 ? entry.isIntersecting && ratio >= threshold - RATIO_EPSILON : entry.isIntersecting;
|
|
80
131
|
this.element.style.setProperty(RATIO_PROPERTY, String(ratio));
|
|
81
132
|
this.dispatch("change", { detail: { intersecting, ratio } });
|
|
82
133
|
this.#syncIntersecting(intersecting, ratio, entry);
|
|
83
|
-
this.#syncPassed(!intersecting &&
|
|
134
|
+
this.#syncPassed(!intersecting && isBeforeRootStart(entry));
|
|
84
135
|
}
|
|
85
136
|
}
|
|
86
137
|
connect() {
|
|
87
138
|
if (this.onceValue && this.element.getAttribute("data-intersecting") === "true") return;
|
|
139
|
+
this.#effectiveThreshold = this.#clampedThreshold();
|
|
88
140
|
this.#watcher.start(this.element, {
|
|
89
141
|
rootSelector: this.rootSelectorValue,
|
|
90
142
|
rootMargin: this.rootMarginValue,
|
|
91
143
|
threshold: this.#thresholds()
|
|
92
144
|
});
|
|
145
|
+
if (this.#watcher.usingPlatformDefaults) this.#effectiveThreshold = 0;
|
|
93
146
|
}
|
|
94
147
|
disconnect() {
|
|
95
148
|
this.#watcher.stop();
|
|
@@ -128,7 +181,7 @@ var IntersectionController = class extends Controller {
|
|
|
128
181
|
if (this.onceValue) this.#watcher.stop();
|
|
129
182
|
} else if (!intersecting && previous === "true") {
|
|
130
183
|
this.dispatch("exit", {
|
|
131
|
-
detail: { ratio, position:
|
|
184
|
+
detail: { ratio, position: isBeforeRootStart(entry) ? "before" : "after" }
|
|
132
185
|
});
|
|
133
186
|
}
|
|
134
187
|
}
|
|
@@ -144,11 +197,6 @@ var IntersectionController = class extends Controller {
|
|
|
144
197
|
const changed = previous === null ? passed : previous === "true" !== passed;
|
|
145
198
|
if (changed) this.dispatch("passed", { detail: { passed } });
|
|
146
199
|
}
|
|
147
|
-
/** True when the element sits entirely before the root's start (top) edge. */
|
|
148
|
-
#isBefore(entry) {
|
|
149
|
-
const rootTop = entry.rootBounds?.top ?? 0;
|
|
150
|
-
return entry.boundingClientRect.bottom <= rootTop;
|
|
151
|
-
}
|
|
152
200
|
/** The configured `threshold`, clamped to the 0..1 the observer accepts. */
|
|
153
201
|
#clampedThreshold() {
|
|
154
202
|
return Math.min(1, Math.max(0, this.thresholdValue));
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/utils/intersection_watcher.ts","../../src/controllers/intersection_controller.ts"],"names":[],"mappings":"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","file":"intersection_controller.js","sourcesContent":["/**\n * Shared `IntersectionObserver` plumbing for Stimeo's scroll-triggered\n * controllers (`intersection`, `scrollspy`, `sticky-observer`, `lazy-frame`).\n *\n * It centralizes the `IntersectionObserver` support guard, root resolution from\n * a selector, observer creation/teardown, the **active guard** (the browser may\n * flush a final queued callback batch right after `disconnect()`, and a\n * detached controller must not mutate possibly-cached DOM), and the\n * unobserve→observe **re-arm** that re-delivers the current state even when the\n * target never leaves the viewport.\n *\n * Like {@link RovingTabindex} and `FocusTrap`, this is a policy-free internal\n * util: what an intersection *means* (a spied link, a stuck header, a lazy\n * load) stays in each controller. The public `stimeo--intersection` controller\n * is its thin declarative face.\n */\nexport interface IntersectionWatchOptions {\n /**\n * The observation root. Pass an element (or `null` for the viewport) when\n * the caller already resolved it; omit to resolve from `rootSelector`.\n */\n root?: Element | null;\n /** Selector for the observation root; empty/omitted = viewport. */\n rootSelector?: string;\n rootMargin?: string;\n threshold?: number | number[];\n}\n\nexport class IntersectionWatcher {\n readonly #onEntries: (entries: IntersectionObserverEntry[]) => void;\n #observer: IntersectionObserver | null = null;\n #active = false;\n\n constructor(onEntries: (entries: IntersectionObserverEntry[]) => void) {\n this.#onEntries = onEntries;\n }\n\n /** Whether an observer is live (started, `IntersectionObserver` supported). */\n get active(): boolean {\n return this.#active;\n }\n\n /**\n * (Re)creates the observer and observes `targets`. Returns `false` — leaving\n * the watcher inert — without `IntersectionObserver` support (very old\n * browsers; the caller's no-JS fallback stays in charge) or with no targets.\n */\n start(targets: Element | readonly Element[], options: IntersectionWatchOptions = {}): boolean {\n this.stop();\n if (typeof IntersectionObserver === \"undefined\") return false;\n const list = Array.isArray(targets) ? (targets as readonly Element[]) : [targets as Element];\n if (list.length === 0) return false;\n\n const root =\n \"root\" in options\n ? (options.root ?? null)\n : options.rootSelector\n ? document.querySelector(options.rootSelector)\n : null;\n\n this.#active = true;\n this.#observer = new IntersectionObserver(\n (entries) => {\n if (this.#active) this.#onEntries(entries);\n },\n { root, rootMargin: options.rootMargin, threshold: options.threshold },\n );\n for (const target of list) this.#observer.observe(target);\n return true;\n }\n\n /**\n * Re-delivers `target`'s CURRENT intersection state: `IntersectionObserver`\n * only reports *changes*, but `observe()` always reports the present state,\n * so unobserve→observe turns \"still intersecting\" into a fresh callback.\n */\n rearm(target: Element): void {\n if (!this.#observer) return;\n this.#observer.unobserve(target);\n this.#observer.observe(target);\n }\n\n /** Severs the observer; late queued callbacks become no-ops via the guard. */\n stop(): void {\n this.#active = false;\n this.#observer?.disconnect();\n this.#observer = null;\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { IntersectionWatcher } from \"../utils/intersection_watcher\";\n\n/** Name of the CSS custom property exposing the visible ratio (0..1). */\nconst RATIO_PROPERTY = \"--stimeo--intersection-ratio\";\n\n/**\n * Tolerance for the visibility test. Real observers can report a ratio a hair\n * below the configured threshold at that threshold's own crossing callback\n * (fractional device pixels / zoom), most visibly at threshold 1 where \"fully\n * visible\" may arrive as 0.99x — a strict `>=` would then never see it.\n */\nconst RATIO_EPSILON = 0.01;\n\n/**\n * Headless **intersection primitive**: a thin declarative wrapper over\n * {@link IntersectionObserver} that turns viewport visibility into events and\n * state hooks. It is the scroll-triggered building block that infinite-scroll,\n * reading-progress, count-up (\"animate when visible\") and smart sticky headers\n * compose from, without each writing its own observer. No APG widget — a pure\n * state-detection utility. Core (zero dependencies).\n *\n * Markup contract (identifier: `stimeo--intersection`):\n * <div data-controller=\"stimeo--intersection\"\n * data-stimeo--intersection-root-margin-value=\"200px\"\n * data-action=\"stimeo--intersection:enter->feed#loadNextPage\"></div>\n *\n * The controller observes its own element. `enter` fires when the element\n * becomes visible (intersection ratio reaches `threshold`), `exit` when it\n * leaves (detail carries `position`: `\"before\"` = scrolled past the root's\n * start edge, `\"after\"` = still ahead), `change` on every observed update\n * (detail `{ intersecting, ratio }` — set `ratioSteps` for fine-grained ratio\n * reporting), and `passed` when the element fully crosses the root's start edge\n * in either direction (detail `{ passed }` — the sticky/progress line). The\n * visibility is mirrored as `data-intersecting`/`data-passed` and the ratio as\n * the `--stimeo--intersection-ratio` custom property for consumer CSS.\n *\n * @remarks\n * Behavior only — what visibility *means* (load a page, start an animation,\n * pin a header) belongs to the consumer via `data-action`/CSS. `connect()` is\n * idempotent: the previous state is read back from `data-intersecting`/\n * `data-passed`, so a Turbo cache restore does not re-fire `enter` for an\n * element that was already visible (and with `once`, an element whose enter\n * already fired is not observed again). Without `IntersectionObserver` (very\n * old browsers) the controller stays inert — consumers keep whatever no-JS\n * fallback their markup provides. The observer is disconnected on\n * `disconnect()` (Turbo navigation included).\n */\nexport class IntersectionController extends Controller<HTMLElement> {\n static override values = {\n threshold: { type: Number, default: 0 },\n ratioSteps: { type: Number, default: 0 },\n rootMargin: { type: String, default: \"0px\" },\n rootSelector: { type: String, default: \"\" },\n once: { type: Boolean, default: false },\n };\n static actions = [\"refresh\"] as const;\n static events = [\"enter\", \"exit\", \"change\", \"passed\"] as const;\n\n declare thresholdValue: number;\n declare ratioStepsValue: number;\n declare rootMarginValue: string;\n declare rootSelectorValue: string;\n declare onceValue: boolean;\n\n /** Shared IO plumbing (support guard, root resolution, active guard, re-arm). */\n readonly #watcher = new IntersectionWatcher((entries) => this.#onIntersect(entries));\n /** Bumped by `refresh()`: an in-flight batch becomes stale and stops. */\n #generation = 0;\n\n #onIntersect(entries: IntersectionObserverEntry[]): void {\n // A single callback can batch several transitions for the same target\n // (delivery lagging behind a fast scroll), so process every entry in\n // order — collapsing to the last one alone would drop an enter→exit pair\n // and, under `once`, lose the one-shot enter entirely. If a handler calls\n // `refresh()` mid-batch (enter → append content → re-arm), the remaining\n // entries describe a state `refresh` just reset — replaying them would\n // re-fire `enter` for the same visibility episode — so the generation\n // bump abandons them and the re-observation delivers the fresh state\n // (`once` stopping the watcher mid-batch is caught by the active check).\n const generation = this.#generation;\n for (const entry of entries) {\n if (!this.#watcher.active || this.#generation !== generation) return;\n\n const ratio = entry.intersectionRatio;\n // `isIntersecting` is geometric (\"any overlap\"), so a non-zero `threshold`\n // (\"counts as visible at ≥N%\") must be applied to the ratio ourselves —\n // against the same 0..1-clamped value the observer was configured with, or\n // a `threshold` above 1 would make `intersecting` unreachable while the\n // observer still fires at ratio 1. The epsilon absorbs subpixel rounding\n // (see RATIO_EPSILON); keeping the geometric `isIntersecting` conjunct\n // stops it from underflowing a tiny threshold into \"always visible\".\n const threshold = this.#clampedThreshold();\n const intersecting =\n threshold > 0\n ? entry.isIntersecting && ratio >= threshold - RATIO_EPSILON\n : entry.isIntersecting;\n\n this.element.style.setProperty(RATIO_PROPERTY, String(ratio));\n this.dispatch(\"change\", { detail: { intersecting, ratio } });\n this.#syncIntersecting(intersecting, ratio, entry);\n this.#syncPassed(!intersecting && this.#isBefore(entry));\n }\n }\n\n override connect(): void {\n // A cache restore may bring back an element whose one-shot enter already\n // fired; honor it instead of re-observing (mirrors `data-lazy-loaded`).\n if (this.onceValue && this.element.getAttribute(\"data-intersecting\") === \"true\") return;\n this.#watcher.start(this.element, {\n rootSelector: this.rootSelectorValue,\n rootMargin: this.rootMarginValue,\n threshold: this.#thresholds(),\n });\n }\n\n override disconnect(): void {\n this.#watcher.stop();\n }\n\n /**\n * Re-delivers the current intersection state as a fresh transition. Bound via\n * `data-action` (e.g. `my-feed:appended@window->stimeo--intersection#refresh`).\n *\n * `IntersectionObserver` only reports state *changes*, so a sentinel that\n * stays visible while content is appended below it never fires `enter` again\n * and a hand-rolled infinite scroll stalls. `observe()` always delivers the\n * current state, and clearing the recorded `data-intersecting`/`data-passed`\n * makes that delivery count as a transition — a still-visible sentinel\n * re-fires `enter`. No-op once the observer is gone (`once` fired, no\n * `IntersectionObserver` support, or after `disconnect()`).\n */\n refresh(): void {\n if (!this.#watcher.active) return;\n this.#generation += 1;\n this.element.removeAttribute(\"data-intersecting\");\n this.element.removeAttribute(\"data-passed\");\n this.#watcher.rearm(this.element);\n }\n\n /**\n * Reflects the visibility onto `data-intersecting` and fires `enter`/`exit`\n * on transitions. The previous state is the DOM attribute (source of truth),\n * so the observer's initial callback fires `enter` for an element that starts\n * visible but stays silent after a cache restore that already recorded it.\n * An initial not-visible state is established silently (no `exit`).\n */\n #syncIntersecting(intersecting: boolean, ratio: number, entry: IntersectionObserverEntry): void {\n const previous = this.element.getAttribute(\"data-intersecting\");\n this.element.setAttribute(\"data-intersecting\", intersecting ? \"true\" : \"false\");\n\n if (intersecting && previous !== \"true\") {\n this.dispatch(\"enter\", { detail: { ratio } });\n // One-shot mode: the enter fired; stop observing and leave the hooks in\n // their final state (`data-intersecting=\"true\"` marks it for reconnects).\n if (this.onceValue) this.#watcher.stop();\n } else if (!intersecting && previous === \"true\") {\n this.dispatch(\"exit\", {\n detail: { ratio, position: this.#isBefore(entry) ? \"before\" : \"after\" },\n });\n }\n }\n\n /**\n * Reflects the \"scrolled past\" state onto `data-passed` and fires `passed` on\n * transitions — the line sticky headers and reading progress key off. Like\n * `enter`, an initial `passed=true` (page restored mid-scroll) fires; the\n * initial `false` is established silently.\n */\n #syncPassed(passed: boolean): void {\n const previous = this.element.getAttribute(\"data-passed\");\n this.element.setAttribute(\"data-passed\", passed ? \"true\" : \"false\");\n const changed = previous === null ? passed : (previous === \"true\") !== passed;\n if (changed) this.dispatch(\"passed\", { detail: { passed } });\n }\n\n /** True when the element sits entirely before the root's start (top) edge. */\n #isBefore(entry: IntersectionObserverEntry): boolean {\n // rootBounds is null for a cross-origin/removed root; fall back to the\n // viewport origin.\n const rootTop = entry.rootBounds?.top ?? 0;\n return entry.boundingClientRect.bottom <= rootTop;\n }\n\n /** The configured `threshold`, clamped to the 0..1 the observer accepts. */\n #clampedThreshold(): number {\n return Math.min(1, Math.max(0, this.thresholdValue));\n }\n\n /**\n * Observer thresholds: the `threshold` line itself, plus `ratioSteps` evenly\n * spaced steps when fine-grained `change` ratios are wanted (progress bars).\n */\n #thresholds(): number[] {\n const thresholds = new Set<number>([this.#clampedThreshold()]);\n if (this.ratioStepsValue > 0) {\n // i counts up to ratioSteps, so i/ratioSteps is inherently 0..1.\n for (let i = 0; i <= this.ratioStepsValue; i += 1) {\n thresholds.add(i / this.ratioStepsValue);\n }\n }\n return [...thresholds].sort((a, b) => a - b);\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/utils/intersection_watcher.ts","../../src/controllers/intersection_controller.ts"],"names":[],"mappings":"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","file":"intersection_controller.js","sourcesContent":["/**\n * Shared `IntersectionObserver` plumbing for Stimeo's scroll-triggered\n * controllers (`intersection`, `scrollspy`, `sticky-observer`, `lazy-frame`).\n *\n * It centralizes the `IntersectionObserver` support guard, root resolution from\n * a selector, observer creation/teardown, the **active guard** (the browser may\n * flush a final queued callback batch right after `disconnect()`, and a\n * detached controller must not mutate possibly-cached DOM), and the\n * unobserve→observe **re-arm** that re-delivers the current state even when the\n * target never leaves the viewport.\n *\n * Like {@link RovingTabindex} and `FocusTrap`, this is a policy-free internal\n * util: what an intersection *means* (a spied link, a stuck header, a lazy\n * load) stays in each controller. The public `stimeo--intersection` controller\n * is its thin declarative face.\n */\n/**\n * Whether `entry`'s target sits entirely before the root's **start (top)** edge —\n * the \"scrolled past the top\" half of a non-intersecting entry, as opposed to\n * \"not reached yet\" below the root.\n *\n * A target with no layout box (`display: none`, a `hidden` ancestor, a collapsed\n * `<details>`) is reported with an **empty rect**, whose `bottom` of `0` would\n * otherwise satisfy `bottom <= rootTop` for a viewport root and read as \"passed\"\n * even though the target was never scrolled anywhere. An empty rect carries no\n * position at all, so it is deliberately never \"before the edge\"; what a caller\n * publishes for that case is its own policy (both consumers treat it as the\n * neutral \"not passed\"/\"not stuck\", and the real rect that arrives once the\n * target is laid out re-establishes the true state).\n */\nexport function isBeforeRootStart(entry: IntersectionObserverEntry): boolean {\n const rect = entry.boundingClientRect;\n if (rect.width === 0 && rect.height === 0) return false;\n // rootBounds is null for a cross-origin/removed root; fall back to the\n // viewport origin.\n const rootTop = entry.rootBounds?.top ?? 0;\n return rect.bottom <= rootTop;\n}\n\nexport interface IntersectionWatchOptions {\n /**\n * The observation root. Pass an element (or `null` for the viewport) when\n * the caller already resolved it; omit to resolve from `rootSelector`.\n */\n root?: Element | null;\n /** Selector for the observation root; empty/omitted = viewport. */\n rootSelector?: string;\n rootMargin?: string;\n threshold?: number | number[];\n}\n\nexport class IntersectionWatcher {\n readonly #onEntries: (entries: IntersectionObserverEntry[]) => void;\n #observer: IntersectionObserver | null = null;\n #active = false;\n #usingPlatformDefaults = false;\n\n constructor(onEntries: (entries: IntersectionObserverEntry[]) => void) {\n this.#onEntries = onEntries;\n }\n\n /** Whether an observer is live (started, `IntersectionObserver` supported). */\n get active(): boolean {\n return this.#active;\n }\n\n /** Whether the live observer discarded configured options after construction failed. */\n get usingPlatformDefaults(): boolean {\n return this.#usingPlatformDefaults;\n }\n\n /**\n * (Re)creates the observer and observes `targets`. Returns `false` — leaving\n * the watcher inert — without `IntersectionObserver` support (very old\n * browsers; the caller's no-JS fallback stays in charge) or with no targets.\n * If initial construction with the configured options fails, the watcher\n * warns and retries once with the same root and platform defaults.\n *\n * @throws The fallback constructor error if both construction attempts fail,\n * or whatever the platform throws from `observe()`. The exception is passed\n * through unchanged, but the watcher rolls back first: every target observed\n * so far is released and `active` stays `false`, so a caller that retries\n * starts from a clean slate.\n */\n start(targets: Element | readonly Element[], options: IntersectionWatchOptions = {}): boolean {\n this.stop();\n if (typeof IntersectionObserver === \"undefined\") return false;\n const list = Array.isArray(targets) ? (targets as readonly Element[]) : [targets as Element];\n if (list.length === 0) return false;\n\n const root =\n \"root\" in options\n ? (options.root ?? null)\n : options.rootSelector\n ? document.querySelector(options.rootSelector)\n : null;\n\n let observer: IntersectionObserver | null = null;\n try {\n const onEntries = (entries: IntersectionObserverEntry[]): void => {\n // Identity matters across an immediate restart: the old observer can\n // flush a queued batch after the new observer has made `active` true.\n if (this.#active && this.#observer === observer) this.#onEntries(entries);\n };\n try {\n observer = new IntersectionObserver(onEntries, {\n root,\n rootMargin: options.rootMargin,\n threshold: options.threshold,\n });\n } catch (error) {\n console.warn(\n \"Stimeo UI: IntersectionObserver could not be constructed with the configured options; retrying with platform defaults.\",\n error,\n );\n observer = new IntersectionObserver(onEntries, { root });\n this.#usingPlatformDefaults = true;\n }\n for (const target of list) observer.observe(target);\n this.#observer = observer;\n this.#active = true;\n return true;\n } catch (error) {\n // A constructor or partial observe failure must not leave earlier targets\n // observed or report an active watcher. Preserve the platform exception.\n observer?.disconnect();\n this.#observer = null;\n this.#active = false;\n this.#usingPlatformDefaults = false;\n throw error;\n }\n }\n\n /**\n * Re-delivers `target`'s CURRENT intersection state: `IntersectionObserver`\n * only reports *changes*, but `observe()` always reports the present state,\n * so unobserve→observe turns \"still intersecting\" into a fresh callback.\n *\n * @throws Whatever `unobserve()`/`observe()` throws. The watcher is stopped\n * first, so it never stays live with a half-rearmed target.\n */\n rearm(target: Element): void {\n if (!this.#observer) return;\n try {\n this.#observer.unobserve(target);\n this.#observer.observe(target);\n } catch (error) {\n this.stop();\n throw error;\n }\n }\n\n /** Severs the observer; late queued callbacks become no-ops via the guard. */\n stop(): void {\n this.#active = false;\n this.#observer?.disconnect();\n this.#observer = null;\n this.#usingPlatformDefaults = false;\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { IntersectionWatcher, isBeforeRootStart } from \"../utils/intersection_watcher\";\n\n/** Name of the CSS custom property exposing the visible ratio (0..1). */\nconst RATIO_PROPERTY = \"--stimeo--intersection-ratio\";\n\n/**\n * Tolerance for the visibility test. Real observers can report a ratio a hair\n * below the configured threshold at that threshold's own crossing callback\n * (fractional device pixels / zoom), most visibly at threshold 1 where \"fully\n * visible\" may arrive as 0.99x — a strict `>=` would then never see it.\n */\nconst RATIO_EPSILON = 0.01;\n\n/**\n * Headless **intersection primitive**: a thin declarative wrapper over\n * {@link IntersectionObserver} that turns viewport visibility into events and\n * state hooks. It is the scroll-triggered building block that infinite-scroll,\n * reading-progress, count-up (\"animate when visible\") and smart sticky headers\n * compose from, without each writing its own observer. No APG widget — a pure\n * state-detection utility. Core (zero dependencies).\n *\n * Markup contract (identifier: `stimeo--intersection`):\n * <div data-controller=\"stimeo--intersection\"\n * data-stimeo--intersection-root-margin-value=\"200px\"\n * data-action=\"stimeo--intersection:enter->feed#loadNextPage\"></div>\n *\n * The controller observes its own element. `enter` fires when the element\n * becomes visible (intersection ratio reaches `threshold`), `exit` when it\n * leaves (detail carries `position`: `\"before\"` = scrolled past the root's\n * start edge, `\"after\"` = still ahead), `change` on every observed update\n * (detail `{ intersecting, ratio }` — set `ratioSteps` for fine-grained ratio\n * reporting), and `passed` when the element fully crosses the root's start edge\n * in either direction (detail `{ passed }` — the sticky/progress line). The\n * visibility is mirrored as `data-intersecting`/`data-passed` and the ratio as\n * the `--stimeo--intersection-ratio` custom property for consumer CSS.\n *\n * @remarks\n * Behavior only — what visibility *means* (load a page, start an animation,\n * pin a header) belongs to the consumer via `data-action`/CSS. `connect()` is\n * idempotent: the previous state is read back from `data-intersecting`/\n * `data-passed`, so a Turbo cache restore does not re-fire `enter` for an\n * element that was already visible (and with `once`, an element whose enter\n * already fired is not observed again). Without `IntersectionObserver` (very\n * old browsers) the controller stays inert — consumers keep whatever no-JS\n * fallback their markup provides. The observer is disconnected on\n * `disconnect()` (Turbo navigation included).\n */\nexport class IntersectionController extends Controller<HTMLElement> {\n static override values = {\n threshold: { type: Number, default: 0 },\n ratioSteps: { type: Number, default: 0 },\n rootMargin: { type: String, default: \"0px\" },\n rootSelector: { type: String, default: \"\" },\n once: { type: Boolean, default: false },\n };\n static actions = [\"refresh\"] as const;\n static events = [\"enter\", \"exit\", \"change\", \"passed\"] as const;\n\n declare thresholdValue: number;\n declare ratioStepsValue: number;\n declare rootMarginValue: string;\n declare rootSelectorValue: string;\n declare onceValue: boolean;\n\n /** Shared IO plumbing (support guard, root resolution, active guard, re-arm). */\n readonly #watcher = new IntersectionWatcher((entries) => this.#onIntersect(entries));\n /** Threshold actually installed in the live observer (0 after option fallback). */\n #effectiveThreshold = 0;\n /** Bumped by `refresh()`: an in-flight batch becomes stale and stops. */\n #generation = 0;\n\n #onIntersect(entries: IntersectionObserverEntry[]): void {\n // A single callback can batch several transitions for the same target\n // (delivery lagging behind a fast scroll), so process every entry in\n // order — collapsing to the last one alone would drop an enter→exit pair\n // and, under `once`, lose the one-shot enter entirely. If a handler calls\n // `refresh()` mid-batch (enter → append content → re-arm), the remaining\n // entries describe a state `refresh` just reset — replaying them would\n // re-fire `enter` for the same visibility episode — so the generation\n // bump abandons them and the re-observation delivers the fresh state\n // (`once` stopping the watcher mid-batch is caught by the active check).\n const generation = this.#generation;\n for (const entry of entries) {\n if (!this.#watcher.active || this.#generation !== generation) return;\n\n const ratio = entry.intersectionRatio;\n // `isIntersecting` is geometric (\"any overlap\"), so a non-zero `threshold`\n // (\"counts as visible at ≥N%\") must be applied to the ratio ourselves —\n // against the same 0..1-clamped value the observer was configured with, or\n // a `threshold` above 1 would make `intersecting` unreachable while the\n // observer still fires at ratio 1. The epsilon absorbs subpixel rounding\n // (see RATIO_EPSILON); keeping the geometric `isIntersecting` conjunct\n // stops it from underflowing a tiny threshold into \"always visible\".\n // A constructor fallback omits the configured threshold, so the observer\n // can only notify at its effective default line (0). Applying the authored\n // line here would wait for a callback that the fallback observer never\n // schedules after an initially intersecting entry.\n const threshold = this.#effectiveThreshold;\n const intersecting =\n threshold > 0\n ? entry.isIntersecting && ratio >= threshold - RATIO_EPSILON\n : entry.isIntersecting;\n\n this.element.style.setProperty(RATIO_PROPERTY, String(ratio));\n this.dispatch(\"change\", { detail: { intersecting, ratio } });\n this.#syncIntersecting(intersecting, ratio, entry);\n this.#syncPassed(!intersecting && isBeforeRootStart(entry));\n }\n }\n\n override connect(): void {\n // A cache restore may bring back an element whose one-shot enter already\n // fired; honor it instead of re-observing (mirrors `data-lazy-loaded`).\n if (this.onceValue && this.element.getAttribute(\"data-intersecting\") === \"true\") return;\n this.#effectiveThreshold = this.#clampedThreshold();\n this.#watcher.start(this.element, {\n rootSelector: this.rootSelectorValue,\n rootMargin: this.rootMarginValue,\n threshold: this.#thresholds(),\n });\n if (this.#watcher.usingPlatformDefaults) this.#effectiveThreshold = 0;\n }\n\n override disconnect(): void {\n this.#watcher.stop();\n }\n\n /**\n * Re-delivers the current intersection state as a fresh transition. Bound via\n * `data-action` (e.g. `my-feed:appended@window->stimeo--intersection#refresh`).\n *\n * `IntersectionObserver` only reports state *changes*, so a sentinel that\n * stays visible while content is appended below it never fires `enter` again\n * and a hand-rolled infinite scroll stalls. `observe()` always delivers the\n * current state, and clearing the recorded `data-intersecting`/`data-passed`\n * makes that delivery count as a transition — a still-visible sentinel\n * re-fires `enter`. No-op once the observer is gone (`once` fired, no\n * `IntersectionObserver` support, or after `disconnect()`).\n */\n refresh(): void {\n if (!this.#watcher.active) return;\n this.#generation += 1;\n this.element.removeAttribute(\"data-intersecting\");\n this.element.removeAttribute(\"data-passed\");\n this.#watcher.rearm(this.element);\n }\n\n /**\n * Reflects the visibility onto `data-intersecting` and fires `enter`/`exit`\n * on transitions. The previous state is the DOM attribute (source of truth),\n * so the observer's initial callback fires `enter` for an element that starts\n * visible but stays silent after a cache restore that already recorded it.\n * An initial not-visible state is established silently (no `exit`).\n */\n #syncIntersecting(intersecting: boolean, ratio: number, entry: IntersectionObserverEntry): void {\n const previous = this.element.getAttribute(\"data-intersecting\");\n this.element.setAttribute(\"data-intersecting\", intersecting ? \"true\" : \"false\");\n\n if (intersecting && previous !== \"true\") {\n this.dispatch(\"enter\", { detail: { ratio } });\n // One-shot mode: the enter fired; stop observing and leave the hooks in\n // their final state (`data-intersecting=\"true\"` marks it for reconnects).\n if (this.onceValue) this.#watcher.stop();\n } else if (!intersecting && previous === \"true\") {\n this.dispatch(\"exit\", {\n detail: { ratio, position: isBeforeRootStart(entry) ? \"before\" : \"after\" },\n });\n }\n }\n\n /**\n * Reflects the \"scrolled past\" state onto `data-passed` and fires `passed` on\n * transitions — the line sticky headers and reading progress key off. Like\n * `enter`, an initial `passed=true` (page restored mid-scroll) fires; the\n * initial `false` is established silently.\n */\n #syncPassed(passed: boolean): void {\n const previous = this.element.getAttribute(\"data-passed\");\n this.element.setAttribute(\"data-passed\", passed ? \"true\" : \"false\");\n const changed = previous === null ? passed : (previous === \"true\") !== passed;\n if (changed) this.dispatch(\"passed\", { detail: { passed } });\n }\n\n /** The configured `threshold`, clamped to the 0..1 the observer accepts. */\n #clampedThreshold(): number {\n return Math.min(1, Math.max(0, this.thresholdValue));\n }\n\n /**\n * Observer thresholds: the `threshold` line itself, plus `ratioSteps` evenly\n * spaced steps when fine-grained `change` ratios are wanted (progress bars).\n */\n #thresholds(): number[] {\n const thresholds = new Set<number>([this.#clampedThreshold()]);\n if (this.ratioStepsValue > 0) {\n // i counts up to ratioSteps, so i/ratioSteps is inherently 0..1.\n for (let i = 0; i <= this.ratioStepsValue; i += 1) {\n thresholds.add(i / this.ratioStepsValue);\n }\n }\n return [...thresholds].sort((a, b) => a - b);\n }\n}\n"]}
|
|
@@ -7,6 +7,7 @@ var IntersectionWatcher = class {
|
|
|
7
7
|
#onEntries;
|
|
8
8
|
#observer = null;
|
|
9
9
|
#active = false;
|
|
10
|
+
#usingPlatformDefaults = false;
|
|
10
11
|
constructor(onEntries) {
|
|
11
12
|
this.#onEntries = onEntries;
|
|
12
13
|
}
|
|
@@ -14,10 +15,22 @@ var IntersectionWatcher = class {
|
|
|
14
15
|
get active() {
|
|
15
16
|
return this.#active;
|
|
16
17
|
}
|
|
18
|
+
/** Whether the live observer discarded configured options after construction failed. */
|
|
19
|
+
get usingPlatformDefaults() {
|
|
20
|
+
return this.#usingPlatformDefaults;
|
|
21
|
+
}
|
|
17
22
|
/**
|
|
18
23
|
* (Re)creates the observer and observes `targets`. Returns `false` — leaving
|
|
19
24
|
* the watcher inert — without `IntersectionObserver` support (very old
|
|
20
25
|
* browsers; the caller's no-JS fallback stays in charge) or with no targets.
|
|
26
|
+
* If initial construction with the configured options fails, the watcher
|
|
27
|
+
* warns and retries once with the same root and platform defaults.
|
|
28
|
+
*
|
|
29
|
+
* @throws The fallback constructor error if both construction attempts fail,
|
|
30
|
+
* or whatever the platform throws from `observe()`. The exception is passed
|
|
31
|
+
* through unchanged, but the watcher rolls back first: every target observed
|
|
32
|
+
* so far is released and `active` stays `false`, so a caller that retries
|
|
33
|
+
* starts from a clean slate.
|
|
21
34
|
*/
|
|
22
35
|
start(targets, options = {}) {
|
|
23
36
|
this.stop();
|
|
@@ -25,31 +38,61 @@ var IntersectionWatcher = class {
|
|
|
25
38
|
const list = Array.isArray(targets) ? targets : [targets];
|
|
26
39
|
if (list.length === 0) return false;
|
|
27
40
|
const root = "root" in options ? options.root ?? null : options.rootSelector ? document.querySelector(options.rootSelector) : null;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
(entries) => {
|
|
31
|
-
if (this.#active) this.#onEntries(entries);
|
|
32
|
-
}
|
|
33
|
-
{
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
41
|
+
let observer = null;
|
|
42
|
+
try {
|
|
43
|
+
const onEntries = (entries) => {
|
|
44
|
+
if (this.#active && this.#observer === observer) this.#onEntries(entries);
|
|
45
|
+
};
|
|
46
|
+
try {
|
|
47
|
+
observer = new IntersectionObserver(onEntries, {
|
|
48
|
+
root,
|
|
49
|
+
rootMargin: options.rootMargin,
|
|
50
|
+
threshold: options.threshold
|
|
51
|
+
});
|
|
52
|
+
} catch (error) {
|
|
53
|
+
console.warn(
|
|
54
|
+
"Stimeo UI: IntersectionObserver could not be constructed with the configured options; retrying with platform defaults.",
|
|
55
|
+
error
|
|
56
|
+
);
|
|
57
|
+
observer = new IntersectionObserver(onEntries, { root });
|
|
58
|
+
this.#usingPlatformDefaults = true;
|
|
59
|
+
}
|
|
60
|
+
for (const target of list) observer.observe(target);
|
|
61
|
+
this.#observer = observer;
|
|
62
|
+
this.#active = true;
|
|
63
|
+
return true;
|
|
64
|
+
} catch (error) {
|
|
65
|
+
observer?.disconnect();
|
|
66
|
+
this.#observer = null;
|
|
67
|
+
this.#active = false;
|
|
68
|
+
this.#usingPlatformDefaults = false;
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
37
71
|
}
|
|
38
72
|
/**
|
|
39
73
|
* Re-delivers `target`'s CURRENT intersection state: `IntersectionObserver`
|
|
40
74
|
* only reports *changes*, but `observe()` always reports the present state,
|
|
41
75
|
* so unobserve→observe turns "still intersecting" into a fresh callback.
|
|
76
|
+
*
|
|
77
|
+
* @throws Whatever `unobserve()`/`observe()` throws. The watcher is stopped
|
|
78
|
+
* first, so it never stays live with a half-rearmed target.
|
|
42
79
|
*/
|
|
43
80
|
rearm(target) {
|
|
44
81
|
if (!this.#observer) return;
|
|
45
|
-
|
|
46
|
-
|
|
82
|
+
try {
|
|
83
|
+
this.#observer.unobserve(target);
|
|
84
|
+
this.#observer.observe(target);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
this.stop();
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
47
89
|
}
|
|
48
90
|
/** Severs the observer; late queued callbacks become no-ops via the guard. */
|
|
49
91
|
stop() {
|
|
50
92
|
this.#active = false;
|
|
51
93
|
this.#observer?.disconnect();
|
|
52
94
|
this.#observer = null;
|
|
95
|
+
this.#usingPlatformDefaults = false;
|
|
53
96
|
}
|
|
54
97
|
};
|
|
55
98
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/utils/intersection_watcher.ts","../../src/controllers/lazy_frame_controller.ts"],"names":[],"mappings":"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","file":"lazy_frame_controller.js","sourcesContent":["/**\n * Shared `IntersectionObserver` plumbing for Stimeo's scroll-triggered\n * controllers (`intersection`, `scrollspy`, `sticky-observer`, `lazy-frame`).\n *\n * It centralizes the `IntersectionObserver` support guard, root resolution from\n * a selector, observer creation/teardown, the **active guard** (the browser may\n * flush a final queued callback batch right after `disconnect()`, and a\n * detached controller must not mutate possibly-cached DOM), and the\n * unobserve→observe **re-arm** that re-delivers the current state even when the\n * target never leaves the viewport.\n *\n * Like {@link RovingTabindex} and `FocusTrap`, this is a policy-free internal\n * util: what an intersection *means* (a spied link, a stuck header, a lazy\n * load) stays in each controller. The public `stimeo--intersection` controller\n * is its thin declarative face.\n */\nexport interface IntersectionWatchOptions {\n /**\n * The observation root. Pass an element (or `null` for the viewport) when\n * the caller already resolved it; omit to resolve from `rootSelector`.\n */\n root?: Element | null;\n /** Selector for the observation root; empty/omitted = viewport. */\n rootSelector?: string;\n rootMargin?: string;\n threshold?: number | number[];\n}\n\nexport class IntersectionWatcher {\n readonly #onEntries: (entries: IntersectionObserverEntry[]) => void;\n #observer: IntersectionObserver | null = null;\n #active = false;\n\n constructor(onEntries: (entries: IntersectionObserverEntry[]) => void) {\n this.#onEntries = onEntries;\n }\n\n /** Whether an observer is live (started, `IntersectionObserver` supported). */\n get active(): boolean {\n return this.#active;\n }\n\n /**\n * (Re)creates the observer and observes `targets`. Returns `false` — leaving\n * the watcher inert — without `IntersectionObserver` support (very old\n * browsers; the caller's no-JS fallback stays in charge) or with no targets.\n */\n start(targets: Element | readonly Element[], options: IntersectionWatchOptions = {}): boolean {\n this.stop();\n if (typeof IntersectionObserver === \"undefined\") return false;\n const list = Array.isArray(targets) ? (targets as readonly Element[]) : [targets as Element];\n if (list.length === 0) return false;\n\n const root =\n \"root\" in options\n ? (options.root ?? null)\n : options.rootSelector\n ? document.querySelector(options.rootSelector)\n : null;\n\n this.#active = true;\n this.#observer = new IntersectionObserver(\n (entries) => {\n if (this.#active) this.#onEntries(entries);\n },\n { root, rootMargin: options.rootMargin, threshold: options.threshold },\n );\n for (const target of list) this.#observer.observe(target);\n return true;\n }\n\n /**\n * Re-delivers `target`'s CURRENT intersection state: `IntersectionObserver`\n * only reports *changes*, but `observe()` always reports the present state,\n * so unobserve→observe turns \"still intersecting\" into a fresh callback.\n */\n rearm(target: Element): void {\n if (!this.#observer) return;\n this.#observer.unobserve(target);\n this.#observer.observe(target);\n }\n\n /** Severs the observer; late queued callbacks become no-ops via the guard. */\n stop(): void {\n this.#active = false;\n this.#observer?.disconnect();\n this.#observer = null;\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { IntersectionWatcher } from \"../utils/intersection_watcher\";\n\n/**\n * Headless **lazy frame**: defers a `<turbo-frame>`'s load until it nears the viewport\n * (or focus reaches it), to keep the initial render light. Turbo's own `loading=\"lazy\"`\n * fires on *render*, not on viewport entry, so this drives an explicit `IntersectionObserver`\n * with a configurable `rootMargin` for early loading, plus a focus fallback so keyboard /\n * assistive-tech users trigger the load too (no APG pattern).\n *\n * Markup contract (identifier: `stimeo--lazy-frame`):\n * <turbo-frame id=\"comments\" data-controller=\"stimeo--lazy-frame\"\n * data-stimeo--lazy-frame-url-value=\"/posts/1/comments\"\n * data-stimeo--lazy-frame-root-margin-value=\"200px\">Loading…</turbo-frame>\n *\n * The URL is *held* in the `url` value (not on `src`) so Turbo does not load it eagerly;\n * when the frame intersects (within `rootMargin`) or focus enters it, the controller\n * writes `url` to `src` — which starts the Turbo load — marks `data-lazy-loaded`, and\n * emits `load`. With `once` (default) it then stops observing; otherwise re-entry asks\n * Turbo to `reload()` the frame.\n *\n * @remarks\n * Behavior only — the load itself and the frame's content are Turbo's / the server's job,\n * and the loading UI (skeleton / `aria-busy`) belongs to Frame Loading State. The trigger\n * is idempotent (`data-lazy-loaded` guards a double load and is honored on a Turbo cache\n * restore — a frame that already loaded is not observed again). The `IntersectionObserver`\n * and focus listener are released once loaded (when `once`) and on `disconnect()` (Turbo\n * navigation included).\n */\nexport class LazyFrameController extends Controller<HTMLElement> {\n static override values = {\n url: { type: String, default: \"\" },\n rootMargin: { type: String, default: \"0px\" },\n once: { type: Boolean, default: true },\n };\n static events = [\"load\"] as const;\n\n declare urlValue: string;\n declare rootMarginValue: string;\n declare onceValue: boolean;\n\n /** Shared IO plumbing (support guard, active guard, teardown). */\n readonly #watcher = new IntersectionWatcher((entries) => this.#onIntersect(entries));\n #loaded = false;\n\n /** Focus reaching the frame triggers the load before it intersects (keyboard / AT). */\n readonly #onFocus = (): void => this.#trigger();\n\n override connect(): void {\n // A cache restore may bring back an already-loaded frame; respect it, do not reload.\n if (this.element.hasAttribute(\"data-lazy-loaded\")) {\n this.#loaded = true;\n return;\n }\n if (!this.urlValue) return;\n\n this.element.addEventListener(\"focusin\", this.#onFocus);\n this.#watcher.start(this.element, { rootMargin: this.rootMarginValue });\n }\n\n override disconnect(): void {\n this.#stopObserving();\n }\n\n #onIntersect(entries: IntersectionObserverEntry[]): void {\n for (const entry of entries) {\n if (entry.isIntersecting) {\n this.#trigger();\n return;\n }\n }\n }\n\n #trigger(): void {\n if (!this.#loaded) this.#load();\n else if (!this.onceValue) this.#reload();\n }\n\n /** Starts the load by writing the held URL to `src`. */\n #load(): void {\n this.#loaded = true;\n this.element.setAttribute(\"src\", this.urlValue);\n this.element.setAttribute(\"data-lazy-loaded\", \"true\");\n this.dispatch(\"load\", { detail: { url: this.urlValue } });\n if (this.onceValue) this.#stopObserving();\n }\n\n /** Re-entry while `once` is off: ask Turbo to reload the frame's current `src`. */\n #reload(): void {\n const frame = this.element as HTMLElement & { reload?: () => void };\n // Only signal `load` when a reload actually happens. On a non-`<turbo-frame>`\n // host (no `reload()`), firing it would announce a load that never occurred.\n if (typeof frame.reload !== \"function\") return;\n frame.reload();\n this.dispatch(\"load\", { detail: { url: this.urlValue } });\n }\n\n #stopObserving(): void {\n this.#watcher.stop();\n this.element.removeEventListener(\"focusin\", this.#onFocus);\n }\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../../src/utils/intersection_watcher.ts","../../src/controllers/lazy_frame_controller.ts"],"names":[],"mappings":"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","file":"lazy_frame_controller.js","sourcesContent":["/**\n * Shared `IntersectionObserver` plumbing for Stimeo's scroll-triggered\n * controllers (`intersection`, `scrollspy`, `sticky-observer`, `lazy-frame`).\n *\n * It centralizes the `IntersectionObserver` support guard, root resolution from\n * a selector, observer creation/teardown, the **active guard** (the browser may\n * flush a final queued callback batch right after `disconnect()`, and a\n * detached controller must not mutate possibly-cached DOM), and the\n * unobserve→observe **re-arm** that re-delivers the current state even when the\n * target never leaves the viewport.\n *\n * Like {@link RovingTabindex} and `FocusTrap`, this is a policy-free internal\n * util: what an intersection *means* (a spied link, a stuck header, a lazy\n * load) stays in each controller. The public `stimeo--intersection` controller\n * is its thin declarative face.\n */\n/**\n * Whether `entry`'s target sits entirely before the root's **start (top)** edge —\n * the \"scrolled past the top\" half of a non-intersecting entry, as opposed to\n * \"not reached yet\" below the root.\n *\n * A target with no layout box (`display: none`, a `hidden` ancestor, a collapsed\n * `<details>`) is reported with an **empty rect**, whose `bottom` of `0` would\n * otherwise satisfy `bottom <= rootTop` for a viewport root and read as \"passed\"\n * even though the target was never scrolled anywhere. An empty rect carries no\n * position at all, so it is deliberately never \"before the edge\"; what a caller\n * publishes for that case is its own policy (both consumers treat it as the\n * neutral \"not passed\"/\"not stuck\", and the real rect that arrives once the\n * target is laid out re-establishes the true state).\n */\nexport function isBeforeRootStart(entry: IntersectionObserverEntry): boolean {\n const rect = entry.boundingClientRect;\n if (rect.width === 0 && rect.height === 0) return false;\n // rootBounds is null for a cross-origin/removed root; fall back to the\n // viewport origin.\n const rootTop = entry.rootBounds?.top ?? 0;\n return rect.bottom <= rootTop;\n}\n\nexport interface IntersectionWatchOptions {\n /**\n * The observation root. Pass an element (or `null` for the viewport) when\n * the caller already resolved it; omit to resolve from `rootSelector`.\n */\n root?: Element | null;\n /** Selector for the observation root; empty/omitted = viewport. */\n rootSelector?: string;\n rootMargin?: string;\n threshold?: number | number[];\n}\n\nexport class IntersectionWatcher {\n readonly #onEntries: (entries: IntersectionObserverEntry[]) => void;\n #observer: IntersectionObserver | null = null;\n #active = false;\n #usingPlatformDefaults = false;\n\n constructor(onEntries: (entries: IntersectionObserverEntry[]) => void) {\n this.#onEntries = onEntries;\n }\n\n /** Whether an observer is live (started, `IntersectionObserver` supported). */\n get active(): boolean {\n return this.#active;\n }\n\n /** Whether the live observer discarded configured options after construction failed. */\n get usingPlatformDefaults(): boolean {\n return this.#usingPlatformDefaults;\n }\n\n /**\n * (Re)creates the observer and observes `targets`. Returns `false` — leaving\n * the watcher inert — without `IntersectionObserver` support (very old\n * browsers; the caller's no-JS fallback stays in charge) or with no targets.\n * If initial construction with the configured options fails, the watcher\n * warns and retries once with the same root and platform defaults.\n *\n * @throws The fallback constructor error if both construction attempts fail,\n * or whatever the platform throws from `observe()`. The exception is passed\n * through unchanged, but the watcher rolls back first: every target observed\n * so far is released and `active` stays `false`, so a caller that retries\n * starts from a clean slate.\n */\n start(targets: Element | readonly Element[], options: IntersectionWatchOptions = {}): boolean {\n this.stop();\n if (typeof IntersectionObserver === \"undefined\") return false;\n const list = Array.isArray(targets) ? (targets as readonly Element[]) : [targets as Element];\n if (list.length === 0) return false;\n\n const root =\n \"root\" in options\n ? (options.root ?? null)\n : options.rootSelector\n ? document.querySelector(options.rootSelector)\n : null;\n\n let observer: IntersectionObserver | null = null;\n try {\n const onEntries = (entries: IntersectionObserverEntry[]): void => {\n // Identity matters across an immediate restart: the old observer can\n // flush a queued batch after the new observer has made `active` true.\n if (this.#active && this.#observer === observer) this.#onEntries(entries);\n };\n try {\n observer = new IntersectionObserver(onEntries, {\n root,\n rootMargin: options.rootMargin,\n threshold: options.threshold,\n });\n } catch (error) {\n console.warn(\n \"Stimeo UI: IntersectionObserver could not be constructed with the configured options; retrying with platform defaults.\",\n error,\n );\n observer = new IntersectionObserver(onEntries, { root });\n this.#usingPlatformDefaults = true;\n }\n for (const target of list) observer.observe(target);\n this.#observer = observer;\n this.#active = true;\n return true;\n } catch (error) {\n // A constructor or partial observe failure must not leave earlier targets\n // observed or report an active watcher. Preserve the platform exception.\n observer?.disconnect();\n this.#observer = null;\n this.#active = false;\n this.#usingPlatformDefaults = false;\n throw error;\n }\n }\n\n /**\n * Re-delivers `target`'s CURRENT intersection state: `IntersectionObserver`\n * only reports *changes*, but `observe()` always reports the present state,\n * so unobserve→observe turns \"still intersecting\" into a fresh callback.\n *\n * @throws Whatever `unobserve()`/`observe()` throws. The watcher is stopped\n * first, so it never stays live with a half-rearmed target.\n */\n rearm(target: Element): void {\n if (!this.#observer) return;\n try {\n this.#observer.unobserve(target);\n this.#observer.observe(target);\n } catch (error) {\n this.stop();\n throw error;\n }\n }\n\n /** Severs the observer; late queued callbacks become no-ops via the guard. */\n stop(): void {\n this.#active = false;\n this.#observer?.disconnect();\n this.#observer = null;\n this.#usingPlatformDefaults = false;\n }\n}\n","import { Controller } from \"@hotwired/stimulus\";\nimport { IntersectionWatcher } from \"../utils/intersection_watcher\";\n\n/**\n * Headless **lazy frame**: defers a `<turbo-frame>`'s load until it nears the viewport\n * (or focus reaches it), to keep the initial render light. Turbo's own `loading=\"lazy\"`\n * fires on *render*, not on viewport entry, so this drives an explicit `IntersectionObserver`\n * with a configurable `rootMargin` for early loading, plus a focus fallback so keyboard /\n * assistive-tech users trigger the load too (no APG pattern).\n *\n * Markup contract (identifier: `stimeo--lazy-frame`):\n * <turbo-frame id=\"comments\" data-controller=\"stimeo--lazy-frame\"\n * data-stimeo--lazy-frame-url-value=\"/posts/1/comments\"\n * data-stimeo--lazy-frame-root-margin-value=\"200px\">Loading…</turbo-frame>\n *\n * The URL is *held* in the `url` value (not on `src`) so Turbo does not load it eagerly;\n * when the frame intersects (within `rootMargin`) or focus enters it, the controller\n * writes `url` to `src` — which starts the Turbo load — marks `data-lazy-loaded`, and\n * emits `load`. With `once` (default) it then stops observing; otherwise re-entry asks\n * Turbo to `reload()` the frame.\n *\n * @remarks\n * Behavior only — the load itself and the frame's content are Turbo's / the server's job,\n * and the loading UI (skeleton / `aria-busy`) belongs to Frame Loading State. The trigger\n * is idempotent (`data-lazy-loaded` guards a double load and is honored on a Turbo cache\n * restore — a frame that already loaded is not observed again). The `IntersectionObserver`\n * and focus listener are released once loaded (when `once`) and on `disconnect()` (Turbo\n * navigation included).\n */\nexport class LazyFrameController extends Controller<HTMLElement> {\n static override values = {\n url: { type: String, default: \"\" },\n rootMargin: { type: String, default: \"0px\" },\n once: { type: Boolean, default: true },\n };\n static events = [\"load\"] as const;\n\n declare urlValue: string;\n declare rootMarginValue: string;\n declare onceValue: boolean;\n\n /** Shared IO plumbing (support guard, active guard, teardown). */\n readonly #watcher = new IntersectionWatcher((entries) => this.#onIntersect(entries));\n #loaded = false;\n\n /** Focus reaching the frame triggers the load before it intersects (keyboard / AT). */\n readonly #onFocus = (): void => this.#trigger();\n\n override connect(): void {\n // A cache restore may bring back an already-loaded frame; respect it, do not reload.\n if (this.element.hasAttribute(\"data-lazy-loaded\")) {\n this.#loaded = true;\n return;\n }\n if (!this.urlValue) return;\n\n this.element.addEventListener(\"focusin\", this.#onFocus);\n this.#watcher.start(this.element, { rootMargin: this.rootMarginValue });\n }\n\n override disconnect(): void {\n this.#stopObserving();\n }\n\n #onIntersect(entries: IntersectionObserverEntry[]): void {\n for (const entry of entries) {\n if (entry.isIntersecting) {\n this.#trigger();\n return;\n }\n }\n }\n\n #trigger(): void {\n if (!this.#loaded) this.#load();\n else if (!this.onceValue) this.#reload();\n }\n\n /** Starts the load by writing the held URL to `src`. */\n #load(): void {\n this.#loaded = true;\n this.element.setAttribute(\"src\", this.urlValue);\n this.element.setAttribute(\"data-lazy-loaded\", \"true\");\n this.dispatch(\"load\", { detail: { url: this.urlValue } });\n if (this.onceValue) this.#stopObserving();\n }\n\n /** Re-entry while `once` is off: ask Turbo to reload the frame's current `src`. */\n #reload(): void {\n const frame = this.element as HTMLElement & { reload?: () => void };\n // Only signal `load` when a reload actually happens. On a non-`<turbo-frame>`\n // host (no `reload()`), firing it would announce a load that never occurred.\n if (typeof frame.reload !== \"function\") return;\n frame.reload();\n this.dispatch(\"load\", { detail: { url: this.urlValue } });\n }\n\n #stopObserving(): void {\n this.#watcher.stop();\n this.element.removeEventListener(\"focusin\", this.#onFocus);\n }\n}\n"]}
|
|
@@ -65,8 +65,15 @@ declare class ListboxController extends Controller<HTMLElement> {
|
|
|
65
65
|
readonly hasValueTarget: boolean;
|
|
66
66
|
readonly hasListTarget: boolean;
|
|
67
67
|
readonly hasFieldTarget: boolean;
|
|
68
|
-
/**
|
|
68
|
+
/** Establishes the ARIA baseline, starts closed, and listens for outside clicks. */
|
|
69
69
|
connect(): void;
|
|
70
|
+
/**
|
|
71
|
+
* Establishes an inactive baseline for a late option, re-resolves active
|
|
72
|
+
* identity, and re-applies the selection baseline.
|
|
73
|
+
*/
|
|
74
|
+
optionTargetConnected(option: HTMLElement): void;
|
|
75
|
+
/** Removes controller-owned active state and reconciles the surviving targets. */
|
|
76
|
+
optionTargetDisconnected(option: HTMLElement): void;
|
|
70
77
|
/** Removes the document listener and clears the typeahead timer. */
|
|
71
78
|
disconnect(): void;
|
|
72
79
|
/**
|
|
@@ -75,7 +82,7 @@ declare class ListboxController extends Controller<HTMLElement> {
|
|
|
75
82
|
* drives that, so the synthetic click is ignored to avoid double-toggling.
|
|
76
83
|
*/
|
|
77
84
|
toggle(event: MouseEvent): void;
|
|
78
|
-
/**
|
|
85
|
+
/** Yields claimed keys; otherwise routes the APG select-only keyboard model. */
|
|
79
86
|
onTriggerKeydown(event: KeyboardEvent): void;
|
|
80
87
|
/** Selects the clicked option and closes, returning focus to the trigger. */
|
|
81
88
|
select(event: Event): void;
|