fix(app): home page scroll target (#36664)

Co-authored-by: Jay V <air@live.ca>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
This commit is contained in:
Aarav Sareen
2026-07-16 09:51:42 +00:00
committed by GitHub
co-authored by Jay V Brendan Allan
parent 958e08e109
commit dc3ff4b7aa
4 changed files with 302 additions and 143 deletions
+183 -118
View File
@@ -5,6 +5,7 @@ import {
createMemo, createMemo,
createResource, createResource,
createRoot, createRoot,
createSignal,
For, For,
Match, Match,
on, on,
@@ -78,6 +79,23 @@ const HOME_SESSION_LIMIT = 64
const HOME_SESSION_HEADER_STICKY_TOP = 12 const HOME_SESSION_HEADER_STICKY_TOP = 12
const HOME_SESSION_HEADER_TEXT_HEIGHT = 16 const HOME_SESSION_HEADER_TEXT_HEIGHT = 16
const HOME_SESSION_HEADER_FADE_DISTANCE = 16 const HOME_SESSION_HEADER_FADE_DISTANCE = 16
function containHomeWheel(event: WheelEvent, viewport: HTMLElement) {
if (event.defaultPrevented || event.ctrlKey || !event.deltaY) return
if (!(event.target instanceof Element)) return
const scrollable = event.target.closest<HTMLElement>("[data-scrollable]")
if (
scrollable !== viewport &&
scrollable &&
(event.deltaY < 0
? scrollable.scrollTop > 0
: scrollable.scrollTop < scrollable.scrollHeight - scrollable.clientHeight)
)
return
event.preventDefault()
}
const SHOW_HOME_SESSION_ARCHIVE = false const SHOW_HOME_SESSION_ARCHIVE = false
const HOME_ROW_LAYOUT = const HOME_ROW_LAYOUT =
"flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none" "flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none"
@@ -150,6 +168,7 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
let content: HTMLDivElement | undefined let content: HTMLDivElement | undefined
let positionFrame: number | undefined let positionFrame: number | undefined
let resizeObserver: ResizeObserver | undefined let resizeObserver: ResizeObserver | undefined
let stickyTop = HOME_SESSION_HEADER_STICKY_TOP
const headerRefs = new Map<HomeSessionGroup["id"], HTMLDivElement>() const headerRefs = new Map<HomeSessionGroup["id"], HTMLDivElement>()
const headerOffsets = new Map<HomeSessionGroup["id"], number>() const headerOffsets = new Map<HomeSessionGroup["id"], number>()
const [state, setState] = createStore({ const [state, setState] = createStore({
@@ -208,6 +227,13 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
function updatePositionCache() { function updatePositionCache() {
if (!viewport) return if (!viewport) return
const header = groups()
.map((group) => headerRefs.get(group.id))
.find((el) => el !== undefined)
if (header && typeof getComputedStyle === "function") {
const top = Number.parseFloat(getComputedStyle(header).top)
if (Number.isFinite(top)) stickyTop = top
}
groups().forEach((group) => { groups().forEach((group) => {
const el = headerRefs.get(group.id) const el = headerRefs.get(group.id)
if (!el) return if (!el) return
@@ -223,7 +249,7 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
.slice(index + 1) .slice(index + 1)
.map((item) => headerOffsets.get(item.id)) .map((item) => headerOffsets.get(item.id))
.find((offset) => offset !== undefined) .find((offset) => offset !== undefined)
const fadeEnd = HOME_SESSION_HEADER_STICKY_TOP + HOME_SESSION_HEADER_TEXT_HEIGHT const fadeEnd = stickyTop + HOME_SESSION_HEADER_TEXT_HEIGHT
const nextTop = nextOffset === undefined ? undefined : nextOffset - scrollTop const nextTop = nextOffset === undefined ? undefined : nextOffset - scrollTop
const opacity = const opacity =
nextTop === undefined ? 1 : Math.max(0, Math.min(1, (nextTop - fadeEnd) / HOME_SESSION_HEADER_FADE_DISTANCE)) nextTop === undefined ? 1 : Math.max(0, Math.min(1, (nextTop - fadeEnd) / HOME_SESSION_HEADER_FADE_DISTANCE))
@@ -277,6 +303,9 @@ export function NewHome() {
const marked = useMarked() const marked = useMarked()
const openSettings = useSettingsCommand() const openSettings = useSettingsCommand()
let focusSessionSearch: (() => void) | undefined let focusSessionSearch: (() => void) | undefined
let sessionViewport: HTMLDivElement | undefined
const [sessionThumbTrack, setSessionThumbTrack] = createSignal<HTMLDivElement>()
const [sessionHoverTarget, setSessionHoverTarget] = createSignal<HTMLElement>()
const [state, setState] = createStore({ const [state, setState] = createStore({
search: "", search: "",
searchFocused: false, searchFocused: false,
@@ -604,127 +633,158 @@ export function NewHome() {
} }
return ( return (
<div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 lg:overflow-hidden bg-v2-background-bg-base self-stretch flex-1"> <div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 overflow-hidden bg-v2-background-bg-base self-stretch flex-1">
<div class="mx-auto grid h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6"> <ScrollView
<HomeProjectColumn class="h-full [container-type:size]"
projects={projects()} thumbContainer={sessionThumbTrack}
recentlyClosed={recentlyClosed()} thumbHoverTarget={sessionHoverTarget}
homedir={homedir()} viewportRef={(el) => {
selected={selection()} sessionViewport = el
focusServer={focusServer} sessionHeaderOpacity.setViewport(el)
selectProject={selectProject} }}
openNewSession={openProjectNewSession} onScroll={(event) => sessionHeaderOpacity.update(event.currentTarget.scrollTop)}
openRecentProject={(conn, directory) => addProjects(conn, [directory])} onWheel={(event) => {
chooseProject={(conn) => void chooseProject(conn)} if (!sessionViewport) return
editProject={editProject} if (event.target instanceof Node && sessionViewport.contains(event.target)) return
closeProject={(conn, directory) => { containHomeWheel(event, sessionViewport)
const next = closeHomeProject( }}
selection(), >
ServerConnection.key(conn), <div class="mx-auto grid min-h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6">
global.ensureServerCtx(conn).projects, <HomeProjectColumn
directory, projects={projects()}
) recentlyClosed={recentlyClosed()}
if (next) setSelection(next) homedir={homedir()}
}} selected={selection()}
clearNotifications={clearNotifications} focusServer={focusServer}
unseenCount={unseenCount} selectProject={selectProject}
openSettings={openSettings} openNewSession={openProjectNewSession}
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")} openRecentProject={(conn, directory) => addProjects(conn, [directory])}
language={language} chooseProject={(conn) => void chooseProject(conn)}
/> editProject={editProject}
closeProject={(conn, directory) => {
<section const next = closeHomeProject(
class="min-h-0 min-w-0 flex-1 flex flex-col pt-6 lg:pt-12 relative" selection(),
aria-label={language.t("sidebar.project.recentSessions")} ServerConnection.key(conn),
> global.ensureServerCtx(conn).projects,
<HomeSessionSearch directory,
value={state.search} )
placeholder={searchPlaceholder()} if (next) setSelection(next)
open={searchOpen()} }}
loading={sessionLoad.isLoading} clearNotifications={clearNotifications}
results={searchResults()} unseenCount={unseenCount}
showProjectName={!selectedProject()} openSettings={openSettings}
server={selection().server} openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
noResultsLabel={language.t("home.sessions.search.noResults", { query: search() })} language={language}
bindFocus={(focus) => { onWheel={(event) => {
focusSessionSearch = focus if (sessionViewport) containHomeWheel(event, sessionViewport)
}} }}
onInput={(value) => setState("search", value)}
onFocus={() => setState("searchFocused", true)}
onClose={closeSearch}
onSelect={selectSearchSession}
/> />
<ScrollView
class="mt-3 -mr-3 min-h-0 flex-1 relative" <section
viewportRef={sessionHeaderOpacity.setViewport} ref={setSessionHoverTarget}
onScroll={(event) => sessionHeaderOpacity.update(event.currentTarget.scrollTop)} class="min-h-0 min-w-0 flex-1 flex flex-col"
aria-label={language.t("sidebar.project.recentSessions")}
> >
<Show when={groups().length > 0 && newSessionProject()}> <div
<div class="pointer-events-none absolute top-3 right-3 z-20 flex"> class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-3 pt-6 lg:pt-12"
<ButtonV2 onWheel={(event) => {
data-action="home-new-session" if (sessionViewport) containHomeWheel(event, sessionViewport)
variant="ghost-muted" }}
size="normal"
icon="edit"
class="pointer-events-auto h-7 px-2 [font-weight:530]"
onClick={openNewSession}
>
{language.t("command.session.new")}
</ButtonV2>
</div>
</Show>
<Show
when={!sessionLoad.isLoading}
fallback={
<div class="pt-3">
<HomeSessionSkeleton label={language.t("common.loading")} />
</div>
}
> >
<Show <HomeSessionSearch
when={groups().length > 0} value={state.search}
fallback={<HomeSessionsEmpty onNewSession={newSessionProject() ? openNewSession : undefined} />} placeholder={searchPlaceholder()}
> open={searchOpen()}
<div ref={sessionHeaderOpacity.setContentRef} class="flex flex-col pt-3 pr-3 pb-16"> loading={sessionLoad.isLoading}
<For each={groups()}> results={searchResults()}
{(group, index) => ( showProjectName={!selectedProject()}
<> server={selection().server}
<HomeSessionGroupHeader noResultsLabel={language.t("home.sessions.search.noResults", { query: search() })}
title={group.title} bindFocus={(focus) => {
titleOpacity={sessionHeaderOpacity.titleOpacity(group.id)} focusSessionSearch = focus
ref={(el) => sessionHeaderOpacity.setHeaderRef(group.id, el)} }}
elevated={index() === 0} onInput={(value) => setState("search", value)}
/> onFocus={() => setState("searchFocused", true)}
<div onClose={closeSearch}
class={`flex min-w-0 flex-col gap-px pt-4 ${index() === groups().length - 1 ? "" : "mb-6"}`} onSelect={selectSearchSession}
> />
<For each={group.sessions}> <Show when={groups().length > 0 && newSessionProject()}>
{(record) => ( <div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
<HomeSessionRow <ButtonV2
record={record} data-action="home-new-session"
showProjectName={!selectedProject()} variant="ghost-muted"
server={selection().server} size="normal"
openSession={openSession} icon="edit"
archiveSession={archiveSession} class="pointer-events-auto h-7 px-2 [font-weight:530]"
/> onClick={openNewSession}
)} >
</For> {language.t("command.session.new")}
</div> </ButtonV2>
</>
)}
</For>
</div> </div>
</Show> </Show>
</Show> </div>
</ScrollView> {/* Sticky chrome for the portaled session scrollbar — matches old sessions ScrollView bounds */}
</section> <div class="pointer-events-none sticky top-[84px] z-40 h-0 -mr-3 lg:top-[108px]">
<HomeUtilityNav <div
class="flex lg:hidden" ref={setSessionThumbTrack}
openSettings={openSettings} data-component="home-session-scroll-track"
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")} class="relative ml-auto h-[calc(100cqh-84px)] w-3 lg:h-[calc(100cqh-108px)]"
language={language} />
/> </div>
</div> <div class="-mr-3 min-h-[calc(100cqh-72px)] lg:min-h-[calc(100cqh-96px)]">
<Show
when={!sessionLoad.isLoading}
fallback={
<div class="pt-3">
<HomeSessionSkeleton label={language.t("common.loading")} />
</div>
}
>
<Show
when={groups().length > 0}
fallback={<HomeSessionsEmpty onNewSession={newSessionProject() ? openNewSession : undefined} />}
>
<div ref={sessionHeaderOpacity.setContentRef} class="flex flex-col pt-3 pr-3 pb-16">
<For each={groups()}>
{(group, index) => (
<>
<HomeSessionGroupHeader
title={group.title}
titleOpacity={sessionHeaderOpacity.titleOpacity(group.id)}
ref={(el) => sessionHeaderOpacity.setHeaderRef(group.id, el)}
elevated={index() === 0}
/>
<div
class={`flex min-w-0 flex-col gap-px pt-4 ${index() === groups().length - 1 ? "" : "mb-6"}`}
>
<For each={group.sessions}>
{(record) => (
<HomeSessionRow
record={record}
showProjectName={!selectedProject()}
server={selection().server}
openSession={openSession}
archiveSession={archiveSession}
/>
)}
</For>
</div>
</>
)}
</For>
</div>
</Show>
</Show>
</div>
</section>
<HomeUtilityNav
class="flex lg:hidden"
openSettings={openSettings}
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
language={language}
/>
</div>
</ScrollView>
</div> </div>
) )
} }
@@ -746,6 +806,7 @@ function HomeProjectColumn(props: {
openSettings: () => void openSettings: () => void
openHelp: () => void openHelp: () => void
language: ReturnType<typeof useLanguage> language: ReturnType<typeof useLanguage>
onWheel: (event: WheelEvent) => void
}) { }) {
const global = useGlobal() const global = useGlobal()
const dialog = useDialog() const dialog = useDialog()
@@ -762,8 +823,12 @@ function HomeProjectColumn(props: {
return ( return (
<aside <aside
class="mt-6 flex min-h-0 min-w-0 flex-col gap-4 overflow-hidden lg:mt-14 lg:pt-[52px]" class="mt-6 flex min-h-0 min-w-0 flex-col gap-4 overflow-hidden lg:sticky lg:top-14 lg:mt-14 lg:h-[calc(100cqh-56px)] lg:self-start lg:pt-[52px]"
aria-label={props.language.t("home.projects")} aria-label={props.language.t("home.projects")}
onWheel={(event) => {
if (event.target === event.currentTarget) return
props.onWheel(event)
}}
> >
<div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3"> <div class="flex h-7 min-w-0 shrink-0 items-center justify-between pl-1.5 pr-3">
<div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div> <div class="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div>
@@ -1489,7 +1554,7 @@ function HomeSessionGroupHeader(props: {
return ( return (
<div <div
ref={props.ref} ref={props.ref}
class={`pointer-events-none sticky top-3 flex h-7 min-w-0 items-center justify-between pl-3 bg-v2-background-bg-base ${props.elevated ? "home-session-group-header z-[5]" : "z-10"}`} class={`pointer-events-none sticky top-[84px] lg:top-[108px] flex h-7 min-w-0 items-center justify-between pl-3 bg-v2-background-bg-base ${props.elevated ? "home-session-group-header z-[5]" : "z-10"}`}
> >
<div class={HOME_SECTION_LABEL} style={{ opacity: props.titleOpacity }}> <div class={HOME_SECTION_LABEL} style={{ opacity: props.titleOpacity }}>
{props.title} {props.title}
@@ -27,6 +27,7 @@
transition: opacity 200ms ease; transition: opacity 200ms ease;
cursor: default; cursor: default;
user-select: none; user-select: none;
pointer-events: auto;
opacity: 0; opacity: 0;
} }
@@ -72,4 +72,19 @@ describe("scrollTopFromThumbPointer", () => {
expect(scrollTopFromThumbPointer({ ...input, pointer: 0 })).toBe(0) expect(scrollTopFromThumbPointer({ ...input, pointer: 0 })).toBe(0)
expect(scrollTopFromThumbPointer({ ...input, pointer: 1_000 })).toBe(5_400) expect(scrollTopFromThumbPointer({ ...input, pointer: 1_000 })).toBe(5_400)
}) })
test("uses scrollClientHeight when the thumb track differs from the viewport", () => {
const input = {
pointer: 400,
viewportTop: 100,
grabOffset: 0,
clientHeight: 400,
scrollClientHeight: 800,
scrollHeight: 8_000,
thumbHeight: 40,
}
// track usable = 400 - 16 - 40 = 344; thumbTop = 400 - 100 - 8 = 292
// maxScroll = 8000 - 800 = 7200 → 292/344 * 7200
expect(scrollTopFromThumbPointer(input)).toBeCloseTo((292 / 344) * 7200)
})
}) })
+103 -25
View File
@@ -1,4 +1,15 @@
import { onCleanup, onMount, splitProps, type ComponentProps, Show, mergeProps } from "solid-js" 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 { createResizeObserver } from "@solid-primitives/resize-observer"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useI18n } from "../context/i18n" import { useI18n } from "../context/i18n"
@@ -8,7 +19,17 @@ export type ScrollViewThumbVisibility = "hover" | "scroll"
export interface ScrollViewProps extends ComponentProps<"div"> { export interface ScrollViewProps extends ComponentProps<"div"> {
viewportRef?: (el: HTMLDivElement) => void viewportRef?: (el: HTMLDivElement) => void
orientation?: "vertical" | "horizontal" // currently only vertical is fully implemented for thumb 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 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">) => { export const scrollKey = (event: Pick<KeyboardEvent, "key" | "altKey" | "ctrlKey" | "metaKey" | "shiftKey">) => {
@@ -65,12 +86,14 @@ export function scrollTopFromThumbPointer(input: {
clientHeight: number clientHeight: number
scrollHeight: number scrollHeight: number
thumbHeight: number thumbHeight: number
/** Viewport height used for max scroll. Defaults to `clientHeight` (track == viewport). */
scrollClientHeight?: number
}) { }) {
const padding = 8 const padding = 8
const maxThumbTop = input.clientHeight - padding * 2 - input.thumbHeight const maxThumbTop = input.clientHeight - padding * 2 - input.thumbHeight
if (maxThumbTop <= 0) return 0 if (maxThumbTop <= 0) return 0
const thumbTop = Math.max(0, Math.min(input.pointer - input.viewportTop - padding - input.grabOffset, maxThumbTop)) 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) return (thumbTop / maxThumbTop) * Math.max(0, input.scrollHeight - (input.scrollClientHeight ?? input.clientHeight))
} }
export function ScrollView(props: ScrollViewProps) { export function ScrollView(props: ScrollViewProps) {
@@ -78,7 +101,16 @@ export function ScrollView(props: ScrollViewProps) {
const merged = mergeProps({ orientation: "vertical", thumbVisibility: "hover" }, props) const merged = mergeProps({ orientation: "vertical", thumbVisibility: "hover" }, props)
const [local, events, rest] = splitProps( const [local, events, rest] = splitProps(
merged, merged,
["class", "children", "viewportRef", "orientation", "thumbVisibility", "style"], [
"class",
"children",
"viewportRef",
"orientation",
"thumbVisibility",
"thumbContainer",
"thumbHoverTarget",
"style",
],
[ [
"onScroll", "onScroll",
"onWheel", "onWheel",
@@ -96,6 +128,15 @@ export function ScrollView(props: ScrollViewProps) {
let viewportRef!: HTMLDivElement let viewportRef!: HTMLDivElement
let thumbRef!: 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({ const [state, setState] = createStore({
isHovered: false, isHovered: false,
isDragging: false, isDragging: false,
@@ -114,7 +155,6 @@ export function ScrollView(props: ScrollViewProps) {
let scrollIdleTimer: ReturnType<typeof setTimeout> | undefined let scrollIdleTimer: ReturnType<typeof setTimeout> | undefined
const markScrolling = () => { const markScrolling = () => {
if (local.thumbVisibility !== "scroll") return
setState("isScrolling", true) setState("isScrolling", true)
if (scrollIdleTimer !== undefined) clearTimeout(scrollIdleTimer) if (scrollIdleTimer !== undefined) clearTimeout(scrollIdleTimer)
scrollIdleTimer = setTimeout(() => setState("isScrolling", false), 800) scrollIdleTimer = setTimeout(() => setState("isScrolling", false), 800)
@@ -122,8 +162,8 @@ export function ScrollView(props: ScrollViewProps) {
const thumbVisible = () => { const thumbVisible = () => {
if (isDragging()) return true if (isDragging()) return true
if (local.thumbVisibility === "scroll") return isScrolling() if (isScrolling()) return true
return isHovered() return local.thumbVisibility === "hover" && isHovered()
} }
onCleanup(() => { onCleanup(() => {
@@ -141,7 +181,8 @@ export function ScrollView(props: ScrollViewProps) {
setState("showThumb", true) setState("showThumb", true)
const trackPadding = 8 const trackPadding = 8
const trackHeight = clientHeight - trackPadding * 2 const trackClientHeight = thumbMount()?.clientHeight || clientHeight
const trackHeight = trackClientHeight - trackPadding * 2
const minThumbHeight = 32 const minThumbHeight = 32
// Calculate raw thumb height based on ratio // Calculate raw thumb height based on ratio
@@ -165,16 +206,40 @@ export function ScrollView(props: ScrollViewProps) {
local.viewportRef(viewportRef) local.viewportRef(viewportRef)
} }
createResizeObserver([viewportRef, viewportRef.firstElementChild], updateThumb) createResizeObserver(
() => [viewportRef, viewportRef.firstElementChild, thumbMount()].filter(Boolean) as HTMLElement[],
updateThumb,
)
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) => { const onThumbPointerDown = (e: PointerEvent) => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
setState("isDragging", true) setState("isDragging", true)
const grabOffset = e.clientY - thumbRef.getBoundingClientRect().top const grabOffset = e.clientY - thumbRef.getBoundingClientRect().top
const track = thumbMount() ?? viewportRef
thumbRef.setPointerCapture(e.pointerId) thumbRef.setPointerCapture(e.pointerId)
@@ -182,9 +247,10 @@ export function ScrollView(props: ScrollViewProps) {
const { scrollHeight, clientHeight } = viewportRef const { scrollHeight, clientHeight } = viewportRef
viewportRef.scrollTop = scrollTopFromThumbPointer({ viewportRef.scrollTop = scrollTopFromThumbPointer({
pointer: e.clientY, pointer: e.clientY,
viewportTop: viewportRef.getBoundingClientRect().top, viewportTop: track.getBoundingClientRect().top,
grabOffset, grabOffset,
clientHeight, clientHeight: track.clientHeight,
scrollClientHeight: clientHeight,
scrollHeight, scrollHeight,
thumbHeight: thumbHeight(), thumbHeight: thumbHeight(),
}) })
@@ -203,6 +269,23 @@ export function ScrollView(props: ScrollViewProps) {
thumbRef.addEventListener("pointercancel", 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 // Keybinds implementation
// We ensure the viewport has a tabindex so it can receive focus // 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, // We can also explicitly catch PageUp/Down if we want smooth scroll or specific behavior,
@@ -253,8 +336,12 @@ export function ScrollView(props: ScrollViewProps) {
ref={rootRef} ref={rootRef}
class={`scroll-view ${local.class || ""}`} class={`scroll-view ${local.class || ""}`}
style={local.style} style={local.style}
onPointerEnter={() => setState("isHovered", true)} onPointerEnter={() => {
onPointerLeave={() => setState("isHovered", false)} if (hoverRoot()) setState("isHovered", true)
}}
onPointerLeave={() => {
if (hoverRoot()) setState("isHovered", false)
}}
{...rest} {...rest}
> >
{/* Viewport */} {/* Viewport */}
@@ -290,20 +377,11 @@ export function ScrollView(props: ScrollViewProps) {
{local.children} {local.children}
</div> </div>
{/* Thumb Overlay */} {/* Thumb Overlay — optionally portaled into an external track */}
<Show when={showThumb()}> <Show when={showThumb()}>
<div <Show when={thumbMount()} fallback={renderThumb()}>
ref={thumbRef} {(mount) => <Portal mount={mount()}>{renderThumb()}</Portal>}
onPointerDown={onThumbPointerDown} </Show>
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> </Show>
</div> </div>
) )