/** * Index of the offset nearest to a scroll position. * * Used to derive the "current" slide from the gallery's real scroll offset * rather than from whichever item last crossed an IntersectionObserver * threshold — which, when several items are visible at once, is * direction-dependent and causes the focused index to drift. * * Ties resolve to the lower index. Returns 0 for an empty list. */ export const nearestIndex = ( offsets: number[], scrollStart: number, ): number => { let bestIndex = 0 let bestDistance = Number.POSITIVE_INFINITY for (let i = 0; i < offsets.length; i++) { const distance = Math.abs(offsets[i] - scrollStart) if (distance >= bestDistance) return bestIndex bestDistance = distance bestIndex = i } return bestIndex } /** * Whether a horizontal scroll container sits at its start / end edge, computed * from real scroll geometry. * * Index-based edge detection is wrong when several items are visible at once: * at the true end the left-most (focused) item is not the last item, so a * `focusedIndex === itemCount - 1` check never becomes true and a "next" * control never disables on its own. */ export const scrollEdges = ( scrollLeft: number, scrollWidth: number, clientWidth: number, tolerance = 1, ): { atStart: boolean; atEnd: boolean } => { const max = scrollWidth - clientWidth return { atStart: scrollLeft <= tolerance, atEnd: scrollLeft >= max - tolerance, } }