Send a carousel scroll on when it comes to rest short of its item
tests / lint (push) Has been cancelled
tests / feature (8.4) (push) Has been cancelled
tests / feature (8.5) (push) Has been cancelled
tests / browser (chrome, chromium) (push) Has been cancelled
tests / browser (firefox, firefox) (push) Has been cancelled
tests / browser (safari, webkit) (push) Has been cancelled

CI's WebKit twice left the carousel on its first item after Next. A
scroll started by the buttons or keys that comes to rest anywhere but
its item is now sent there once more; a scroll the person starts
themselves cancels that.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
This commit is contained in:
Andreas Reinhold / reini
2026-09-13 09:40:40 +02:00
co-authored by Claude Opus 5
parent 889f8a8aa1
commit 7ebcb0565f
+38 -1
View File
@@ -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()