scroll-view.tsx 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. import { onCleanup, onMount, splitProps, type ComponentProps, Show, mergeProps } from "solid-js"
  2. import { createResizeObserver } from "@solid-primitives/resize-observer"
  3. import { createStore } from "solid-js/store"
  4. import { useI18n } from "../context/i18n"
  5. export type ScrollViewThumbVisibility = "hover" | "scroll"
  6. export interface ScrollViewProps extends ComponentProps<"div"> {
  7. viewportRef?: (el: HTMLDivElement) => void
  8. orientation?: "vertical" | "horizontal" // currently only vertical is fully implemented for thumb
  9. thumbVisibility?: ScrollViewThumbVisibility
  10. }
  11. export const scrollKey = (event: Pick<KeyboardEvent, "key" | "altKey" | "ctrlKey" | "metaKey" | "shiftKey">) => {
  12. if (event.altKey || event.ctrlKey || event.metaKey) return
  13. if (event.shiftKey && event.key !== " ") return
  14. switch (event.key) {
  15. case "PageDown":
  16. return "page-down"
  17. case "PageUp":
  18. return "page-up"
  19. case "Home":
  20. return "home"
  21. case "End":
  22. return "end"
  23. case "ArrowUp":
  24. return "up"
  25. case "ArrowDown":
  26. return "down"
  27. case " ":
  28. return event.shiftKey ? "page-up" : "page-down"
  29. }
  30. }
  31. export function canScrollKey(element: HTMLElement, key: NonNullable<ReturnType<typeof scrollKey>>) {
  32. const up = key === "up" || key === "page-up" || key === "home"
  33. return up ? element.scrollTop > 0 : element.scrollTop + element.clientHeight < element.scrollHeight
  34. }
  35. export function scrollKeyOwner(
  36. root: HTMLElement,
  37. target: EventTarget | null,
  38. key: NonNullable<ReturnType<typeof scrollKey>>,
  39. ) {
  40. const element = target instanceof Element ? target : undefined
  41. const owner = element?.closest<HTMLElement>("[data-scrollable]")
  42. if (!owner || owner === root) return root
  43. if (!root.contains(owner)) return owner
  44. return canScrollKey(owner, key) ? owner : root
  45. }
  46. export function isScrollKeyTarget(target: EventTarget | null, key: NonNullable<ReturnType<typeof scrollKey>>) {
  47. const element = target instanceof HTMLElement ? target : undefined
  48. if (!element) return true
  49. if (["INPUT", "TEXTAREA", "SELECT"].includes(element.tagName) || element.isContentEditable) return false
  50. if ((key === "page-up" || key === "page-down") && element.closest('button, a[href], [role="button"]')) return false
  51. return true
  52. }
  53. export function scrollTopFromThumbPointer(input: {
  54. pointer: number
  55. viewportTop: number
  56. grabOffset: number
  57. clientHeight: number
  58. scrollHeight: number
  59. thumbHeight: number
  60. }) {
  61. const padding = 8
  62. const maxThumbTop = input.clientHeight - padding * 2 - input.thumbHeight
  63. if (maxThumbTop <= 0) return 0
  64. const thumbTop = Math.max(0, Math.min(input.pointer - input.viewportTop - padding - input.grabOffset, maxThumbTop))
  65. return (thumbTop / maxThumbTop) * Math.max(0, input.scrollHeight - input.clientHeight)
  66. }
  67. export function ScrollView(props: ScrollViewProps) {
  68. const i18n = useI18n()
  69. const merged = mergeProps({ orientation: "vertical", thumbVisibility: "hover" }, props)
  70. const [local, events, rest] = splitProps(
  71. merged,
  72. ["class", "children", "viewportRef", "orientation", "thumbVisibility", "style"],
  73. [
  74. "onScroll",
  75. "onWheel",
  76. "onTouchStart",
  77. "onTouchMove",
  78. "onTouchEnd",
  79. "onTouchCancel",
  80. "onPointerDown",
  81. "onClick",
  82. "onKeyDown",
  83. ],
  84. )
  85. let rootRef!: HTMLDivElement
  86. let viewportRef!: HTMLDivElement
  87. let thumbRef!: HTMLDivElement
  88. const [state, setState] = createStore({
  89. isHovered: false,
  90. isDragging: false,
  91. isScrolling: false,
  92. thumbHeight: 0,
  93. thumbTop: 0,
  94. showThumb: false,
  95. })
  96. const isHovered = () => state.isHovered
  97. const isDragging = () => state.isDragging
  98. const isScrolling = () => state.isScrolling
  99. const thumbHeight = () => state.thumbHeight
  100. const thumbTop = () => state.thumbTop
  101. const showThumb = () => state.showThumb
  102. let scrollIdleTimer: ReturnType<typeof setTimeout> | undefined
  103. const markScrolling = () => {
  104. if (local.thumbVisibility !== "scroll") return
  105. setState("isScrolling", true)
  106. if (scrollIdleTimer !== undefined) clearTimeout(scrollIdleTimer)
  107. scrollIdleTimer = setTimeout(() => setState("isScrolling", false), 800)
  108. }
  109. const thumbVisible = () => {
  110. if (isDragging()) return true
  111. if (local.thumbVisibility === "scroll") return isScrolling()
  112. return isHovered()
  113. }
  114. onCleanup(() => {
  115. if (scrollIdleTimer !== undefined) clearTimeout(scrollIdleTimer)
  116. })
  117. const updateThumb = () => {
  118. if (!viewportRef) return
  119. const { scrollTop, scrollHeight, clientHeight } = viewportRef
  120. if (scrollHeight <= clientHeight || scrollHeight === 0) {
  121. setState("showThumb", false)
  122. return
  123. }
  124. setState("showThumb", true)
  125. const trackPadding = 8
  126. const trackHeight = clientHeight - trackPadding * 2
  127. const minThumbHeight = 32
  128. // Calculate raw thumb height based on ratio
  129. let height = (clientHeight / scrollHeight) * trackHeight
  130. height = Math.max(height, minThumbHeight)
  131. const maxScrollTop = scrollHeight - clientHeight
  132. const maxThumbTop = trackHeight - height
  133. const top = maxScrollTop > 0 ? (scrollTop / maxScrollTop) * maxThumbTop : 0
  134. // Ensure thumb stays within bounds (shouldn't be necessary due to math above, but good for safety)
  135. const boundedTop = trackPadding + Math.max(0, Math.min(top, maxThumbTop))
  136. setState("thumbHeight", height)
  137. setState("thumbTop", boundedTop)
  138. }
  139. onMount(() => {
  140. if (local.viewportRef) {
  141. local.viewportRef(viewportRef)
  142. }
  143. createResizeObserver([viewportRef, viewportRef.firstElementChild], updateThumb)
  144. updateThumb()
  145. })
  146. const onThumbPointerDown = (e: PointerEvent) => {
  147. e.preventDefault()
  148. e.stopPropagation()
  149. setState("isDragging", true)
  150. const grabOffset = e.clientY - thumbRef.getBoundingClientRect().top
  151. thumbRef.setPointerCapture(e.pointerId)
  152. const onPointerMove = (e: PointerEvent) => {
  153. const { scrollHeight, clientHeight } = viewportRef
  154. viewportRef.scrollTop = scrollTopFromThumbPointer({
  155. pointer: e.clientY,
  156. viewportTop: viewportRef.getBoundingClientRect().top,
  157. grabOffset,
  158. clientHeight,
  159. scrollHeight,
  160. thumbHeight: thumbHeight(),
  161. })
  162. }
  163. const done = (e: PointerEvent) => {
  164. setState("isDragging", false)
  165. thumbRef.releasePointerCapture(e.pointerId)
  166. thumbRef.removeEventListener("pointermove", onPointerMove)
  167. thumbRef.removeEventListener("pointerup", done)
  168. thumbRef.removeEventListener("pointercancel", done)
  169. }
  170. thumbRef.addEventListener("pointermove", onPointerMove)
  171. thumbRef.addEventListener("pointerup", done)
  172. thumbRef.addEventListener("pointercancel", done)
  173. }
  174. // Keybinds implementation
  175. // We ensure the viewport has a tabindex so it can receive focus
  176. // We can also explicitly catch PageUp/Down if we want smooth scroll or specific behavior,
  177. // but native usually handles this perfectly. Let's explicitly ensure it behaves well.
  178. const onKeyDown = (e: KeyboardEvent) => {
  179. // If user is focused on an input inside the scroll view, don't hijack keys
  180. if (document.activeElement && ["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement.tagName)) {
  181. return
  182. }
  183. const next = scrollKey(e)
  184. if (!next) return
  185. if (!isScrollKeyTarget(e.target, next)) return
  186. if (scrollKeyOwner(viewportRef, e.target, next) !== viewportRef) return
  187. const scrollAmount = viewportRef.clientHeight * 0.8
  188. const lineAmount = 40
  189. switch (next) {
  190. case "page-down":
  191. e.preventDefault()
  192. viewportRef.scrollBy({ top: scrollAmount, behavior: "smooth" })
  193. break
  194. case "page-up":
  195. e.preventDefault()
  196. viewportRef.scrollBy({ top: -scrollAmount, behavior: "smooth" })
  197. break
  198. case "home":
  199. e.preventDefault()
  200. viewportRef.scrollTo({ top: 0, behavior: "smooth" })
  201. break
  202. case "end":
  203. e.preventDefault()
  204. viewportRef.scrollTo({ top: viewportRef.scrollHeight, behavior: "smooth" })
  205. break
  206. case "up":
  207. e.preventDefault()
  208. viewportRef.scrollBy({ top: -lineAmount, behavior: "smooth" })
  209. break
  210. case "down":
  211. e.preventDefault()
  212. viewportRef.scrollBy({ top: lineAmount, behavior: "smooth" })
  213. break
  214. }
  215. }
  216. return (
  217. <div
  218. ref={rootRef}
  219. class={`scroll-view ${local.class || ""}`}
  220. style={local.style}
  221. onPointerEnter={() => setState("isHovered", true)}
  222. onPointerLeave={() => setState("isHovered", false)}
  223. {...rest}
  224. >
  225. {/* Viewport */}
  226. <div
  227. ref={viewportRef}
  228. class="scroll-view__viewport"
  229. data-scrollable
  230. onScroll={(e) => {
  231. updateThumb()
  232. markScrolling()
  233. if (typeof events.onScroll === "function") events.onScroll(e as any)
  234. }}
  235. onWheel={(e) => {
  236. markScrolling()
  237. const handler = events.onWheel
  238. if (typeof handler === "function") handler(e as any)
  239. if (Array.isArray(handler)) handler[0](handler[1], e as any)
  240. }}
  241. onTouchStart={events.onTouchStart as any}
  242. onTouchMove={events.onTouchMove as any}
  243. onTouchEnd={events.onTouchEnd as any}
  244. onTouchCancel={events.onTouchCancel as any}
  245. onPointerDown={events.onPointerDown as any}
  246. onClick={events.onClick as any}
  247. tabIndex={0}
  248. role="region"
  249. aria-label={i18n.t("ui.scrollView.ariaLabel")}
  250. onKeyDown={(e) => {
  251. onKeyDown(e)
  252. if (typeof events.onKeyDown === "function") events.onKeyDown(e as any)
  253. }}
  254. >
  255. {local.children}
  256. </div>
  257. {/* Thumb Overlay */}
  258. <Show when={showThumb()}>
  259. <div
  260. ref={thumbRef}
  261. onPointerDown={onThumbPointerDown}
  262. class="scroll-view__thumb"
  263. data-visible={thumbVisible()}
  264. data-dragging={isDragging()}
  265. style={{
  266. height: `${thumbHeight()}px`,
  267. transform: `translateY(${thumbTop()}px)`,
  268. "z-index": 100, // ensure it displays over content
  269. }}
  270. />
  271. </Show>
  272. </div>
  273. )
  274. }