import type { JSX, ValidComponent } from "@solidjs/web"; import { dynamic } from "@solidjs/web"; import { createEffect, createRenderEffect, createSignal, omit } from "solid-js"; import { useDirection } from "../../direction-provider/direction-provider-context.ts"; import { composeRefs, userRef } from "../../internals/compose-refs"; import { mergeRootProps } from "../../internals/merge-root-props"; import { resolveClassStyle } from "../../internals/resolve-class-style"; import { normalizeScrollOffset } from "../../internals/scroll-area-utils/scroll-edges.ts"; import { DISABLE_SCROLLBAR_CLASS_NAME, styleDisableScrollbar, } from "../../internals/scroll-area-utils/style-disable-scrollbar.ts"; import { clamp } from "../../internals/slider-utils/clamp.ts"; import { createStableCallback } from "../../internals/stable-callback"; import type { BaseUIComponentProps } from "../../internals/types"; import { useTimeout } from "../../internals/use-timeout.ts"; import { MIN_THUMB_SIZE } from "../constants.ts"; import { type HiddenState, type ScrollAreaRootState, useScrollAreaRootContext, } from "../root/scroll-area-root-context.ts"; import { getOffset } from "../utils/get-offset.ts"; import { scrollAreaDataAttrs } from "../utils/state-attrs.ts"; import { ScrollAreaViewportContext, type ScrollAreaViewportContextValue, } from "./scroll-area-viewport-context.ts"; import { ScrollAreaViewportCssVars } from "./scroll-area-viewport-css-vars.ts"; export interface ScrollAreaViewportState extends ScrollAreaRootState {} export interface ScrollAreaViewportProps extends BaseUIComponentProps<"div", ScrollAreaViewportState> { /** Tag/component to render. Defaults to `"div"`. */ as?: ValidComponent; } // Cross-component latch. `CSS.registerProperty` is global, so we guard // against re-registering when multiple scroll areas mount. let scrollAreaOverflowVarsRegistered = false; /** * Opts the scroll-area overflow CSS variables out of inheritance. With * `inherits: false`, descendants that want to read these vars must opt in * explicitly via `inherit`. Safari (WebKit) has bugs around that opt-in so * we skip the optimization there. */ function removeCSSVariableInheritance(): void { if (scrollAreaOverflowVarsRegistered) return; // Feature-detect WebKit via navigator — happy-dom exposes // `navigator.userAgent` without the WebKit markers, so the check is a // no-op in tests which is correct. const isWebKit = typeof navigator !== "undefined" && /AppleWebKit/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent); if (isWebKit) return; if (typeof CSS === "undefined" || !("registerProperty" in CSS)) { scrollAreaOverflowVarsRegistered = true; return; } for (const name of [ ScrollAreaViewportCssVars.scrollAreaOverflowXStart, ScrollAreaViewportCssVars.scrollAreaOverflowXEnd, ScrollAreaViewportCssVars.scrollAreaOverflowYStart, ScrollAreaViewportCssVars.scrollAreaOverflowYEnd, ]) { try { CSS.registerProperty({ name, syntax: "", inherits: false, initialValue: "0px", }); } catch { // Already registered — ignore. } } scrollAreaOverflowVarsRegistered = true; } function getHiddenState(viewport: HTMLElement): HiddenState { const y = viewport.clientHeight >= viewport.scrollHeight; const x = viewport.clientWidth >= viewport.scrollWidth; return { y, x, corner: y || x }; } function mergeHiddenState(prev: HiddenState, next: HiddenState): HiddenState { if (prev.y === next.y && prev.x === next.x && prev.corner === next.corner) { return prev; } return next; } /** * The scrollable container of the scroll area. Renders a `
` element. * * Responsibilities: * * - Hides the native scrollbars via the shared `.base-ui-disable-scrollbar` * class (the root injects the matching stylesheet). * - Publishes thumb/corner sizes, per-axis hidden state, overflow-edge * flags, and per-edge scroll-offset CSS vars for descendant scrollbars. * - Observes the viewport with a `ResizeObserver` and recomputes the thumb * on size changes. Also recomputes after in-progress animations settle. */ export function ScrollAreaViewport( props: ScrollAreaViewportProps, ): JSX.Element { const context = useScrollAreaRootContext(); const direction = useDirection(); // Tracks whether the most-recent scroll was user-driven. Flipped to // false by any interaction that implies user intent; reset to true // after ~100ms of no scroll events (matches React's programmatic-scroll // detection). let programmaticScroll = true; const lastMeasuredViewportMetrics: [number, number, number, number] = [ Number.NaN, Number.NaN, Number.NaN, Number.NaN, ]; const scrollEndTimeout = useTimeout(); const waitForAnimationsTimeout = useTimeout(); // Local element signal so effects retrigger when the viewport mounts. // Context refs are imperative containers, not signals. const [viewportEl, setViewportEl] = createSignal( null, { ownedWrite: true }, ); const computeThumbPosition = createStableCallback(() => (): void => { const viewportEl = context.viewportRef.current; const scrollbarYEl = context.scrollbarYRef.current; const scrollbarXEl = context.scrollbarXRef.current; const thumbYEl = context.thumbYRef.current; const thumbXEl = context.thumbXRef.current; const cornerEl = context.cornerRef.current; if (!viewportEl) return; const scrollableContentHeight = viewportEl.scrollHeight; const scrollableContentWidth = viewportEl.scrollWidth; const viewportHeight = viewportEl.clientHeight; const viewportWidth = viewportEl.clientWidth; const scrollTop = viewportEl.scrollTop; const scrollLeft = viewportEl.scrollLeft; const isFirstMeasurement = Number.isNaN(lastMeasuredViewportMetrics[0]); lastMeasuredViewportMetrics[0] = viewportHeight; lastMeasuredViewportMetrics[1] = scrollableContentHeight; lastMeasuredViewportMetrics[2] = viewportWidth; lastMeasuredViewportMetrics[3] = scrollableContentWidth; if (isFirstMeasurement) { context.setHasMeasuredScrollbar(true); } if (scrollableContentHeight === 0 || scrollableContentWidth === 0) { // Matches React `ScrollAreaViewport.tsx:142-144` — with zero // content there's no meaningful hidden-state to publish, so bail // before doing any further work. A later ResizeObserver callback // will re-run this once the content mounts. return; } const nextHiddenState = getHiddenState(viewportEl); const scrollbarYHidden = nextHiddenState.y; const scrollbarXHidden = nextHiddenState.x; const ratioX = viewportWidth / scrollableContentWidth; const ratioY = viewportHeight / scrollableContentHeight; const maxScrollLeft = Math.max(0, scrollableContentWidth - viewportWidth); const maxScrollTop = Math.max(0, scrollableContentHeight - viewportHeight); let scrollLeftFromStart = 0; let scrollLeftFromEnd = 0; if (!scrollbarXHidden) { const rawScrollLeftFromStart = direction() === "rtl" ? clamp(-scrollLeft, 0, maxScrollLeft) : clamp(scrollLeft, 0, maxScrollLeft); scrollLeftFromStart = normalizeScrollOffset( rawScrollLeftFromStart, maxScrollLeft, ); scrollLeftFromEnd = maxScrollLeft - scrollLeftFromStart; } const rawScrollTopFromStart = !scrollbarYHidden ? clamp(scrollTop, 0, maxScrollTop) : 0; const scrollTopFromStart = !scrollbarYHidden ? normalizeScrollOffset(rawScrollTopFromStart, maxScrollTop) : 0; const scrollTopFromEnd = !scrollbarYHidden ? maxScrollTop - scrollTopFromStart : 0; const nextWidth = scrollbarXHidden ? 0 : viewportWidth; const nextHeight = scrollbarYHidden ? 0 : viewportHeight; let nextCornerWidth = 0; let nextCornerHeight = 0; if (!scrollbarXHidden && !scrollbarYHidden) { nextCornerWidth = scrollbarYEl?.offsetWidth ?? 0; nextCornerHeight = scrollbarXEl?.offsetHeight ?? 0; } // Only subtract corner size from scrollbar dimensions on the first // pass, before the layout has been told about the corner. After // that, layout already accounts for it. const cornerSize = context.cornerSize(); const cornerNotYetSized = cornerSize.width === 0 && cornerSize.height === 0; const cornerWidthOffset = cornerNotYetSized ? nextCornerWidth : 0; const cornerHeightOffset = cornerNotYetSized ? nextCornerHeight : 0; const scrollbarXOffset = getOffset(scrollbarXEl, "padding", "x"); const scrollbarYOffset = getOffset(scrollbarYEl, "padding", "y"); const thumbXOffset = getOffset(thumbXEl, "margin", "x"); const thumbYOffset = getOffset(thumbYEl, "margin", "y"); const idealNextWidth = nextWidth - scrollbarXOffset - thumbXOffset; const idealNextHeight = nextHeight - scrollbarYOffset - thumbYOffset; const maxNextWidth = scrollbarXEl ? Math.min(scrollbarXEl.offsetWidth - cornerWidthOffset, idealNextWidth) : idealNextWidth; const maxNextHeight = scrollbarYEl ? Math.min( scrollbarYEl.offsetHeight - cornerHeightOffset, idealNextHeight, ) : idealNextHeight; const clampedNextWidth = Math.max(MIN_THUMB_SIZE, maxNextWidth * ratioX); const clampedNextHeight = Math.max(MIN_THUMB_SIZE, maxNextHeight * ratioY); context.setThumbSize((prev) => { if ( prev.height === clampedNextHeight && prev.width === clampedNextWidth ) { return prev; } return { width: clampedNextWidth, height: clampedNextHeight }; }); // Position the vertical thumb. NB: this uses `Math.min/Math.max` // nesting rather than `clamp` — on a tiny track where the max offset // goes negative the two diverge (this branch yields the negative // offset, the horizontal branch's `clamp` yields 0); preserve as-is. function applyVerticalThumbTransform(): void { if (!scrollbarYEl || !thumbYEl) return; const maxThumbOffsetY = scrollbarYEl.offsetHeight - clampedNextHeight - scrollbarYOffset - thumbYOffset; const scrollRangeY = scrollableContentHeight - viewportHeight; const scrollRatioY = scrollRangeY === 0 ? 0 : scrollTop / scrollRangeY; const thumbOffsetY = Math.min( maxThumbOffsetY, Math.max(0, scrollRatioY * maxThumbOffsetY), ); thumbYEl.style.transform = `translate3d(0,${thumbOffsetY}px,0)`; } function applyHorizontalThumbTransform(): void { if (!scrollbarXEl || !thumbXEl) return; const maxThumbOffsetX = scrollbarXEl.offsetWidth - clampedNextWidth - scrollbarXOffset - thumbXOffset; const scrollRangeX = scrollableContentWidth - viewportWidth; const scrollRatioX = scrollRangeX === 0 ? 0 : scrollLeft / scrollRangeX; const thumbOffsetX = direction() === "rtl" ? clamp(scrollRatioX * maxThumbOffsetX, -maxThumbOffsetX, 0) : clamp(scrollRatioX * maxThumbOffsetX, 0, maxThumbOffsetX); thumbXEl.style.transform = `translate3d(${thumbOffsetX}px,0,0)`; } // Publish overflow offsets (as CSS vars), corner size, hidden state, // and the past-threshold overflow-edge flags back to the context. function commitOverflowAndCornerState(): void { if (!viewportEl) return; const overflowMetricsPx: Array<[string, number]> = [ [ ScrollAreaViewportCssVars.scrollAreaOverflowXStart, scrollLeftFromStart, ], [ScrollAreaViewportCssVars.scrollAreaOverflowXEnd, scrollLeftFromEnd], [ ScrollAreaViewportCssVars.scrollAreaOverflowYStart, scrollTopFromStart, ], [ScrollAreaViewportCssVars.scrollAreaOverflowYEnd, scrollTopFromEnd], ]; for (const [cssVar, value] of overflowMetricsPx) { viewportEl.style.setProperty(cssVar, `${value}px`); } if (cornerEl) { if (scrollbarXHidden || scrollbarYHidden) { context.setCornerSize({ width: 0, height: 0 }); } else { context.setCornerSize({ width: nextCornerWidth, height: nextCornerHeight, }); } } context.setHiddenState((prev) => mergeHiddenState(prev, nextHiddenState)); const threshold = context.overflowEdgeThreshold; const nextOverflowEdges = { xStart: !scrollbarXHidden && scrollLeftFromStart > threshold.xStart, xEnd: !scrollbarXHidden && scrollLeftFromEnd > threshold.xEnd, yStart: !scrollbarYHidden && scrollTopFromStart > threshold.yStart, yEnd: !scrollbarYHidden && scrollTopFromEnd > threshold.yEnd, }; context.setOverflowEdges((prev) => { if ( prev.xStart === nextOverflowEdges.xStart && prev.xEnd === nextOverflowEdges.xEnd && prev.yStart === nextOverflowEdges.yStart && prev.yEnd === nextOverflowEdges.yEnd ) { return prev; } return nextOverflowEdges; }); } applyVerticalThumbTransform(); applyHorizontalThumbTransform(); commitOverflowAndCornerState(); }); // One-time setup: register the CSS vars as non-inheriting. createRenderEffect( () => viewportEl(), (el) => { if (!el) return; removeCSSVariableInheritance(); }, ); // Recompute whenever hidden-state or direction flips. `queueMicrotask` // so the scrollbar/thumb refs are populated before we measure them. createRenderEffect( () => ({ hidden: context.hiddenState(), dir: direction() }), () => { queueMicrotask(computeThumbPosition); }, ); // Initial hover catch: `onMouseEnter` doesn't fire on mount, so if the // viewport is already being hovered when it mounts, flip the flag. createRenderEffect( () => viewportEl(), (el) => { if (el?.matches?.(":hover")) { context.setHovering(true); } }, ); // ResizeObserver wiring. Fires on viewport resize, which also covers // most font-loading / layout-shift cases. createEffect( () => viewportEl(), (viewport) => { if (typeof ResizeObserver === "undefined" || !viewport) return; let hasInitialized = false; const ro = new ResizeObserver(() => { // Skip the initial observe call when the metrics match what the // mount-time pass already measured. if (!hasInitialized) { hasInitialized = true; if ( lastMeasuredViewportMetrics[0] === viewport.clientHeight && lastMeasuredViewportMetrics[1] === viewport.scrollHeight && lastMeasuredViewportMetrics[2] === viewport.clientWidth && lastMeasuredViewportMetrics[3] === viewport.scrollWidth ) { return; } } computeThumbPosition(); }); ro.observe(viewport); // Wait for any in-flight animations (dialog open, etc.) to settle // and recompute so the thumb size isn't stuck at mid-animation. waitForAnimationsTimeout.start(0, () => { if (typeof viewport.getAnimations !== "function") return; const animations = viewport.getAnimations({ subtree: true }); if (animations.length === 0) return; Promise.allSettled(animations.map((animation) => animation.finished)) .then(() => computeThumbPosition()) .catch(() => {}); }); return () => { ro.disconnect(); waitForAnimationsTimeout.clear(); }; }, ); // The root's derived state is identical to what this part needs for its // `data-*` projection — read it straight from context instead of rebuilding // the memo. (`ScrollAreaViewportState` is structurally `ScrollAreaRootState`.) const viewportState = context.viewportState; const contextValue: ScrollAreaViewportContextValue = { computeThumbPosition, }; function handleUserInteraction(): void { programmaticScroll = false; } const userStyling = resolveClassStyle(props, () => context.viewportState()); const userProps = omit(props, "class", "style", "attrs", "as", "children"); const localRef = (el: HTMLDivElement | null): void => { context.viewportRef.current = el; setViewportEl(el); }; const internalSlot = { role: "presentation" as const, "data-id": `${context.rootId}-viewport`, // Non-scrollable viewports don't belong in tab order. get tabIndex(): number { const h = context.hiddenState(); return h.x && h.y ? -1 : 0; }, class: DISABLE_SCROLLBAR_CLASS_NAME, style: { overflow: "scroll" } as JSX.CSSProperties, onScroll: () => { const viewportEl = context.viewportRef.current; if (!viewportEl) return; computeThumbPosition(); if (!programmaticScroll) { context.handleScroll({ x: viewportEl.scrollLeft, y: viewportEl.scrollTop, }); } // Debounce the programmatic-scroll reset so momentum scrolling // (where no user-interaction events fire after the initial // gesture) is still treated as user-driven. scrollEndTimeout.start(100, () => { programmaticScroll = true; }); }, // `wheel` / `touchmove` are scroll-blocking events: a plain `onWheel` / // `onTouchMove` prop registers a NON-passive listener (Solid can't know // the handler won't `preventDefault`), which Chrome flags as a perf // violation. `handleUserInteraction` only flips a flag, so register them // passive via Solid's `on:` namespace (a non-function value is forwarded // as the `addEventListener` options). The non-scroll-blocking handlers // below stay as ordinary props. "on:wheel": { handleEvent: handleUserInteraction, passive: true }, "on:touchmove": { handleEvent: handleUserInteraction, passive: true }, onPointerMove: handleUserInteraction, onPointerEnter: handleUserInteraction, onKeyDown: handleUserInteraction, get children() { return ( {props.children} ); }, }; const styledUserProps = mergeRootProps(userStyling, userProps); let merged: Record = mergeRootProps( internalSlot, scrollAreaDataAttrs(viewportState), ); merged = mergeRootProps(merged, styledUserProps); const Tag = dynamic(() => props.as ?? "div"); return ; } // Re-export the class name constant for consumers needing to reach the // underlying style token (rare — normally handled by the root). export { styleDisableScrollbar };