Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 | 4x 801x 801x 801x 376x 214x 214x 162x 162x 376x 801x 351x 9x 9x 9x 5x 5x 5x 9x 9x 2x 2x 9x 6x 6x 4x 6x 6x 9x 9x 9x 9x 9x 9x 801x | import { useEffect, useState } from "react";
import { useIsomorphicLayoutEffect } from "@usefy/use-isomorphic-layout-effect";
/** Quiet period (ms) without scroll events after which the scroll counts as settled. */
export const SCROLL_SETTLE_QUIET_MS = 150;
/** Options for {@link useAutoScroll}. */
export interface UseAutoScrollOptions {
/** The resolved step target, or `null` (nothing to scroll to). */
element: Element | null;
/** Master switch — `open && step.scrollIntoView !== false`. */
enabled: boolean;
/** Scroll instantly instead of smoothly (reduced motion). */
instant: boolean;
}
/**
* Scroll an off-viewport step target into view and report while the scroll is
* in flight (SPEC §3.4 auto-scroll).
*
* When `element` changes (a step settles) and its box is not fully inside the
* viewport, the hook calls `scrollIntoView({ block: 'center' })` — `smooth`
* normally, instant under reduced motion — and returns `true` until the
* scroll **settles**. Settling is detected via the `scrollend` event where
* supported, always backed by a quiet-period fallback timer
* ({@link SCROLL_SETTLE_QUIET_MS} ms without scroll events), which also
* covers the cases where no scroll event ever fires (already at the edge,
* instant behavior).
*
* **Scroll-lock sequencing**: the decision (`scrolling` flips true) happens in
* a layout effect, but the actual `scrollIntoView` call is deferred by a
* 0-ms timer inside a passive effect. Consumers gate their scroll lock on
* `!scrolling`, so by the time the scroll starts, the re-render that released
* the body lock has been committed and its effects flushed — the page can
* actually move.
*
* SSR-safe; all listeners and timers are cleaned up on step change, close,
* and unmount.
*
* @param options - {@link UseAutoScrollOptions}
* @returns `true` while an auto-scroll is in flight (feed into `pending`).
*
* @internal
*/
export function useAutoScroll(options: UseAutoScrollOptions): boolean {
const { element, enabled, instant } = options;
const [scrolling, setScrolling] = useState(false);
// Decision — synchronous, before paint, so the tooltip's "pending" hide and
// the scroll-lock release are committed before any scrolling begins.
useIsomorphicLayoutEffect(() => {
if (!enabled || !element || typeof window === "undefined") {
setScrolling(false);
return;
}
const rect = element.getBoundingClientRect();
const outside =
rect.top < 0 ||
rect.left < 0 ||
rect.bottom > window.innerHeight ||
rect.right > window.innerWidth;
setScrolling(outside);
}, [element, enabled]);
// Action + settle detection.
useEffect(() => {
if (!scrolling || !element || typeof window === "undefined") return;
let settleTimer: ReturnType<typeof setTimeout> | null = null;
let done = false;
const settle = () => {
Iif (done) return;
done = true;
setScrolling(false);
};
const onScrollEnd = () => settle();
const onScroll = () => {
// Still moving — push the quiet-period deadline out.
Eif (settleTimer !== null) clearTimeout(settleTimer);
settleTimer = setTimeout(settle, SCROLL_SETTLE_QUIET_MS);
};
// Deferred start (see the scroll-lock sequencing note in the JSDoc).
const startTimer = setTimeout(() => {
element.scrollIntoView({
block: "center",
inline: "nearest",
behavior: instant ? "auto" : "smooth",
});
if ("onscrollend" in window) {
window.addEventListener("scrollend", onScrollEnd);
}
// Quiet-period fallback runs in every mode: it settles when scroll
// events stop, and also when none ever fire (nothing had to move, or
// the scroll completed synchronously).
window.addEventListener("scroll", onScroll, {
capture: true,
passive: true,
});
settleTimer = setTimeout(settle, SCROLL_SETTLE_QUIET_MS);
}, 0);
return () => {
done = true;
clearTimeout(startTimer);
if (settleTimer !== null) clearTimeout(settleTimer);
window.removeEventListener("scrollend", onScrollEnd);
window.removeEventListener("scroll", onScroll, { capture: true });
};
}, [scrolling, element, instant]);
return scrolling;
}
|