UNPKG

@blossom-carousel/svelte

Version:

A native-scroll-first carousel component for Svelte.

126 lines (125 loc) 5.8 kB
/** * Self-contained, depth-agnostic readers of inline-axis scroll-snap geometry. * Used by the prev/next page-scroll logic and the dots' active-marker * computation so both are deterministic regardless of whether Blossom is * initialized. This intentionally does not depend on `@blossom-carousel/core` * so that the navigation controls stay lightweight and usable without a * Blossom instance. */ /** Whether the scroller lays out its inline axis right-to-left. */ export function isRtl(scroller) { return getComputedStyle(scroller).direction === "rtl"; } /** * The logical inline scroll offset: `0` at the inline-start edge, increasing * towards the inline-end. In RTL the physical `scrollLeft` runs `[-max, 0]`, * so it is negated to recover the same `[0, max]` range as LTR. Every snap * position in this package lives in this logical space, so comparisons against * the live scroll offset must go through this helper rather than reading * `scrollLeft` directly. */ export function logicalScrollLeft(scroller, rtl) { return rtl ? -scroller.scrollLeft : scroller.scrollLeft; } /** * Resolves the inline-axis component of a computed `scroll-snap-align` value. * Returns `"none"` when snapping is not requested on the inline axis. */ export function inlineSnapAlign(value) { if (!value || value === "none") return "none"; const parts = value.trim().split(/\s+/); // `scroll-snap-align` is `<block> <inline>` when two values are present, // otherwise the single value applies to both axes. const inline = parts.length === 2 ? parts[1] : parts[0]; return inline === "center" || inline === "end" ? inline : "start"; } /** * Like `inlineSnapAlign`, but resolves the `"none"` case to `"start"` so the * result can be passed straight to `scrollIntoView({ inline })`. */ export function resolveInlineAlign(value) { const align = inlineSnapAlign(value); return align === "none" ? "start" : align; } /** * The (unclamped) logical scroll offset at which `target` would rest when * snapped to the given inline alignment. Unclamped on purpose: callers use the * raw value to tell whether a target can physically reach its snap position. * * Computed in logical coordinates (see {@link logicalScrollLeft}): in RTL the * inline-start edge is the physical right, so the target's offset is measured * from the scroller's right edge and the result compares directly against the * logical scroll offset in both directions. */ export function snapPositionFor(scroller, target, align, scrollerRect, scrollPaddingStart, scrollPaddingEnd, rtl, styles = getComputedStyle(target)) { const rect = target.getBoundingClientRect(); // Offset of the target's inline-start edge from the scrollport's // inline-start edge, measured along the logical scroll direction. const startEdge = rtl ? scrollerRect.right - rect.right : rect.left - scrollerRect.left; const left = startEdge + logicalScrollLeft(scroller, rtl); const marginStart = parseFloat(styles.scrollMarginInlineStart) || 0; const marginEnd = parseFloat(styles.scrollMarginInlineEnd) || 0; const snapAreaStart = left - marginStart; const snapAreaEnd = left + target.clientWidth + marginEnd; const scrollport = scroller.clientWidth; switch (align) { case "end": return snapAreaEnd - scrollport + scrollPaddingEnd; case "center": return (snapAreaStart + snapAreaEnd) / 2 - scrollport / 2; case "start": default: return snapAreaStart - scrollPaddingStart; } } /** * The snap target for each marker, in tree order (parallel to * `getMarkerTargets`): its element and resolved inline alignment. `goto` indexes * straight into the result and hands both to `scrollIntoView`, which resolves * the final scroll position natively — so this needs only a style read per * target (no layout), making it cheap to (re)build on resize/mutation. */ export function getMarkerSnaps(targets) { return targets.map((el) => ({ el, align: resolveInlineAlign(getComputedStyle(el).scrollSnapAlign), })); } /** * Returns the inline-axis snap points (ascending by position, near-duplicates * dropped) that prev/next paging steps through. Each carries its element and * resolved alignment so paging can `scrollIntoView` the adjacent target with * its own inline alignment, plus the clamped `x` used to locate it. */ export function getSnapPositions(scroller) { const scrollerRect = scroller.getBoundingClientRect(); const scrollerStyles = getComputedStyle(scroller); const rtl = scrollerStyles.direction === "rtl"; const scrollPaddingStart = parseFloat(scrollerStyles.scrollPaddingInlineStart) || 0; const scrollPaddingEnd = parseFloat(scrollerStyles.scrollPaddingInlineEnd) || 0; const maxScroll = Math.max(scroller.scrollWidth - scroller.clientWidth, 0); const walker = document.createTreeWalker(scroller, NodeFilter.SHOW_ELEMENT); const points = []; let node = walker.nextNode(); while (node) { const el = node; const styles = getComputedStyle(el); const align = inlineSnapAlign(styles.scrollSnapAlign); if (align !== "none") { const x = snapPositionFor(scroller, el, align, scrollerRect, scrollPaddingStart, scrollPaddingEnd, rtl, styles); points.push({ el, align, x: Math.min(Math.max(x, 0), maxScroll) }); } node = walker.nextNode(); } // Sort ascending and drop near-duplicates (e.g. multi-row carousels). points.sort((a, b) => a.x - b.x); return points.reduce((acc, p) => { if (acc.length === 0 || Math.abs(acc[acc.length - 1].x - p.x) > 1) { acc.push(p); } return acc; }, []); }