diff --git a/resources/js/carousel.js b/resources/js/carousel.js index e7d77874..f02e0fb2 100644 --- a/resources/js/carousel.js +++ b/resources/js/carousel.js @@ -69,6 +69,9 @@ const MEDIUM_ITEM_FLEX_PERCENTAGE = 0.1 /** A programmatic scroll still counts as where the carousel is going for this long. */ const TARGET_MS = 700 +// How long the row must go without a scroll event to count as having come to rest. +const SETTLE_MS = 150 + const ITEM = '[data-material-carousel-item]' const INTERACTIVE = 'a[href], button, input, select, textarea, summary, [contenteditable], [tabindex]:not([tabindex="-1"])' @@ -805,6 +808,7 @@ document.addEventListener('alpine:init', () => { frame: null, target: null, targetAt: 0, + settle: null, listeners: [], mutations: null, resizes: null, @@ -817,7 +821,12 @@ document.addEventListener('alpine:init', () => { state.reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)') - this.listen(scroller, 'scroll', () => this.schedule(), { passive: true }) + this.listen(scroller, 'scroll', () => this.scrolled(), { passive: true }) + + // A scroll the person makes themselves is theirs to end wherever it ends. + for (const type of ['pointerdown', 'wheel', 'touchstart']) { + this.listen(scroller, type, () => (state.target = null), { passive: true }) + } this.listen(scroller, 'keydown', (event) => this.navigate(event)) this.listen(scroller, 'focusin', (event) => this.reveal(event)) this.listen(scroller, 'click', (event) => this.open(event)) @@ -913,6 +922,33 @@ document.addEventListener('alpine:init', () => { state.frame ??= requestAnimationFrame(() => this.render()) }, + /** + * Each scroll frame, and once the row comes to rest: a scroll the buttons or keys started + * must end on its item. WebKit on Linux can re-snap a smooth scroll to the item it left + * when the masks change the layout under it, so an arrival somewhere else is sent on + * again, once, at once. + */ + scrolled() { + this.schedule() + + clearTimeout(state.settle) + state.settle = setTimeout(() => { + const target = state.target + + if (target === null || performance.now() - state.targetAt > TARGET_MS * 3) { + return + } + + state.target = null + + const left = state.snaps[target] + + if (left !== undefined && Math.abs(this.scrollOffset() - left) > 1) { + this.$refs.scroller.scrollTo({ left: state.rtl ? -left : left, behavior: 'instant' }) + } + }, SETTLE_MS) + }, + /** Carousel.kt's carouselItem layer block, for every item. */ render() { cancelAnimationFrame(state.frame) @@ -1076,6 +1112,7 @@ document.addEventListener('alpine:init', () => { destroy() { cancelAnimationFrame(state.frame) + clearTimeout(state.settle) state.listeners.forEach((remove) => remove()) state.resizes?.disconnect() state.mutations?.disconnect()