@blossom-carousel/svelte
Version:
A native-scroll-first carousel component for Svelte.
154 lines (153 loc) • 7.62 kB
JavaScript
import { inlineSnapAlign, isRtl, logicalScrollLeft, snapPositionFor, } from "./snap.js";
/** Attribute the author adds to each element that should get a dot. */
export const SLIDE_ATTR = "data-blossom-slide";
const SLIDE_SELECTOR = `[${SLIDE_ATTR}]`;
/** Tolerance (px) when comparing the scroll position against snap positions. */
const EPSILON = 1;
/**
* Returns the marker targets for a scroller. Mirrors `::scroll-marker`
* authoring: targets are explicitly opted-in via the `data-blossom-slide`
* attribute and collected depth-agnostically (at any nesting level), in tree
* order. There is no implicit detection from `scroll-snap-align` or direct
* children; if nothing is marked, there are no markers.
*/
export function getMarkerTargets(scroller) {
return Array.from(scroller.querySelectorAll(SLIDE_SELECTOR));
}
/**
* The redistributed activation position of each target, in tree order (parallel
* to `getMarkerTargets`). Positions are logical scroll offsets (see
* `logicalScrollLeft`), so they hold in both LTR and RTL. Scroll-invariant: it
* depends only on layout, never on the scroll offset, so the host can compute
* it once on init/resize/mutation and cache it. {@link selectActiveIndex} then
* maps the live logical scroll offset onto it each frame without touching
* layout.
*
* This is a single-axis (inline) implementation of the geometry behind the CSS
* Overflow Level 5 "Calculating the Active Scroll Marker" algorithm (§3.1.8,
* https://drafts.csswg.org/css-overflow-5/#active-scroll-markers-calculation):
* each target's unclamped scroll-into-view position (respecting its own
* `scroll-snap-align`), with the leading/trailing unreachable targets
* redistributed across the `min(scrollport / 8, scrollRange / 2)` edge bands so
* each gets a distinct activation point.
*/
export function getMarkerPositions(scroller, targets) {
const scrollportSize = scroller.clientWidth;
const scrollRange = scroller.scrollWidth - scrollportSize;
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;
// The associated target position of each target — its scroll-into-view
// position, respecting its own `scroll-snap-align`.
const positions = targets.map((target) => {
const styles = getComputedStyle(target);
const align = inlineSnapAlign(styles.scrollSnapAlign);
return snapPositionFor(scroller, target, align === "none" ? "start" : align, scrollerRect, scrollPaddingStart, scrollPaddingEnd, rtl, styles);
});
// Redistribute unreachable target positions into the edge bands.
if (scrollRange > 0) {
const distributeRange = Math.min(scrollportSize / 8, scrollRange / 2);
// Leading band. Targets whose position is before `distributeRange` are
// remapped from `[minimum, distributeRange)` onto `[0, distributeRange)`.
const beforeTargets = positions.filter((p) => p < distributeRange);
if (beforeTargets.length > 0) {
const minimum = Math.min(...beforeTargets);
const denominator = distributeRange - minimum;
if (denominator > 0) {
for (let i = 0; i < positions.length; i++) {
if (positions[i] < distributeRange) {
positions[i] =
((positions[i] - minimum) / denominator) * distributeRange;
}
}
}
}
// Trailing band. Targets whose position is past `scrollRange -
// distributeRange` are remapped onto that band up to `scrollRange`.
const upperEdge = scrollRange - distributeRange;
const afterTargets = positions.filter((p) => p > upperEdge);
if (afterTargets.length > 0) {
const maximum = Math.max(...afterTargets);
const denominator = maximum - upperEdge;
if (denominator > 0) {
for (let i = 0; i < positions.length; i++) {
if (positions[i] > upperEdge) {
positions[i] =
((positions[i] - upperEdge) / denominator) * distributeRange +
upperEdge;
}
}
}
}
}
return positions;
}
/**
* Maps the current logical scroll offset onto precomputed marker `positions`
* and returns the tree-order index of the active target. Pure and layout-free,
* so it can run on every scroll frame; pass the cached `sorted` copy to keep it
* allocation-free too.
*
* The selected position is the largest target position at or before the current
* position, or — to surface section-header style markers early — one whose
* nearest smaller target position sits more than half a scrollport behind and
* which itself sits less than half a scrollport ahead. Ties resolve to the
* earliest target in tree order.
*/
export function selectActiveIndex(positions, position, scrollportSize,
// In ascending order each target's nearest smaller position is simply its
// predecessor, turning the selection scan into a single pass. The sorted
// order is scroll-invariant, so hosts precompute it in the snap cache; this
// default only runs on the live (uncached) path.
sorted = [...positions].sort((a, b) => a - b)) {
if (positions.length === 0)
return -1;
if (positions.length === 1)
return 0;
const halfScrollport = scrollportSize / 2;
let selectedPosition = null;
for (let i = 0; i < sorted.length; i++) {
const p = sorted[i];
const nearestSmaller = i > 0 ? sorted[i - 1] : Number.NEGATIVE_INFINITY;
const qualifies = p <= position + EPSILON ||
(nearestSmaller < position - halfScrollport &&
p < position + halfScrollport);
// Ascending order: the last qualifier is the largest qualifying position.
if (qualifies)
selectedPosition = p;
}
// Fall back to the first (smallest) position when nothing qualifies.
if (selectedPosition === null)
selectedPosition = sorted[0];
// The active target is the earliest in tree order whose position resolves to
// the selected position.
for (let i = 0; i < positions.length; i++) {
if (Math.abs(positions[i] - selectedPosition) <= EPSILON)
return i;
}
return 0;
}
/**
* Returns the tree-order index of the active marker target along the inline
* axis, or -1 when there are none.
*
* Convenience wrapper that composes {@link getMarkerPositions} and
* {@link selectActiveIndex} for live (uncached) use. Pass `cached` geometry
* from a host-maintained cache to skip the per-call style/layout reads and
* the sort on the scroll path.
*/
export function getActiveMarkerIndex(scroller, targets, cached) {
if (targets.length === 0)
return -1;
if (targets.length === 1)
return 0;
const scrollportSize = scroller.clientWidth;
// Non-scrollable scroller: everything fits, so the first target is active.
if (scroller.scrollWidth - scrollportSize <= 0)
return 0;
const rtl = cached?.rtl ?? isRtl(scroller);
const positions = cached?.activePositions ?? getMarkerPositions(scroller, targets);
return selectActiveIndex(positions, logicalScrollLeft(scroller, rtl), scrollportSize, cached?.sortedActivePositions);
}