mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-27 08:53:40 +00:00
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:
parent
958e08e109
commit
dc3ff4b7aa
4 changed files with 302 additions and 143 deletions
|
|
@ -5,6 +5,7 @@ import {
|
|||
createMemo,
|
||||
createResource,
|
||||
createRoot,
|
||||
createSignal,
|
||||
For,
|
||||
Match,
|
||||
on,
|
||||
|
|
@ -78,6 +79,23 @@ const HOME_SESSION_LIMIT = 64
|
|||
const HOME_SESSION_HEADER_STICKY_TOP = 12
|
||||
const HOME_SESSION_HEADER_TEXT_HEIGHT = 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 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"
|
||||
|
|
@ -150,6 +168,7 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
|
|||
let content: HTMLDivElement | undefined
|
||||
let positionFrame: number | undefined
|
||||
let resizeObserver: ResizeObserver | undefined
|
||||
let stickyTop = HOME_SESSION_HEADER_STICKY_TOP
|
||||
const headerRefs = new Map<HomeSessionGroup["id"], HTMLDivElement>()
|
||||
const headerOffsets = new Map<HomeSessionGroup["id"], number>()
|
||||
const [state, setState] = createStore({
|
||||
|
|
@ -208,6 +227,13 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
|
|||
|
||||
function updatePositionCache() {
|
||||
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) => {
|
||||
const el = headerRefs.get(group.id)
|
||||
if (!el) return
|
||||
|
|
@ -223,7 +249,7 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
|
|||
.slice(index + 1)
|
||||
.map((item) => headerOffsets.get(item.id))
|
||||
.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 opacity =
|
||||
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 openSettings = useSettingsCommand()
|
||||
let focusSessionSearch: (() => void) | undefined
|
||||
let sessionViewport: HTMLDivElement | undefined
|
||||
const [sessionThumbTrack, setSessionThumbTrack] = createSignal<HTMLDivElement>()
|
||||
const [sessionHoverTarget, setSessionHoverTarget] = createSignal<HTMLElement>()
|
||||
const [state, setState] = createStore({
|
||||
search: "",
|
||||
searchFocused: false,
|
||||
|
|
@ -604,127 +633,158 @@ export function NewHome() {
|
|||
}
|
||||
|
||||
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="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">
|
||||
<HomeProjectColumn
|
||||
projects={projects()}
|
||||
recentlyClosed={recentlyClosed()}
|
||||
homedir={homedir()}
|
||||
selected={selection()}
|
||||
focusServer={focusServer}
|
||||
selectProject={selectProject}
|
||||
openNewSession={openProjectNewSession}
|
||||
openRecentProject={(conn, directory) => addProjects(conn, [directory])}
|
||||
chooseProject={(conn) => void chooseProject(conn)}
|
||||
editProject={editProject}
|
||||
closeProject={(conn, directory) => {
|
||||
const next = closeHomeProject(
|
||||
selection(),
|
||||
ServerConnection.key(conn),
|
||||
global.ensureServerCtx(conn).projects,
|
||||
directory,
|
||||
)
|
||||
if (next) setSelection(next)
|
||||
}}
|
||||
clearNotifications={clearNotifications}
|
||||
unseenCount={unseenCount}
|
||||
openSettings={openSettings}
|
||||
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
||||
language={language}
|
||||
/>
|
||||
|
||||
<section
|
||||
class="min-h-0 min-w-0 flex-1 flex flex-col pt-6 lg:pt-12 relative"
|
||||
aria-label={language.t("sidebar.project.recentSessions")}
|
||||
>
|
||||
<HomeSessionSearch
|
||||
value={state.search}
|
||||
placeholder={searchPlaceholder()}
|
||||
open={searchOpen()}
|
||||
loading={sessionLoad.isLoading}
|
||||
results={searchResults()}
|
||||
showProjectName={!selectedProject()}
|
||||
server={selection().server}
|
||||
noResultsLabel={language.t("home.sessions.search.noResults", { query: search() })}
|
||||
bindFocus={(focus) => {
|
||||
focusSessionSearch = focus
|
||||
<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">
|
||||
<ScrollView
|
||||
class="h-full [container-type:size]"
|
||||
thumbContainer={sessionThumbTrack}
|
||||
thumbHoverTarget={sessionHoverTarget}
|
||||
viewportRef={(el) => {
|
||||
sessionViewport = el
|
||||
sessionHeaderOpacity.setViewport(el)
|
||||
}}
|
||||
onScroll={(event) => sessionHeaderOpacity.update(event.currentTarget.scrollTop)}
|
||||
onWheel={(event) => {
|
||||
if (!sessionViewport) return
|
||||
if (event.target instanceof Node && sessionViewport.contains(event.target)) return
|
||||
containHomeWheel(event, sessionViewport)
|
||||
}}
|
||||
>
|
||||
<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">
|
||||
<HomeProjectColumn
|
||||
projects={projects()}
|
||||
recentlyClosed={recentlyClosed()}
|
||||
homedir={homedir()}
|
||||
selected={selection()}
|
||||
focusServer={focusServer}
|
||||
selectProject={selectProject}
|
||||
openNewSession={openProjectNewSession}
|
||||
openRecentProject={(conn, directory) => addProjects(conn, [directory])}
|
||||
chooseProject={(conn) => void chooseProject(conn)}
|
||||
editProject={editProject}
|
||||
closeProject={(conn, directory) => {
|
||||
const next = closeHomeProject(
|
||||
selection(),
|
||||
ServerConnection.key(conn),
|
||||
global.ensureServerCtx(conn).projects,
|
||||
directory,
|
||||
)
|
||||
if (next) setSelection(next)
|
||||
}}
|
||||
clearNotifications={clearNotifications}
|
||||
unseenCount={unseenCount}
|
||||
openSettings={openSettings}
|
||||
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
||||
language={language}
|
||||
onWheel={(event) => {
|
||||
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"
|
||||
viewportRef={sessionHeaderOpacity.setViewport}
|
||||
onScroll={(event) => sessionHeaderOpacity.update(event.currentTarget.scrollTop)}
|
||||
|
||||
<section
|
||||
ref={setSessionHoverTarget}
|
||||
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 class="pointer-events-none absolute top-3 right-3 z-20 flex">
|
||||
<ButtonV2
|
||||
data-action="home-new-session"
|
||||
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>
|
||||
}
|
||||
<div
|
||||
class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-3 pt-6 lg:pt-12"
|
||||
onWheel={(event) => {
|
||||
if (sessionViewport) containHomeWheel(event, sessionViewport)
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<HomeSessionSearch
|
||||
value={state.search}
|
||||
placeholder={searchPlaceholder()}
|
||||
open={searchOpen()}
|
||||
loading={sessionLoad.isLoading}
|
||||
results={searchResults()}
|
||||
showProjectName={!selectedProject()}
|
||||
server={selection().server}
|
||||
noResultsLabel={language.t("home.sessions.search.noResults", { query: search() })}
|
||||
bindFocus={(focus) => {
|
||||
focusSessionSearch = focus
|
||||
}}
|
||||
onInput={(value) => setState("search", value)}
|
||||
onFocus={() => setState("searchFocused", true)}
|
||||
onClose={closeSearch}
|
||||
onSelect={selectSearchSession}
|
||||
/>
|
||||
<Show when={groups().length > 0 && newSessionProject()}>
|
||||
<div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
|
||||
<ButtonV2
|
||||
data-action="home-new-session"
|
||||
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>
|
||||
</ScrollView>
|
||||
</section>
|
||||
<HomeUtilityNav
|
||||
class="flex lg:hidden"
|
||||
openSettings={openSettings}
|
||||
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
||||
language={language}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Sticky chrome for the portaled session scrollbar — matches old sessions ScrollView bounds */}
|
||||
<div class="pointer-events-none sticky top-[84px] z-40 h-0 -mr-3 lg:top-[108px]">
|
||||
<div
|
||||
ref={setSessionThumbTrack}
|
||||
data-component="home-session-scroll-track"
|
||||
class="relative ml-auto h-[calc(100cqh-84px)] w-3 lg:h-[calc(100cqh-108px)]"
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
@ -746,6 +806,7 @@ function HomeProjectColumn(props: {
|
|||
openSettings: () => void
|
||||
openHelp: () => void
|
||||
language: ReturnType<typeof useLanguage>
|
||||
onWheel: (event: WheelEvent) => void
|
||||
}) {
|
||||
const global = useGlobal()
|
||||
const dialog = useDialog()
|
||||
|
|
@ -762,8 +823,12 @@ function HomeProjectColumn(props: {
|
|||
|
||||
return (
|
||||
<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")}
|
||||
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="text-v2-text-text-muted [font-weight:530]">{props.language.t("home.projects")}</div>
|
||||
|
|
@ -1489,7 +1554,7 @@ function HomeSessionGroupHeader(props: {
|
|||
return (
|
||||
<div
|
||||
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 }}>
|
||||
{props.title}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@
|
|||
transition: opacity 200ms ease;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
pointer-events: auto;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -72,4 +72,19 @@ describe("scrollTopFromThumbPointer", () => {
|
|||
expect(scrollTopFromThumbPointer({ ...input, pointer: 0 })).toBe(0)
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 { createStore } from "solid-js/store"
|
||||
import { useI18n } from "../context/i18n"
|
||||
|
|
@ -8,7 +19,17 @@ 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">) => {
|
||||
|
|
@ -65,12 +86,14 @@ export function scrollTopFromThumbPointer(input: {
|
|||
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.clientHeight)
|
||||
return (thumbTop / maxThumbTop) * Math.max(0, input.scrollHeight - (input.scrollClientHeight ?? input.clientHeight))
|
||||
}
|
||||
|
||||
export function ScrollView(props: ScrollViewProps) {
|
||||
|
|
@ -78,7 +101,16 @@ export function ScrollView(props: ScrollViewProps) {
|
|||
const merged = mergeProps({ orientation: "vertical", thumbVisibility: "hover" }, props)
|
||||
const [local, events, rest] = splitProps(
|
||||
merged,
|
||||
["class", "children", "viewportRef", "orientation", "thumbVisibility", "style"],
|
||||
[
|
||||
"class",
|
||||
"children",
|
||||
"viewportRef",
|
||||
"orientation",
|
||||
"thumbVisibility",
|
||||
"thumbContainer",
|
||||
"thumbHoverTarget",
|
||||
"style",
|
||||
],
|
||||
[
|
||||
"onScroll",
|
||||
"onWheel",
|
||||
|
|
@ -96,6 +128,15 @@ export function ScrollView(props: ScrollViewProps) {
|
|||
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,
|
||||
|
|
@ -114,7 +155,6 @@ export function ScrollView(props: ScrollViewProps) {
|
|||
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)
|
||||
|
|
@ -122,8 +162,8 @@ export function ScrollView(props: ScrollViewProps) {
|
|||
|
||||
const thumbVisible = () => {
|
||||
if (isDragging()) return true
|
||||
if (local.thumbVisibility === "scroll") return isScrolling()
|
||||
return isHovered()
|
||||
if (isScrolling()) return true
|
||||
return local.thumbVisibility === "hover" && isHovered()
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
|
|
@ -141,7 +181,8 @@ export function ScrollView(props: ScrollViewProps) {
|
|||
|
||||
setState("showThumb", true)
|
||||
const trackPadding = 8
|
||||
const trackHeight = clientHeight - trackPadding * 2
|
||||
const trackClientHeight = thumbMount()?.clientHeight || clientHeight
|
||||
const trackHeight = trackClientHeight - trackPadding * 2
|
||||
|
||||
const minThumbHeight = 32
|
||||
// Calculate raw thumb height based on ratio
|
||||
|
|
@ -165,16 +206,40 @@ export function ScrollView(props: ScrollViewProps) {
|
|||
local.viewportRef(viewportRef)
|
||||
}
|
||||
|
||||
createResizeObserver([viewportRef, viewportRef.firstElementChild], updateThumb)
|
||||
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)
|
||||
|
||||
|
|
@ -182,9 +247,10 @@ export function ScrollView(props: ScrollViewProps) {
|
|||
const { scrollHeight, clientHeight } = viewportRef
|
||||
viewportRef.scrollTop = scrollTopFromThumbPointer({
|
||||
pointer: e.clientY,
|
||||
viewportTop: viewportRef.getBoundingClientRect().top,
|
||||
viewportTop: track.getBoundingClientRect().top,
|
||||
grabOffset,
|
||||
clientHeight,
|
||||
clientHeight: track.clientHeight,
|
||||
scrollClientHeight: clientHeight,
|
||||
scrollHeight,
|
||||
thumbHeight: thumbHeight(),
|
||||
})
|
||||
|
|
@ -203,6 +269,23 @@ export function ScrollView(props: ScrollViewProps) {
|
|||
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,
|
||||
|
|
@ -253,8 +336,12 @@ export function ScrollView(props: ScrollViewProps) {
|
|||
ref={rootRef}
|
||||
class={`scroll-view ${local.class || ""}`}
|
||||
style={local.style}
|
||||
onPointerEnter={() => setState("isHovered", true)}
|
||||
onPointerLeave={() => setState("isHovered", false)}
|
||||
onPointerEnter={() => {
|
||||
if (hoverRoot()) setState("isHovered", true)
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
if (hoverRoot()) setState("isHovered", false)
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{/* Viewport */}
|
||||
|
|
@ -290,20 +377,11 @@ export function ScrollView(props: ScrollViewProps) {
|
|||
{local.children}
|
||||
</div>
|
||||
|
||||
{/* Thumb Overlay */}
|
||||
{/* Thumb Overlay — optionally portaled into an external track */}
|
||||
<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 when={thumbMount()} fallback={renderThumb()}>
|
||||
{(mount) => <Portal mount={mount()}>{renderThumb()}</Portal>}
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue