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:
parent
88b8883122
commit
e916b99742
79 changed files with 871 additions and 432 deletions
|
|
@ -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