"use client" import * as React from "react" import { getPageScrollElement, PAGE_SCROLL_ATTR, STICKY_SUBHEADER_SLOTS, } from "../lib/page-scroll-port" /** The tab row / views strip that pins under the utility bar, one per surface. */ const STICKY_STRIP_SELECTOR = STICKY_SUBHEADER_SLOTS.map( slot => `[data-slot="${slot}"]`, ).join(", ") /** * Give each destination on a page its own scroll position. * * Tab panels and hub views share one scrollport (`[data-page-scroll]`), and * nothing about swapping the panel tells the scrollport to move, so the position * you were at simply stays. Whether that reads as a bug depends on the panel you * land on: one tall enough to hold the old offset opens halfway down content you * have never seen, and one too short gets clamped, so the same click either * strands you mid-panel or throws you back past the page header to the top. * * Neither is what was asked for. A destination you have visited opens where you * left it. One you have not opens at its start, which is the top of its own body * rather than the top of the page: the row of tabs is sticky, so it should not * move under a click that lands on it, and the title and KPIs above it belong to * every destination equally. In practice the chrome stays where it is and the new * panel begins under it. * * Recording is synchronous in the scroll handler rather than coalesced into a * frame: the click that changes tabs can land in the same frame as the last * scroll event, and a coalesced write would file that final position under the * wrong destination. * * @param key Identifies the destination, e.g. the selected tab's value or the * active view's id. `undefined` records nothing, for a set with no selection. * @param options.enabled Pass `false` for a tab row that does not own the page * scroll (a row inside a card), where moving the page is the wrong answer. * @param options.node A node inside the surface. Supplies the scrollport (its * nearest, falling back to the page's) and scopes the panel lookup. * @param options.panelSelector Finds the panel body inside `node`, whose top is * where an unvisited destination starts. Without it, an unvisited destination * leaves the scroll alone. */ export function useRememberedPageScroll( key: string | undefined, { enabled = true, node, panelSelector, }: { enabled?: boolean; node?: HTMLElement | null; panelSelector?: string } = {}, ) { /** * Recorded position per destination, built on the first scroll rather than in * render, so a render that React replays or discards does not allocate a map * only to throw it away. */ const offsets = React.useRef | null>(null) /** Where scroll events are filed. Updated before any move runs. */ const liveKey = React.useRef(key) /** The first key is where the user already is, so it is not scrolled to. */ const movedOnce = React.useRef(false) /** * Distance from the scrollport's own top to the top of the panel area, in * content pixels. * * Cached from the settled frame rather than read during the switch, because * the panel is not measurable then: Radix keeps the outgoing tab panel on * screen and leaves the incoming one `hidden` for a frame, and a keyed hub view * body remounts empty. Caching is sound because the value describes the *slot*, * not its occupant, so every destination shares it. */ const panelFlowTop = React.useRef(null) /** * Height of the tab row that pins over the panel, so the panel's first row can * land under it rather than behind it. Taken as a height rather than from the * live pin line for the same reason as above: mid-switch the strip may have been * un-pinned by the clamp, and would report no overlay at all. */ const chromeHeight = React.useRef(0) const scroller = React.useMemo(() => { if (!enabled || typeof document === "undefined") return null return node?.closest(`[${PAGE_SCROLL_ATTR}]`) ?? getPageScrollElement() }, [enabled, node]) React.useEffect(() => { if (!scroller) return const record = () => { const at = liveKey.current if (at !== undefined) (offsets.current ??= new Map()).set(at, scroller.scrollTop) } scroller.addEventListener("scroll", record, { passive: true }) return () => scroller.removeEventListener("scroll", record) }, [scroller]) // After paint, so the panel that just arrived has its real box. React.useEffect(() => { if (!scroller || !panelSelector) return const within = node ?? scroller const measure = () => { const panel = renderedPanel(within, panelSelector) if (!panel) return panelFlowTop.current = scroller.scrollTop + panel.getBoundingClientRect().top - scroller.getBoundingClientRect().top const strip = within.querySelector(STICKY_STRIP_SELECTOR) chromeHeight.current = strip?.getBoundingClientRect().height ?? 0 } // The body can arrive a frame late (lazy chunk, keyed remount). const raf = requestAnimationFrame(measure) measure() // Anything that reflows the header or KPIs above the tabs moves the slot. window.addEventListener("resize", measure, { passive: true }) return () => { cancelAnimationFrame(raf) window.removeEventListener("resize", measure) } }, [key, node, panelSelector, scroller]) React.useLayoutEffect(() => { const leaving = liveKey.current liveKey.current = key if (!scroller || key === undefined) return if (!movedOnce.current) { movedOnce.current = true return } /** * Where the user was, taken from the record rather than read live. A keyed * view body remounts empty in this same commit, so the scrollport has already * been clamped by the time it can be asked, and it answers 0 for a user who * was a thousand pixels down. */ const from = (leaving !== undefined ? offsets.current?.get(leaving) : undefined) ?? scroller.scrollTop const target = offsets.current?.get(key) ?? panelStart(panelFlowTop.current, chromeHeight.current, from) if (target === null) return scroller.scrollTop = target // Landing at the top needs no help: no panel is too short for it. if (target === 0) return /** * A panel that is shorter than its final height while it mounts clamps the * offset just set, so keep re-applying while it grows. Tab panels arrive from * a lazy chunk, keyed view bodies remount from nothing, tables measure their * columns, images settle: all after this commit. */ let frames = 0 let raf = 0 let cancelled = false // The user reaching for the scroll outranks anything computed here. const stop = () => { cancelled = true cancelAnimationFrame(raf) } const tick = () => { if (cancelled) return scroller.scrollTop = target if (Math.abs(scroller.scrollTop - target) < 1) return // ~half a second at 60fps, by which point the panel is as tall as it will // get and the clamped position is the honest answer. if (frames++ > 30) return raf = requestAnimationFrame(tick) } raf = requestAnimationFrame(tick) scroller.addEventListener("wheel", stop, { passive: true }) scroller.addEventListener("touchstart", stop, { passive: true }) scroller.addEventListener("keydown", stop) return () => { cancelled = true cancelAnimationFrame(raf) scroller.removeEventListener("wheel", stop) scroller.removeEventListener("touchstart", stop) scroller.removeEventListener("keydown", stop) } }, [key, scroller]) } /** * Where a destination the user has not visited should open, or `null` to leave * the scroll alone because the panel area was never measured. * * The panel's first row lands just under the sticky chrome rather than at the top * of the scrollport, so the tabs that were clicked stay visible above it. * * Never scrolls *down*: from the page header, clicking a tab must not drag the * header away to chase a panel that is already in view. */ function panelStart(flowTop: number | null, chrome: number, from: number) { if (flowTop === null) return null return Math.max(0, Math.min(from, flowTop - chrome)) } /** * The panel box currently taking up space, which is not always the selected one. * * Radix keeps the outgoing panel mounted for its exit and leaves the incoming one * `hidden` until a later frame, so mid-switch the selected panel measures 0×0 * while the one being replaced still has the real box. Either answers the only * question asked here — where does the panel area begin — because they occupy the * same slot. */ function renderedPanel(within: HTMLElement, selector: string) { for (const el of within.querySelectorAll(selector)) { if (el.getClientRects().length > 0) return el } return null }