opencode/packages/ui/src/components/scroll-view.tsx
opencode-agent[bot] c69abee0c7 chore: generate
2026-07-16 09:53:45 +00:00

388 lines
12 KiB
TypeScript

import {
createEffect,
createMemo,
mergeProps,
onCleanup,
onMount,
Show,
splitProps,
type Accessor,
type ComponentProps,
} from "solid-js"
import { Portal } from "solid-js/web"
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
/**
* `hover`: show while hovered or scrolling. `scroll`: show only while scrolling.
*
* In most cases, scrolling a container = hovering over it, so this change has no effect.
* This is a special case to account for the home page scroll, where scrolling a container != hovering over it
* */
thumbVisibility?: ScrollViewThumbVisibility
/** Mount the thumb into an external track. Scroll metrics still come from this ScrollView. */
thumbContainer?: HTMLElement | Accessor<HTMLElement | undefined>
/** Element whose hover reveals the thumb. Defaults to the ScrollView root when unset. */
thumbHoverTarget?: HTMLElement | Accessor<HTMLElement | undefined>
}
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
/** Viewport height used for max scroll. Defaults to `clientHeight` (track == viewport). */
scrollClientHeight?: 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.scrollClientHeight ?? 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",
"thumbContainer",
"thumbHoverTarget",
"style",
],
[
"onScroll",
"onWheel",
"onTouchStart",
"onTouchMove",
"onTouchEnd",
"onTouchCancel",
"onPointerDown",
"onClick",
"onKeyDown",
],
)
let rootRef!: HTMLDivElement
let viewportRef!: HTMLDivElement
let thumbRef!: HTMLDivElement
const resolveEl = (value: HTMLElement | Accessor<HTMLElement | undefined> | undefined) => {
if (typeof value === "function") return value()
return value
}
const thumbMount = createMemo(() => resolveEl(local.thumbContainer))
const thumbHover = createMemo(() => resolveEl(local.thumbHoverTarget))
const hoverRoot = () => !local.thumbHoverTarget && !local.thumbContainer
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 = () => {
setState("isScrolling", true)
if (scrollIdleTimer !== undefined) clearTimeout(scrollIdleTimer)
scrollIdleTimer = setTimeout(() => setState("isScrolling", false), 800)
}
const thumbVisible = () => {
if (isDragging()) return true
if (isScrolling()) return true
return local.thumbVisibility === "hover" && 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 trackClientHeight = thumbMount()?.clientHeight || clientHeight
const trackHeight = trackClientHeight - 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, thumbMount()].filter(Boolean) as HTMLElement[],
updateThumb,
)
updateThumb()
})
createEffect(() => {
thumbMount()
updateThumb()
})
createEffect(() => {
const target = thumbHover()
if (!target) return
const enter = () => setState("isHovered", true)
const leave = () => setState("isHovered", false)
target.addEventListener("pointerenter", enter)
target.addEventListener("pointerleave", leave)
onCleanup(() => {
target.removeEventListener("pointerenter", enter)
target.removeEventListener("pointerleave", leave)
setState("isHovered", false)
})
})
const onThumbPointerDown = (e: PointerEvent) => {
e.preventDefault()
e.stopPropagation()
setState("isDragging", true)
const grabOffset = e.clientY - thumbRef.getBoundingClientRect().top
const track = thumbMount() ?? viewportRef
thumbRef.setPointerCapture(e.pointerId)
const onPointerMove = (e: PointerEvent) => {
const { scrollHeight, clientHeight } = viewportRef
viewportRef.scrollTop = scrollTopFromThumbPointer({
pointer: e.clientY,
viewportTop: track.getBoundingClientRect().top,
grabOffset,
clientHeight: track.clientHeight,
scrollClientHeight: 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)
}
const renderThumb = () => (
<div
ref={(el) => {
thumbRef = el
}}
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
}}
/>
)
// 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={() => {
if (hoverRoot()) setState("isHovered", true)
}}
onPointerLeave={() => {
if (hoverRoot()) 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 — optionally portaled into an external track */}
<Show when={showThumb()}>
<Show when={thumbMount()} fallback={renderThumb()}>
{(mount) => <Portal mount={mount()}>{renderThumb()}</Portal>}
</Show>
</Show>
</div>
)
}