// // Copyright 2025 DXOS.org // import { createContext } from '@radix-ui/react-context'; import React, { type PropsWithChildren, type RefObject, forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, } from 'react'; import { addEventListener, combine } from '@dxos/async'; import { invariant } from '@dxos/invariant'; import { useMergeRefs } from '@dxos/react-hooks'; import { mx } from '@dxos/ui-theme'; import { type SlottableProps } from '@dxos/ui-types'; import { composable, composableProps, slottable } from '../../util'; import { type ThemedClassName } from '../../util'; import { IconButton } from '../Button'; import { ScrollArea, type ScrollAreaRootProps } from '../ScrollArea'; const isBottom = (el: HTMLElement | null) => { return !!(el && el.scrollHeight - el.scrollTop === el.clientHeight); }; export interface ScrollController { viewport: HTMLDivElement | null; scrollToTop: (behavior?: ScrollBehavior) => void; scrollToBottom: (behavior?: ScrollBehavior) => void; } type ScrollContainerContextValue = { controller?: ScrollController; pinned?: boolean; overflow?: boolean; /** Called by Viewport to register/unregister the scroll element. */ setViewport: (el: HTMLDivElement | null) => void; /** Called by Viewport on wheel events to update pinned state. */ setPinned: (value: boolean) => void; /** Called by Viewport on scroll events to update overflow state. */ setOverflow: (value: boolean) => void; }; const [ScrollContainerProvider, useScrollContainerContext] = createContext('ScrollContainer'); // // Root // type ScrollContainerRootProps = PropsWithChildren<{ pin?: boolean; behavior?: ScrollBehavior; }>; /** * Headless scroll container that provides context for scroll state. * Render ScrollContainer.Content and ScrollContainer.Viewport as children. */ const ScrollContainerRoot = forwardRef( ({ children, pin, behavior: behaviorProp = 'smooth' }, forwardedRef) => { const scrollerRef = useRef(null); const autoScrollRef = useRef(false); const [pinned, setPinned] = useState(pin); const [overflow, setOverflow] = useState(false); const timeoutRef = useRef(undefined); const scrollToBottom = useCallback( (behavior: ScrollBehavior = behaviorProp) => { if (scrollerRef.current) { if (behavior !== 'instant') { // Temporarily hide scrollbar to prevent flickering during smooth scroll. // For instant scrolling we skip this — there's no animation to hide, // and adding the class changes element size which re-fires the ResizeObserver. autoScrollRef.current = true; scrollerRef.current.classList.add('scrollbar-none'); clearTimeout(timeoutRef.current); timeoutRef.current = setTimeout(() => { scrollerRef.current?.classList.remove('scrollbar-none'); autoScrollRef.current = false; }, 500); } scrollerRef.current.scrollTo({ top: scrollerRef.current.scrollHeight, behavior, }); setPinned(true); } }, [behaviorProp], ); const controller = useMemo( () => ({ get viewport() { return scrollerRef.current; }, scrollToTop: () => { invariant(scrollerRef.current); scrollerRef.current.scrollTo({ top: 0, behavior: 'smooth' }); setPinned(false); }, scrollToBottom: (behavior = 'smooth' as ScrollBehavior) => { scrollToBottom(behavior); }, }), [scrollToBottom], ); // Scroll controller imperative ref. useImperativeHandle(forwardedRef, () => controller, [controller]); // Called by Viewport when the scroll element mounts/unmounts. const setViewport = useCallback((el: HTMLDivElement | null) => { scrollerRef.current = el; }, []); return ( {children} ); }, ); ScrollContainerRoot.displayName = 'ScrollContainer.Root'; // // Content // type ScrollContainerContentProps = Pick; /** * Composable wrapper around ScrollArea.Root. * Provides the DOM structure for the scroll container. */ const ScrollContainerContent = composable( ({ children, thin, padding, centered, ...props }, forwardedRef) => { return ( {children} ); }, ); ScrollContainerContent.displayName = 'ScrollContainer.Content'; // // Viewport // const VIEWPORT_NAME = 'ScrollContainer.Viewport'; type ScrollContainerViewportProps = SlottableProps; const ScrollContainerViewport = slottable( ({ children, asChild, ...props }, forwardedRef) => { const scrollerRef = useRef(null); const mergedRef = useMergeRefs([forwardedRef, scrollerRef]); const { setViewport, setPinned, setOverflow } = useScrollContainerContext(VIEWPORT_NAME); // Register the scroll element with Root and set up wheel/scroll listeners. useEffect(() => { const el = scrollerRef.current; if (!el) { return; } setViewport(el); return combine( addEventListener(el, 'wheel', () => setPinned(isBottom(el))), addEventListener(el, 'scroll', () => setOverflow((el.scrollTop ?? 0) > 0)), () => setViewport(null), ); }, [setViewport, setPinned, setOverflow]); return ( <> {children} ); }, ); ScrollContainerViewport.displayName = VIEWPORT_NAME; /** * Isolated component that consumes pinned/controller from context. * Kept separate so that Viewport does not re-render when pinned changes. */ const PIN_EFFECT_NAME = 'ScrollContainer.PinEffect'; const ScrollContainerPinEffect = ({ scrollerRef }: { scrollerRef: RefObject }) => { const { pinned, controller } = useScrollContainerContext(PIN_EFFECT_NAME); // Pin scroll to bottom when content changes. useEffect(() => { const viewport = scrollerRef.current; if (!pinned || !viewport) { return; } // Scroll instantly so we don't visually jump while content is being added. controller?.scrollToBottom('instant'); // Setup resize observer on content children to detect size changes (e.g. streaming). // We observe children rather than the viewport itself, because the viewport's size // stays fixed — only its content grows. // Use instant scroll in the callback — smooth scrolling adds/removes the // scrollbar-none class, which changes the element size and re-fires the // observer, creating an infinite loop. const resizeObserver = new ResizeObserver(() => controller?.scrollToBottom('smooth')); Array.from(viewport.children).forEach((child) => { resizeObserver.observe(child); }); // Watch for added/removed children. const mutationObserver = new MutationObserver((mutations) => { mutations.forEach((mutation) => { mutation.addedNodes.forEach((node) => { if (node instanceof Element) { resizeObserver.observe(node); } }); }); controller?.scrollToBottom('smooth'); }); mutationObserver.observe(viewport, { childList: true }); return () => { resizeObserver.disconnect(); mutationObserver.disconnect(); }; }, [pinned, controller, scrollerRef]); return null; }; // // Fade // const FADE_NAME = 'ScrollContainer.Fade'; type ScrollContainerFadeProps = {}; function ScrollContainerFade() { const { overflow } = useScrollContainerContext(FADE_NAME); return (
); } ScrollContainerFade.displayName = FADE_NAME; // // ScrollDownButton // const SCROLL_DOWN_BUTTON_NAME = 'ScrollContainer.ScrollDownButton'; type ScrollContainerScrollDownButtonProps = ThemedClassName; function ScrollContainerScrollDownButton({ classNames }: ScrollContainerScrollDownButtonProps) { const { pinned, controller } = useScrollContainerContext(SCROLL_DOWN_BUTTON_NAME); return (
controller?.scrollToBottom()} />
); } ScrollContainerScrollDownButton.displayName = SCROLL_DOWN_BUTTON_NAME; // // ScrollContainer // export { useScrollContainerContext }; export const ScrollContainer = { Root: ScrollContainerRoot, Content: ScrollContainerContent, Viewport: ScrollContainerViewport, Fade: ScrollContainerFade, ScrollDownButton: ScrollContainerScrollDownButton, }; export type { ScrollContainerContentProps, ScrollContainerFadeProps, ScrollContainerRootProps, ScrollContainerScrollDownButtonProps, ScrollContainerViewportProps, };