UNPKG

bits-ui

Version:

The headless components for Svelte.

798 lines (797 loc) 31.4 kB
/** * This logic is adapted from Radix UI ScrollArea component. * https://github.com/radix-ui/primitives/blob/main/packages/react/scroll-area/src/ScrollArea.tsx * Credit to Jenna Smith (@jjenzz) for the original implementation. * Incredible thought must have went into solving all the intricacies of this component. */ import { Context, useDebounce, watch } from "runed"; import { untrack } from "svelte"; import { simpleBox, executeCallbacks, attachRef, DOMContext, getWindow, } from "svelte-toolbelt"; import { mergeProps, useId } from "../../shared/index.js"; import { clamp } from "../../internal/clamp.js"; import { on } from "svelte/events"; import { createBitsAttrs } from "../../internal/attrs.js"; import { StateMachine } from "../../internal/state-machine.js"; import { SvelteResizeObserver } from "../../internal/svelte-resize-observer.svelte.js"; const scrollAreaAttrs = createBitsAttrs({ component: "scroll-area", parts: ["root", "viewport", "corner", "thumb", "scrollbar"], }); export const ScrollAreaRootContext = new Context("ScrollArea.Root"); export const ScrollAreaScrollbarContext = new Context("ScrollArea.Scrollbar"); export const ScrollAreaScrollbarVisibleContext = new Context("ScrollArea.ScrollbarVisible"); export const ScrollAreaScrollbarAxisContext = new Context("ScrollArea.ScrollbarAxis"); export const ScrollAreaScrollbarSharedContext = new Context("ScrollArea.ScrollbarShared"); export class ScrollAreaRootState { static create(opts) { return ScrollAreaRootContext.set(new ScrollAreaRootState(opts)); } opts; attachment; scrollAreaNode = $state(null); viewportNode = $state(null); contentNode = $state(null); scrollbarXNode = $state(null); scrollbarYNode = $state(null); cornerWidth = $state(0); cornerHeight = $state(0); scrollbarXEnabled = $state(false); scrollbarYEnabled = $state(false); domContext; constructor(opts) { this.opts = opts; this.attachment = attachRef(opts.ref, (v) => (this.scrollAreaNode = v)); this.domContext = new DOMContext(opts.ref); } props = $derived.by(() => ({ id: this.opts.id.current, dir: this.opts.dir.current, style: { position: "relative", "--bits-scroll-area-corner-height": `${this.cornerHeight}px`, "--bits-scroll-area-corner-width": `${this.cornerWidth}px`, }, [scrollAreaAttrs.root]: "", ...this.attachment, })); } export class ScrollAreaViewportState { static create(opts) { return new ScrollAreaViewportState(opts, ScrollAreaRootContext.get()); } opts; root; attachment; #contentId = simpleBox(useId()); #contentRef = simpleBox(null); contentAttachment = attachRef(this.#contentRef, (v) => (this.root.contentNode = v)); constructor(opts, root) { this.opts = opts; this.root = root; this.attachment = attachRef(opts.ref, (v) => (this.root.viewportNode = v)); } props = $derived.by(() => ({ id: this.opts.id.current, style: { overflowX: this.root.scrollbarXEnabled ? "scroll" : "hidden", overflowY: this.root.scrollbarYEnabled ? "scroll" : "hidden", }, [scrollAreaAttrs.viewport]: "", ...this.attachment, })); contentProps = $derived.by(() => ({ id: this.#contentId.current, "data-scroll-area-content": "", /** * When horizontal scrollbar is visible: this element should be at least * as wide as its children for size calculations to work correctly. * * When horizontal scrollbar is NOT visible: this element's width should * be constrained by the parent container to enable `text-overflow: ellipsis` */ style: { minWidth: this.root.scrollbarXEnabled ? "fit-content" : undefined }, ...this.contentAttachment, })); } export class ScrollAreaScrollbarState { static create(opts) { return ScrollAreaScrollbarContext.set(new ScrollAreaScrollbarState(opts, ScrollAreaRootContext.get())); } opts; root; isHorizontal = $derived.by(() => this.opts.orientation.current === "horizontal"); hasThumb = $state(false); constructor(opts, root) { this.opts = opts; this.root = root; watch(() => this.isHorizontal, (isHorizontal) => { if (isHorizontal) { this.root.scrollbarXEnabled = true; return () => { this.root.scrollbarXEnabled = false; }; } else { this.root.scrollbarYEnabled = true; return () => { this.root.scrollbarYEnabled = false; }; } }); } } export class ScrollAreaScrollbarHoverState { static create() { return new ScrollAreaScrollbarHoverState(ScrollAreaScrollbarContext.get()); } scrollbar; root; isVisible = $state(false); constructor(scrollbar) { this.scrollbar = scrollbar; this.root = scrollbar.root; $effect(() => { const scrollAreaNode = this.root.scrollAreaNode; const hideDelay = this.root.opts.scrollHideDelay.current; let hideTimer = 0; if (!scrollAreaNode) return; const handlePointerEnter = () => { this.root.domContext.clearTimeout(hideTimer); untrack(() => (this.isVisible = true)); }; const handlePointerLeave = () => { if (hideTimer) this.root.domContext.clearTimeout(hideTimer); hideTimer = this.root.domContext.setTimeout(() => { untrack(() => { this.scrollbar.hasThumb = false; this.isVisible = false; }); }, hideDelay); }; const unsubListeners = executeCallbacks(on(scrollAreaNode, "pointerenter", handlePointerEnter), on(scrollAreaNode, "pointerleave", handlePointerLeave)); return () => { this.root.domContext.getWindow().clearTimeout(hideTimer); unsubListeners(); }; }); } props = $derived.by(() => ({ "data-state": this.isVisible ? "visible" : "hidden", })); } export class ScrollAreaScrollbarScrollState { static create() { return new ScrollAreaScrollbarScrollState(ScrollAreaScrollbarContext.get()); } scrollbar; root; machine = new StateMachine("hidden", { hidden: { SCROLL: "scrolling", }, scrolling: { SCROLL_END: "idle", POINTER_ENTER: "interacting", }, interacting: { SCROLL: "interacting", POINTER_LEAVE: "idle", }, idle: { HIDE: "hidden", SCROLL: "scrolling", POINTER_ENTER: "interacting", }, }); isHidden = $derived.by(() => this.machine.state.current === "hidden"); constructor(scrollbar) { this.scrollbar = scrollbar; this.root = scrollbar.root; const debounceScrollend = useDebounce(() => this.machine.dispatch("SCROLL_END"), 100); $effect(() => { const _state = this.machine.state.current; const scrollHideDelay = this.root.opts.scrollHideDelay.current; if (_state === "idle") { const hideTimer = this.root.domContext.setTimeout(() => this.machine.dispatch("HIDE"), scrollHideDelay); return () => this.root.domContext.clearTimeout(hideTimer); } }); $effect(() => { const viewportNode = this.root.viewportNode; if (!viewportNode) return; const scrollDirection = this.scrollbar.isHorizontal ? "scrollLeft" : "scrollTop"; let prevScrollPos = viewportNode[scrollDirection]; const handleScroll = () => { const scrollPos = viewportNode[scrollDirection]; const hasScrollInDirectionChanged = prevScrollPos !== scrollPos; if (hasScrollInDirectionChanged) { this.machine.dispatch("SCROLL"); debounceScrollend(); } prevScrollPos = scrollPos; }; const unsubListener = on(viewportNode, "scroll", handleScroll); return unsubListener; }); this.onpointerenter = this.onpointerenter.bind(this); this.onpointerleave = this.onpointerleave.bind(this); } onpointerenter(_) { this.machine.dispatch("POINTER_ENTER"); } onpointerleave(_) { this.machine.dispatch("POINTER_LEAVE"); } props = $derived.by(() => ({ "data-state": this.machine.state.current === "hidden" ? "hidden" : "visible", onpointerenter: this.onpointerenter, onpointerleave: this.onpointerleave, })); } export class ScrollAreaScrollbarAutoState { static create() { return new ScrollAreaScrollbarAutoState(ScrollAreaScrollbarContext.get()); } scrollbar; root; isVisible = $state(false); constructor(scrollbar) { this.scrollbar = scrollbar; this.root = scrollbar.root; const handleResize = useDebounce(() => { const viewportNode = this.root.viewportNode; if (!viewportNode) return; const isOverflowX = viewportNode.offsetWidth < viewportNode.scrollWidth; const isOverflowY = viewportNode.offsetHeight < viewportNode.scrollHeight; this.isVisible = this.scrollbar.isHorizontal ? isOverflowX : isOverflowY; }, 10); new SvelteResizeObserver(() => this.root.viewportNode, handleResize); new SvelteResizeObserver(() => this.root.contentNode, handleResize); } props = $derived.by(() => ({ "data-state": this.isVisible ? "visible" : "hidden", })); } export class ScrollAreaScrollbarVisibleState { static create() { return ScrollAreaScrollbarVisibleContext.set(new ScrollAreaScrollbarVisibleState(ScrollAreaScrollbarContext.get())); } scrollbar; root; thumbNode = $state(null); pointerOffset = $state(0); sizes = $state.raw({ content: 0, viewport: 0, scrollbar: { size: 0, paddingStart: 0, paddingEnd: 0 }, }); thumbRatio = $derived.by(() => getThumbRatio(this.sizes.viewport, this.sizes.content)); hasThumb = $derived.by(() => Boolean(this.thumbRatio > 0 && this.thumbRatio < 1)); // this needs to be a $state to properly restore the transform style when the scrollbar // goes from a hidden to visible state, otherwise it will start at the beginning of the // scrollbar and flicker to the correct position after prevTransformStyle = $state(""); constructor(scrollbar) { this.scrollbar = scrollbar; this.root = scrollbar.root; $effect(() => { this.scrollbar.hasThumb = this.hasThumb; }); $effect(() => { if (!this.scrollbar.hasThumb && this.thumbNode) { this.prevTransformStyle = this.thumbNode.style.transform; } }); } setSizes(sizes) { this.sizes = sizes; } getScrollPosition(pointerPos, dir) { return getScrollPositionFromPointer({ pointerPos, pointerOffset: this.pointerOffset, sizes: this.sizes, dir, }); } onThumbPointerUp() { this.pointerOffset = 0; } onThumbPointerDown(pointerPos) { this.pointerOffset = pointerPos; } xOnThumbPositionChange() { if (!(this.root.viewportNode && this.thumbNode)) return; const scrollPos = this.root.viewportNode.scrollLeft; const offset = getThumbOffsetFromScroll({ scrollPos, sizes: this.sizes, dir: this.root.opts.dir.current, }); const transformStyle = `translate3d(${offset}px, 0, 0)`; this.thumbNode.style.transform = transformStyle; this.prevTransformStyle = transformStyle; } xOnWheelScroll(scrollPos) { if (!this.root.viewportNode) return; this.root.viewportNode.scrollLeft = scrollPos; } xOnDragScroll(pointerPos) { if (!this.root.viewportNode) return; this.root.viewportNode.scrollLeft = this.getScrollPosition(pointerPos, this.root.opts.dir.current); } yOnThumbPositionChange() { if (!(this.root.viewportNode && this.thumbNode)) return; const scrollPos = this.root.viewportNode.scrollTop; const offset = getThumbOffsetFromScroll({ scrollPos, sizes: this.sizes }); const transformStyle = `translate3d(0, ${offset}px, 0)`; this.thumbNode.style.transform = transformStyle; this.prevTransformStyle = transformStyle; } yOnWheelScroll(scrollPos) { if (!this.root.viewportNode) return; this.root.viewportNode.scrollTop = scrollPos; } yOnDragScroll(pointerPos) { if (!this.root.viewportNode) return; this.root.viewportNode.scrollTop = this.getScrollPosition(pointerPos, this.root.opts.dir.current); } } export class ScrollAreaScrollbarXState { static create(opts) { return ScrollAreaScrollbarAxisContext.set(new ScrollAreaScrollbarXState(opts, ScrollAreaScrollbarVisibleContext.get())); } opts; scrollbarVis; root; scrollbar; attachment; computedStyle = $state(); constructor(opts, scrollbarVis) { this.opts = opts; this.scrollbarVis = scrollbarVis; this.root = scrollbarVis.root; this.scrollbar = scrollbarVis.scrollbar; this.attachment = attachRef(this.scrollbar.opts.ref, (v) => (this.root.scrollbarXNode = v)); $effect(() => { if (!this.scrollbar.opts.ref.current) return; if (this.opts.mounted.current) { this.computedStyle = getComputedStyle(this.scrollbar.opts.ref.current); } }); $effect(() => { // Ensure when a user scrolls down and then the scrollbar is hidden // that when it shows again it will be positioned correctly. this.onResize(); }); } onThumbPointerDown = (pointerPos) => { this.scrollbarVis.onThumbPointerDown(pointerPos.x); }; onDragScroll = (pointerPos) => { this.scrollbarVis.xOnDragScroll(pointerPos.x); }; onThumbPointerUp = () => { this.scrollbarVis.onThumbPointerUp(); }; onThumbPositionChange = () => { this.scrollbarVis.xOnThumbPositionChange(); }; onWheelScroll = (e, maxScrollPos) => { if (!this.root.viewportNode) return; const scrollPos = this.root.viewportNode.scrollLeft + e.deltaX; this.scrollbarVis.xOnWheelScroll(scrollPos); // prevent window scroll when wheeling scrollbar if (isScrollingWithinScrollbarBounds(scrollPos, maxScrollPos)) { e.preventDefault(); } }; onResize = () => { if (!(this.scrollbar.opts.ref.current && this.root.viewportNode && this.computedStyle)) return; this.scrollbarVis.setSizes({ content: this.root.viewportNode.scrollWidth, viewport: this.root.viewportNode.offsetWidth, scrollbar: { size: this.scrollbar.opts.ref.current.clientWidth, paddingStart: toInt(this.computedStyle.paddingLeft), paddingEnd: toInt(this.computedStyle.paddingRight), }, }); }; thumbSize = $derived.by(() => { return getThumbSize(this.scrollbarVis.sizes); }); props = $derived.by(() => ({ id: this.scrollbar.opts.id.current, "data-orientation": "horizontal", style: { bottom: 0, left: this.root.opts.dir.current === "rtl" ? "var(--bits-scroll-area-corner-width)" : 0, right: this.root.opts.dir.current === "ltr" ? "var(--bits-scroll-area-corner-width)" : 0, "--bits-scroll-area-thumb-width": `${this.thumbSize}px`, }, ...this.attachment, })); } export class ScrollAreaScrollbarYState { static create(opts) { return ScrollAreaScrollbarAxisContext.set(new ScrollAreaScrollbarYState(opts, ScrollAreaScrollbarVisibleContext.get())); } opts; scrollbarVis; root; scrollbar; attachment; computedStyle = $state(); constructor(opts, scrollbarVis) { this.opts = opts; this.scrollbarVis = scrollbarVis; this.root = scrollbarVis.root; this.scrollbar = scrollbarVis.scrollbar; this.attachment = attachRef(this.scrollbar.opts.ref, (v) => (this.root.scrollbarYNode = v)); $effect(() => { if (!this.scrollbar.opts.ref.current) return; if (this.opts.mounted.current) { this.computedStyle = getComputedStyle(this.scrollbar.opts.ref.current); } }); $effect(() => { // Ensure when a user scrolls down and then the scrollbar is hidden // that when it shows again it will be positioned correctly. this.onResize(); }); this.onThumbPointerDown = this.onThumbPointerDown.bind(this); this.onDragScroll = this.onDragScroll.bind(this); this.onThumbPointerUp = this.onThumbPointerUp.bind(this); this.onThumbPositionChange = this.onThumbPositionChange.bind(this); this.onWheelScroll = this.onWheelScroll.bind(this); this.onResize = this.onResize.bind(this); } onThumbPointerDown(pointerPos) { this.scrollbarVis.onThumbPointerDown(pointerPos.y); } onDragScroll(pointerPos) { this.scrollbarVis.yOnDragScroll(pointerPos.y); } onThumbPointerUp() { this.scrollbarVis.onThumbPointerUp(); } onThumbPositionChange() { this.scrollbarVis.yOnThumbPositionChange(); } onWheelScroll(e, maxScrollPos) { if (!this.root.viewportNode) return; const scrollPos = this.root.viewportNode.scrollTop + e.deltaY; this.scrollbarVis.yOnWheelScroll(scrollPos); // prevent window scroll when wheeling scrollbar if (isScrollingWithinScrollbarBounds(scrollPos, maxScrollPos)) { e.preventDefault(); } } onResize() { if (!(this.scrollbar.opts.ref.current && this.root.viewportNode && this.computedStyle)) return; this.scrollbarVis.setSizes({ content: this.root.viewportNode.scrollHeight, viewport: this.root.viewportNode.offsetHeight, scrollbar: { size: this.scrollbar.opts.ref.current.clientHeight, paddingStart: toInt(this.computedStyle.paddingTop), paddingEnd: toInt(this.computedStyle.paddingBottom), }, }); } thumbSize = $derived.by(() => { return getThumbSize(this.scrollbarVis.sizes); }); props = $derived.by(() => ({ id: this.scrollbar.opts.id.current, "data-orientation": "vertical", style: { top: 0, right: this.root.opts.dir.current === "ltr" ? 0 : undefined, left: this.root.opts.dir.current === "rtl" ? 0 : undefined, bottom: "var(--bits-scroll-area-corner-height)", "--bits-scroll-area-thumb-height": `${this.thumbSize}px`, }, ...this.attachment, })); } export class ScrollAreaScrollbarSharedState { static create() { return ScrollAreaScrollbarSharedContext.set(new ScrollAreaScrollbarSharedState(ScrollAreaScrollbarAxisContext.get())); } scrollbarState; root; scrollbarVis; scrollbar; rect = $state.raw(null); prevWebkitUserSelect = $state(""); handleResize; handleThumbPositionChange; handleWheelScroll; handleThumbPointerDown; handleThumbPointerUp; maxScrollPos = $derived.by(() => this.scrollbarVis.sizes.content - this.scrollbarVis.sizes.viewport); constructor(scrollbarState) { this.scrollbarState = scrollbarState; this.root = scrollbarState.root; this.scrollbarVis = scrollbarState.scrollbarVis; this.scrollbar = scrollbarState.scrollbarVis.scrollbar; this.handleResize = useDebounce(() => this.scrollbarState.onResize(), 10); this.handleThumbPositionChange = this.scrollbarState.onThumbPositionChange; this.handleWheelScroll = this.scrollbarState.onWheelScroll; this.handleThumbPointerDown = this.scrollbarState.onThumbPointerDown; this.handleThumbPointerUp = this.scrollbarState.onThumbPointerUp; $effect(() => { const maxScrollPos = this.maxScrollPos; const scrollbarNode = this.scrollbar.opts.ref.current; // we want to react to the viewport node changing so we leave this here this.root.viewportNode; const handleWheel = (e) => { const node = e.target; const isScrollbarWheel = scrollbarNode?.contains(node); if (isScrollbarWheel) this.handleWheelScroll(e, maxScrollPos); }; const unsubListener = on(this.root.domContext.getDocument(), "wheel", handleWheel, { passive: false, }); return unsubListener; }); $effect.pre(() => { // react to changes to this: this.scrollbarVis.sizes; untrack(() => this.handleThumbPositionChange()); }); // $effect.pre(() => { // this.handleThumbPositionChange(); // }); new SvelteResizeObserver(() => this.scrollbar.opts.ref.current, this.handleResize); new SvelteResizeObserver(() => this.root.contentNode, this.handleResize); this.onpointerdown = this.onpointerdown.bind(this); this.onpointermove = this.onpointermove.bind(this); this.onpointerup = this.onpointerup.bind(this); this.onlostpointercapture = this.onlostpointercapture.bind(this); } handleDragScroll(e) { if (!this.rect) return; const x = e.clientX - this.rect.left; const y = e.clientY - this.rect.top; this.scrollbarState.onDragScroll({ x, y }); } #cleanupPointerState() { if (this.rect === null) return; this.root.domContext.getDocument().body.style.webkitUserSelect = this.prevWebkitUserSelect; if (this.root.viewportNode) this.root.viewportNode.style.scrollBehavior = ""; this.rect = null; } onpointerdown(e) { if (e.button !== 0) return; const target = e.target; target.setPointerCapture(e.pointerId); this.rect = this.scrollbar.opts.ref.current?.getBoundingClientRect() ?? null; // pointer capture doesn't prevent text selection in Safari // so we remove text selection manually when scrolling this.prevWebkitUserSelect = this.root.domContext.getDocument().body.style.webkitUserSelect; this.root.domContext.getDocument().body.style.webkitUserSelect = "none"; if (this.root.viewportNode) this.root.viewportNode.style.scrollBehavior = "auto"; this.handleDragScroll(e); } onpointermove(e) { this.handleDragScroll(e); } onpointerup(e) { const target = e.target; if (target.hasPointerCapture(e.pointerId)) { target.releasePointerCapture(e.pointerId); } this.#cleanupPointerState(); } onlostpointercapture(_) { this.#cleanupPointerState(); } props = $derived.by(() => mergeProps({ ...this.scrollbarState.props, style: { position: "absolute", ...this.scrollbarState.props.style, }, [scrollAreaAttrs.scrollbar]: "", onpointerdown: this.onpointerdown, onpointermove: this.onpointermove, onpointerup: this.onpointerup, onlostpointercapture: this.onlostpointercapture, })); } export class ScrollAreaThumbImplState { static create(opts) { return new ScrollAreaThumbImplState(opts, ScrollAreaScrollbarSharedContext.get()); } opts; scrollbarState; attachment; #root; #removeUnlinkedScrollListener = $state(); #debounceScrollEnd = useDebounce(() => { if (this.#removeUnlinkedScrollListener) { this.#removeUnlinkedScrollListener(); this.#removeUnlinkedScrollListener = undefined; } }, 100); constructor(opts, scrollbarState) { this.opts = opts; this.scrollbarState = scrollbarState; this.#root = scrollbarState.root; this.attachment = attachRef(this.opts.ref, (v) => (this.scrollbarState.scrollbarVis.thumbNode = v)); $effect(() => { const viewportNode = this.#root.viewportNode; if (!viewportNode) return; const handleScroll = () => { this.#debounceScrollEnd(); if (!this.#removeUnlinkedScrollListener) { const listener = addUnlinkedScrollListener(viewportNode, this.scrollbarState.handleThumbPositionChange); this.#removeUnlinkedScrollListener = listener; this.scrollbarState.handleThumbPositionChange(); } }; untrack(() => this.scrollbarState.handleThumbPositionChange()); const unsubListener = on(viewportNode, "scroll", handleScroll); return unsubListener; }); this.onpointerdowncapture = this.onpointerdowncapture.bind(this); this.onpointerup = this.onpointerup.bind(this); } onpointerdowncapture(e) { const thumb = e.target; if (!thumb) return; const thumbRect = thumb.getBoundingClientRect(); const x = e.clientX - thumbRect.left; const y = e.clientY - thumbRect.top; this.scrollbarState.handleThumbPointerDown({ x, y }); } onpointerup(_) { this.scrollbarState.handleThumbPointerUp(); } props = $derived.by(() => ({ id: this.opts.id.current, "data-state": this.scrollbarState.scrollbarVis.hasThumb ? "visible" : "hidden", style: { width: "var(--bits-scroll-area-thumb-width)", height: "var(--bits-scroll-area-thumb-height)", transform: this.scrollbarState.scrollbarVis.prevTransformStyle, }, onpointerdowncapture: this.onpointerdowncapture, onpointerup: this.onpointerup, [scrollAreaAttrs.thumb]: "", ...this.attachment, })); } export class ScrollAreaCornerImplState { static create(opts) { return new ScrollAreaCornerImplState(opts, ScrollAreaRootContext.get()); } opts; root; attachment; #width = $state(0); #height = $state(0); hasSize = $derived(Boolean(this.#width && this.#height)); constructor(opts, root) { this.opts = opts; this.root = root; this.attachment = attachRef(this.opts.ref); new SvelteResizeObserver(() => this.root.scrollbarXNode, () => { const height = this.root.scrollbarXNode?.offsetHeight || 0; this.root.cornerHeight = height; this.#height = height; }); new SvelteResizeObserver(() => this.root.scrollbarYNode, () => { const width = this.root.scrollbarYNode?.offsetWidth || 0; this.root.cornerWidth = width; this.#width = width; }); } props = $derived.by(() => ({ id: this.opts.id.current, style: { width: this.#width, height: this.#height, position: "absolute", right: this.root.opts.dir.current === "ltr" ? 0 : undefined, left: this.root.opts.dir.current === "rtl" ? 0 : undefined, bottom: 0, }, [scrollAreaAttrs.corner]: "", ...this.attachment, })); } function toInt(value) { return value ? Number.parseInt(value, 10) : 0; } function getThumbRatio(viewportSize, contentSize) { const ratio = viewportSize / contentSize; return Number.isNaN(ratio) ? 0 : ratio; } function getThumbSize(sizes) { const ratio = getThumbRatio(sizes.viewport, sizes.content); const scrollbarPadding = sizes.scrollbar.paddingStart + sizes.scrollbar.paddingEnd; const thumbSize = (sizes.scrollbar.size - scrollbarPadding) * ratio; return Math.max(thumbSize, 18); } function getScrollPositionFromPointer({ pointerPos, pointerOffset, sizes, dir = "ltr", }) { const thumbSizePx = getThumbSize(sizes); const thumbCenter = thumbSizePx / 2; const offset = pointerOffset || thumbCenter; const thumbOffsetFromEnd = thumbSizePx - offset; const minPointerPos = sizes.scrollbar.paddingStart + offset; const maxPointerPos = sizes.scrollbar.size - sizes.scrollbar.paddingEnd - thumbOffsetFromEnd; const maxScrollPos = sizes.content - sizes.viewport; const scrollRange = dir === "ltr" ? [0, maxScrollPos] : [maxScrollPos * -1, 0]; const interpolate = linearScale([minPointerPos, maxPointerPos], scrollRange); return interpolate(pointerPos); } function getThumbOffsetFromScroll({ scrollPos, sizes, dir = "ltr", }) { const thumbSizePx = getThumbSize(sizes); const scrollbarPadding = sizes.scrollbar.paddingStart + sizes.scrollbar.paddingEnd; const scrollbar = sizes.scrollbar.size - scrollbarPadding; const maxScrollPos = sizes.content - sizes.viewport; const maxThumbPos = scrollbar - thumbSizePx; const scrollClampRange = dir === "ltr" ? [0, maxScrollPos] : [maxScrollPos * -1, 0]; const scrollWithoutMomentum = clamp(scrollPos, scrollClampRange[0], scrollClampRange[1]); const interpolate = linearScale([0, maxScrollPos], [0, maxThumbPos]); return interpolate(scrollWithoutMomentum); } // https://github.com/tmcw-up-for-adoption/simple-linear-scale/blob/master/index.js function linearScale(input, output) { return (value) => { if (input[0] === input[1] || output[0] === output[1]) return output[0]; const ratio = (output[1] - output[0]) / (input[1] - input[0]); return output[0] + ratio * (value - input[0]); }; } function isScrollingWithinScrollbarBounds(scrollPos, maxScrollPos) { return scrollPos > 0 && scrollPos < maxScrollPos; } function addUnlinkedScrollListener(node, handler) { let prevPosition = { left: node.scrollLeft, top: node.scrollTop }; let rAF = 0; const win = getWindow(node); (function loop() { const position = { left: node.scrollLeft, top: node.scrollTop }; const isHorizontalScroll = prevPosition.left !== position.left; const isVerticalScroll = prevPosition.top !== position.top; if (isHorizontalScroll || isVerticalScroll) handler(); prevPosition = position; rAF = win.requestAnimationFrame(loop); })(); return () => win.cancelAnimationFrame(rAF); }