scroll-view.tsx 8.9 KB

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