| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310 |
- import { onCleanup, onMount, splitProps, type ComponentProps, Show, mergeProps } from "solid-js"
- import { createResizeObserver } from "@solid-primitives/resize-observer"
- import { createStore } from "solid-js/store"
- import { useI18n } from "../context/i18n"
- export type ScrollViewThumbVisibility = "hover" | "scroll"
- export interface ScrollViewProps extends ComponentProps<"div"> {
- viewportRef?: (el: HTMLDivElement) => void
- orientation?: "vertical" | "horizontal" // currently only vertical is fully implemented for thumb
- thumbVisibility?: ScrollViewThumbVisibility
- }
- export const scrollKey = (event: Pick<KeyboardEvent, "key" | "altKey" | "ctrlKey" | "metaKey" | "shiftKey">) => {
- if (event.altKey || event.ctrlKey || event.metaKey) return
- if (event.shiftKey && event.key !== " ") return
- switch (event.key) {
- case "PageDown":
- return "page-down"
- case "PageUp":
- return "page-up"
- case "Home":
- return "home"
- case "End":
- return "end"
- case "ArrowUp":
- return "up"
- case "ArrowDown":
- return "down"
- case " ":
- return event.shiftKey ? "page-up" : "page-down"
- }
- }
- export function canScrollKey(element: HTMLElement, key: NonNullable<ReturnType<typeof scrollKey>>) {
- const up = key === "up" || key === "page-up" || key === "home"
- return up ? element.scrollTop > 0 : element.scrollTop + element.clientHeight < element.scrollHeight
- }
- export function scrollKeyOwner(
- root: HTMLElement,
- target: EventTarget | null,
- key: NonNullable<ReturnType<typeof scrollKey>>,
- ) {
- const element = target instanceof Element ? target : undefined
- const owner = element?.closest<HTMLElement>("[data-scrollable]")
- if (!owner || owner === root) return root
- if (!root.contains(owner)) return owner
- return canScrollKey(owner, key) ? owner : root
- }
- export function isScrollKeyTarget(target: EventTarget | null, key: NonNullable<ReturnType<typeof scrollKey>>) {
- const element = target instanceof HTMLElement ? target : undefined
- if (!element) return true
- if (["INPUT", "TEXTAREA", "SELECT"].includes(element.tagName) || element.isContentEditable) return false
- if ((key === "page-up" || key === "page-down") && element.closest('button, a[href], [role="button"]')) return false
- return true
- }
- export function scrollTopFromThumbPointer(input: {
- pointer: number
- viewportTop: number
- grabOffset: number
- clientHeight: number
- scrollHeight: number
- thumbHeight: number
- }) {
- const padding = 8
- const maxThumbTop = input.clientHeight - padding * 2 - input.thumbHeight
- if (maxThumbTop <= 0) return 0
- const thumbTop = Math.max(0, Math.min(input.pointer - input.viewportTop - padding - input.grabOffset, maxThumbTop))
- return (thumbTop / maxThumbTop) * Math.max(0, input.scrollHeight - input.clientHeight)
- }
- export function ScrollView(props: ScrollViewProps) {
- const i18n = useI18n()
- const merged = mergeProps({ orientation: "vertical", thumbVisibility: "hover" }, props)
- const [local, events, rest] = splitProps(
- merged,
- ["class", "children", "viewportRef", "orientation", "thumbVisibility", "style"],
- [
- "onScroll",
- "onWheel",
- "onTouchStart",
- "onTouchMove",
- "onTouchEnd",
- "onTouchCancel",
- "onPointerDown",
- "onClick",
- "onKeyDown",
- ],
- )
- let rootRef!: HTMLDivElement
- let viewportRef!: HTMLDivElement
- let thumbRef!: HTMLDivElement
- const [state, setState] = createStore({
- isHovered: false,
- isDragging: false,
- isScrolling: false,
- thumbHeight: 0,
- thumbTop: 0,
- showThumb: false,
- })
- const isHovered = () => state.isHovered
- const isDragging = () => state.isDragging
- const isScrolling = () => state.isScrolling
- const thumbHeight = () => state.thumbHeight
- const thumbTop = () => state.thumbTop
- const showThumb = () => state.showThumb
- let scrollIdleTimer: ReturnType<typeof setTimeout> | undefined
- const markScrolling = () => {
- if (local.thumbVisibility !== "scroll") return
- setState("isScrolling", true)
- if (scrollIdleTimer !== undefined) clearTimeout(scrollIdleTimer)
- scrollIdleTimer = setTimeout(() => setState("isScrolling", false), 800)
- }
- const thumbVisible = () => {
- if (isDragging()) return true
- if (local.thumbVisibility === "scroll") return isScrolling()
- return isHovered()
- }
- onCleanup(() => {
- if (scrollIdleTimer !== undefined) clearTimeout(scrollIdleTimer)
- })
- const updateThumb = () => {
- if (!viewportRef) return
- const { scrollTop, scrollHeight, clientHeight } = viewportRef
- if (scrollHeight <= clientHeight || scrollHeight === 0) {
- setState("showThumb", false)
- return
- }
- setState("showThumb", true)
- const trackPadding = 8
- const trackHeight = clientHeight - trackPadding * 2
- const minThumbHeight = 32
- // Calculate raw thumb height based on ratio
- let height = (clientHeight / scrollHeight) * trackHeight
- height = Math.max(height, minThumbHeight)
- const maxScrollTop = scrollHeight - clientHeight
- const maxThumbTop = trackHeight - height
- const top = maxScrollTop > 0 ? (scrollTop / maxScrollTop) * maxThumbTop : 0
- // Ensure thumb stays within bounds (shouldn't be necessary due to math above, but good for safety)
- const boundedTop = trackPadding + Math.max(0, Math.min(top, maxThumbTop))
- setState("thumbHeight", height)
- setState("thumbTop", boundedTop)
- }
- onMount(() => {
- if (local.viewportRef) {
- local.viewportRef(viewportRef)
- }
- createResizeObserver([viewportRef, viewportRef.firstElementChild], updateThumb)
- updateThumb()
- })
- const onThumbPointerDown = (e: PointerEvent) => {
- e.preventDefault()
- e.stopPropagation()
- setState("isDragging", true)
- const grabOffset = e.clientY - thumbRef.getBoundingClientRect().top
- thumbRef.setPointerCapture(e.pointerId)
- const onPointerMove = (e: PointerEvent) => {
- const { scrollHeight, clientHeight } = viewportRef
- viewportRef.scrollTop = scrollTopFromThumbPointer({
- pointer: e.clientY,
- viewportTop: viewportRef.getBoundingClientRect().top,
- grabOffset,
- clientHeight,
- scrollHeight,
- thumbHeight: thumbHeight(),
- })
- }
- const done = (e: PointerEvent) => {
- setState("isDragging", false)
- thumbRef.releasePointerCapture(e.pointerId)
- thumbRef.removeEventListener("pointermove", onPointerMove)
- thumbRef.removeEventListener("pointerup", done)
- thumbRef.removeEventListener("pointercancel", done)
- }
- thumbRef.addEventListener("pointermove", onPointerMove)
- thumbRef.addEventListener("pointerup", done)
- thumbRef.addEventListener("pointercancel", done)
- }
- // Keybinds implementation
- // We ensure the viewport has a tabindex so it can receive focus
- // We can also explicitly catch PageUp/Down if we want smooth scroll or specific behavior,
- // but native usually handles this perfectly. Let's explicitly ensure it behaves well.
- const onKeyDown = (e: KeyboardEvent) => {
- // If user is focused on an input inside the scroll view, don't hijack keys
- if (document.activeElement && ["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement.tagName)) {
- return
- }
- const next = scrollKey(e)
- if (!next) return
- if (!isScrollKeyTarget(e.target, next)) return
- if (scrollKeyOwner(viewportRef, e.target, next) !== viewportRef) return
- const scrollAmount = viewportRef.clientHeight * 0.8
- const lineAmount = 40
- switch (next) {
- case "page-down":
- e.preventDefault()
- viewportRef.scrollBy({ top: scrollAmount, behavior: "smooth" })
- break
- case "page-up":
- e.preventDefault()
- viewportRef.scrollBy({ top: -scrollAmount, behavior: "smooth" })
- break
- case "home":
- e.preventDefault()
- viewportRef.scrollTo({ top: 0, behavior: "smooth" })
- break
- case "end":
- e.preventDefault()
- viewportRef.scrollTo({ top: viewportRef.scrollHeight, behavior: "smooth" })
- break
- case "up":
- e.preventDefault()
- viewportRef.scrollBy({ top: -lineAmount, behavior: "smooth" })
- break
- case "down":
- e.preventDefault()
- viewportRef.scrollBy({ top: lineAmount, behavior: "smooth" })
- break
- }
- }
- return (
- <div
- ref={rootRef}
- class={`scroll-view ${local.class || ""}`}
- style={local.style}
- onPointerEnter={() => setState("isHovered", true)}
- onPointerLeave={() => setState("isHovered", false)}
- {...rest}
- >
- {/* Viewport */}
- <div
- ref={viewportRef}
- class="scroll-view__viewport"
- data-scrollable
- onScroll={(e) => {
- updateThumb()
- markScrolling()
- if (typeof events.onScroll === "function") events.onScroll(e as any)
- }}
- onWheel={(e) => {
- markScrolling()
- const handler = events.onWheel
- if (typeof handler === "function") handler(e as any)
- if (Array.isArray(handler)) handler[0](handler[1], e as any)
- }}
- onTouchStart={events.onTouchStart as any}
- onTouchMove={events.onTouchMove as any}
- onTouchEnd={events.onTouchEnd as any}
- onTouchCancel={events.onTouchCancel as any}
- onPointerDown={events.onPointerDown as any}
- onClick={events.onClick as any}
- tabIndex={0}
- role="region"
- aria-label={i18n.t("ui.scrollView.ariaLabel")}
- onKeyDown={(e) => {
- onKeyDown(e)
- if (typeof events.onKeyDown === "function") events.onKeyDown(e as any)
- }}
- >
- {local.children}
- </div>
- {/* Thumb Overlay */}
- <Show when={showThumb()}>
- <div
- ref={thumbRef}
- onPointerDown={onThumbPointerDown}
- class="scroll-view__thumb"
- data-visible={thumbVisible()}
- data-dragging={isDragging()}
- style={{
- height: `${thumbHeight()}px`,
- transform: `translateY(${thumbTop()}px)`,
- "z-index": 100, // ensure it displays over content
- }}
- />
- </Show>
- </div>
- )
- }
|