import React, { type PropsWithChildren, type Ref, useImperativeHandle, useRef, useState, } from 'react' import type { Except } from 'type-fest' import { getConfig } from '../../providers/index.js' import type { DOMElement } from '../dom.js' import { markDirty, scheduleRenderFrom } from '../dom.js' import { markCommitStart } from '../reconciler.js' import type { Styles } from '../styles.js' import Box from './Box.js' export type ScrollBoxHandle = { scrollTo: (y: number) => void scrollBy: (dy: number) => void scrollToElement: (el: DOMElement, offset?: number) => void scrollToBottom: () => void getScrollTop: () => number getPendingDelta: () => number getScrollHeight: () => number getFreshScrollHeight: () => number getViewportHeight: () => number getViewportTop: () => number isSticky: () => boolean subscribe: (listener: () => void) => () => void setClampBounds: (min: number | undefined, max: number | undefined) => void } export type ScrollBoxProps = Except & { ref?: Ref stickyScroll?: boolean } function ScrollBox({ children, ref, stickyScroll, ...style }: PropsWithChildren): React.ReactNode { const domRef = useRef(null) const [, forceRender] = useState(0) const listenersRef = useRef(new Set<() => void>()) const renderQueuedRef = useRef(false) const notify = () => { for (const l of listenersRef.current) l() } function scrollMutated(el: DOMElement): void { getConfig().onScrollActivity() markDirty(el) markCommitStart() notify() if (renderQueuedRef.current) return renderQueuedRef.current = true queueMicrotask(() => { renderQueuedRef.current = false scheduleRenderFrom(el) }) } useImperativeHandle( ref, (): ScrollBoxHandle => ({ scrollTo(y: number) { const el = domRef.current if (!el) return el.stickyScroll = false el.pendingScrollDelta = undefined el.scrollAnchor = undefined el.scrollTop = Math.max(0, Math.floor(y)) scrollMutated(el) }, scrollToElement(el: DOMElement, offset = 0) { const box = domRef.current if (!box) return box.stickyScroll = false box.pendingScrollDelta = undefined box.scrollAnchor = { el, offset, } scrollMutated(box) }, scrollBy(dy: number) { const el = domRef.current if (!el) return el.stickyScroll = false el.scrollAnchor = undefined el.pendingScrollDelta = (el.pendingScrollDelta ?? 0) + Math.floor(dy) scrollMutated(el) }, scrollToBottom() { const el = domRef.current if (!el) return el.pendingScrollDelta = undefined el.stickyScroll = true markDirty(el) notify() forceRender((n) => n + 1) }, getScrollTop() { return domRef.current?.scrollTop ?? 0 }, getPendingDelta() { return domRef.current?.pendingScrollDelta ?? 0 }, getScrollHeight() { return domRef.current?.scrollHeight ?? 0 }, getFreshScrollHeight() { const content = domRef.current?.childNodes[0] as DOMElement | undefined return content?.yogaNode?.getComputedHeight() ?? domRef.current?.scrollHeight ?? 0 }, getViewportHeight() { return domRef.current?.scrollViewportHeight ?? 0 }, getViewportTop() { return domRef.current?.scrollViewportTop ?? 0 }, isSticky() { const el = domRef.current if (!el) return false return el.stickyScroll ?? Boolean(el.attributes['stickyScroll']) }, subscribe(listener: () => void) { listenersRef.current.add(listener) return () => listenersRef.current.delete(listener) }, setClampBounds(min, max) { const el = domRef.current if (!el) return el.scrollClampMin = min el.scrollClampMax = max }, }), // eslint-disable-next-line react-hooks/exhaustive-deps [scrollMutated, notify], ) // ScrollBox must use flexBasis=0 when flexGrow>0 so Yoga distributes space // from zero rather than from content height. Without this, when content grows // beyond the viewport, Yoga uses the unbounded content height as the flex // basis and shrinks sibling elements (e.g. a border Box title row) to 0. const resolvedFlexGrow = style.flexGrow ?? 0 const resolvedFlexBasis = 'flexBasis' in style ? style.flexBasis : resolvedFlexGrow > 0 ? 0 : undefined return ( { domRef.current = el if (el) el.scrollTop ??= 0 }} style={{ flexWrap: 'nowrap', flexDirection: style.flexDirection ?? 'row', flexGrow: resolvedFlexGrow, flexShrink: style.flexShrink ?? 1, ...style, ...(resolvedFlexBasis !== undefined ? { flexBasis: resolvedFlexBasis } : {}), overflowX: 'scroll', overflowY: 'scroll', }} {...(stickyScroll ? { stickyScroll: true, } : {})} > {children} ) } export default ScrollBox