diff --git a/packages/ui/src/components/scroll-fade.css b/packages/ui/src/components/scroll-fade.css new file mode 100644 index 0000000000..f379df70c4 --- /dev/null +++ b/packages/ui/src/components/scroll-fade.css @@ -0,0 +1,114 @@ +[data-component="scroll-fade"] { + overflow: auto; + overscroll-behavior: contain; + + &[data-direction="horizontal"] { + overflow-x: auto; + overflow-y: hidden; + + /* Both fades */ + &[data-fade-start][data-fade-end] { + mask-image: linear-gradient( + to right, + transparent, + black var(--scroll-fade-start), + black calc(100% - var(--scroll-fade-end)), + transparent + ); + -webkit-mask-image: linear-gradient( + to right, + transparent, + black var(--scroll-fade-start), + black calc(100% - var(--scroll-fade-end)), + transparent + ); + } + + /* Only start fade */ + &[data-fade-start]:not([data-fade-end]) { + mask-image: linear-gradient( + to right, + transparent, + black var(--scroll-fade-start), + black 100% + ); + -webkit-mask-image: linear-gradient( + to right, + transparent, + black var(--scroll-fade-start), + black 100% + ); + } + + /* Only end fade */ + &:not([data-fade-start])[data-fade-end] { + mask-image: linear-gradient( + to right, + black 0%, + black calc(100% - var(--scroll-fade-end)), + transparent + ); + -webkit-mask-image: linear-gradient( + to right, + black 0%, + black calc(100% - var(--scroll-fade-end)), + transparent + ); + } + } + + &[data-direction="vertical"] { + overflow-y: auto; + overflow-x: hidden; + + /* Both fades */ + &[data-fade-start][data-fade-end] { + mask-image: linear-gradient( + to bottom, + transparent, + black var(--scroll-fade-start), + black calc(100% - var(--scroll-fade-end)), + transparent + ); + -webkit-mask-image: linear-gradient( + to bottom, + transparent, + black var(--scroll-fade-start), + black calc(100% - var(--scroll-fade-end)), + transparent + ); + } + + /* Only start fade */ + &[data-fade-start]:not([data-fade-end]) { + mask-image: linear-gradient( + to bottom, + transparent, + black var(--scroll-fade-start), + black 100% + ); + -webkit-mask-image: linear-gradient( + to bottom, + transparent, + black var(--scroll-fade-start), + black 100% + ); + } + + /* Only end fade */ + &:not([data-fade-start])[data-fade-end] { + mask-image: linear-gradient( + to bottom, + black 0%, + black calc(100% - var(--scroll-fade-end)), + transparent + ); + -webkit-mask-image: linear-gradient( + to bottom, + black 0%, + black calc(100% - var(--scroll-fade-end)), + transparent + ); + } + } +} diff --git a/packages/ui/src/components/scroll-fade.tsx b/packages/ui/src/components/scroll-fade.tsx new file mode 100644 index 0000000000..6d06fdfe9d --- /dev/null +++ b/packages/ui/src/components/scroll-fade.tsx @@ -0,0 +1,183 @@ +import { + type JSX, + createEffect, + createSignal, + onCleanup, + onMount, + splitProps, +} from "solid-js" +import "./scroll-fade.css" + +export interface ScrollFadeProps extends JSX.HTMLAttributes { + direction?: "horizontal" | "vertical" + fadeStartSize?: number + fadeEndSize?: number + trackTransformSelector?: string + ref?: (el: HTMLDivElement) => void +} + +export function ScrollFade(props: ScrollFadeProps) { + const [local, others] = splitProps(props, [ + "children", + "direction", + "fadeStartSize", + "fadeEndSize", + "trackTransformSelector", + "class", + "style", + "ref", + ]) + + const direction = () => local.direction ?? "vertical" + const fadeStartSize = () => local.fadeStartSize ?? 20 + const fadeEndSize = () => local.fadeEndSize ?? 20 + + const getTransformOffset = (element: Element): number => { + const style = getComputedStyle(element) + const transform = style.transform + if (!transform || transform === "none") return 0 + + const match = transform.match(/matrix(?:3d)?\(([^)]+)\)/) + if (!match) return 0 + + const values = match[1].split(",").map((v) => parseFloat(v.trim())) + const isHorizontal = direction() === "horizontal" + + if (transform.startsWith("matrix3d")) { + return isHorizontal ? -(values[12] || 0) : -(values[13] || 0) + } else { + return isHorizontal ? -(values[4] || 0) : -(values[5] || 0) + } + } + + let containerRef: HTMLDivElement | undefined + + const [fadeStart, setFadeStart] = createSignal(0) + const [fadeEnd, setFadeEnd] = createSignal(0) + const [isScrollable, setIsScrollable] = createSignal(false) + + let lastScrollPos = 0 + let lastTransformPos = 0 + let lastScrollSize = 0 + let lastClientSize = 0 + + const updateFade = () => { + if (!containerRef) return + + const isHorizontal = direction() === "horizontal" + const scrollPos = isHorizontal ? containerRef.scrollLeft : containerRef.scrollTop + const scrollSize = isHorizontal ? containerRef.scrollWidth : containerRef.scrollHeight + const clientSize = isHorizontal ? containerRef.clientWidth : containerRef.clientHeight + + let transformPos = 0 + if (local.trackTransformSelector) { + const transformElement = containerRef.querySelector(local.trackTransformSelector) + if (transformElement) { + transformPos = getTransformOffset(transformElement) + } + } + + const effectiveScrollPos = Math.max(scrollPos, transformPos) + + if ( + effectiveScrollPos === lastScrollPos && + transformPos === lastTransformPos && + scrollSize === lastScrollSize && + clientSize === lastClientSize + ) { + return + } + + lastScrollPos = effectiveScrollPos + lastTransformPos = transformPos + lastScrollSize = scrollSize + lastClientSize = clientSize + + const maxScroll = scrollSize - clientSize + const canScroll = maxScroll > 1 + + setIsScrollable(canScroll) + + if (!canScroll) { + setFadeStart(0) + setFadeEnd(0) + return + } + + const progress = maxScroll > 0 ? effectiveScrollPos / maxScroll : 0 + + const startProgress = Math.min(progress / 0.1, 1) + setFadeStart(startProgress * fadeStartSize()) + + const endProgress = progress > 0.9 ? (1 - progress) / 0.1 : 1 + setFadeEnd(Math.max(0, endProgress) * fadeEndSize()) + } + + onMount(() => { + if (!containerRef) return + + updateFade() + + containerRef.addEventListener("scroll", updateFade, { passive: true }) + + const resizeObserver = new ResizeObserver(() => { + lastScrollSize = 0 + lastClientSize = 0 + updateFade() + }) + resizeObserver.observe(containerRef) + + const mutationObserver = new MutationObserver(() => { + lastScrollSize = 0 + lastClientSize = 0 + requestAnimationFrame(updateFade) + }) + mutationObserver.observe(containerRef, { + childList: true, + subtree: true, + characterData: true, + }) + + let rafId: number + const pollScroll = () => { + updateFade() + rafId = requestAnimationFrame(pollScroll) + } + rafId = requestAnimationFrame(pollScroll) + + onCleanup(() => { + containerRef?.removeEventListener("scroll", updateFade) + resizeObserver.disconnect() + mutationObserver.disconnect() + cancelAnimationFrame(rafId) + }) + }) + + createEffect(() => { + local.children + requestAnimationFrame(updateFade) + }) + + return ( +
{ + containerRef = el + local.ref?.(el) + }} + data-component="scroll-fade" + data-direction={direction()} + data-scrollable={isScrollable() || undefined} + data-fade-start={fadeStart() > 0 || undefined} + data-fade-end={fadeEnd() > 0 || undefined} + class={local.class} + style={{ + ...(typeof local.style === "object" ? local.style : {}), + "--scroll-fade-start": `${fadeStart()}px`, + "--scroll-fade-end": `${fadeEnd()}px`, + }} + {...others} + > + {local.children} +
+ ) +}