@opencode-ai/ui 1.18.1 → 1.18.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,19 @@
1
- import { type ComponentProps } from "solid-js";
1
+ import { type Accessor, type ComponentProps } from "solid-js";
2
2
  export type ScrollViewThumbVisibility = "hover" | "scroll";
3
3
  export interface ScrollViewProps extends ComponentProps<"div"> {
4
4
  viewportRef?: (el: HTMLDivElement) => void;
5
5
  orientation?: "vertical" | "horizontal";
6
+ /**
7
+ * `hover`: show while hovered or scrolling. `scroll`: show only while scrolling.
8
+ *
9
+ * In most cases, scrolling a container = hovering over it, so this change has no effect.
10
+ * This is a special case to account for the home page scroll, where scrolling a container != hovering over it
11
+ * */
6
12
  thumbVisibility?: ScrollViewThumbVisibility;
13
+ /** Mount the thumb into an external track. Scroll metrics still come from this ScrollView. */
14
+ thumbContainer?: HTMLElement | Accessor<HTMLElement | undefined>;
15
+ /** Element whose hover reveals the thumb. Defaults to the ScrollView root when unset. */
16
+ thumbHoverTarget?: HTMLElement | Accessor<HTMLElement | undefined>;
7
17
  }
8
18
  export declare const scrollKey: (event: Pick<KeyboardEvent, "key" | "altKey" | "ctrlKey" | "metaKey" | "shiftKey">) => "end" | "home" | "page-down" | "page-up" | "up" | "down" | undefined;
9
19
  export declare function canScrollKey(element: HTMLElement, key: NonNullable<ReturnType<typeof scrollKey>>): boolean;
@@ -16,5 +26,7 @@ export declare function scrollTopFromThumbPointer(input: {
16
26
  clientHeight: number;
17
27
  scrollHeight: number;
18
28
  thumbHeight: number;
29
+ /** Viewport height used for max scroll. Defaults to `clientHeight` (track == viewport). */
30
+ scrollClientHeight?: number;
19
31
  }): number;
20
32
  export declare function ScrollView(props: ScrollViewProps): import("solid-js").JSX.Element;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opencode-ai/ui",
3
- "version": "1.18.1",
3
+ "version": "1.18.3",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -27,6 +27,7 @@
27
27
  transition: opacity 200ms ease;
28
28
  cursor: default;
29
29
  user-select: none;
30
+ pointer-events: auto;
30
31
  opacity: 0;
31
32
  }
32
33
 
@@ -1,4 +1,15 @@
1
- import { onCleanup, onMount, splitProps, type ComponentProps, Show, mergeProps } from "solid-js"
1
+ import {
2
+ createEffect,
3
+ createMemo,
4
+ mergeProps,
5
+ onCleanup,
6
+ onMount,
7
+ Show,
8
+ splitProps,
9
+ type Accessor,
10
+ type ComponentProps,
11
+ } from "solid-js"
12
+ import { Portal } from "solid-js/web"
2
13
  import { createResizeObserver } from "@solid-primitives/resize-observer"
3
14
  import { createStore } from "solid-js/store"
4
15
  import { useI18n } from "../context/i18n"
@@ -8,7 +19,17 @@ export type ScrollViewThumbVisibility = "hover" | "scroll"
8
19
  export interface ScrollViewProps extends ComponentProps<"div"> {
9
20
  viewportRef?: (el: HTMLDivElement) => void
10
21
  orientation?: "vertical" | "horizontal" // currently only vertical is fully implemented for thumb
22
+ /**
23
+ * `hover`: show while hovered or scrolling. `scroll`: show only while scrolling.
24
+ *
25
+ * In most cases, scrolling a container = hovering over it, so this change has no effect.
26
+ * This is a special case to account for the home page scroll, where scrolling a container != hovering over it
27
+ * */
11
28
  thumbVisibility?: ScrollViewThumbVisibility
29
+ /** Mount the thumb into an external track. Scroll metrics still come from this ScrollView. */
30
+ thumbContainer?: HTMLElement | Accessor<HTMLElement | undefined>
31
+ /** Element whose hover reveals the thumb. Defaults to the ScrollView root when unset. */
32
+ thumbHoverTarget?: HTMLElement | Accessor<HTMLElement | undefined>
12
33
  }
13
34
 
14
35
  export const scrollKey = (event: Pick<KeyboardEvent, "key" | "altKey" | "ctrlKey" | "metaKey" | "shiftKey">) => {
@@ -65,12 +86,14 @@ export function scrollTopFromThumbPointer(input: {
65
86
  clientHeight: number
66
87
  scrollHeight: number
67
88
  thumbHeight: number
89
+ /** Viewport height used for max scroll. Defaults to `clientHeight` (track == viewport). */
90
+ scrollClientHeight?: number
68
91
  }) {
69
92
  const padding = 8
70
93
  const maxThumbTop = input.clientHeight - padding * 2 - input.thumbHeight
71
94
  if (maxThumbTop <= 0) return 0
72
95
  const thumbTop = Math.max(0, Math.min(input.pointer - input.viewportTop - padding - input.grabOffset, maxThumbTop))
73
- return (thumbTop / maxThumbTop) * Math.max(0, input.scrollHeight - input.clientHeight)
96
+ return (thumbTop / maxThumbTop) * Math.max(0, input.scrollHeight - (input.scrollClientHeight ?? input.clientHeight))
74
97
  }
75
98
 
76
99
  export function ScrollView(props: ScrollViewProps) {
@@ -78,7 +101,16 @@ export function ScrollView(props: ScrollViewProps) {
78
101
  const merged = mergeProps({ orientation: "vertical", thumbVisibility: "hover" }, props)
79
102
  const [local, events, rest] = splitProps(
80
103
  merged,
81
- ["class", "children", "viewportRef", "orientation", "thumbVisibility", "style"],
104
+ [
105
+ "class",
106
+ "children",
107
+ "viewportRef",
108
+ "orientation",
109
+ "thumbVisibility",
110
+ "thumbContainer",
111
+ "thumbHoverTarget",
112
+ "style",
113
+ ],
82
114
  [
83
115
  "onScroll",
84
116
  "onWheel",
@@ -96,6 +128,15 @@ export function ScrollView(props: ScrollViewProps) {
96
128
  let viewportRef!: HTMLDivElement
97
129
  let thumbRef!: HTMLDivElement
98
130
 
131
+ const resolveEl = (value: HTMLElement | Accessor<HTMLElement | undefined> | undefined) => {
132
+ if (typeof value === "function") return value()
133
+ return value
134
+ }
135
+
136
+ const thumbMount = createMemo(() => resolveEl(local.thumbContainer))
137
+ const thumbHover = createMemo(() => resolveEl(local.thumbHoverTarget))
138
+ const hoverRoot = () => !local.thumbHoverTarget && !local.thumbContainer
139
+
99
140
  const [state, setState] = createStore({
100
141
  isHovered: false,
101
142
  isDragging: false,
@@ -114,7 +155,6 @@ export function ScrollView(props: ScrollViewProps) {
114
155
  let scrollIdleTimer: ReturnType<typeof setTimeout> | undefined
115
156
 
116
157
  const markScrolling = () => {
117
- if (local.thumbVisibility !== "scroll") return
118
158
  setState("isScrolling", true)
119
159
  if (scrollIdleTimer !== undefined) clearTimeout(scrollIdleTimer)
120
160
  scrollIdleTimer = setTimeout(() => setState("isScrolling", false), 800)
@@ -122,8 +162,8 @@ export function ScrollView(props: ScrollViewProps) {
122
162
 
123
163
  const thumbVisible = () => {
124
164
  if (isDragging()) return true
125
- if (local.thumbVisibility === "scroll") return isScrolling()
126
- return isHovered()
165
+ if (isScrolling()) return true
166
+ return local.thumbVisibility === "hover" && isHovered()
127
167
  }
128
168
 
129
169
  onCleanup(() => {
@@ -141,7 +181,8 @@ export function ScrollView(props: ScrollViewProps) {
141
181
 
142
182
  setState("showThumb", true)
143
183
  const trackPadding = 8
144
- const trackHeight = clientHeight - trackPadding * 2
184
+ const trackClientHeight = thumbMount()?.clientHeight || clientHeight
185
+ const trackHeight = trackClientHeight - trackPadding * 2
145
186
 
146
187
  const minThumbHeight = 32
147
188
  // Calculate raw thumb height based on ratio
@@ -165,16 +206,40 @@ export function ScrollView(props: ScrollViewProps) {
165
206
  local.viewportRef(viewportRef)
166
207
  }
167
208
 
168
- createResizeObserver([viewportRef, viewportRef.firstElementChild], updateThumb)
209
+ createResizeObserver(
210
+ () => [viewportRef, viewportRef.firstElementChild, thumbMount()].filter(Boolean) as HTMLElement[],
211
+ updateThumb,
212
+ )
169
213
 
170
214
  updateThumb()
171
215
  })
172
216
 
217
+ createEffect(() => {
218
+ thumbMount()
219
+ updateThumb()
220
+ })
221
+
222
+ createEffect(() => {
223
+ const target = thumbHover()
224
+ if (!target) return
225
+
226
+ const enter = () => setState("isHovered", true)
227
+ const leave = () => setState("isHovered", false)
228
+ target.addEventListener("pointerenter", enter)
229
+ target.addEventListener("pointerleave", leave)
230
+ onCleanup(() => {
231
+ target.removeEventListener("pointerenter", enter)
232
+ target.removeEventListener("pointerleave", leave)
233
+ setState("isHovered", false)
234
+ })
235
+ })
236
+
173
237
  const onThumbPointerDown = (e: PointerEvent) => {
174
238
  e.preventDefault()
175
239
  e.stopPropagation()
176
240
  setState("isDragging", true)
177
241
  const grabOffset = e.clientY - thumbRef.getBoundingClientRect().top
242
+ const track = thumbMount() ?? viewportRef
178
243
 
179
244
  thumbRef.setPointerCapture(e.pointerId)
180
245
 
@@ -182,9 +247,10 @@ export function ScrollView(props: ScrollViewProps) {
182
247
  const { scrollHeight, clientHeight } = viewportRef
183
248
  viewportRef.scrollTop = scrollTopFromThumbPointer({
184
249
  pointer: e.clientY,
185
- viewportTop: viewportRef.getBoundingClientRect().top,
250
+ viewportTop: track.getBoundingClientRect().top,
186
251
  grabOffset,
187
- clientHeight,
252
+ clientHeight: track.clientHeight,
253
+ scrollClientHeight: clientHeight,
188
254
  scrollHeight,
189
255
  thumbHeight: thumbHeight(),
190
256
  })
@@ -203,6 +269,23 @@ export function ScrollView(props: ScrollViewProps) {
203
269
  thumbRef.addEventListener("pointercancel", done)
204
270
  }
205
271
 
272
+ const renderThumb = () => (
273
+ <div
274
+ ref={(el) => {
275
+ thumbRef = el
276
+ }}
277
+ onPointerDown={onThumbPointerDown}
278
+ class="scroll-view__thumb"
279
+ data-visible={thumbVisible()}
280
+ data-dragging={isDragging()}
281
+ style={{
282
+ height: `${thumbHeight()}px`,
283
+ transform: `translateY(${thumbTop()}px)`,
284
+ "z-index": 100, // ensure it displays over content
285
+ }}
286
+ />
287
+ )
288
+
206
289
  // Keybinds implementation
207
290
  // We ensure the viewport has a tabindex so it can receive focus
208
291
  // We can also explicitly catch PageUp/Down if we want smooth scroll or specific behavior,
@@ -253,8 +336,12 @@ export function ScrollView(props: ScrollViewProps) {
253
336
  ref={rootRef}
254
337
  class={`scroll-view ${local.class || ""}`}
255
338
  style={local.style}
256
- onPointerEnter={() => setState("isHovered", true)}
257
- onPointerLeave={() => setState("isHovered", false)}
339
+ onPointerEnter={() => {
340
+ if (hoverRoot()) setState("isHovered", true)
341
+ }}
342
+ onPointerLeave={() => {
343
+ if (hoverRoot()) setState("isHovered", false)
344
+ }}
258
345
  {...rest}
259
346
  >
260
347
  {/* Viewport */}
@@ -290,20 +377,11 @@ export function ScrollView(props: ScrollViewProps) {
290
377
  {local.children}
291
378
  </div>
292
379
 
293
- {/* Thumb Overlay */}
380
+ {/* Thumb Overlay — optionally portaled into an external track */}
294
381
  <Show when={showThumb()}>
295
- <div
296
- ref={thumbRef}
297
- onPointerDown={onThumbPointerDown}
298
- class="scroll-view__thumb"
299
- data-visible={thumbVisible()}
300
- data-dragging={isDragging()}
301
- style={{
302
- height: `${thumbHeight()}px`,
303
- transform: `translateY(${thumbTop()}px)`,
304
- "z-index": 100, // ensure it displays over content
305
- }}
306
- />
382
+ <Show when={thumbMount()} fallback={renderThumb()}>
383
+ {(mount) => <Portal mount={mount()}>{renderThumb()}</Portal>}
384
+ </Show>
307
385
  </Show>
308
386
  </div>
309
387
  )