feat: scroll fade component
This commit is contained in:
parent
0296ab2cee
commit
ecbda741d9
2 changed files with 297 additions and 0 deletions
114
packages/ui/src/components/scroll-fade.css
Normal file
114
packages/ui/src/components/scroll-fade.css
Normal file
|
|
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
183
packages/ui/src/components/scroll-fade.tsx
Normal file
183
packages/ui/src/components/scroll-fade.tsx
Normal file
|
|
@ -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<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()
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue