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:
co-authored by
Jay V
Brendan Allan
parent
958e08e109
commit
dc3ff4b7aa
@@ -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,8 +633,23 @@ 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
|
||||||
|
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
|
<HomeProjectColumn
|
||||||
projects={projects()}
|
projects={projects()}
|
||||||
recentlyClosed={recentlyClosed()}
|
recentlyClosed={recentlyClosed()}
|
||||||
@@ -631,11 +675,21 @@ export function NewHome() {
|
|||||||
openSettings={openSettings}
|
openSettings={openSettings}
|
||||||
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
|
||||||
language={language}
|
language={language}
|
||||||
|
onWheel={(event) => {
|
||||||
|
if (sessionViewport) containHomeWheel(event, sessionViewport)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
class="min-h-0 min-w-0 flex-1 flex flex-col pt-6 lg:pt-12 relative"
|
ref={setSessionHoverTarget}
|
||||||
|
class="min-h-0 min-w-0 flex-1 flex flex-col"
|
||||||
aria-label={language.t("sidebar.project.recentSessions")}
|
aria-label={language.t("sidebar.project.recentSessions")}
|
||||||
|
>
|
||||||
|
<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)
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<HomeSessionSearch
|
<HomeSessionSearch
|
||||||
value={state.search}
|
value={state.search}
|
||||||
@@ -654,13 +708,8 @@ export function NewHome() {
|
|||||||
onClose={closeSearch}
|
onClose={closeSearch}
|
||||||
onSelect={selectSearchSession}
|
onSelect={selectSearchSession}
|
||||||
/>
|
/>
|
||||||
<ScrollView
|
|
||||||
class="mt-3 -mr-3 min-h-0 flex-1 relative"
|
|
||||||
viewportRef={sessionHeaderOpacity.setViewport}
|
|
||||||
onScroll={(event) => sessionHeaderOpacity.update(event.currentTarget.scrollTop)}
|
|
||||||
>
|
|
||||||
<Show when={groups().length > 0 && newSessionProject()}>
|
<Show when={groups().length > 0 && newSessionProject()}>
|
||||||
<div class="pointer-events-none absolute top-3 right-3 z-20 flex">
|
<div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
|
||||||
<ButtonV2
|
<ButtonV2
|
||||||
data-action="home-new-session"
|
data-action="home-new-session"
|
||||||
variant="ghost-muted"
|
variant="ghost-muted"
|
||||||
@@ -673,6 +722,16 @@ export function NewHome() {
|
|||||||
</ButtonV2>
|
</ButtonV2>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
</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
|
<Show
|
||||||
when={!sessionLoad.isLoading}
|
when={!sessionLoad.isLoading}
|
||||||
fallback={
|
fallback={
|
||||||
@@ -716,7 +775,7 @@ export function NewHome() {
|
|||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
</ScrollView>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<HomeUtilityNav
|
<HomeUtilityNav
|
||||||
class="flex lg:hidden"
|
class="flex lg:hidden"
|
||||||
@@ -725,6 +784,7 @@ export function NewHome() {
|
|||||||
language={language}
|
language={language}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user