UNPKG

@zag-js/scroll-snap

Version:

Scroll snap utilities

207 lines (206 loc) 8.15 kB
// src/index.ts import { getComputedStyle, getScale } from "@zag-js/dom-query"; var getDirection = (element) => getComputedStyle(element).direction; var convert = (raw, size) => { let n = parseFloat(raw); if (/%/.test(raw)) { n /= 100; n *= size; } return Number.isNaN(n) ? 0 : n; }; function getScrollPadding(element) { const style = getComputedStyle(element); const layoutWidth = element.offsetWidth; const layoutHeight = element.offsetHeight; let xBeforeRaw = style.getPropertyValue("scroll-padding-left").replace("auto", "0px"); let yBeforeRaw = style.getPropertyValue("scroll-padding-top").replace("auto", "0px"); let xAfterRaw = style.getPropertyValue("scroll-padding-right").replace("auto", "0px"); let yAfterRaw = style.getPropertyValue("scroll-padding-bottom").replace("auto", "0px"); let xBefore = convert(xBeforeRaw, layoutWidth); let yBefore = convert(yBeforeRaw, layoutHeight); let xAfter = convert(xAfterRaw, layoutWidth); let yAfter = convert(yAfterRaw, layoutHeight); return { x: { before: xBefore, after: xAfter }, y: { before: yBefore, after: yAfter } }; } function isRectIntersecting(a, b, axis = "both") { return axis === "x" && a.right >= b.left && a.left <= b.right || axis === "y" && a.bottom >= b.top && a.top <= b.bottom || axis === "both" && a.right >= b.left && a.left <= b.right && a.bottom >= b.top && a.top <= b.bottom; } function getDescendants(parent) { let children = []; for (const child of parent.children) { children = children.concat(child, getDescendants(child)); } return children; } function getSnapPositions(parent, subtree = false) { const parentRect = parent.getBoundingClientRect(); const dir = getDirection(parent); const isRtl = dir === "rtl"; const scale = getScale(parent); const positions = { x: { start: [], center: [], end: [] }, y: { start: [], center: [], end: [] } }; const children = subtree ? getDescendants(parent) : parent.children; for (const axis of ["x", "y"]) { const orthogonalAxis = axis === "x" ? "y" : "x"; const axisStart = axis === "x" ? "left" : "top"; const axisEnd = axis === "x" ? "right" : "bottom"; const axisSize = axis === "x" ? "width" : "height"; const axisScroll = axis === "x" ? "scrollLeft" : "scrollTop"; const axisScale = axis === "x" ? scale.x : scale.y; const useRtlCalc = isRtl && axis === "x"; for (const child of children) { const childRect = child.getBoundingClientRect(); if (!isRectIntersecting(parentRect, childRect, orthogonalAxis)) { continue; } const childStyle = getComputedStyle(child); let [childAlignY, childAlignX] = childStyle.getPropertyValue("scroll-snap-align").split(" "); if (typeof childAlignX === "undefined") { childAlignX = childAlignY; } const childAlign = axis === "x" ? childAlignX : childAlignY; let childOffsetStart; let childOffsetEnd; let childOffsetCenter; if (useRtlCalc) { const scrollOffset = Math.abs(parent[axisScroll]); const rightOffset = (parentRect[axisEnd] - childRect[axisEnd]) / axisScale + scrollOffset; childOffsetStart = rightOffset; childOffsetEnd = rightOffset + childRect[axisSize] / axisScale; childOffsetCenter = rightOffset + childRect[axisSize] / (2 * axisScale); } else { childOffsetStart = (childRect[axisStart] - parentRect[axisStart]) / axisScale + parent[axisScroll]; childOffsetEnd = childOffsetStart + childRect[axisSize] / axisScale; childOffsetCenter = childOffsetStart + childRect[axisSize] / (2 * axisScale); } switch (childAlign) { case "none": break; case "start": positions[axis].start.push({ node: child, position: childOffsetStart }); break; case "center": positions[axis].center.push({ node: child, position: childOffsetCenter }); break; case "end": positions[axis].end.push({ node: child, position: childOffsetEnd }); break; } } } return positions; } function getScrollSnapPositions(element) { const dir = getDirection(element); const scrollPadding = getScrollPadding(element); const snapPositions = getSnapPositions(element); const layoutWidth = element.offsetWidth; const layoutHeight = element.offsetHeight; const maxScroll = { x: element.scrollWidth - element.offsetWidth, y: element.scrollHeight - element.offsetHeight }; const isRtl = dir === "rtl"; const usesNegativeScrollLeft = isRtl && element.scrollLeft <= 0; let xPositions; if (isRtl) { xPositions = uniq( [ ...snapPositions.x.start.map((v) => v.position - scrollPadding.x.after), ...snapPositions.x.center.map((v) => v.position - layoutWidth / 2), ...snapPositions.x.end.map((v) => v.position - layoutWidth + scrollPadding.x.before) ].map(clamp(0, maxScroll.x)) ); if (usesNegativeScrollLeft) { xPositions = xPositions.map((pos) => -pos); } } else { xPositions = uniq( [ ...snapPositions.x.start.map((v) => v.position - scrollPadding.x.before), ...snapPositions.x.center.map((v) => v.position - layoutWidth / 2), ...snapPositions.x.end.map((v) => v.position - layoutWidth + scrollPadding.x.after) ].map(clamp(0, maxScroll.x)) ); } return { x: xPositions, y: uniq( [ ...snapPositions.y.start.map((v) => v.position - scrollPadding.y.before), ...snapPositions.y.center.map((v) => v.position - layoutHeight / 2), ...snapPositions.y.end.map((v) => v.position - layoutHeight + scrollPadding.y.after) ].map(clamp(0, maxScroll.y)) ) }; } function findSnapPoint(parent, axis, predicate) { const dir = getDirection(parent); const scrollPadding = getScrollPadding(parent); const snapPositions = getSnapPositions(parent); const items = [...snapPositions[axis].start, ...snapPositions[axis].center, ...snapPositions[axis].end]; const isRtl = dir === "rtl"; const usesNegativeScrollLeft = isRtl && axis === "x" && parent.scrollLeft <= 0; for (const item of items) { if (predicate(item.node)) { let position; if (axis === "x" && isRtl) { position = item.position - scrollPadding.x.after; if (usesNegativeScrollLeft) { position = -position; } } else { position = item.position - (axis === "x" ? scrollPadding.x.before : scrollPadding.y.before); } return position; } } } function getSnapPointTarget(parent, snapPoint) { const rect = parent.getBoundingClientRect(); const scale = getScale(parent); const scrollPadding = getScrollPadding(parent); const children = Array.from(parent.children); const layoutWidth = parent.offsetWidth; const layoutHeight = parent.offsetHeight; for (const child of children) { const childRect = child.getBoundingClientRect(); const childOffsetStart = { x: (childRect.left - rect.left) / scale.x + parent.scrollLeft, y: (childRect.top - rect.top) / scale.y + parent.scrollTop }; const childLayoutWidth = childRect.width / scale.x; const childLayoutHeight = childRect.height / scale.y; const matchesX = [ childOffsetStart.x - scrollPadding.x.before, // start childOffsetStart.x + childLayoutWidth / 2 - layoutWidth / 2, // center childOffsetStart.x + childLayoutWidth - layoutWidth + scrollPadding.x.after // end ].some((pos) => Math.abs(pos - snapPoint) < 1); const matchesY = [ childOffsetStart.y - scrollPadding.y.before, childOffsetStart.y + childLayoutHeight / 2 - layoutHeight / 2, childOffsetStart.y + childLayoutHeight - layoutHeight + scrollPadding.y.after ].some((pos) => Math.abs(pos - snapPoint) < 1); if (matchesX || matchesY) { return child; } } return children[0]; } var uniq = (arr) => [...new Set(arr)]; var clamp = (min, max) => (value) => Math.max(min, Math.min(max, value)); export { findSnapPoint, getScrollSnapPositions, getSnapPointTarget, getSnapPositions };