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
@@ -499,7 +499,7 @@ An M3 bottom sheet, bound like `<x-modal>`: modal by default (scrim, inert page,
</x-carousel>
```
A row of items that change size between M3's keylines as it scrolls (native scroll snap; items are masked, content keeps its size). `<x-carousel>`: `layout` (`multi-browse` default, `hero`, `uncontained`, `full-screen`), `item-width` (px or any CSS length; the large size multi-browse aims for, the fixed size uncontained keeps, the cap for hero; 186 by default), `height` (205px), `padding` (px at the ends, **16** — M3's specs table; leading only for `uncontained`, none for `full-screen`), `centered` (hero), `label` (the region's name, "Carousel" by default), `controls` (previous/next buttons: default fine pointers only, `true` always, `false` never). `<x-carousel-item>`: slot is an `<img>` (fills and crops) or an element sized `size-full`; `label` overlays a line of text. A `region` of `slide` groups named "n of m", each item a tab stop and the row itself not one, as M3 asks; from a focused item the arrow keys move one item, Home/End go to the ends and Space/Enter opens one that is not fully in view. Works after a Livewire morph, in RTL and under reduced motion. Give items a `wire:key` in a loop.
A row of items that change size between M3's keylines as it scrolls (native scroll snap; items are masked, content keeps its size). `<x-carousel>`: `layout` (`multi-browse` default, `hero`, `uncontained`, `full-screen` — one edge-to-edge item at a time scrolled **vertically**, which M3 gives to compact and medium windows in portrait only, and never to landscape), `item-width` (px or any CSS length; the large size multi-browse aims for, the fixed size uncontained keeps, the cap for hero; 186 by default), `height` (205px), `padding` (px at the ends, **16** — M3's specs table; leading only for `uncontained`, none for `full-screen`), `centered` (hero), `label` (the region's name, "Carousel" by default), `controls` (previous/next buttons: default fine pointers only, `true` always, `false` never). `<x-carousel-item>`: slot is an `<img>` (fills and crops) or an element sized `size-full`; `label` overlays a line of text. A `region` of `slide` groups named "n of m", each item a tab stop and the row itself not one, as M3 asks; from a focused item the arrow keys move one item, Home/End go to the ends and Space/Enter opens one that is not fully in view. Works after a Livewire morph, in RTL and under reduced motion. Give items a `wire:key` in a loop.
### `<x-chip>`
+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() {
@@ -19,15 +19,28 @@
the surface inside is clipped by `--material-carousel-inset` from both sides with M3's
extra-large corner (28px, CarouselDefaults' item shape) and moved by
`--material-carousel-shift`, both written by resources/js/carousel.js. Without script the
item shows unmasked, at its `item-width`. Only inside `<x-carousel>`. --}}
item shows unmasked, at its `item-width`. In a `full-screen` carousel it is none of that: the
item fills the row, edge to edge, with no corner and no mask. Only inside `<x-carousel>`. --}}
@props([
'label' => null,
])
{{-- The layout of the `<x-carousel>` around it: the full-screen one is a vertical row of
edge-to-edge items, which M3 gives no corner and no mask. --}}
@aware([
'layout' => 'multi-browse',
])
@php
$vertical = $layout === 'full-screen';
@endphp
<div {{ $attributes
->class([
'focus-ring relative h-full w-(--material-carousel-slot) max-w-full shrink-0 snap-start snap-always rounded-corner-xl focus-visible:-outline-offset-3',
'focus-ring relative h-full shrink-0 snap-start snap-always focus-visible:-outline-offset-3',
'w-full' => $vertical,
'w-(--material-carousel-slot) max-w-full rounded-corner-xl' => ! $vertical,
])
->merge([
'role' => 'group',
@@ -38,7 +51,10 @@
]) }}>
<div
data-material-carousel-surface
class="relative size-full overflow-hidden rounded-corner-xl bg-surface-container-highest text-on-surface translate-x-(--material-carousel-shift) [clip-path:inset(0_var(--material-carousel-inset,0px)_round_var(--md-sys-shape-corner-xl))]"
@class([
'relative size-full overflow-hidden bg-surface-container-highest text-on-surface',
'rounded-corner-xl translate-x-(--material-carousel-shift) [clip-path:inset(0_var(--material-carousel-inset,0px)_round_var(--md-sys-shape-corner-xl))]' => ! $vertical,
])
>
<div data-material-carousel-content class="size-full [&>img]:size-full [&>img]:object-cover">
{{ $slot }}
+21 -14
View File
@@ -19,8 +19,13 @@
does.
- `uncontained`: items keep `item-width`; the one cut off at the end narrows as it leaves
HorizontalUncontainedCarousel. No snapping, as Compose's uncontained fling.
- `full-screen`: one item the width of the carousel at a time
(FullScreenCarouselStrategy).
- `full-screen`: one edge-to-edge item at a time, scrolled **vertically** — M3: "this layout
works best with content that is taller than it is wide, and scrolls vertically. It only
works in portrait orientation in compact and medium breakpoints. Don't use this layout in
landscape orientation." Items have no corner and no mask, 16px apart, no end padding, and
the row is never wider than the 840px medium window it is meant for. `item-width` and
`padding` do not apply; `height` is the height of each item, so give it the room a
portrait image wants.
`item-width` takes pixels or any CSS length. `height` is the items' height (205px, Compose's
sample). `padding` is Compose's `contentPadding` in pixels: the first and last items rest
that far in from the edges while items in between scroll to them. M3's specs table gives
@@ -66,6 +71,7 @@
@php
$layout = in_array($layout, ['multi-browse', 'hero', 'uncontained', 'full-screen'], true) ? $layout : 'multi-browse';
$vertical = $layout === 'full-screen';
$controls = $controls === null ? null : filter_var($controls, FILTER_VALIDATE_BOOL);
$label ??= __('Carousel');
$scrollerId = 'material-carousel-'.\Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10));
@@ -82,8 +88,9 @@
'hero' => $cssLength($itemWidth),
'full-screen' => null,
};
// The full-screen item is `h-full w-full`: it takes the row, not a slot.
$slotWidth = match (true) {
$layout === 'full-screen' => '100%',
$vertical => null,
$preferredWidth === null => 'calc(100% - 64px)',
default => $preferredWidth,
};
@@ -106,8 +113,8 @@
// The specs table's leading and trailing padding: 16dp for multi-browse and hero, leading
// only for uncontained, none for full-screen, which is edge to edge.
$padding = max(0, (float) $padding);
$paddingStart = $layout === 'full-screen' ? 0.0 : $padding;
$paddingEnd = in_array($layout, ['uncontained', 'full-screen'], true) ? 0.0 : $padding;
$paddingStart = $vertical ? 0.0 : $padding;
$paddingEnd = $vertical || $layout === 'uncontained' ? 0.0 : $padding;
$attributes = $attributes
->class('relative')
@@ -118,7 +125,7 @@
'data-padding-end' => (string) $paddingEnd,
'style' => implode('; ', array_filter([
$preferredWidth ? "--material-carousel-item-width: {$preferredWidth}" : null,
"--material-carousel-slot: {$slotWidth}",
$slotWidth ? "--material-carousel-slot: {$slotWidth}" : null,
'--material-carousel-height: '.($cssLength($height) ?? '205px'),
])),
], fn ($value): bool => $value !== null));
@@ -137,11 +144,11 @@
aria-roledescription="{{ __('carousel') }}"
aria-label="{{ $label }}"
@class([
'flex gap-2 overflow-x-auto overflow-y-hidden overscroll-x-contain',
'flex',
'[scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
'h-(--material-carousel-height)' => $layout === 'full-screen',
'h-[calc(var(--material-carousel-height)+1rem)] py-2' => $layout !== 'full-screen',
'snap-x snap-mandatory' => $layout !== 'uncontained',
'mx-auto h-(--material-carousel-height) max-w-210 snap-y snap-mandatory flex-col gap-4 overflow-x-hidden overflow-y-auto overscroll-y-contain' => $vertical,
'h-[calc(var(--material-carousel-height)+1rem)] gap-2 overflow-x-auto overflow-y-hidden overscroll-x-contain py-2' => ! $vertical,
'snap-x snap-mandatory' => ! $vertical && $layout !== 'uncontained',
])
>
{!! $slides !!}
@@ -154,22 +161,22 @@
'flex' => $controls === true,
])>
<x-livewire-material::button
icon="chevron_left"
:icon="$vertical ? 'keyboard_arrow_up' : 'chevron_left'"
variant="tonal"
:tooltip="__('Previous')"
aria-controls="{{ $scrollerId }}"
x-ref="previous"
x-on:click="previous()"
class="rtl:-scale-x-100"
:class="$vertical ? '' : 'rtl:-scale-x-100'"
/>
<x-livewire-material::button
icon="chevron_right"
:icon="$vertical ? 'keyboard_arrow_down' : 'chevron_right'"
variant="tonal"
:tooltip="__('Next')"
aria-controls="{{ $scrollerId }}"
x-ref="next"
x-on:click="next()"
class="rtl:-scale-x-100"
:class="$vertical ? '' : 'rtl:-scale-x-100'"
/>
</div>
@endif
@@ -55,8 +55,8 @@
@endforeach
</x-carousel>
BLADE,
'Full-screen' => <<<'BLADE'
<x-carousel layout="full-screen" label="Wallpapers" height="240" :controls="true">
'Full-screen: one item at a time, scrolled down' => <<<'BLADE'
<x-carousel layout="full-screen" label="Wallpapers" height="320" :controls="true">
@foreach ([
['Morning', 'very-sunny', 'bg-linear-to-br from-primary-container to-tertiary-container text-on-primary-container'],
['Noon', 'clam-shell', 'bg-linear-to-br from-secondary-container to-primary-container text-on-secondary-container'],
+13 -5
View File
@@ -60,7 +60,7 @@ it('browses many by default: 186px items that snap one at a time, 205px high', f
->toContain('x-ref="probe"')
->toContain('data-padding="16"')
->toContain('data-padding-end="16"')
->toContain('h-[calc(var(--material-carousel-height)+1rem)] py-2')
->toContain('h-[calc(var(--material-carousel-height)+1rem)] gap-2 overflow-x-auto overflow-y-hidden overscroll-x-contain py-2')
->toContain('snap-x snap-mandatory')
->toContain('snap-start snap-always')
->toContain('rounded-corner-xl')
@@ -95,15 +95,23 @@ it('lays out a hero that fills the width unless capped, centred on request', fun
->not->toContain('data-centered');
});
it('leaves an uncontained carousel unsnapped, and a full-screen one a page wide', function () {
it('leaves an uncontained carousel unsnapped, and scrolls a full-screen one down the page', function () {
expect((string) $this->blade('<x-carousel layout="uncontained" item-width="180"><x-carousel-item>A</x-carousel-item></x-carousel>'))
->toContain('data-material-carousel="uncontained"')
->toContain('--material-carousel-item-width: 180px')
->not->toContain('snap-mandatory')
->and((string) $this->blade('<x-carousel layout="full-screen" centered><x-carousel-item>A</x-carousel-item></x-carousel>'))
->and((string) $this->blade('<x-carousel layout="full-screen" centered controls><x-carousel-item label="Morning">A</x-carousel-item></x-carousel>'))
->toContain('data-material-carousel="full-screen"')
->toContain('--material-carousel-slot: 100%')
->toContain('snap-mandatory')
// M3: one edge-to-edge item at a time, scrolled vertically, 16px apart, no end padding,
// no corner, no mask, and never wider than the medium window it is meant for.
->toContain('mx-auto h-(--material-carousel-height) max-w-210 snap-y snap-mandatory flex-col gap-4 overflow-x-hidden overflow-y-auto overscroll-y-contain')
->toContain('data-padding="0" data-padding-end="0"')
->toContain('aria-label="Previous"')
->not->toContain('--material-carousel-slot')
->not->toContain('snap-x')
->not->toContain('rounded-corner-xl')
->not->toContain('clip-path')
->not->toContain('rtl:-scale-x-100')
->not->toContain('x-ref="probe"')
->not->toContain('data-centered')
->and((string) $this->blade('<x-carousel layout="sideways"><x-carousel-item>A</x-carousel-item></x-carousel>'))