798 lines
31 KiB
JavaScript
798 lines
31 KiB
JavaScript
/**
|
|
* 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);
|
|
}
|