scroll-view.tsx 7.1 KB

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