Scroll the full-screen carousel down the page, as M3's does

Plan step 19, containment.md C-11. The full-screen layout was another
horizontal row with a 28px corner and a mask, where M3's "shows one
edge-to-edge large item at a time and scrolls vertically". It is now a
vertical scroll-snap column: items fill the row with no corner and no
mask, 16dp apart, no end padding, the previous/next buttons point up and
down and the arrow keys are Up and Down. The row is capped at the 840px
medium window, which with the portrait rule in the header comment is as
far as CSS can hold M3's "compact and medium, portrait only". The ported
FullScreenCarouselStrategy keylines go with it: nothing measures them
any more.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 06:17:31 +02:00
co-authored by Claude Opus 5
parent 9875ba156e
commit 5e58dd5449
6 changed files with 126 additions and 57 deletions
+70 -32
View File
@@ -40,8 +40,9 @@
* compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/carousel/Carousel.kt
* (carouselItem, calculateMaxScrollOffset, CarouselDefaults)
*
* The full-screen arrangement follows material-components-android's
* FullScreenCarouselStrategy (one large item the size of the container).
* The full-screen layout uses none of it: M3 gives it one edge-to-edge item at a time, scrolled
* vertically, so the browser's own scroll snap is the whole of it and this script only works out
* where each item comes to rest, for the buttons and the keys.
*
* Copyright 2023-2024 The Android Open Source Project
*
@@ -547,11 +548,6 @@ const heroKeylineList = (space, maxItemSize, itemSpacing, itemCount, isCentered)
: leftAlignedKeylineList(space, itemSpacing, ANCHOR_SIZE, ANCHOR_SIZE, arrangement)
}
const fullScreenKeylineList = (space, itemSpacing) =>
space === 0
? EMPTY
: leftAlignedKeylineList(space, itemSpacing, ANCHOR_SIZE, ANCHOR_SIZE, { priority: 0, smallSize: 0, smallCount: 0, mediumSize: 0, mediumCount: 0, largeSize: space, largeCount: 1 })
// Strategy.kt ------------------------------------------------------------------------------
const shiftedKeylineListForContentPadding = (from, space, itemSpacing, contentPadding, pivot, pivotIndex) => {
@@ -806,6 +802,7 @@ document.addEventListener('alpine:init', () => {
snaps: [],
maxScroll: 0,
rtl: false,
vertical: false,
frame: null,
target: null,
targetAt: 0,
@@ -861,14 +858,10 @@ document.addEventListener('alpine:init', () => {
refresh() {
const root = this.$root
const scroller = this.$refs.scroller
const space = scroller.clientWidth
const itemSpacing = parseFloat(getComputedStyle(scroller).columnGap) || 0
const padding = Number(root.dataset.padding) || 0
const paddingEnd = Number(root.dataset.paddingEnd) || 0
const probe = this.$refs.probe
const preferred = probe ? probe.getBoundingClientRect().width : null
const style = getComputedStyle(scroller)
state.rtl = getComputedStyle(scroller).direction === 'rtl'
state.rtl = style.direction === 'rtl'
state.vertical = root.dataset.materialCarousel === 'full-screen'
state.items = [...scroller.children]
.filter((element) => element.matches(ITEM))
.map((element) => ({
@@ -879,11 +872,33 @@ document.addEventListener('alpine:init', () => {
written: '',
}))
// M3's full-screen layout is one edge-to-edge item at a time, scrolled
// vertically: no keylines, no mask, no end padding — the browser's scroll snap
// does the whole of it, and this only works out where each item comes to rest.
if (state.vertical) {
const space = scroller.clientHeight
const spacing = parseFloat(style.rowGap) || 0
state.strategy = null
state.maxScroll = Math.max(0, (space + spacing) * state.items.length - spacing - space)
state.snaps = state.items.map((_, index) => clamp(index * (space + spacing), 0, state.maxScroll))
this.render()
return
}
const space = scroller.clientWidth
const itemSpacing = parseFloat(style.columnGap) || 0
const padding = Number(root.dataset.padding) || 0
const paddingEnd = Number(root.dataset.paddingEnd) || 0
const probe = this.$refs.probe
const preferred = probe ? probe.getBoundingClientRect().width : null
const count = state.items.length
const keylines = {
hero: () => heroKeylineList(space, preferred, itemSpacing, count, root.dataset.centered !== undefined),
uncontained: () => uncontainedKeylineList(space, preferred ?? 0, itemSpacing),
'full-screen': () => fullScreenKeylineList(space, itemSpacing),
}[root.dataset.materialCarousel] ?? (() => multiBrowseKeylineList(space, preferred ?? 0, itemSpacing, count))
const strategy = count === 0 ? createStrategy(EMPTY, space, itemSpacing, 0, 0) : createStrategy(keylines(), space, itemSpacing, padding, paddingEnd)
@@ -942,10 +957,10 @@ document.addEventListener('alpine:init', () => {
state.target = null
const left = state.snaps[target]
const offset = state.snaps[target]
if (left !== undefined && Math.abs(this.scrollOffset() - left) > 1) {
this.$refs.scroller.scrollTo({ left: state.rtl ? -left : left, behavior: 'instant' })
if (offset !== undefined && Math.abs(this.scrollOffset() - offset) > 1) {
this.scrollTo(offset, 'instant')
}
}, SETTLE_MS)
},
@@ -955,6 +970,14 @@ document.addEventListener('alpine:init', () => {
cancelAnimationFrame(state.frame)
state.frame = null
// Nothing is masked in the vertical full-screen layout; only the buttons change.
if (state.vertical) {
this.buttons()
state.mutations?.takeRecords()
return
}
const strategy = state.strategy
if (!strategy?.valid) {
@@ -1003,6 +1026,15 @@ document.addEventListener('alpine:init', () => {
item.surface.style.setProperty('--material-carousel-label', opacity.toFixed(3))
})
this.buttons()
state.mutations?.takeRecords()
},
/** A control that would scroll past an end is off. */
buttons() {
const scroll = this.scrollOffset()
if (this.$refs.previous) {
this.$refs.previous.disabled = scroll <= 1
}
@@ -1010,13 +1042,21 @@ document.addEventListener('alpine:init', () => {
if (this.$refs.next) {
this.$refs.next.disabled = scroll >= state.maxScroll - 1
}
state.mutations?.takeRecords()
},
/** The scroll offset from the start edge, positive in both directions. */
/** The scroll offset from the start edge, positive in every direction. */
scrollOffset() {
return clamp(Math.abs(this.$refs.scroller.scrollLeft), 0, state.maxScroll)
const scroller = this.$refs.scroller
return clamp(state.vertical ? scroller.scrollTop : Math.abs(scroller.scrollLeft), 0, state.maxScroll)
},
/** Scrolls to an offset on whichever axis this carousel runs along. */
scrollTo(offset, behavior) {
this.$refs.scroller.scrollTo({
...(state.vertical ? { top: offset } : { left: state.rtl ? -offset : offset }),
behavior,
})
},
/** The item nearest the current scroll position. */
@@ -1052,19 +1092,14 @@ document.addEventListener('alpine:init', () => {
},
scrollToItem(index) {
if (!state.strategy?.valid || state.snaps[index] === undefined) {
if (state.snaps[index] === undefined || (!state.vertical && !state.strategy?.valid)) {
return
}
state.target = index
state.targetAt = performance.now()
const left = state.snaps[index]
this.$refs.scroller.scrollTo({
left: state.rtl ? -left : left,
behavior: state.reducedMotion.matches ? 'instant' : 'smooth',
})
this.scrollTo(state.snaps[index], state.reducedMotion.matches ? 'instant' : 'smooth')
},
/**
@@ -1093,8 +1128,8 @@ document.addEventListener('alpine:init', () => {
return
}
const forward = state.rtl ? 'ArrowLeft' : 'ArrowRight'
const backward = state.rtl ? 'ArrowRight' : 'ArrowLeft'
const forward = state.vertical ? 'ArrowDown' : state.rtl ? 'ArrowLeft' : 'ArrowRight'
const backward = state.vertical ? 'ArrowUp' : state.rtl ? 'ArrowRight' : 'ArrowLeft'
const to = {
[forward]: index + 1,
@@ -1138,8 +1173,11 @@ document.addEventListener('alpine:init', () => {
}
},
/** Whether item `index` is not fully in focus, so a press or focus should bring it there. */
isMasked(index) {
return parseFloat(state.items[index].surface.style.getPropertyValue('--material-carousel-inset')) > 0.5
return state.vertical
? Math.abs(state.snaps[index] - this.scrollOffset()) > 1
: parseFloat(state.items[index].surface.style.getPropertyValue('--material-carousel-inset')) > 0.5
},
destroy() {