|
|
@@ -0,0 +1,206 @@
|
|
|
+import { type JSX, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
|
|
|
+
|
|
|
+export interface ScrollFadeProps extends JSX.HTMLAttributes<HTMLDivElement> {
|
|
|
+ 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()
|
|
|
+
|
|
|
+ let rafId: number | undefined
|
|
|
+ let isPolling = false
|
|
|
+ let pollTimeout: ReturnType<typeof setTimeout> | undefined
|
|
|
+
|
|
|
+ const startPolling = () => {
|
|
|
+ if (isPolling) return
|
|
|
+ isPolling = true
|
|
|
+
|
|
|
+ const pollScroll = () => {
|
|
|
+ updateFade()
|
|
|
+ rafId = requestAnimationFrame(pollScroll)
|
|
|
+ }
|
|
|
+ rafId = requestAnimationFrame(pollScroll)
|
|
|
+ }
|
|
|
+
|
|
|
+ const stopPolling = () => {
|
|
|
+ if (!isPolling) return
|
|
|
+ isPolling = false
|
|
|
+ if (rafId !== undefined) {
|
|
|
+ cancelAnimationFrame(rafId)
|
|
|
+ rafId = undefined
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const schedulePollingStop = () => {
|
|
|
+ if (pollTimeout !== undefined) clearTimeout(pollTimeout)
|
|
|
+ pollTimeout = setTimeout(stopPolling, 1000)
|
|
|
+ }
|
|
|
+
|
|
|
+ const onActivity = () => {
|
|
|
+ updateFade()
|
|
|
+ if (local.trackTransformSelector) {
|
|
|
+ startPolling()
|
|
|
+ schedulePollingStop()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ containerRef.addEventListener("scroll", onActivity, { passive: true })
|
|
|
+
|
|
|
+ const resizeObserver = new ResizeObserver(() => {
|
|
|
+ lastScrollSize = 0
|
|
|
+ lastClientSize = 0
|
|
|
+ onActivity()
|
|
|
+ })
|
|
|
+ resizeObserver.observe(containerRef)
|
|
|
+
|
|
|
+ const mutationObserver = new MutationObserver(() => {
|
|
|
+ lastScrollSize = 0
|
|
|
+ lastClientSize = 0
|
|
|
+ requestAnimationFrame(onActivity)
|
|
|
+ })
|
|
|
+ mutationObserver.observe(containerRef, {
|
|
|
+ childList: true,
|
|
|
+ subtree: true,
|
|
|
+ characterData: true,
|
|
|
+ })
|
|
|
+
|
|
|
+ onCleanup(() => {
|
|
|
+ containerRef?.removeEventListener("scroll", onActivity)
|
|
|
+ resizeObserver.disconnect()
|
|
|
+ mutationObserver.disconnect()
|
|
|
+ stopPolling()
|
|
|
+ if (pollTimeout !== undefined) clearTimeout(pollTimeout)
|
|
|
+ })
|
|
|
+ })
|
|
|
+
|
|
|
+ createEffect(() => {
|
|
|
+ local.children
|
|
|
+ requestAnimationFrame(updateFade)
|
|
|
+ })
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div
|
|
|
+ ref={(el) => {
|
|
|
+ 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}
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+}
|