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 @@ ]) }}>