Add the M3 Expressive carousel
tests / browser (chrome, chromium) (push) Successful in 2m24s
tests / browser (firefox, firefox) (push) Successful in 2m48s
tests / browser (safari, webkit) (push) Successful in 3m42s
tests / lint (push) Successful in 1m0s
tests / feature (8.4) (push) Successful in 1m7s
tests / feature (8.5) (push) Successful in 1m2s

Multi-browse, hero, uncontained and full-screen layouts on a native
scroll-snap row, with Compose's keyline maths masking each item as it
scrolls. Completes Phase 5.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
This commit is contained in:
Andreas Reinhold / reini
2026-09-13 07:40:30 +02:00
co-authored by Claude Opus 5
parent fef20a9178
commit ad724662ca
12 changed files with 1792 additions and 1 deletions
@@ -0,0 +1,50 @@
{{-- One item of an `<x-carousel>`: a slide of art an `<img>`, which fills and crops to the
item, or any element sized `size-full`.
<x-carousel-item label="Lake Constance">
<img src="{{ $photo->url }}" alt="Lake Constance at dusk" />
</x-carousel-item>
`label` lays a short line of text over the bottom of the art, on a scrim, pinned to the
item's visible edge and fading out as the item narrows — Compose's carousel sample, which
fades its label chip in once the mask is wide enough to hold it.
A `group` with `aria-roledescription="slide"`, named "n of m" by the carousel around it
(WAI-ARIA's carousel pattern). The item is laid out at the carousel's large size and masked:
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>`. --}}
@props([
'label' => null,
])
<div {{ $attributes
->class([
'relative h-full w-(--material-carousel-slot) max-w-full shrink-0 snap-start snap-always',
])
->merge([
'role' => 'group',
'aria-roledescription' => __('slide'),
'aria-label' => '[material-carousel-position]',
'data-material-carousel-item' => true,
]) }}>
<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))]"
>
<div data-material-carousel-content class="size-full translate-x-(--material-carousel-pin) [&>img]:size-full [&>img]:object-cover">
{{ $slot }}
</div>
@if (filled($label))
<div
data-material-carousel-label
class="pointer-events-none absolute inset-x-0 bottom-0 flex bg-linear-to-t from-scrim/60 to-transparent px-4 pt-10 pb-4 opacity-(--material-carousel-label)"
>
<span class="truncate type-title-md text-white translate-x-(--material-carousel-label-shift)">{{ $label }}</span>
</div>
@endif
</div>
</div>
@@ -0,0 +1,159 @@
{{-- M3 Expressive's carousel: a row of `<x-carousel-item>`s that grow and shrink as they scroll.
<x-carousel label="Recent uploads" item-width="220">
@foreach ($photos as $photo)
<x-carousel-item :label="$photo->title">
<img src="{{ $photo->url }}" alt="{{ $photo->alt }}" />
</x-carousel-item>
@endforeach
</x-carousel>
`layout` is one of M3's four:
- `multi-browse` (the default): large items at the start, then a medium and a small one, for
browsing many HorizontalMultiBrowseCarousel. `item-width` is the width large items
would like to be (186px, Compose's sample); the carousel adjusts it so a whole
arrangement fits, small items between 40 and 56px.
- `hero`: one large item and a small one after it, `centered` between two small ones —
HorizontalCenteredHeroCarousel and material-components-android's start-aligned hero. The
large item fills the width unless `item-width` caps it, and more large items fit when it
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).
`item-width` takes pixels or any CSS length. `height` is the items' height (205px, Compose's
sample). `padding` is Compose's `contentPadding` in pixels (0): the first and last items
rest that far in from the edges while items in between scroll to them. Items are 8px apart
with M3's extra-large corner.
The row is a native scroll container with CSS scroll snap, one item per swipe, as Compose's
single-advance fling; touch, trackpad and Shift with the wheel scroll it. resources/js/
carousel.js ports Compose's keylines (Arrangement, Keylines, KeylineList, Strategy,
KeylineSnapPosition and Carousel.kt at androidx commit
7ac433e44e797de53af85226797862687f37735f, Apache-2.0) and masks each item on every scroll
frame, content at full size, so items change size between the keylines. Without script
the row still scrolls and snaps, unmasked.
WAI-ARIA's carousel pattern: the row is a focusable `region` with
`aria-roledescription="carousel"`, named by `label` ("Carousel" by default); each item is a
`group` with `aria-roledescription="slide"` named "n of m". With the row focused the arrow
keys move one item, Home and End to the ends; focus moving into an item, or a press on one
that is not fully open, brings it into focus. `controls` adds previous and next icon
buttons under the row by default only where the pointer is fine (a mouse or trackpad);
`true` always, `false` never. Under reduced motion they scroll instantly and content no
longer slides inside its mask. RTL mirrors the keylines, keys and buttons.
Re-measures itself when resized, when a Livewire morph resets its styles and when items
come and go. --}}
@props([
'layout' => 'multi-browse',
'itemWidth' => null,
'height' => null,
'padding' => 0,
'centered' => false,
'label' => null,
'controls' => null,
])
@php
$layout = in_array($layout, ['multi-browse', 'hero', 'uncontained', 'full-screen'], true) ? $layout : 'multi-browse';
$controls = $controls === null ? null : filter_var($controls, FILTER_VALIDATE_BOOL);
$label ??= __('Carousel');
$scrollerId = 'material-carousel-'.\Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10));
$cssLength = fn (mixed $value): ?string => match (true) {
$value === null, $value === '' => null,
is_numeric($value) => ((float) $value).'px',
default => (string) $value,
};
// What the keylines are asked for, and the width items take before (or without) script.
$preferredWidth = match ($layout) {
'multi-browse', 'uncontained' => $cssLength($itemWidth) ?? '186px',
'hero' => $cssLength($itemWidth),
'full-screen' => null,
};
$slotWidth = match (true) {
$layout === 'full-screen' => '100%',
$preferredWidth === null => 'calc(100% - 64px)',
default => $preferredWidth,
};
// "n of m": every item rendered in the slot leaves a placeholder for its position. An inner
// carousel has already replaced its own by the time this one renders.
$slides = $slot->toHtml();
$slideCount = substr_count($slides, '[material-carousel-position]');
$slidePosition = 0;
$slides = preg_replace_callback(
'/\[material-carousel-position\]/',
function () use (&$slidePosition, $slideCount): string {
$slidePosition++;
return e(__(':position of :count', ['position' => $slidePosition, 'count' => $slideCount]));
},
$slides,
);
$attributes = $attributes
->class('relative')
->merge(array_filter([
'data-material-carousel' => $layout,
'data-centered' => $layout === 'hero' && $centered ? true : null,
'data-padding' => (string) max(0, (float) $padding),
'style' => implode('; ', array_filter([
$preferredWidth ? "--material-carousel-item-width: {$preferredWidth}" : null,
"--material-carousel-slot: {$slotWidth}",
'--material-carousel-height: '.($cssLength($height) ?? '205px'),
])),
], fn ($value): bool => $value !== null));
@endphp
<div x-data="materialCarousel" {{ $attributes }}>
@if ($preferredWidth)
<div x-ref="probe" aria-hidden="true" class="pointer-events-none invisible absolute start-0 top-0 h-0 w-(--material-carousel-item-width)"></div>
@endif
<div
x-ref="scroller"
id="{{ $scrollerId }}"
role="region"
aria-roledescription="{{ __('carousel') }}"
aria-label="{{ $label }}"
tabindex="0"
@class([
'focus-ring flex h-(--material-carousel-height) gap-2 overflow-x-auto overflow-y-hidden overscroll-x-contain',
'[scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
'snap-x snap-mandatory' => $layout !== 'uncontained',
])
>
{!! $slides !!}
</div>
@if ($controls !== false)
<div @class([
'mt-3 justify-end gap-2',
'hidden pointer-fine:flex' => $controls === null,
'flex' => $controls === true,
])>
<x-button
icon="chevron_left"
variant="tonal"
:tooltip="__('Previous')"
aria-controls="{{ $scrollerId }}"
x-ref="previous"
x-on:click="previous()"
class="rtl:-scale-x-100"
/>
<x-button
icon="chevron_right"
variant="tonal"
:tooltip="__('Next')"
aria-controls="{{ $scrollerId }}"
x-ref="next"
x-on:click="next()"
class="rtl:-scale-x-100"
/>
</div>
@endif
</div>
+1
View File
@@ -17,5 +17,6 @@
@include('livewire-material::showcase.sections.communication')
@include('livewire-material::showcase.sections.progress')
@include('livewire-material::showcase.sections.containment')
@include('livewire-material::showcase.sections.carousel')
</main>
@endsection
+1 -1
View File
@@ -18,7 +18,7 @@
<a href="{{ route('livewire-material.showcase') }}" class="shrink-0 type-title-lg max-sm:hidden">Livewire Material</a>
<nav class="-my-2 flex min-w-0 flex-1 gap-x-4 overflow-x-auto py-2 whitespace-nowrap type-label-lg text-on-surface-variant [scrollbar-width:none]" aria-label="Sections">
@foreach (['colour' => 'Colour', 'type' => 'Type', 'shape' => 'Shape', 'elevation' => 'Elevation', 'motion' => 'Motion', 'icons' => 'Icons', 'buttons' => 'Buttons', 'menus' => 'Menus', 'communication' => 'Communication', 'progress' => 'Progress', 'containment' => 'Containment'] as $anchor => $section)
@foreach (['colour' => 'Colour', 'type' => 'Type', 'shape' => 'Shape', 'elevation' => 'Elevation', 'motion' => 'Motion', 'icons' => 'Icons', 'buttons' => 'Buttons', 'menus' => 'Menus', 'communication' => 'Communication', 'progress' => 'Progress', 'containment' => 'Containment', 'carousel' => 'Carousel'] as $anchor => $section)
<a href="#{{ $anchor }}" class="rounded-corner-xs hover:text-on-surface focus-ring">{{ $section }}</a>
@endforeach
</nav>
@@ -0,0 +1,87 @@
@php
$examples = [
'Multi-browse' => <<<'BLADE'
<x-carousel label="Shapes" item-width="220">
@foreach ([
['Cookie', 'cookie-9', 'bg-primary-container text-on-primary-container'],
['Clover', 'clover-4', 'bg-tertiary-container text-on-tertiary-container'],
['Burst', 'soft-burst', 'bg-secondary-container text-on-secondary-container'],
['Flower', 'flower', 'bg-primary text-on-primary'],
['Gem', 'gem', 'bg-surface-container-highest text-primary'],
['Puffy', 'puffy', 'bg-tertiary text-on-tertiary'],
['Sunny', 'sunny', 'bg-secondary text-on-secondary'],
['Heart', 'heart', 'bg-inverse-surface text-inverse-primary'],
] as [$name, $shape, $colours])
<x-carousel-item :label="$name">
<div class="grid size-full place-items-center {{ $colours }}">
<x-shape :name="$shape" class="size-36" />
</div>
</x-carousel-item>
@endforeach
</x-carousel>
BLADE,
'Hero, centred' => <<<'BLADE'
<x-carousel layout="hero" centered label="Featured" height="280">
@foreach ([
['Arch', 'arch', 'bg-secondary-container text-on-secondary-container'],
['Pentagon', 'pentagon', 'bg-primary-container text-on-primary-container'],
['Boom', 'soft-boom', 'bg-tertiary-container text-on-tertiary-container'],
['Bun', 'bun', 'bg-surface-container-highest text-secondary'],
['Ghost', 'ghostish', 'bg-primary text-on-primary'],
] as [$name, $shape, $colours])
<x-carousel-item :label="$name">
<div class="grid size-full place-items-center {{ $colours }}">
<x-shape :name="$shape" class="size-48" />
</div>
</x-carousel-item>
@endforeach
</x-carousel>
BLADE,
'Uncontained' => <<<'BLADE'
<x-carousel layout="uncontained" label="Albums" item-width="180" height="180">
@foreach ([
['cookie-6', 'bg-tertiary-container text-on-tertiary-container'],
['diamond', 'bg-primary-container text-on-primary-container'],
['clover-8', 'bg-secondary-container text-on-secondary-container'],
['pixel-circle', 'bg-surface-container-highest text-tertiary'],
['burst', 'bg-secondary text-on-secondary'],
['oval', 'bg-primary text-on-primary'],
] as [$shape, $colours])
<x-carousel-item>
<div class="grid size-full place-items-center {{ $colours }}">
<x-shape :name="$shape" class="size-24" />
</div>
</x-carousel-item>
@endforeach
</x-carousel>
BLADE,
'Full-screen' => <<<'BLADE'
<x-carousel layout="full-screen" label="Wallpapers" height="240" :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'],
['Night', 'puffy-diamond', 'bg-linear-to-br from-inverse-surface to-primary text-inverse-primary'],
] as [$name, $shape, $colours])
<x-carousel-item :label="$name">
<div class="grid size-full place-items-center {{ $colours }}">
<x-shape :name="$shape" class="size-40" />
</div>
</x-carousel-item>
@endforeach
</x-carousel>
BLADE,
];
@endphp
<section id="carousel" class="scroll-mt-24 space-y-6">
<h2 class="type-headline-md">Carousel</h2>
<p class="max-w-3xl type-body-md text-on-surface-variant">
<code>&lt;x-carousel&gt;</code> and <code>&lt;x-carousel-item&gt;</code>: items that change size between M3's keylines as they scroll.
Swipe, scroll with Shift and the wheel, or focus a row and use the arrow keys.
</p>
@foreach ($examples as $title => $code)
<x-showcase::example :$title :$code stack />
@endforeach
</section>