import type { JSX } from "@solidjs/web"; import { createMemo, createSignal } from "solid-js"; import { useBaseUiId } from "../../internals/base-ui-id"; import { contains } from "../../internals/dom/contains.ts"; import { makeSlotRefDirective } from "../../internals/make-slot-ref-directive"; import { type MaybeAccessor, readOption } from "../../internals/maybe-accessor"; import { mergeRootProps } from "../../internals/merge-root-props"; import { SNAPSHOT_KEYS, type Snapshottable, snapshotKeys, } from "../../internals/observability/snapshot"; import { useTimeout } from "../../internals/use-timeout.ts"; import { SCROLL_TIMEOUT } from "../constants.ts"; import { getOffset } from "../utils/get-offset.ts"; import { scrollAreaDataAttrs } from "../utils/state-attrs.ts"; import { type Coords, type HiddenState, type MutableRef, type OverflowEdges, type OverflowEdgeThreshold, ScrollAreaRootContext, type ScrollAreaRootContextValue, type ScrollAreaRootState, type Size, } from "./scroll-area-root-context.ts"; import { ScrollAreaRootCssVars } from "./scroll-area-root-css-vars.ts"; const DEFAULT_SIZE: Size = { width: 0, height: 0 }; const DEFAULT_OVERFLOW_EDGES: OverflowEdges = { xStart: false, xEnd: false, yStart: false, yEnd: false, }; const DEFAULT_HIDDEN_STATE: HiddenState = { x: true, y: true, corner: true }; /** Raw consumer shape for the overflow-edge threshold. */ export type OverflowEdgeThresholdInput = | number | Partial<{ xStart: number; xEnd: number; yStart: number; yEnd: number; }> | undefined; function normalizeOverflowEdgeThreshold( threshold: OverflowEdgeThresholdInput, ): OverflowEdgeThreshold { if (typeof threshold === "number") { const value = Math.max(0, threshold); return { xStart: value, xEnd: value, yStart: value, yEnd: value }; } return { xStart: Math.max(0, threshold?.xStart ?? 0), xEnd: Math.max(0, threshold?.xEnd ?? 0), yStart: Math.max(0, threshold?.yStart ?? 0), yEnd: Math.max(0, threshold?.yEnd ?? 0), }; } /** * Options for {@link createScrollArea}. Every reactive input is * `MaybeAccessor`. */ export interface CreateScrollAreaOptions { /** * Threshold in pixels that must be exceeded before the overflow-edge * attributes are applied. Accepts a number (applied to all four edges) or * a partial per-edge object. * @default 0 */ overflowEdgeThreshold?: MaybeAccessor; } /** Shape of attributes emitted by `c.root()`. */ export interface ScrollAreaRootSlotProps { role: "presentation"; style: JSX.CSSProperties; onPointerEnter: (event: PointerEvent) => void; onPointerMove: (event: PointerEvent) => void; onPointerLeave: () => void; } /** * Public API returned by {@link createScrollArea}. A superset of * {@link ScrollAreaRootContextValue} — the api object IS the value published * to descendants via the root context, so `` / * `` / `` keep reading it unchanged. * * On top of the context fields it adds the `root()` slot factory, a * `bindRoot` ref setter, a `Provider`, and the observability surface. * * **Inherently DOM-coupled.** Unlike `createSwitch`/`createCollapsible`, this * primitive owns no consumer logical state — its accessors reflect DOM * measurement (thumb/corner sizes, per-axis overflow) populated by the * viewport's `ResizeObserver` + scroll handler and the scrollbar/thumb drag * math. Bound to no elements it reports all-default (everything hidden, sizes * 0). It exists for parity / observability and to consolidate the root's * state machinery in one place, not as a standalone headless engine. */ export interface ScrollAreaApi extends ScrollAreaRootContextValue { /** * Root slot — getter-bag for the presentation `
` (role, pointer * hover handlers, corner-size CSS-var style). Does NOT include `ref` or * `children`; bind the element via {@link bindRoot} and wrap descendants * in {@link Provider} (or render ``). */ root: ( userProps?: U, ) => ScrollAreaRootSlotProps & (U extends object ? U : object); /** Plain setter that binds the live root `
` (writes `rootRef`). */ bindRoot: (el: HTMLDivElement | null) => void; /** * Directive form — a `ref` callback that spreads the root slot bag onto * the element AND binds it. For consumers owning the root element's prop * surface directly. */ rootRefDirective: () => (el: HTMLDivElement | null) => void; /** Context provider. Wrap a subtree to expose the api to descendants. */ Provider: (props: { children: JSX.Element }) => JSX.Element; } /** * Solid 2.0-native scroll-area primitive. Owns the root's reactive state * (hover / scrolling / measured-scrollbar flags, thumb & corner sizes, * per-axis hidden state, overflow-edge flags), the element refs every part * reads, and the shared pointer/scroll handlers. The viewport's measurement * pipeline and the scrollbar/thumb drag math compose against the refs and * setters exposed here. * * Must be called in an owner scope (component body or `createRoot`) — it * allocates timeouts that install cleanups. */ export function createScrollArea( options: CreateScrollAreaOptions = {}, ): ScrollAreaApi { const rootId = useBaseUiId(); const scrollYTimeout = useTimeout(); const scrollXTimeout = useTimeout(); const [hovering, setHovering] = createSignal(false, { name: "scroll-area.hovering", ownedWrite: true, }); const [scrollingX, setScrollingX] = createSignal(false, { name: "scroll-area.scrollingX", ownedWrite: true, }); const [scrollingY, setScrollingY] = createSignal(false, { name: "scroll-area.scrollingY", ownedWrite: true, }); const [hasMeasuredScrollbar, setHasMeasuredScrollbar] = createSignal(false, { name: "scroll-area.hasMeasuredScrollbar", ownedWrite: true, }); const [cornerSize, setCornerSize] = createSignal(DEFAULT_SIZE, { name: "scroll-area.cornerSize", ownedWrite: true, }); const [thumbSize, setThumbSize] = createSignal(DEFAULT_SIZE, { name: "scroll-area.thumbSize", ownedWrite: true, }); const [overflowEdges, setOverflowEdges] = createSignal( DEFAULT_OVERFLOW_EDGES, { name: "scroll-area.overflowEdges", ownedWrite: true }, ); const [hiddenState, setHiddenState] = createSignal( DEFAULT_HIDDEN_STATE, { name: "scroll-area.hiddenState", ownedWrite: true }, ); const rootRef: MutableRef = { current: null }; const viewportRef: MutableRef = { current: null }; const scrollbarYRef: MutableRef = { current: null }; const scrollbarXRef: MutableRef = { current: null }; const thumbYRef: MutableRef = { current: null }; const thumbXRef: MutableRef = { current: null }; const cornerRef: MutableRef = { current: null }; // Imperative thumb-drag state. Not reactive — lives for the duration of a // single pointer gesture. Matches the React ref pattern 1:1. let thumbDragging = false; let startY = 0; let startX = 0; let startScrollTop = 0; let startScrollLeft = 0; let currentOrientation: "vertical" | "horizontal" = "vertical"; const scrollPositionRef: Coords = { x: 0, y: 0 }; function handleScroll(scrollPosition: Coords): void { const offsetX = scrollPosition.x - scrollPositionRef.x; const offsetY = scrollPosition.y - scrollPositionRef.y; scrollPositionRef.x = scrollPosition.x; scrollPositionRef.y = scrollPosition.y; if (offsetY !== 0) { setScrollingY(true); scrollYTimeout.start(SCROLL_TIMEOUT, () => { setScrollingY(false); }); } if (offsetX !== 0) { setScrollingX(true); scrollXTimeout.start(SCROLL_TIMEOUT, () => { setScrollingX(false); }); } } function handlePointerDown(event: PointerEvent): void { if (event.button !== 0) return; thumbDragging = true; startY = event.clientY; startX = event.clientX; const target = event.currentTarget as HTMLElement | null; const orientationAttr = target?.getAttribute("data-orientation"); currentOrientation = orientationAttr === "horizontal" ? "horizontal" : "vertical"; if (viewportRef.current) { startScrollTop = viewportRef.current.scrollTop; startScrollLeft = viewportRef.current.scrollLeft; } if (thumbYRef.current && currentOrientation === "vertical") { thumbYRef.current.setPointerCapture(event.pointerId); } if (thumbXRef.current && currentOrientation === "horizontal") { thumbXRef.current.setPointerCapture(event.pointerId); } } function handlePointerMove(event: PointerEvent): void { if (!thumbDragging) return; const deltaY = event.clientY - startY; const deltaX = event.clientX - startX; const viewportEl = viewportRef.current; if (!viewportEl) return; const scrollableContentHeight = viewportEl.scrollHeight; const viewportHeight = viewportEl.clientHeight; const scrollableContentWidth = viewportEl.scrollWidth; const viewportWidth = viewportEl.clientWidth; if ( thumbYRef.current && scrollbarYRef.current && currentOrientation === "vertical" ) { const scrollbarYOffset = getOffset(scrollbarYRef.current, "padding", "y"); const thumbYOffset = getOffset(thumbYRef.current, "margin", "y"); const thumbHeight = thumbYRef.current.offsetHeight; const maxThumbOffsetY = scrollbarYRef.current.offsetHeight - thumbHeight - scrollbarYOffset - thumbYOffset; const scrollRatioY = deltaY / maxThumbOffsetY; viewportEl.scrollTop = startScrollTop + scrollRatioY * (scrollableContentHeight - viewportHeight); event.preventDefault(); setScrollingY(true); scrollYTimeout.start(SCROLL_TIMEOUT, () => { setScrollingY(false); }); } if ( thumbXRef.current && scrollbarXRef.current && currentOrientation === "horizontal" ) { const scrollbarXOffset = getOffset(scrollbarXRef.current, "padding", "x"); const thumbXOffset = getOffset(thumbXRef.current, "margin", "x"); const thumbWidth = thumbXRef.current.offsetWidth; const maxThumbOffsetX = scrollbarXRef.current.offsetWidth - thumbWidth - scrollbarXOffset - thumbXOffset; const scrollRatioX = deltaX / maxThumbOffsetX; viewportEl.scrollLeft = startScrollLeft + scrollRatioX * (scrollableContentWidth - viewportWidth); event.preventDefault(); setScrollingX(true); scrollXTimeout.start(SCROLL_TIMEOUT, () => { setScrollingX(false); }); } } function handlePointerUp(event: PointerEvent): void { thumbDragging = false; if (thumbYRef.current && currentOrientation === "vertical") { thumbYRef.current.releasePointerCapture(event.pointerId); } if (thumbXRef.current && currentOrientation === "horizontal") { thumbXRef.current.releasePointerCapture(event.pointerId); } } function handlePointerEnterOrMove(event: PointerEvent): void { if (event.pointerType !== "touch") { const isTargetRootChild = contains( rootRef.current, event.target as Element | null, ); setHovering(isTargetRootChild); } } const state = createMemo( () => { const edges = overflowEdges(); const hidden = hiddenState(); return { scrolling: scrollingX() || scrollingY(), hasOverflowX: !hidden.x, hasOverflowY: !hidden.y, overflowXStart: edges.xStart, overflowXEnd: edges.xEnd, overflowYStart: edges.yStart, overflowYEnd: edges.yEnd, cornerHidden: hidden.corner, }; }, { name: "scroll-area.state" }, ); // ---------- root slot ---------- const rootInternal = { role: "presentation" as const, onPointerEnter: handlePointerEnterOrMove, onPointerMove: handlePointerEnterOrMove, onPointerLeave: () => { setHovering(false); }, get style(): JSX.CSSProperties { const size = cornerSize(); return { position: "relative", [ScrollAreaRootCssVars.scrollAreaCornerHeight]: `${size.height}px`, [ScrollAreaRootCssVars.scrollAreaCornerWidth]: `${size.width}px`, }; }, }; const root = (userProps?: U) => { const merged = mergeRootProps(rootInternal, scrollAreaDataAttrs(state)); return mergeRootProps(merged, userProps) as ScrollAreaRootSlotProps & (U extends object ? U : object); }; const bindRoot = (el: HTMLDivElement | null): void => { rootRef.current = el; }; const rootRefDirective = (): ((el: HTMLDivElement | null) => void) => { const baseDirective = makeSlotRefDirective( () => root() as Record, ); return (el: HTMLDivElement | null): void => { bindRoot(el); baseDirective(el); }; }; // ---------- assemble api ---------- const api: ScrollAreaApi = { handlePointerDown, handlePointerMove, handlePointerUp, handleScroll, cornerSize, setCornerSize, thumbSize, setThumbSize, hasMeasuredScrollbar, setHasMeasuredScrollbar, cornerRef, scrollingX, setScrollingX, scrollingY, setScrollingY, hovering, setHovering, viewportRef, rootRef, scrollbarYRef, scrollbarXRef, thumbYRef, thumbXRef, rootId, hiddenState, setHiddenState, overflowEdges, setOverflowEdges, viewportState: state, get overflowEdgeThreshold(): OverflowEdgeThreshold { return normalizeOverflowEdgeThreshold( readOption(options.overflowEdgeThreshold), ); }, root, bindRoot, rootRefDirective, Provider: (providerProps) => ( {providerProps.children} ), }; (api as ScrollAreaApi & Snapshottable)[SNAPSHOT_KEYS] = SCROLL_AREA_SNAPSHOT_KEYS; return api; } const SCROLL_AREA_SNAPSHOT_KEYS = snapshotKeys( "hovering", "scrollingX", "scrollingY", "hasMeasuredScrollbar", "cornerSize", "thumbSize", "hiddenState", "overflowEdges", );