diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 4ecea7f2..742b1232 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -510,7 +510,9 @@ 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` — 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. +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`, `multi-aspect`, `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; `aspect` is its ratio in a `multi-aspect` carousel. 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. + +`layout="multi-aspect"` is M3's uncontained multi-aspect-ratio layout (November 2025): each `` keeps its own ratio at the row's `height`, held inside M3's 9:16-to-16:9 range, so the widths come from the art. Only use it when the items really do have various widths. It is a plain flex row with uncontained scrolling — no keylines and no masks, since an arrangement of one item size cannot describe it — while the buttons, the arrow keys, Home/End and bring-into-view still work, from resting positions measured off the DOM. ### `` diff --git a/resources/js/carousel.js b/resources/js/carousel.js index eb0eb46b..3e9e26a7 100644 --- a/resources/js/carousel.js +++ b/resources/js/carousel.js @@ -44,6 +44,13 @@ * 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. * + * The uncontained multi-aspect-ratio layout uses none of it either, and for a reason that is in + * the maths: an Arrangement counts large, medium and small items of one size each, and every + * keyline, snap position and mask follows from that one size, so items that each keep their own + * aspect ratio have no arrangement to fit. That layout is a plain flex row the browser scrolls, + * every item laid out at its own ratio and none of them masked; the resting positions the buttons, + * the keys and bring-into-view need are measured off the DOM instead. + * * Copyright 2023-2024 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); @@ -803,6 +810,7 @@ document.addEventListener('alpine:init', () => { maxScroll: 0, rtl: false, vertical: false, + measured: false, frame: null, target: null, targetAt: 0, @@ -862,6 +870,7 @@ document.addEventListener('alpine:init', () => { state.rtl = style.direction === 'rtl' state.vertical = root.dataset.materialCarousel === 'full-screen' + state.measured = root.dataset.materialCarousel === 'multi-aspect' state.items = [...scroller.children] .filter((element) => element.matches(ITEM)) .map((element) => ({ @@ -888,6 +897,28 @@ document.addEventListener('alpine:init', () => { return } + // The uncontained multi-aspect-ratio layout: no strategy, no mask, and each item + // resting where its own width puts it. `scrollLeft` is negative in RTL, so an + // offset is taken from whichever edge the row starts at. + if (state.measured) { + const width = scroller.clientWidth + const leading = parseFloat(style.paddingInlineStart) || 0 + const box = scroller.getBoundingClientRect() + const scroll = Math.abs(scroller.scrollLeft) + + state.strategy = null + state.maxScroll = Math.max(0, scroller.scrollWidth - width) + state.snaps = state.items.map(({ element }) => { + const item = element.getBoundingClientRect() + + return clamp(scroll + (state.rtl ? box.right - item.right : item.left - box.left) - leading, 0, state.maxScroll) + }) + + this.render() + + return + } + const space = scroller.clientWidth const itemSpacing = parseFloat(style.columnGap) || 0 const padding = Number(root.dataset.padding) || 0 @@ -970,8 +1001,9 @@ 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) { + // Nothing is masked in the full-screen or multi-aspect layouts; only the buttons + // change. + if (state.vertical || state.measured) { this.buttons() state.mutations?.takeRecords() @@ -1092,7 +1124,7 @@ document.addEventListener('alpine:init', () => { }, scrollToItem(index) { - if (state.snaps[index] === undefined || (!state.vertical && !state.strategy?.valid)) { + if (state.snaps[index] === undefined || (!state.vertical && !state.measured && !state.strategy?.valid)) { return } @@ -1175,9 +1207,19 @@ document.addEventListener('alpine:init', () => { /** Whether item `index` is not fully in focus, so a press or focus should bring it there. */ isMasked(index) { - return state.vertical - ? Math.abs(state.snaps[index] - this.scrollOffset()) > 1 - : parseFloat(state.items[index].surface.style.getPropertyValue('--material-carousel-inset')) > 0.5 + if (state.vertical) { + return Math.abs(state.snaps[index] - this.scrollOffset()) > 1 + } + + // Nothing masks a multi-aspect item, so "not fully open" is "cut off by the row". + if (state.measured) { + const row = this.$refs.scroller.getBoundingClientRect() + const item = state.items[index].element.getBoundingClientRect() + + return item.left < row.left - 1 || item.right > row.right + 1 + } + + return 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 99ba077b..05ae1400 100644 --- a/resources/views/components/carousel-item.blade.php +++ b/resources/views/components/carousel-item.blade.php @@ -21,40 +21,67 @@ 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`. 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 ``. --}} + item fills the row, edge to edge, with no corner and no mask. + + In a `multi-aspect` carousel the item is as wide as its own `aspect` makes it at the row's + height, and nothing masks it: M3's uncontained multi-aspect-ratio layout is for items whose + widths "vary… ranging from a 9:16 minimum to a 16:9 maximum aspect ratio", so `aspect` is + held inside that range (`16/9`, `16:9` or a plain number; a square by default, and ignored in + every other layout, where the keylines size the items) + (docs/reference/m3/components-actions-communication-containment.md § Carousel → Variants, + Specs). Only inside ``. --}} @props([ 'label' => null, + 'aspect' => 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. --}} + edge-to-edge items, which M3 gives no corner and no mask, and the multi-aspect one sizes each + item by its own aspect ratio and masks none of them. --}} @aware([ 'layout' => 'multi-browse', ]) @php $vertical = $layout === 'full-screen'; + $multiAspect = $layout === 'multi-aspect'; + + // M3's 9:16 minimum and 16:9 maximum. `16/9`, `16:9` and `1.78` all say the same thing. + $ratio = null; + + if ($multiAspect) { + $value = $aspect ?? 1; + + if (is_string($value) && preg_match('/^\s*([\d.]+)\s*[\/:]\s*([\d.]+)\s*$/', $value, $parts) && (float) $parts[2] > 0) { + $value = (float) $parts[1] / (float) $parts[2]; + } + + $ratio = round(min(max((float) $value, 9 / 16), 16 / 9), 4); + } @endphp
class([ '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, + 'w-auto rounded-corner-xl' => $multiAspect, + 'w-(--material-carousel-slot) max-w-full rounded-corner-xl' => ! $vertical && ! $multiAspect, ]) - ->merge([ + ->merge(array_filter([ 'role' => 'group', 'aria-roledescription' => __('slide'), 'aria-label' => '[material-carousel-position]', 'data-material-carousel-item' => true, 'tabindex' => '0', - ]) }}> + 'style' => $ratio === null ? null : "aspect-ratio: {$ratio}", + ], fn ($value): bool => $value !== null)) }}>
! $vertical, + 'rounded-corner-xl' => $multiAspect, + '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 && ! $multiAspect, ]) >
diff --git a/resources/views/components/carousel.blade.php b/resources/views/components/carousel.blade.php index bf180ed9..f8d9858a 100644 --- a/resources/views/components/carousel.blade.php +++ b/resources/views/components/carousel.blade.php @@ -19,6 +19,12 @@ 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. + - `multi-aspect`: M3's **uncontained multi-aspect-ratio** layout, added November 2025 — + "same as Uncontained but items vary in width, ranging from a 9:16 minimum to a 16:9 maximum + aspect ratio", and "only use this layout if the items have various widths". Each + `` keeps its own ratio at the row's fixed `height`, so the + widths follow from the art rather than from a keyline. Uncontained scrolling, 16px of + leading padding, 8px between items, the extra-large corner. - `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 @@ -26,6 +32,15 @@ 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. + The keyline machinery fits every layout but `multi-aspect`: an Arrangement is a count of + large, medium and small items of **one** size each, and the snap positions and masks it + produces all follow from that one size, so a row of items of different widths has no + arrangement to fit. That layout is therefore a plain flex row — the browser scrolls it and + each item is laid out at its own aspect ratio, unmasked — and only the parts of + resources/js/carousel.js that need no Strategy work on it: the previous and next buttons, the + arrow keys, Home and End, and bringing a clipped item into view, all from resting positions + measured off the DOM rather than computed from keylines. + `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 @@ -70,8 +85,9 @@ ]) @php - $layout = in_array($layout, ['multi-browse', 'hero', 'uncontained', 'full-screen'], true) ? $layout : 'multi-browse'; + $layout = in_array($layout, ['multi-browse', 'hero', 'uncontained', 'multi-aspect', 'full-screen'], true) ? $layout : 'multi-browse'; $vertical = $layout === 'full-screen'; + $multiAspect = $layout === 'multi-aspect'; $controls = $controls === null ? null : filter_var($controls, FILTER_VALIDATE_BOOL); $label ??= __('Carousel'); $scrollerId = 'material-carousel-'.\Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10)); @@ -86,11 +102,12 @@ $preferredWidth = match ($layout) { 'multi-browse', 'uncontained' => $cssLength($itemWidth) ?? '186px', 'hero' => $cssLength($itemWidth), - 'full-screen' => null, + 'multi-aspect', 'full-screen' => null, }; - // The full-screen item is `h-full w-full`: it takes the row, not a slot. + // The full-screen item is `h-full w-full` and a multi-aspect one is as wide as its own ratio + // makes it: neither takes a slot. $slotWidth = match (true) { - $vertical => null, + $vertical, $multiAspect => null, $preferredWidth === null => 'calc(100% - 64px)', default => $preferredWidth, }; @@ -114,7 +131,7 @@ // only for uncontained, none for full-screen, which is edge to edge. $padding = max(0, (float) $padding); $paddingStart = $vertical ? 0.0 : $padding; - $paddingEnd = $vertical || $layout === 'uncontained' ? 0.0 : $padding; + $paddingEnd = $vertical || $layout === 'uncontained' || $multiAspect ? 0.0 : $padding; $attributes = $attributes ->class('relative') @@ -126,6 +143,9 @@ 'style' => implode('; ', array_filter([ $preferredWidth ? "--material-carousel-item-width: {$preferredWidth}" : null, $slotWidth ? "--material-carousel-slot: {$slotWidth}" : null, + // Without keylines there is nothing to anchor the first item in from the edge, so + // the multi-aspect row carries the specs table's leading padding itself. + $multiAspect ? "--material-carousel-pad: {$paddingStart}px" : null, '--material-carousel-height: '.($cssLength($height) ?? '205px'), ])), ], fn ($value): bool => $value !== null)); @@ -148,7 +168,10 @@ '[scrollbar-width:none] [&::-webkit-scrollbar]:hidden', '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', + 'ps-(--material-carousel-pad)' => $multiAspect, + // M3's two scrolling modes: snap-scrolling everywhere but the two uncontained + // layouts, which it gives default scrolling. + 'snap-x snap-mandatory' => ! $vertical && $layout !== 'uncontained' && ! $multiAspect, ]) > {!! $slides !!} diff --git a/resources/views/showcase/sections/carousel.blade.php b/resources/views/showcase/sections/carousel.blade.php index 11312911..8cb37b30 100644 --- a/resources/views/showcase/sections/carousel.blade.php +++ b/resources/views/showcase/sections/carousel.blade.php @@ -55,6 +55,24 @@ @endforeach BLADE, + 'Uncontained, multi-aspect ratio: every item keeps its own shape' => <<<'BLADE' + + @foreach ([ + ['16/9', 'pill', 'bg-primary-container text-on-primary-container'], + ['1/1', 'cookie-12', 'bg-tertiary-container text-on-tertiary-container'], + ['9/16', 'arch', 'bg-secondary-container text-on-secondary-container'], + ['4/3', 'slanted', 'bg-surface-container-highest text-primary'], + ['3/4', 'bun', 'bg-primary text-on-primary'], + ['16/9', 'very-sunny', 'bg-secondary text-on-secondary'], + ] as [$aspect, $shape, $colours]) + +
+ +
+
+ @endforeach +
+ BLADE, 'Full-screen: one item at a time, scrolled down' => <<<'BLADE' @foreach ([ diff --git a/tests/Feature/Components/CarouselTest.php b/tests/Feature/Components/CarouselTest.php index 60e7a894..8842b40b 100644 --- a/tests/Feature/Components/CarouselTest.php +++ b/tests/Feature/Components/CarouselTest.php @@ -156,3 +156,35 @@ it('lays a label over an item on a scrim, and passes attributes to the item', fu ->and((string) $this->blade('A')) ->not->toContain('data-material-carousel-label'); }); + +it('lets a multi-aspect carousel size each item by its own ratio, between 9:16 and 16:9', function () { + $html = (string) $this->blade(<<<'BLADE' + + A + B + C + D + E + + BLADE); + + expect($html) + ->toContain('data-material-carousel="multi-aspect"') + ->toContain('--material-carousel-pad: 16px') + ->toContain('--material-carousel-height: 180px') + ->toContain('ps-(--material-carousel-pad)') + ->toContain('data-padding="16" data-padding-end="0"') + // M3's own range holds whatever the item asks for: 16/9 at the top, 9/16 at the bottom. + ->toContain('aspect-ratio: 1.7778') + ->toContain('aspect-ratio: 0.5625') + ->toContain('aspect-ratio: 1') + ->toContain('w-auto rounded-corner-xl') + // Uncontained scrolling, and nothing masks an item whose width is its own. + ->not->toContain('snap-mandatory') + ->not->toContain('--material-carousel-slot') + ->not->toContain('clip-path') + ->not->toContain('x-ref="probe"') + ->and(substr_count($html, 'aspect-ratio: 1.7778'))->toBe(2) + ->and((string) $this->blade('A')) + ->not->toContain('aspect-ratio'); +});