@zag-js/scroll-area
Version:
Core logic for the scroll-area widget implemented as a state machine
832 lines (822 loc) • 32.5 kB
JavaScript
import { createAnatomy } from '@zag-js/anatomy';
import { trackPointerMove, query, addDomEvent, getComputedStyle, dataAttr, getEventPoint, contains, getEventTarget } from '@zag-js/dom-query';
import { callAll, isEqual, clampValue, createSplitProps, toPx, compact } from '@zag-js/utils';
import { createMachine } from '@zag-js/core';
import { createProps } from '@zag-js/types';
var __defProp = Object.defineProperty;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
var anatomy = createAnatomy("scroll-area").parts("root", "viewport", "content", "scrollbar", "thumb", "corner");
var parts = anatomy.build();
var getRootId = (ctx) => ctx.ids?.root ?? `scroll-area-${ctx.id}`;
var getViewportId = (ctx) => ctx.ids?.viewport ?? `scroll-area-${ctx.id}:viewport`;
var getContentId = (ctx) => ctx.ids?.content ?? `scroll-area-${ctx.id}:content`;
var getRootEl = (ctx) => ctx.getById(getRootId(ctx));
var getViewportEl = (ctx) => ctx.getById(getViewportId(ctx));
var getContentEl = (ctx) => ctx.getById(getContentId(ctx));
var getScrollbarXEl = (ctx) => query(getRootEl(ctx), `[data-part=scrollbar][data-orientation=horizontal][data-ownedby="${getRootId(ctx)}"]`);
var getScrollbarYEl = (ctx) => query(getRootEl(ctx), `[data-part=scrollbar][data-orientation=vertical][data-ownedby="${getRootId(ctx)}"]`);
var getThumbXEl = (ctx) => query(getScrollbarXEl(ctx), `[data-part=thumb][data-orientation=horizontal][data-ownedby="${getRootId(ctx)}"]`);
var getThumbYEl = (ctx) => query(getScrollbarYEl(ctx), `[data-part=thumb][data-orientation=vertical][data-ownedby="${getRootId(ctx)}"]`);
var getCornerEl = (ctx) => query(getRootEl(ctx), `[data-part=corner][data-ownedby="${getRootId(ctx)}"]`);
// src/utils/scroll-progress.ts
function getScrollProgress(element, scrollThreshold) {
if (!element) return EMPTY_SCROLL_PROGRESS;
let progressX = 0;
let progressY = 0;
const maxScrollX = element.scrollWidth - element.clientWidth;
if (maxScrollX > scrollThreshold) {
progressX = Math.min(1, Math.max(0, element.scrollLeft / maxScrollX));
}
const maxScrollY = element.scrollHeight - element.clientHeight;
if (maxScrollY > scrollThreshold) {
progressY = Math.min(1, Math.max(0, element.scrollTop / maxScrollY));
}
return { x: progressX, y: progressY };
}
var EMPTY_SCROLL_PROGRESS = { x: 0, y: 0 };
// src/utils/smooth-scroll.ts
var DURATION = 300;
var EASE_OUT_QUAD = (t) => t * (2 - t);
function smoothScroll(node, options = {}) {
const { top, left, duration = DURATION, easing = EASE_OUT_QUAD, onComplete } = options;
if (!node) return;
const state = {
startTime: 0,
startScrollTop: node.scrollTop,
startScrollLeft: node.scrollLeft,
targetScrollTop: top ?? node.scrollTop,
targetScrollLeft: left ?? node.scrollLeft
};
let cancelled = false;
const cleanup = () => {
if (state.rafId) {
cancelAnimationFrame(state.rafId);
state.rafId = void 0;
}
cancelled = true;
};
const animate = (currentTime) => {
if (cancelled) return;
if (state.startTime === 0) {
state.startTime = currentTime;
}
const elapsed = currentTime - state.startTime;
const progress = Math.min(elapsed / duration, 1);
const easedProgress = easing(progress);
const deltaTop = state.targetScrollTop - state.startScrollTop;
const deltaLeft = state.targetScrollLeft - state.startScrollLeft;
node.scrollTop = state.startScrollTop + deltaTop * easedProgress;
node.scrollLeft = state.startScrollLeft + deltaLeft * easedProgress;
if (progress < 1) {
state.rafId = requestAnimationFrame(animate);
} else {
onComplete?.();
}
};
state.rafId = requestAnimationFrame(animate);
return cleanup;
}
// src/utils/scroll-to.ts
function scrollTo(node, options = {}) {
if (!node) return;
const { top, left, behavior = "smooth", easing, duration } = options;
if (behavior === "smooth") {
smoothScroll(node, { top, left, easing, duration });
} else {
const scrollOptions = compact({ behavior, top, left });
node.scrollTo(scrollOptions);
}
}
function scrollToEdge(node, edge, dir, behavior = "smooth", easing, duration) {
if (!node) return;
const maxLeft = node.scrollWidth - node.clientWidth;
const maxTop = node.scrollHeight - node.clientHeight;
const isRtl = dir === "rtl";
let targetScrollTop;
let targetScrollLeft;
switch (edge) {
case "top":
targetScrollTop = 0;
break;
case "bottom":
targetScrollTop = maxTop;
break;
case "left":
if (isRtl) {
const negative = node.scrollLeft <= 0;
targetScrollLeft = negative ? -maxLeft : 0;
} else {
targetScrollLeft = 0;
}
break;
case "right":
if (isRtl) {
const negative = node.scrollLeft <= 0;
targetScrollLeft = negative ? 0 : maxLeft;
} else {
targetScrollLeft = maxLeft;
}
break;
}
if (behavior === "smooth") {
smoothScroll(node, { top: targetScrollTop, left: targetScrollLeft, easing, duration });
} else {
const options = compact({ left: targetScrollLeft, top: targetScrollTop, behavior });
node.scrollTo(options);
}
}
// src/scroll-area.connect.ts
function connect(service, normalize) {
const { state, send, context, prop, scope } = service;
const dragging = state.matches("dragging");
const hovering = context.get("hovering");
const cornerSize = context.get("cornerSize");
const thumbSize = context.get("thumbSize");
const hiddenState = context.get("hiddenState");
const atSides = context.get("atSides");
return {
isAtTop: atSides.top,
isAtBottom: atSides.bottom,
isAtLeft: atSides.left,
isAtRight: atSides.right,
hasOverflowX: !hiddenState.scrollbarXHidden,
hasOverflowY: !hiddenState.scrollbarYHidden,
getScrollProgress() {
return getScrollProgress(getViewportEl(scope), 0);
},
scrollToEdge(details) {
const { edge, behavior } = details;
return scrollToEdge(getViewportEl(scope), edge, prop("dir"), behavior);
},
scrollTo(details) {
return scrollTo(getViewportEl(scope), details);
},
getScrollbarState(props2) {
const horizontal = props2.orientation === "horizontal";
return {
hovering,
dragging,
scrolling: context.get(horizontal ? "scrollingX" : "scrollingY"),
hidden: horizontal ? hiddenState.scrollbarXHidden : hiddenState.scrollbarYHidden
};
},
getRootProps() {
return normalize.element({
...parts.root.attrs,
id: getRootId(scope),
dir: prop("dir"),
role: "presentation",
"data-overflow-x": dataAttr(!hiddenState.scrollbarXHidden),
"data-overflow-y": dataAttr(!hiddenState.scrollbarYHidden),
onPointerEnter(event) {
const target = getEventTarget(event);
if (!contains(event.currentTarget, target)) return;
send({ type: "root.pointerenter", pointerType: event.pointerType });
},
onPointerMove(event) {
const target = getEventTarget(event);
if (!contains(event.currentTarget, target)) return;
send({ type: "root.pointerenter", pointerType: event.pointerType });
},
onPointerDown({ pointerType }) {
send({ type: "root.pointerdown", pointerType });
},
onPointerLeave(event) {
if (contains(event.currentTarget, event.relatedTarget)) return;
send({ type: "root.pointerleave" });
},
style: {
position: "relative",
"--corner-width": toPx(cornerSize?.width),
"--corner-height": toPx(cornerSize?.height),
"--thumb-width": toPx(thumbSize?.width),
"--thumb-height": toPx(thumbSize?.height)
}
});
},
getViewportProps() {
const handleUserInteraction = () => {
send({ type: "user.scroll" });
};
return normalize.element({
...parts.viewport.attrs,
role: "presentation",
"data-ownedby": getRootId(scope),
id: getViewportId(scope),
"data-at-top": dataAttr(atSides.top),
"data-at-bottom": dataAttr(atSides.bottom),
"data-at-left": dataAttr(atSides.left),
"data-at-right": dataAttr(atSides.right),
"data-overflow-x": dataAttr(!hiddenState.scrollbarXHidden),
"data-overflow-y": dataAttr(!hiddenState.scrollbarYHidden),
tabIndex: hiddenState.scrollbarXHidden || hiddenState.scrollbarYHidden ? void 0 : 0,
style: {
overflow: "auto"
},
onScroll(event) {
send({ type: "viewport.scroll", target: event.currentTarget });
},
onWheel: handleUserInteraction,
onTouchMove: handleUserInteraction,
onPointerMove: handleUserInteraction,
onPointerEnter: handleUserInteraction,
onKeyDown: handleUserInteraction
});
},
getContentProps() {
return normalize.element({
...parts.content.attrs,
id: getContentId(scope),
role: "presentation",
"data-overflow-x": dataAttr(!hiddenState.scrollbarXHidden),
"data-overflow-y": dataAttr(!hiddenState.scrollbarYHidden),
style: {
minWidth: "fit-content"
}
});
},
getScrollbarProps(props2 = {}) {
const { orientation = "vertical" } = props2;
return normalize.element({
...parts.scrollbar.attrs,
"data-ownedby": getRootId(scope),
"data-orientation": orientation,
"data-scrolling": dataAttr(context.get(orientation === "horizontal" ? "scrollingX" : "scrollingY")),
"data-hover": dataAttr(hovering),
"data-dragging": dataAttr(dragging),
"data-overflow-x": dataAttr(!hiddenState.scrollbarXHidden),
"data-overflow-y": dataAttr(!hiddenState.scrollbarYHidden),
onPointerUp() {
send({ type: "scrollbar.pointerup", orientation });
},
onPointerDown(event) {
if (event.button !== 0) {
return;
}
if (event.currentTarget !== event.target) {
return;
}
const point = getEventPoint(event);
send({ type: "scrollbar.pointerdown", orientation, point });
event.stopPropagation();
},
style: {
position: "absolute",
touchAction: "none",
WebkitUserSelect: "none",
userSelect: "none",
...orientation === "vertical" && {
top: 0,
bottom: `var(--corner-height)`,
insetInlineEnd: 0
},
...orientation === "horizontal" && {
insetInlineStart: 0,
insetInlineEnd: `var(--corner-width)`,
bottom: 0
}
}
});
},
getThumbProps(props2 = {}) {
const { orientation = "vertical" } = props2;
return normalize.element({
...parts.thumb.attrs,
"data-ownedby": getRootId(scope),
"data-orientation": orientation,
"data-hover": dataAttr(hovering),
"data-dragging": dataAttr(dragging),
onPointerDown(event) {
if (event.button !== 0) return;
const point = getEventPoint(event);
send({ type: "thumb.pointerdown", orientation, point });
},
style: {
...orientation === "vertical" && {
height: "var(--thumb-height)"
},
...orientation === "horizontal" && {
width: "var(--thumb-width)"
}
}
});
},
getCornerProps() {
return normalize.element({
...parts.corner.attrs,
"data-ownedby": getRootId(scope),
"data-hover": dataAttr(hovering),
"data-state": hiddenState.cornerHidden ? "hidden" : "visible",
"data-overflow-x": dataAttr(!hiddenState.scrollbarXHidden),
"data-overflow-y": dataAttr(!hiddenState.scrollbarYHidden),
style: {
position: "absolute",
bottom: 0,
insetInlineEnd: 0,
width: "var(--corner-width)",
height: "var(--corner-height)"
}
});
}
};
}
function getScrollOffset(element, prop, axis) {
if (!element) return 0;
const styles = getComputedStyle(element);
const start = axis === "x" ? "Left" : "Top";
const end = axis === "x" ? "Right" : "Bottom";
return parseFloat(styles[`${prop}${start}`]) + parseFloat(styles[`${prop}${end}`]);
}
// src/utils/scroll-sides.ts
function getScrollSides(node, dir) {
const scrollTop = node.scrollTop;
const scrollLeft = node.scrollLeft;
const isRtl = dir === "rtl";
const threshold = 1;
const hasVerticalScroll = node.scrollHeight - node.clientHeight > threshold;
const hasHorizontalScroll = node.scrollWidth - node.clientWidth > threshold;
const maxScrollLeft = node.scrollWidth - node.clientWidth;
const maxScrollTop = node.scrollHeight - node.clientHeight;
let atLeft = false;
let atRight = false;
let atTop = false;
let atBottom = false;
if (hasHorizontalScroll) {
if (isRtl) {
if (scrollLeft <= 0) {
atLeft = Math.abs(scrollLeft) >= maxScrollLeft - threshold;
atRight = Math.abs(scrollLeft) <= threshold;
} else {
atLeft = scrollLeft <= threshold;
atRight = scrollLeft >= maxScrollLeft - threshold;
}
} else {
atLeft = scrollLeft <= threshold;
atRight = scrollLeft >= maxScrollLeft - threshold;
}
}
if (hasVerticalScroll) {
atTop = scrollTop <= threshold;
atBottom = scrollTop >= maxScrollTop - threshold;
}
return {
top: atTop,
right: atRight,
bottom: atBottom,
left: atLeft
};
}
// src/utils/timeout.ts
var EMPTY = 0;
var Timeout = class {
constructor() {
__publicField(this, "currentId", EMPTY);
__publicField(this, "clear", () => {
if (this.currentId !== EMPTY) {
clearTimeout(this.currentId);
this.currentId = EMPTY;
}
});
__publicField(this, "disposeEffect", () => {
return this.clear;
});
}
start(delay, fn) {
this.clear();
this.currentId = setTimeout(() => {
this.currentId = EMPTY;
fn();
}, delay);
}
isStarted() {
return this.currentId !== EMPTY;
}
};
// src/scroll-area.machine.ts
var MIN_THUMB_SIZE = 20;
var SCROLL_TIMEOUT = 1e3;
var machine = createMachine({
props({ props: props2 }) {
return {
id: "sv",
...props2
};
},
context({ bindable }) {
return {
scrollingX: bindable(() => ({ defaultValue: false })),
scrollingY: bindable(() => ({ defaultValue: false })),
hovering: bindable(() => ({ defaultValue: false })),
dragging: bindable(() => ({ defaultValue: false })),
touchModality: bindable(() => ({ defaultValue: false })),
atSides: bindable(() => ({
defaultValue: { top: true, right: false, bottom: false, left: true }
})),
cornerSize: bindable(() => ({
defaultValue: { width: 0, height: 0 }
})),
thumbSize: bindable(() => ({
defaultValue: { width: 0, height: 0 }
})),
hiddenState: bindable(() => ({
defaultValue: {
scrollbarYHidden: false,
scrollbarXHidden: false,
cornerHidden: false
},
hash(a) {
return `Y:${a.scrollbarYHidden} X:${a.scrollbarXHidden} C:${a.cornerHidden}`;
}
}))
};
},
refs() {
return {
orientation: "vertical",
scrollPosition: { x: 0, y: 0 },
scrollYTimeout: new Timeout(),
scrollXTimeout: new Timeout(),
scrollEndTimeout: new Timeout(),
startX: 0,
startY: 0,
startScrollTop: 0,
startScrollLeft: 0,
programmaticScroll: true
};
},
initialState() {
return "idle";
},
watch({ track, prop, context, send }) {
track([() => prop("dir"), () => context.hash("hiddenState")], () => {
send({ type: "thumb.measure" });
});
},
effects: ["trackContentResize", "trackViewportVisibility", "trackWheelEvent"],
entry: ["checkHovering"],
exit: ["clearTimeouts"],
on: {
"thumb.measure": {
actions: ["setThumbSize"]
},
"viewport.scroll": {
actions: ["setThumbSize", "setScrolling", "setProgrammaticScroll"]
},
"root.pointerenter": {
actions: ["setTouchModality", "setHovering"]
},
"root.pointerdown": {
actions: ["setTouchModality"]
},
"root.pointerleave": {
actions: ["clearHovering"]
}
},
states: {
idle: {
on: {
"scrollbar.pointerdown": {
target: "dragging",
actions: ["scrollToPointer", "startDragging"]
},
"thumb.pointerdown": {
target: "dragging",
actions: ["startDragging"]
}
}
},
dragging: {
effects: ["trackPointerMove"],
on: {
"thumb.pointermove": {
actions: ["setDraggingScroll"]
},
"scrollbar.pointerup": {
target: "idle",
actions: ["stopDragging"]
},
"thumb.pointerup": {
target: "idle",
actions: ["clearScrolling", "stopDragging"]
}
}
}
},
implementations: {
actions: {
setTouchModality({ context, event }) {
context.set("touchModality", event.pointerType === "touch");
},
setHovering({ context }) {
context.set("hovering", true);
},
clearHovering({ context }) {
context.set("hovering", false);
},
setProgrammaticScroll({ refs }) {
const scrollEndTimeout = refs.get("scrollEndTimeout");
scrollEndTimeout.start(100, () => {
refs.set("programmaticScroll", true);
});
},
clearScrolling({ context, event }) {
context.set(event.orientation === "vertical" ? "scrollingY" : "scrollingX", false);
},
setThumbSize({ context, scope, prop }) {
const viewportEl = getViewportEl(scope);
if (!viewportEl) return;
const scrollableContentHeight = viewportEl.scrollHeight;
const scrollableContentWidth = viewportEl.scrollWidth;
if (scrollableContentHeight === 0 || scrollableContentWidth === 0) return;
const scrollbarYEl = getScrollbarYEl(scope);
const scrollbarXEl = getScrollbarXEl(scope);
const thumbYEl = getThumbYEl(scope);
const thumbXEl = getThumbXEl(scope);
const viewportHeight = viewportEl.clientHeight;
const viewportWidth = viewportEl.clientWidth;
const scrollTop = viewportEl.scrollTop;
const scrollLeft = viewportEl.scrollLeft;
const scrollbarYHidden = viewportHeight >= scrollableContentHeight;
const scrollbarXHidden = viewportWidth >= scrollableContentWidth;
const ratioX = viewportWidth / scrollableContentWidth;
const ratioY = viewportHeight / scrollableContentHeight;
const nextWidth = scrollbarXHidden ? 0 : viewportWidth;
const nextHeight = scrollbarYHidden ? 0 : viewportHeight;
const scrollbarXOffset = getScrollOffset(scrollbarXEl, "padding", "x");
const scrollbarYOffset = getScrollOffset(scrollbarYEl, "padding", "y");
const thumbXOffset = getScrollOffset(thumbXEl, "margin", "x");
const thumbYOffset = getScrollOffset(thumbYEl, "margin", "y");
const idealNextWidth = nextWidth - scrollbarXOffset - thumbXOffset;
const idealNextHeight = nextHeight - scrollbarYOffset - thumbYOffset;
const maxNextWidth = scrollbarXEl ? Math.min(scrollbarXEl.offsetWidth, idealNextWidth) : idealNextWidth;
const maxNextHeight = scrollbarYEl ? Math.min(scrollbarYEl.offsetHeight, idealNextHeight) : idealNextHeight;
const clampedNextWidth = Math.max(MIN_THUMB_SIZE, maxNextWidth * ratioX);
const clampedNextHeight = Math.max(MIN_THUMB_SIZE, maxNextHeight * ratioY);
context.set("thumbSize", (prevSize) => {
if (prevSize.height === clampedNextHeight && prevSize.width === clampedNextWidth) {
return prevSize;
}
return {
width: clampedNextWidth,
height: clampedNextHeight
};
});
if (scrollbarYEl && thumbYEl) {
const maxThumbOffsetY = scrollbarYEl.offsetHeight - clampedNextHeight - scrollbarYOffset - thumbYOffset;
const scrollRatioY = scrollTop / (scrollableContentHeight - viewportHeight);
const thumbOffsetY = Math.min(maxThumbOffsetY, Math.max(0, scrollRatioY * maxThumbOffsetY));
thumbYEl.style.transform = `translate3d(0,${thumbOffsetY}px,0)`;
}
if (scrollbarXEl && thumbXEl) {
const maxThumbOffsetX = scrollbarXEl.offsetWidth - clampedNextWidth - scrollbarXOffset - thumbXOffset;
const scrollRatioX = scrollLeft / (scrollableContentWidth - viewportWidth);
const thumbOffsetX = prop("dir") === "rtl" ? clampValue(scrollRatioX * maxThumbOffsetX, -maxThumbOffsetX, 0) : clampValue(scrollRatioX * maxThumbOffsetX, 0, maxThumbOffsetX);
thumbXEl.style.transform = `translate3d(${thumbOffsetX}px,0,0)`;
}
const cornerEl = getCornerEl(scope);
if (cornerEl) {
if (scrollbarXHidden || scrollbarYHidden) {
context.set("cornerSize", { width: 0, height: 0 });
} else if (!scrollbarXHidden && !scrollbarYHidden) {
const width = scrollbarYEl?.offsetWidth || 0;
const height = scrollbarXEl?.offsetHeight || 0;
context.set("cornerSize", { width, height });
}
}
context.set("hiddenState", (prevState) => {
const cornerHidden = scrollbarYHidden || scrollbarXHidden;
if (prevState.scrollbarYHidden === scrollbarYHidden && prevState.scrollbarXHidden === scrollbarXHidden && prevState.cornerHidden === cornerHidden) {
return prevState;
}
return {
scrollbarYHidden,
scrollbarXHidden,
cornerHidden
};
});
context.set("atSides", (prev) => {
const next = getScrollSides(viewportEl, prop("dir"));
if (isEqual(prev, next)) return prev;
return next;
});
},
checkHovering({ scope, context }) {
const viewportEl = getViewportEl(scope);
if (viewportEl?.matches(":hover")) {
context.set("hovering", true);
}
},
setScrolling({ event, refs, context, prop }) {
const scrollPosition = {
x: event.target.scrollLeft,
y: event.target.scrollTop
};
const scrollPositionRef = refs.get("scrollPosition");
const offsetX = scrollPosition.x - scrollPositionRef.x;
const offsetY = scrollPosition.y - scrollPositionRef.y;
refs.set("scrollPosition", scrollPosition);
context.set("atSides", (prev) => {
const next = getScrollSides(event.target, prop("dir"));
if (isEqual(prev, next)) return prev;
return next;
});
if (offsetY !== 0) {
context.set("scrollingY", true);
refs.get("scrollYTimeout").start(SCROLL_TIMEOUT, () => {
context.set("scrollingY", false);
});
}
if (offsetX !== 0) {
context.set("scrollingX", true);
refs.get("scrollXTimeout").start(SCROLL_TIMEOUT, () => {
context.set("scrollingX", false);
});
}
},
scrollToPointer({ event, scope, prop }) {
const viewportEl = getViewportEl(scope);
if (!viewportEl) return;
const thumbYRef = getThumbYEl(scope);
const scrollbarYRef = getScrollbarYEl(scope);
const thumbXRef = getThumbXEl(scope);
const scrollbarXRef = getScrollbarXEl(scope);
const client = event.point;
if (thumbYRef && scrollbarYRef && event.orientation === "vertical") {
const thumbYOffset = getScrollOffset(thumbYRef, "margin", "y");
const scrollbarYOffset = getScrollOffset(scrollbarYRef, "padding", "y");
const thumbHeight = thumbYRef.offsetHeight;
const trackRectY = scrollbarYRef.getBoundingClientRect();
const clickY = client.y - trackRectY.top - thumbHeight / 2 - scrollbarYOffset + thumbYOffset / 2;
const scrollableContentHeight = viewportEl.scrollHeight;
const viewportHeight = viewportEl.clientHeight;
const maxThumbOffsetY = scrollbarYRef.offsetHeight - thumbHeight - scrollbarYOffset - thumbYOffset;
const scrollRatioY = clickY / maxThumbOffsetY;
const newScrollTop = scrollRatioY * (scrollableContentHeight - viewportHeight);
viewportEl.scrollTop = newScrollTop;
}
if (thumbXRef && scrollbarXRef && event.orientation === "horizontal") {
const thumbXOffset = getScrollOffset(thumbXRef, "margin", "x");
const scrollbarXOffset = getScrollOffset(scrollbarXRef, "padding", "x");
const thumbWidth = thumbXRef.offsetWidth;
const trackRectX = scrollbarXRef.getBoundingClientRect();
const clickX = client.x - trackRectX.left - thumbWidth / 2 - scrollbarXOffset + thumbXOffset / 2;
const scrollableContentWidth = viewportEl.scrollWidth;
const viewportWidth = viewportEl.clientWidth;
const maxThumbOffsetX = scrollbarXRef.offsetWidth - thumbWidth - scrollbarXOffset - thumbXOffset;
const scrollRatioX = clickX / maxThumbOffsetX;
let newScrollLeft;
if (prop("dir") === "rtl") {
newScrollLeft = (1 - scrollRatioX) * (scrollableContentWidth - viewportWidth);
if (viewportEl.scrollLeft <= 0) {
newScrollLeft = -newScrollLeft;
}
} else {
newScrollLeft = scrollRatioX * (scrollableContentWidth - viewportWidth);
}
viewportEl.scrollLeft = newScrollLeft;
}
},
startDragging({ event, refs, scope }) {
refs.set("startX", event.point.x);
refs.set("startY", event.point.y);
refs.set("orientation", event.orientation);
const viewportEl = getViewportEl(scope);
if (!viewportEl) return;
refs.set("startScrollTop", viewportEl.scrollTop);
refs.set("startScrollLeft", viewportEl.scrollLeft);
},
setDraggingScroll({ event, refs, scope, context }) {
const startY = refs.get("startY");
const startX = refs.get("startX");
const startScrollTop = refs.get("startScrollTop");
const startScrollLeft = refs.get("startScrollLeft");
const client = event.point;
const deltaY = client.y - startY;
const deltaX = client.x - startX;
const viewportEl = getViewportEl(scope);
if (!viewportEl) return;
const scrollableContentHeight = viewportEl.scrollHeight;
const viewportHeight = viewportEl.clientHeight;
const scrollableContentWidth = viewportEl.scrollWidth;
const viewportWidth = viewportEl.clientWidth;
const orientation = refs.get("orientation");
const thumbYEl = getThumbYEl(scope);
const scrollbarYEl = getScrollbarYEl(scope);
if (thumbYEl && scrollbarYEl && orientation === "vertical") {
const scrollbarYOffset = getScrollOffset(scrollbarYEl, "padding", "y");
const thumbYOffset = getScrollOffset(thumbYEl, "margin", "y");
const thumbHeight = thumbYEl.offsetHeight;
const maxThumbOffsetY = scrollbarYEl.offsetHeight - thumbHeight - scrollbarYOffset - thumbYOffset;
const scrollRatioY = deltaY / maxThumbOffsetY;
viewportEl.scrollTop = startScrollTop + scrollRatioY * (scrollableContentHeight - viewportHeight);
context.set("scrollingY", true);
refs.get("scrollYTimeout").start(SCROLL_TIMEOUT, () => {
context.set("scrollingY", false);
});
}
const thumbXEl = getThumbXEl(scope);
const scrollbarXEl = getScrollbarXEl(scope);
if (thumbXEl && scrollbarXEl && orientation === "horizontal") {
const scrollbarXOffset = getScrollOffset(scrollbarXEl, "padding", "x");
const thumbXOffset = getScrollOffset(thumbXEl, "margin", "x");
const thumbWidth = thumbXEl.offsetWidth;
const maxThumbOffsetX = scrollbarXEl.offsetWidth - thumbWidth - scrollbarXOffset - thumbXOffset;
const scrollRatioX = deltaX / maxThumbOffsetX;
viewportEl.scrollLeft = startScrollLeft + scrollRatioX * (scrollableContentWidth - viewportWidth);
context.set("scrollingX", true);
refs.get("scrollXTimeout").start(SCROLL_TIMEOUT, () => {
context.set("scrollingX", false);
});
}
},
stopDragging({ refs }) {
refs.set("orientation", null);
},
clearTimeouts({ refs }) {
refs.get("scrollYTimeout").clear();
refs.get("scrollXTimeout").clear();
refs.get("scrollEndTimeout").clear();
}
},
effects: {
trackContentResize({ scope, send }) {
const contentEl = getContentEl(scope);
if (!contentEl) return;
const win = scope.getWin();
const obs = new win.ResizeObserver(() => {
setTimeout(() => {
send({ type: "thumb.measure" });
}, 1);
});
obs.observe(contentEl);
return () => {
obs.disconnect();
};
},
trackViewportVisibility({ scope, send }) {
const win = scope.getWin();
const viewportEl = getViewportEl(scope);
if (!viewportEl) return;
const observer = new win.IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.intersectionRatio > 0) {
send({ type: "thumb.measure" });
observer.disconnect();
}
});
});
observer.observe(viewportEl);
return () => {
observer.disconnect();
};
},
trackWheelEvent({ scope }) {
const scrollbarYEl = getScrollbarYEl(scope);
const scrollbarXEl = getScrollbarXEl(scope);
if (!scrollbarYEl && !scrollbarXEl) return;
const onWheel = (event) => {
const viewportEl = getViewportEl(scope);
if (!viewportEl || event.ctrlKey) return;
const orientation = event.currentTarget.dataset.orientation;
if (orientation === "vertical") {
const canScrollY = viewportEl.scrollHeight > viewportEl.clientHeight;
const atTop = viewportEl.scrollTop === 0 && event.deltaY < 0;
const atBottom = viewportEl.scrollTop === viewportEl.scrollHeight - viewportEl.clientHeight && event.deltaY > 0;
const shouldScroll = canScrollY && event.deltaY !== 0 && !(atTop || atBottom);
if (!shouldScroll) return;
event.preventDefault();
viewportEl.scrollTop += event.deltaY;
} else if (orientation === "horizontal") {
const canScrollX = viewportEl.scrollWidth > viewportEl.clientWidth;
const atLeft = viewportEl.scrollLeft === 0 && event.deltaX < 0;
const atRight = viewportEl.scrollLeft === viewportEl.scrollWidth - viewportEl.clientWidth && event.deltaX > 0;
const shouldScroll = canScrollX && event.deltaX !== 0 && !(atLeft || atRight);
if (!shouldScroll) return;
event.preventDefault();
viewportEl.scrollLeft += event.deltaX;
}
};
return callAll(
scrollbarYEl && addDomEvent(scrollbarYEl, "wheel", onWheel, { passive: false }),
scrollbarXEl && addDomEvent(scrollbarXEl, "wheel", onWheel, { passive: false })
);
},
trackPointerMove({ scope, send, refs }) {
const doc = scope.getDoc();
const orientation = refs.get("orientation");
return trackPointerMove(doc, {
onPointerMove({ point }) {
send({ type: "thumb.pointermove", orientation, point });
},
onPointerUp() {
send({ type: "thumb.pointerup", orientation });
}
});
}
}
}
});
var props = createProps()(["dir", "getRootNode", "ids", "id"]);
var splitProps = createSplitProps(props);
export { anatomy, connect, machine, props, splitProps };