import type { JSX, ValidComponent } from "@solidjs/web"; import { dynamic } from "@solidjs/web"; import { createEffect, createMemo, createSignal, omit, Show } from "solid-js"; import { useDirection } from "../../direction-provider/direction-provider-context.ts"; import { composeRefs, userRef } from "../../internals/compose-refs"; import { contains } from "../../internals/dom/contains.ts"; import { getTarget } from "../../internals/dom/get-target.ts"; import { mergeRootProps } from "../../internals/merge-root-props"; import { resolveClassStyle } from "../../internals/resolve-class-style"; import type { BaseUIComponentProps } from "../../internals/types"; import type { ScrollAreaRootState } from "../root/scroll-area-root-context.ts"; import { useScrollAreaRootContext } from "../root/scroll-area-root-context.ts"; import { ScrollAreaRootCssVars } from "../root/scroll-area-root-css-vars.ts"; import { ScrollAreaRootDataAttributes } from "../root/scroll-area-root-data-attributes.ts"; import { getOffset } from "../utils/get-offset.ts"; import { ScrollAreaScrollbarContext, type ScrollAreaScrollbarContextValue, } from "./scroll-area-scrollbar-context.ts"; import { ScrollAreaScrollbarCssVars } from "./scroll-area-scrollbar-css-vars.ts"; import { ScrollAreaScrollbarDataAttributes } from "./scroll-area-scrollbar-data-attributes.ts"; export interface ScrollAreaScrollbarState extends ScrollAreaRootState { /** Whether the scroll area is hovered. */ hovering: boolean; /** Whether the scroll area is currently scrolling along this axis. */ scrolling: boolean; /** The scrollbar's orientation. */ orientation: "vertical" | "horizontal"; } export interface ScrollAreaScrollbarProps extends BaseUIComponentProps<"div", ScrollAreaScrollbarState> { /** Tag/component to render. Defaults to `"div"`. */ as?: ValidComponent; /** * Axis the scrollbar controls. * @default 'vertical' */ orientation?: "vertical" | "horizontal" | undefined; /** * Keep the element mounted even when the viewport isn't scrollable. * @default false */ keepMounted?: boolean | undefined; } /** * A vertical or horizontal scrollbar. Renders a `
` element. * * Solid 2 notes: * * - Conditionally rendered via `` when the scrollbar's axis is hidden * (mirrors React's `shouldRender = keepMounted || !isHidden`). * - Wheel handling is added via a native listener (not an `onWheel` prop) * because we need `passive: false` to call `preventDefault`. */ export function ScrollAreaScrollbar( props: ScrollAreaScrollbarProps, ): JSX.Element { const context = useScrollAreaRootContext(); const direction = useDirection(); const orientation = (): "vertical" | "horizontal" => props.orientation ?? "vertical"; const keepMounted = (): boolean => props.keepMounted ?? false; const isHidden = (): boolean => orientation() === "vertical" ? context.hiddenState().y : context.hiddenState().x; const hideTrackUntilMeasured = (): boolean => !context.hasMeasuredScrollbar() && !keepMounted(); const shouldRender = (): boolean => keepMounted() || !isHidden(); const state = createMemo(() => { const hidden = context.hiddenState(); const edges = context.overflowEdges(); const o = orientation(); return { hovering: context.hovering(), scrolling: o === "vertical" ? context.scrollingY() : context.scrollingX(), orientation: o, hasOverflowX: !hidden.x, hasOverflowY: !hidden.y, overflowXStart: edges.xStart, overflowXEnd: edges.xEnd, overflowYStart: edges.yStart, overflowYEnd: edges.yEnd, cornerHidden: hidden.corner, }; }); const scrollbarContextValue: ScrollAreaScrollbarContextValue = { get orientation() { return orientation(); }, }; // Local element signal so the wheel-listener effect retriggers when the // scrollbar mounts (context refs are imperative containers, not signals). const [scrollbarElSignal, setScrollbarElSignal] = createSignal(null, { ownedWrite: true }); // Native wheel listener with `passive: false` so we can call // `preventDefault` when the scroll is in-bounds. createEffect( () => ({ render: shouldRender(), o: orientation(), el: scrollbarElSignal(), }), ({ render, o, el }) => { if (!render || !el) return; const scrollbarEl = el; function handleWheel(event: WheelEvent): void { const viewportEl = context.viewportRef.current; if (!viewportEl || event.ctrlKey) return; event.preventDefault(); if (o === "vertical") { if (viewportEl.scrollTop === 0 && event.deltaY < 0) return; if ( viewportEl.scrollTop === viewportEl.scrollHeight - viewportEl.clientHeight && event.deltaY > 0 ) return; viewportEl.scrollTop += event.deltaY; } else { if (viewportEl.scrollLeft === 0 && event.deltaX < 0) return; if ( viewportEl.scrollLeft === viewportEl.scrollWidth - viewportEl.clientWidth && event.deltaX > 0 ) return; viewportEl.scrollLeft += event.deltaX; } } scrollbarEl.addEventListener("wheel", handleWheel, { passive: false }); return () => { scrollbarEl.removeEventListener("wheel", handleWheel); }; }, ); const userStyling = resolveClassStyle(props, () => state()); const userProps = omit( props, "class", "style", "attrs", "as", "children", "orientation", "keepMounted", ); const localRef = (el: HTMLDivElement | null): void => { if (orientation() === "vertical") { context.scrollbarYRef.current = el; } else { context.scrollbarXRef.current = el; } setScrollbarElSignal(el); }; const internalSlot = { "data-id": `${context.rootId}-scrollbar`, // Inline state-attr getters (replaces the legacy // `scrollbarStateAttributesMapping`). `cornerHidden` is // intentionally not projected. get [ScrollAreaRootDataAttributes.hasOverflowX](): "" | undefined { return state().hasOverflowX ? "" : undefined; }, get [ScrollAreaRootDataAttributes.hasOverflowY](): "" | undefined { return state().hasOverflowY ? "" : undefined; }, get [ScrollAreaRootDataAttributes.overflowXStart](): "" | undefined { return state().overflowXStart ? "" : undefined; }, get [ScrollAreaRootDataAttributes.overflowXEnd](): "" | undefined { return state().overflowXEnd ? "" : undefined; }, get [ScrollAreaRootDataAttributes.overflowYStart](): "" | undefined { return state().overflowYStart ? "" : undefined; }, get [ScrollAreaRootDataAttributes.overflowYEnd](): "" | undefined { return state().overflowYEnd ? "" : undefined; }, get [ScrollAreaScrollbarDataAttributes.hovering](): "" | undefined { return state().hovering ? "" : undefined; }, get [ScrollAreaScrollbarDataAttributes.scrolling](): "" | undefined { return state().scrolling ? "" : undefined; }, get [ScrollAreaScrollbarDataAttributes.orientation](): string { return String(state().orientation); }, onPointerDown: (event: PointerEvent) => { if (event.button !== 0) return; const target = getTarget(event) as Element | null; const o = orientation(); const thumb = o === "vertical" ? context.thumbYRef.current : context.thumbXRef.current; // Ignore clicks on the thumb. if (thumb && contains(thumb, target)) return; const viewportEl = context.viewportRef.current; if (!viewportEl) return; if (o === "vertical") { const thumbYEl = context.thumbYRef.current; const scrollbarYEl = context.scrollbarYRef.current; if (!thumbYEl || !scrollbarYEl) { context.handlePointerDown(event); return; } const thumbYOffset = getOffset(thumbYEl, "margin", "y"); const scrollbarYOffset = getOffset(scrollbarYEl, "padding", "y"); const thumbHeight = thumbYEl.offsetHeight; const trackRectY = scrollbarYEl.getBoundingClientRect(); const clickY = event.clientY - trackRectY.top - thumbHeight / 2 - scrollbarYOffset + thumbYOffset / 2; const scrollableContentHeight = viewportEl.scrollHeight; const viewportHeight = viewportEl.clientHeight; const maxThumbOffsetY = scrollbarYEl.offsetHeight - thumbHeight - scrollbarYOffset - thumbYOffset; const scrollRatioY = clickY / maxThumbOffsetY; viewportEl.scrollTop = scrollRatioY * (scrollableContentHeight - viewportHeight); } else { const thumbXEl = context.thumbXRef.current; const scrollbarXEl = context.scrollbarXRef.current; if (!thumbXEl || !scrollbarXEl) { context.handlePointerDown(event); return; } const thumbXOffset = getOffset(thumbXEl, "margin", "x"); const scrollbarXOffset = getOffset(scrollbarXEl, "padding", "x"); const thumbWidth = thumbXEl.offsetWidth; const trackRectX = scrollbarXEl.getBoundingClientRect(); const clickX = event.clientX - trackRectX.left - thumbWidth / 2 - scrollbarXOffset + thumbXOffset / 2; const scrollableContentWidth = viewportEl.scrollWidth; const viewportWidth = viewportEl.clientWidth; const maxThumbOffsetX = scrollbarXEl.offsetWidth - thumbWidth - scrollbarXOffset - thumbXOffset; const scrollRatioX = clickX / maxThumbOffsetX; let newScrollLeft: number; if (direction() === "rtl") { newScrollLeft = (1 - scrollRatioX) * (scrollableContentWidth - viewportWidth); if (viewportEl.scrollLeft <= 0) { newScrollLeft = -newScrollLeft; } } else { newScrollLeft = scrollRatioX * (scrollableContentWidth - viewportWidth); } viewportEl.scrollLeft = newScrollLeft; } context.handlePointerDown(event); }, onPointerUp: (event: PointerEvent) => { context.handlePointerUp(event); }, get style(): JSX.CSSProperties { const o = orientation(); const thumb = context.thumbSize(); const base: JSX.CSSProperties = { position: "absolute", "touch-action": "none", "-webkit-user-select": "none", "user-select": "none", }; if (hideTrackUntilMeasured()) { base.visibility = "hidden"; } if (o === "vertical") { base.top = 0; base.bottom = `var(${ScrollAreaRootCssVars.scrollAreaCornerHeight})`; base["inset-inline-end"] = 0; base[ScrollAreaScrollbarCssVars.scrollAreaThumbHeight] = `${thumb.height}px`; } else { base["inset-inline-start"] = 0; base["inset-inline-end"] = `var(${ScrollAreaRootCssVars.scrollAreaCornerWidth})`; base.bottom = 0; base[ScrollAreaScrollbarCssVars.scrollAreaThumbWidth] = `${thumb.width}px`; } return base; }, get children() { return ( {props.children} ); }, }; const styledUserProps = mergeRootProps(userStyling, userProps); const merged = mergeRootProps(internalSlot, styledUserProps); const Tag = dynamic(() => props.as ?? "div"); return ( ); }