diff options
| author | Adam <[email protected]> | 2026-02-20 10:43:18 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2026-02-20 10:54:17 -0600 |
| commit | fe89bedfcc6d97fdd4b8066c2c3d8eac92b531ea (patch) | |
| tree | 99eccf57e17ceb90e9f8b28829a8a5f9ab2ecbcc /packages/ui/src | |
| parent | 1e48d7fe8228d94ded379e36975b2cce12f4a510 (diff) | |
| download | opencode-fe89bedfcc6d97fdd4b8066c2c3d8eac92b531ea.tar.gz opencode-fe89bedfcc6d97fdd4b8066c2c3d8eac92b531ea.zip | |
wip(app): custom scroll view
Diffstat (limited to 'packages/ui/src')
| -rw-r--r-- | packages/ui/src/components/scroll-view.css | 61 | ||||
| -rw-r--r-- | packages/ui/src/components/scroll-view.tsx | 217 | ||||
| -rw-r--r-- | packages/ui/src/components/session-review.tsx | 9 | ||||
| -rw-r--r-- | packages/ui/src/styles/index.css | 1 | ||||
| -rw-r--r-- | packages/ui/src/styles/tailwind/utilities.css | 28 |
5 files changed, 284 insertions, 32 deletions
diff --git a/packages/ui/src/components/scroll-view.css b/packages/ui/src/components/scroll-view.css new file mode 100644 index 000000000..f81ae2976 --- /dev/null +++ b/packages/ui/src/components/scroll-view.css @@ -0,0 +1,61 @@ +.scroll-view { + position: relative; + overflow: hidden; +} + +.scroll-view__viewport { + height: 100%; + width: 100%; + overflow-y: auto; + scrollbar-width: none; + outline: none; +} + +.scroll-view__viewport::-webkit-scrollbar { + display: none; +} + +.scroll-view__thumb { + position: absolute; + right: 0; + top: 0; + width: 16px; + transition: opacity 200ms ease; + cursor: default; + user-select: none; + opacity: 0; +} + +.scroll-view__thumb::after { + content: ""; + position: absolute; + right: 4px; + top: 0; + bottom: 0; + width: 6px; + border-radius: 9999px; + background-color: var(--border-weak-base); + backdrop-filter: blur(4px); + transition: background-color 150ms ease; +} + +.scroll-view__thumb:hover::after, +.scroll-view__thumb[data-dragging="true"]::after { + background-color: var(--border-strong-base); +} + +.dark .scroll-view__thumb::after, +[data-theme="dark"] .scroll-view__thumb::after { + background-color: var(--border-weak-base); +} + +.dark .scroll-view__thumb:hover::after, +[data-theme="dark"] .scroll-view__thumb:hover::after, +.dark .scroll-view__thumb[data-dragging="true"]::after, +[data-theme="dark"] .scroll-view__thumb[data-dragging="true"]::after { + background-color: var(--border-strong-base); +} + +.scroll-view__thumb[data-visible="true"] { + opacity: 1; +} diff --git a/packages/ui/src/components/scroll-view.tsx b/packages/ui/src/components/scroll-view.tsx new file mode 100644 index 000000000..acc54c8c3 --- /dev/null +++ b/packages/ui/src/components/scroll-view.tsx @@ -0,0 +1,217 @@ +import { createSignal, onCleanup, onMount, splitProps, type ComponentProps, Show, mergeProps } from "solid-js" + +export interface ScrollViewProps extends ComponentProps<"div"> { + viewportRef?: (el: HTMLDivElement) => void + orientation?: "vertical" | "horizontal" // currently only vertical is fully implemented for thumb +} + +export function ScrollView(props: ScrollViewProps) { + const merged = mergeProps({ orientation: "vertical" }, props) + const [local, events, rest] = splitProps( + merged, + ["class", "children", "viewportRef", "orientation", "style"], + [ + "onScroll", + "onWheel", + "onTouchStart", + "onTouchMove", + "onTouchEnd", + "onTouchCancel", + "onPointerDown", + "onClick", + "onKeyDown", + ], + ) + + let rootRef!: HTMLDivElement + let viewportRef!: HTMLDivElement + let thumbRef!: HTMLDivElement + + const [isHovered, setIsHovered] = createSignal(false) + const [isDragging, setIsDragging] = createSignal(false) + + const [thumbHeight, setThumbHeight] = createSignal(0) + const [thumbTop, setThumbTop] = createSignal(0) + const [showThumb, setShowThumb] = createSignal(false) + + const updateThumb = () => { + if (!viewportRef) return + const { scrollTop, scrollHeight, clientHeight } = viewportRef + + if (scrollHeight <= clientHeight || scrollHeight === 0) { + setShowThumb(false) + return + } + + setShowThumb(true) + const trackPadding = 8 + const trackHeight = clientHeight - trackPadding * 2 + + const minThumbHeight = 32 + // Calculate raw thumb height based on ratio + let height = (clientHeight / scrollHeight) * trackHeight + height = Math.max(height, minThumbHeight) + + const maxScrollTop = scrollHeight - clientHeight + const maxThumbTop = trackHeight - height + + const top = maxScrollTop > 0 ? (scrollTop / maxScrollTop) * maxThumbTop : 0 + + // Ensure thumb stays within bounds (shouldn't be necessary due to math above, but good for safety) + const boundedTop = trackPadding + Math.max(0, Math.min(top, maxThumbTop)) + + setThumbHeight(height) + setThumbTop(boundedTop) + } + + onMount(() => { + if (local.viewportRef) { + local.viewportRef(viewportRef) + } + + const observer = new ResizeObserver(() => { + updateThumb() + }) + + observer.observe(viewportRef) + // Also observe the first child if possible to catch content changes + if (viewportRef.firstElementChild) { + observer.observe(viewportRef.firstElementChild) + } + + onCleanup(() => { + observer.disconnect() + }) + + updateThumb() + }) + + let startY = 0 + let startScrollTop = 0 + + const onThumbPointerDown = (e: PointerEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragging(true) + startY = e.clientY + startScrollTop = viewportRef.scrollTop + + thumbRef.setPointerCapture(e.pointerId) + + const onPointerMove = (e: PointerEvent) => { + const deltaY = e.clientY - startY + const { scrollHeight, clientHeight } = viewportRef + const maxScrollTop = scrollHeight - clientHeight + const maxThumbTop = clientHeight - thumbHeight() + + if (maxThumbTop > 0) { + const scrollDelta = deltaY * (maxScrollTop / maxThumbTop) + viewportRef.scrollTop = startScrollTop + scrollDelta + } + } + + const onPointerUp = (e: PointerEvent) => { + setIsDragging(false) + thumbRef.releasePointerCapture(e.pointerId) + thumbRef.removeEventListener("pointermove", onPointerMove) + thumbRef.removeEventListener("pointerup", onPointerUp) + } + + thumbRef.addEventListener("pointermove", onPointerMove) + thumbRef.addEventListener("pointerup", onPointerUp) + } + + // 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, + // but native usually handles this perfectly. Let's explicitly ensure it behaves well. + const onKeyDown = (e: KeyboardEvent) => { + // If user is focused on an input inside the scroll view, don't hijack keys + if (document.activeElement && ["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement.tagName)) { + return + } + + const scrollAmount = viewportRef.clientHeight * 0.8 + const lineAmount = 40 + + switch (e.key) { + case "PageDown": + e.preventDefault() + viewportRef.scrollBy({ top: scrollAmount, behavior: "smooth" }) + break + case "PageUp": + e.preventDefault() + viewportRef.scrollBy({ top: -scrollAmount, behavior: "smooth" }) + break + case "Home": + e.preventDefault() + viewportRef.scrollTo({ top: 0, behavior: "smooth" }) + break + case "End": + e.preventDefault() + viewportRef.scrollTo({ top: viewportRef.scrollHeight, behavior: "smooth" }) + break + case "ArrowUp": + e.preventDefault() + viewportRef.scrollBy({ top: -lineAmount, behavior: "smooth" }) + break + case "ArrowDown": + e.preventDefault() + viewportRef.scrollBy({ top: lineAmount, behavior: "smooth" }) + break + } + } + + return ( + <div + ref={rootRef} + class={`scroll-view ${local.class || ""}`} + style={local.style} + onPointerEnter={() => setIsHovered(true)} + onPointerLeave={() => setIsHovered(false)} + {...rest} + > + {/* Viewport */} + <div + ref={viewportRef} + class="scroll-view__viewport" + onScroll={(e) => { + updateThumb() + if (typeof events.onScroll === "function") events.onScroll(e as any) + }} + onWheel={events.onWheel as any} + onTouchStart={events.onTouchStart as any} + onTouchMove={events.onTouchMove as any} + onTouchEnd={events.onTouchEnd as any} + onTouchCancel={events.onTouchCancel as any} + onPointerDown={events.onPointerDown as any} + onClick={events.onClick as any} + tabIndex={0} + role="region" + aria-label="scrollable content" + onKeyDown={(e) => { + onKeyDown(e) + if (typeof events.onKeyDown === "function") events.onKeyDown(e as any) + }} + > + {local.children} + </div> + + {/* Thumb Overlay */} + <Show when={showThumb()}> + <div + ref={thumbRef} + onPointerDown={onThumbPointerDown} + class="scroll-view__thumb" + data-visible={isHovered() || isDragging()} + data-dragging={isDragging()} + style={{ + height: `${thumbHeight()}px`, + transform: `translateY(${thumbTop()}px)`, + "z-index": 100, // ensure it displays over content + }} + /> + </Show> + </div> + ) +} diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index fd85fb485..15464d3ba 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -7,6 +7,7 @@ import { Icon } from "./icon" import { LineComment, LineCommentEditor } from "./line-comment" import { StickyAccordionHeader } from "./sticky-accordion-header" import { Tooltip } from "./tooltip" +import { ScrollView } from "./scroll-view" import { useDiffComponent } from "../context/diff" import { useI18n } from "../context/i18n" import { getDirectory, getFilename } from "@opencode-ai/util/path" @@ -274,13 +275,13 @@ export const SessionReview = (props: SessionReviewProps) => { }) return ( - <div + <ScrollView data-component="session-review" - ref={(el) => { + viewportRef={(el) => { scroll = el props.scrollRef?.(el) }} - onScroll={props.onScroll} + onScroll={props.onScroll as any} classList={{ ...(props.classList ?? {}), [props.classes?.root ?? ""]: !!props.classes?.root, @@ -709,6 +710,6 @@ export const SessionReview = (props: SessionReviewProps) => { </Accordion> </Show> </div> - </div> + </ScrollView> ) } diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index efe00e5f1..c0af0ac9b 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -44,6 +44,7 @@ @import "../components/select.css" layer(components); @import "../components/spinner.css" layer(components); @import "../components/switch.css" layer(components); +@import "../components/scroll-view.css" layer(components); @import "../components/session-review.css" layer(components); @import "../components/session-turn.css" layer(components); @import "../components/sticky-accordion-header.css" layer(components); diff --git a/packages/ui/src/styles/tailwind/utilities.css b/packages/ui/src/styles/tailwind/utilities.css index be305b4cb..4318b9ec1 100644 --- a/packages/ui/src/styles/tailwind/utilities.css +++ b/packages/ui/src/styles/tailwind/utilities.css @@ -8,34 +8,6 @@ } } -@utility session-scroller { - &::-webkit-scrollbar { - width: 10px; - height: 10px; - } - - &::-webkit-scrollbar-track { - background: transparent; - border-radius: 5px; - } - - &::-webkit-scrollbar-thumb { - background: var(--border-weak-base); - border-radius: 5px; - border: 3px solid transparent; - background-clip: padding-box; - } - - &::-webkit-scrollbar-thumb:hover { - background: var(--border-weak-base); - } - - & { - scrollbar-width: thin; - scrollbar-color: var(--border-weak-base) transparent; - } -} - @utility badge-mask { -webkit-mask-image: radial-gradient(circle 5px at calc(100% - 4px) 4px, transparent 5px, black 5.5px); mask-image: radial-gradient(circle 5px at calc(100% - 4px) 4px, transparent 5px, black 5.5px); |
