diff --git a/packages/review/app/src/review-components.tsx b/packages/review/app/src/review-components.tsx index 1bda332a7a..e43ebb1b9a 100644 --- a/packages/review/app/src/review-components.tsx +++ b/packages/review/app/src/review-components.tsx @@ -1,6 +1,10 @@ import { documentType } from "@canvas/document-type.stylex"; import { fontSize, fontWeight } from "@canvas/scale.stylex"; import { IconButton } from "@canvas/ui/button"; +import { + OverlayScrollbar, + overlayScrollbarStyles, +} from "@canvas/ui/overlay-scrollbar"; import { textStyles } from "@canvas/ui/text"; import { extractTraceEventText } from "@dev.fast/trace-protocol"; import * as stylex from "@stylexjs/stylex"; @@ -10,6 +14,7 @@ import type { ReactElement, ReactNode, Ref, + RefObject, } from "react"; import { useCallback, @@ -124,7 +129,7 @@ function ReviewPanelFrame({ /** Buttons beside the close button. */ headerActions?: ReactNode; floatingFooter?: ReactNode; - bodyRef?: Ref; + bodyRef?: RefObject; onBodyScroll?: () => void; tour?: boolean; docked?: boolean; @@ -134,6 +139,22 @@ function ReviewPanelFrame({ }) { const appRef = useReviewRoots()?.appRef; const panelMotion = useReviewPanel((state) => state.motion); + const [body, setBody] = useState(null); + + const attachBody = useCallback( + (node: HTMLDivElement | null) => { + setBody(node); + + if (bodyRef) bodyRef.current = node; + + return () => { + setBody(null); + + if (bodyRef) bodyRef.current = null; + }; + }, + [bodyRef], + ); const sheet = useBottomSheetResize({ stateKey: "bottomSheetFraction", @@ -166,7 +187,6 @@ function ReviewPanelFrame({ "side-panel", panelStyles.panel, panelMotion === "restored" && panelStyles.restored, - tour && panelStyles.tour, docked && panelStyles.docked, tray && panelStyles.tray, )} @@ -207,12 +227,17 @@ function ReviewPanelFrame({
{children}
+ {floatingFooter} ); diff --git a/packages/review/app/src/review-panel.browser.test.tsx b/packages/review/app/src/review-panel.browser.test.tsx index 7be9163fb0..7fe160685e 100644 --- a/packages/review/app/src/review-panel.browser.test.tsx +++ b/packages/review/app/src/review-panel.browser.test.tsx @@ -268,6 +268,44 @@ describe("Guided tour panel", () => { }); }); +describe("Side panel scrollbar", () => { + it("overlays the scrollbar so tour content runs to the column's right edge", async () => { + const container = document.createElement("div"); + container.style.cssText = "display: flex; width: 360px; height: 160px"; + document.body.append(container); + root = createRoot(container); + + await act(async () => { + renderWithSession( + + + + + + + , + ); + await Promise.resolve(); + }); + + const panel = container.querySelector(".side-panel")!; + const body = container.querySelector(panelBodySelector)!; + + const stopContent = container.querySelector( + "section[data-review-anchor-id] > :last-child > :last-child", + )!; + + // Headless Chromium hides native scrollbars, so a gutter would not show + // in the widths here; check the scroller opts out of the native bar. + expect(body.scrollHeight).toBeGreaterThan(body.clientHeight); + expect(getComputedStyle(body).scrollbarWidth).toBe("none"); + expect(body.offsetWidth - body.clientWidth).toBe(0); + expect(stopContent.getBoundingClientRect().right).toBe( + panel.getBoundingClientRect().right, + ); + }); +}); + function OpenReplacingPanel() { const openPeek = useReviewPanel((state) => state.openPeek); useEffect(() => { diff --git a/packages/review/app/src/side-panel-styles.ts b/packages/review/app/src/side-panel-styles.ts index 8c80419b15..c73e6f6fad 100644 --- a/packages/review/app/src/side-panel-styles.ts +++ b/packages/review/app/src/side-panel-styles.ts @@ -36,8 +36,9 @@ export const panelStyles = stylex.create({ display: "flex", flexDirection: "column", minWidth: 0, + // Positioned at every width: the body's overlay scrollbar sits in it. position: { - default: null, + default: "relative", [narrowCanvas]: "absolute", [narrow]: "absolute", }, @@ -78,16 +79,9 @@ export const panelStyles = stylex.create({ animationDuration: motion.instant, animationTimingFunction: "ease", }, - tour: { - position: { - default: "relative", - [narrowCanvas]: "absolute", - [narrow]: "absolute", - }, - }, // Inside a diagram tour the panel keeps its column at every width. docked: { - position: "static", + position: "relative", inset: "auto", width: "100%", height: "100%", diff --git a/packages/review/app/src/ui/overlay-scrollbar.tsx b/packages/review/app/src/ui/overlay-scrollbar.tsx new file mode 100644 index 0000000000..928486e15c --- /dev/null +++ b/packages/review/app/src/ui/overlay-scrollbar.tsx @@ -0,0 +1,217 @@ +import { motion, radius } from "@canvas/scale.stylex"; +import { tokens } from "@canvas/tokens.stylex"; +import * as stylex from "@stylexjs/stylex"; +import { + type PointerEvent as ReactPointerEvent, + useEffect, + useRef, + useState, +} from "react"; + +// How long the thumb stays after the last scroll, as Code OSS's overlay +// scrollbars do. +const HIDE_AFTER_MS = 800; + +const MIN_THUMB = 32; + +const reducedMotion = "@media (prefers-reduced-motion: reduce)"; + +/** + * A vertical scrollbar laid over `scroller`'s right edge that takes no layout + * width: it shows while the scroller scrolls, while the pointer is over its + * lane, and while it is dragged, then fades. Render it as a sibling of the + * scroller inside a positioned parent, and give the scroller + * `overlayScrollbarStyles.scroller` to hide its native bar. + */ +export function OverlayScrollbar({ + scroller, +}: { + scroller: HTMLElement | null; +}) { + const laneRef = useRef(null); + const thumbRef = useRef(null); + const [scrolling, setScrolling] = useState(false); + const [hovered, setHovered] = useState(false); + const [dragging, setDragging] = useState(false); + const [scrollable, setScrollable] = useState(false); + const drag = useRef<{ pointerY: number; scrollTop: number } | null>(null); + + useEffect(() => { + if (!scroller) return; + + let hideTimer: ReturnType | undefined; + + const layout = () => { + const lane = laneRef.current; + const thumb = thumbRef.current; + + if (!lane || !thumb) return; + const { scrollHeight, clientHeight, scrollTop } = scroller; + const overflows = scrollHeight > clientHeight + 1; + setScrollable(overflows); + lane.style.top = `${scroller.offsetTop}px`; + lane.style.height = `${clientHeight}px`; + lane.style.left = `${scroller.offsetLeft + scroller.offsetWidth}px`; + + if (!overflows) return; + + const thumbHeight = Math.max( + MIN_THUMB, + (clientHeight * clientHeight) / scrollHeight, + ); + + const travel = clientHeight - thumbHeight; + const offset = (scrollTop / (scrollHeight - clientHeight)) * travel; + thumb.style.height = `${thumbHeight}px`; + thumb.style.transform = `translateY(${offset}px)`; + }; + + const onScroll = () => { + layout(); + setScrolling(true); + clearTimeout(hideTimer); + hideTimer = setTimeout(() => setScrolling(false), HIDE_AFTER_MS); + }; + + // Content grows and shrinks without scrolling (code cards load, stops + // expand), so watch the scroller and everything directly in it. + const resize = new ResizeObserver(layout); + + const observeChildren = () => { + resize.disconnect(); + resize.observe(scroller); + + for (const child of scroller.children) resize.observe(child); + }; + + const children = new MutationObserver(observeChildren); + observeChildren(); + children.observe(scroller, { childList: true }); + scroller.addEventListener("scroll", onScroll, { passive: true }); + layout(); + + return () => { + clearTimeout(hideTimer); + resize.disconnect(); + children.disconnect(); + scroller.removeEventListener("scroll", onScroll); + }; + }, [scroller]); + + const travelRatio = () => { + const thumb = thumbRef.current; + + if (!scroller || !thumb) return 0; + const travel = scroller.clientHeight - thumb.offsetHeight; + + return travel > 0 + ? (scroller.scrollHeight - scroller.clientHeight) / travel + : 0; + }; + + const startDrag = (event: ReactPointerEvent) => { + if (!scroller || event.button !== 0) return; + event.preventDefault(); + event.stopPropagation(); + event.currentTarget.setPointerCapture(event.pointerId); + drag.current = { pointerY: event.clientY, scrollTop: scroller.scrollTop }; + setDragging(true); + }; + + const moveDrag = (event: ReactPointerEvent) => { + if (!scroller || !drag.current) return; + scroller.scrollTop = + drag.current.scrollTop + + (event.clientY - drag.current.pointerY) * travelRatio(); + }; + + const endDrag = () => { + drag.current = null; + setDragging(false); + }; + + // A click in the lane pages toward it, as a native track does. + const pageToward = (event: ReactPointerEvent) => { + const thumb = thumbRef.current; + + if (!scroller || !thumb || event.button !== 0) return; + event.preventDefault(); + const { top, bottom } = thumb.getBoundingClientRect(); + const direction = event.clientY < top ? -1 : event.clientY > bottom ? 1 : 0; + scroller.scrollBy({ top: direction * scroller.clientHeight * 0.9 }); + }; + + return ( +