chore: merge dev into v2 (#37370)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com> Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com> Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Co-authored-by: Brendan Allan <git@brendonovich.dev> Co-authored-by: James Long <longster@gmail.com> Co-authored-by: James Long <jlongster@users.noreply.github.com> Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Jay <53023+jayair@users.noreply.github.com> Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com> Co-authored-by: Dustin Deus <deusdustin@gmail.com> Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: 冯基魁 <56265583+fengjikui@users.noreply.github.com> Co-authored-by: opencode <opencode@sst.dev> Co-authored-by: Frank <frank@anoma.ly> Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com> Co-authored-by: Kit Langton <kit.langton@gmail.com> Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com> Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com> Co-authored-by: Victor Navarro <vn4varro@gmail.com> Co-authored-by: Dax Raad <d@ironbay.co> Co-authored-by: Dax <mail@thdxr.com> Co-authored-by: Nabs <nabil@instafork.com> Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com> Co-authored-by: AidenGeunGeun <eastlandwyvern@gmail.com> Co-authored-by: Mark <geraint0923@users.noreply.github.com> Co-authored-by: Jay <air@live.ca> Co-authored-by: BB84 <110078428+BB-84C@users.noreply.github.com>
This commit is contained in:
co-authored by
Aiden Cline
Adam
opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Aarav Sareen
Brendan Allan
James Long
James Long
opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Jay
Brendan Allan
Luke Parker
Dustin Deus
Aiden Cline
冯基魁
opencode
Frank
Aiden Cline
Kit Langton
David Hill
usrnk1
Victor Navarro
Dax Raad
Dax
Nabs
Vladimir Glafirov
AidenGeunGeun
Mark
Jay
BB84
parent
88b8883122
commit
e916b99742
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user