From 5e58dd54490dbea730b653419ec0afcca74d261f Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 06:17:31 +0200 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- .../livewire-material-development/SKILL.md | 2 +- resources/js/carousel.js | 102 ++++++++++++------ .../views/components/carousel-item.blade.php | 22 +++- resources/views/components/carousel.blade.php | 35 +++--- .../showcase/sections/carousel.blade.php | 4 +- tests/Feature/Components/CarouselTest.php | 18 +++- 6 files changed, 126 insertions(+), 57 deletions(-) diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index b999744c..cd7656a8 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -499,7 +499,7 @@ An M3 bottom sheet, bound like ``: modal by default (scrim, inert page, ``` -A row of items that change size between M3's keylines as it scrolls (native scroll snap; items are masked, content keeps its size). ``: `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). ``: slot is an `` (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). ``: `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). ``: slot is an `` (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. ### `` diff --git a/resources/js/carousel.js b/resources/js/carousel.js index dc7086b8..eb0eb46b 100644 --- a/resources/js/carousel.js +++ b/resources/js/carousel.js @@ -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() { diff --git a/resources/views/components/carousel-item.blade.php b/resources/views/components/carousel-item.blade.php index eefe0e0c..1dc80f68 100644 --- a/resources/views/components/carousel-item.blade.php +++ b/resources/views/components/carousel-item.blade.php @@ -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 ``. --}} + 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 ``. --}} @props([ 'label' => null, ]) +{{-- The layout of the `` 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 +
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 @@ ]) }}>