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:
opencode-agent[bot] 2026-07-16 15:44:26 -05:00 committed by GitHub
commit e916b99742
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
79 changed files with 871 additions and 432 deletions

View file

@ -27,6 +27,7 @@
transition: opacity 200ms ease;
cursor: default;
user-select: none;
pointer-events: auto;
opacity: 0;
}

View file

@ -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)
})
})

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 { 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>
)