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 (
);
}