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
+228
View File
@@ -0,0 +1,228 @@
<?php
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Route;
use Livewire\Component;
use Livewire\Livewire;
/**
* A Livewire component whose items only the server changes, to watch a carousel through a morph.
*/
class CarouselMorphProbe extends Component
{
public int $count = 3;
public function add(): void
{
$this->count++;
}
public function render(): string
{
return <<<'BLADE'
<div class="p-4" style="width: 600px">
<x-carousel label="Server" item-width="200">
@foreach (range(1, $count) as $number)
<x-carousel-item wire:key="item-{{ $number }}"><div class="size-full bg-primary-container"></div></x-carousel-item>
@endforeach
</x-carousel>
<button type="button" wire:click="add">Add</button>
</div>
BLADE;
}
}
/**
* A script run against one carousel of the showcase (0 multi-browse, 1 hero, 2 uncontained,
* 3 full-screen), or of another page under `scope`, scrolled into view. `root`, `scroller` and
* `items` are in scope, with `surface(i)`, `inset(i)` (the mask on each side of item i),
* `snap(i)` (the scroll offset that brings item i into focus) and `at(i)`; the script's value is
* the result. Pest retries a failing assertion and gives each attempt a second: keep scripts well
* inside it.
*/
function onCarousel(int $index, string $body, string $scope = '#carousel'): string
{
return <<<JS
(async () => {
const root = document.querySelectorAll('{$scope} [x-data="materialCarousel"]')[{$index}]
const scroller = root.querySelector('[role="region"]')
const items = [...scroller.querySelectorAll('[data-material-carousel-item]')]
const gap = parseFloat(getComputedStyle(scroller).columnGap)
const size = parseFloat(root.style.getPropertyValue('--material-carousel-slot'))
const surface = (i) => items[i].querySelector('[data-material-carousel-surface]')
const inset = (i) => parseFloat(surface(i).style.getPropertyValue('--material-carousel-inset'))
const snap = (i) => Math.min(Math.max(i * (size + gap) - parseFloat(items[i].style.scrollMarginInlineStart), 0), scroller.scrollWidth - scroller.clientWidth)
const at = (i) => Math.abs(Math.abs(scroller.scrollLeft) - snap(i)) < 1.5
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
if (root.getBoundingClientRect().top < 0 || root.getBoundingClientRect().bottom > innerHeight) root.scrollIntoView({ block: 'center' })
{$body}
})()
JS;
}
const FIRST_SCROLLER = '#carousel [role="region"] >> nth=0';
function carouselShowcase(array $options = [])
{
return visit('/material', $options)
->waitForEvent('networkidle')
->assertScript("typeof window.Alpine !== 'undefined'");
}
it('masks items by their place between the keylines, and changes the large one as it scrolls', function () {
$page = carouselShowcase()
->assertNoJavaScriptErrors()
->assertScript(onCarousel(0, <<<'JS'
return size > 0 && inset(0) < 0.5 && inset(items.length - 1) > 0.5
&& getComputedStyle(surface(0)).clipPath.startsWith('inset(')
JS));
$page->script(onCarousel(0, "scroller.style.scrollSnapType = 'none'; scroller.scrollTo({ left: 2 * (size + gap), behavior: 'instant' })"));
$page->assertScript(onCarousel(0, <<<'JS'
await pause(50)
return inset(0) > 0.5 && inset(2) < 0.5 && surface(0).style.getPropertyValue('--material-carousel-shift') !== '0.00px'
JS));
// Half-way between keylines, an item is part-way between two sizes.
$page->script(onCarousel(0, "scroller.scrollTo({ left: 2.5 * (size + gap), behavior: 'instant' })"));
$page->assertScript(onCarousel(0, <<<'JS'
await pause(50)
return inset(2) > 0.5 && inset(2) < size / 2 - 1
JS));
});
it('moves one item with the next and previous buttons', function () {
$page = carouselShowcase()
->assertScript(onCarousel(0, 'return at(0) && root.querySelector(\'[aria-label="Previous"]\').disabled'));
$page->click('#carousel button[aria-label="Next"] >> nth=0')
->assertScript(onCarousel(0, 'return at(1) && inset(1) < 0.5 && !root.querySelector(\'[aria-label="Previous"]\').disabled'));
$page->click('#carousel button[aria-label="Next"] >> nth=0')
->assertScript(onCarousel(0, 'return at(2)'));
$page->click('#carousel button[aria-label="Previous"] >> nth=0')
->assertScript(onCarousel(0, 'return at(1)'));
});
it('moves one item with the arrow keys, and to the ends with Home and End', function () {
$page = carouselShowcase();
$page->keys(FIRST_SCROLLER, 'ArrowRight')
->assertScript(onCarousel(0, 'return at(1)'));
$page->keys(FIRST_SCROLLER, 'ArrowRight')
->assertScript(onCarousel(0, 'return at(2)'));
$page->keys(FIRST_SCROLLER, 'ArrowLeft')
->assertScript(onCarousel(0, 'return at(1)'));
$page->keys(FIRST_SCROLLER, 'End')
->assertScript(onCarousel(0, 'return Math.abs(scroller.scrollLeft - (scroller.scrollWidth - scroller.clientWidth)) < 1.5 && inset(items.length - 1) < 0.5 && root.querySelector(\'[aria-label="Next"]\').disabled'));
$page->keys(FIRST_SCROLLER, 'Home')
->assertScript(onCarousel(0, 'return at(0)'));
});
it('snaps a scroll that stops between items onto an item', function () {
$page = carouselShowcase();
$page->script(onCarousel(0, "scroller.scrollTo({ left: 0.7 * (size + gap), behavior: 'instant' })"));
$page->assertScript(onCarousel(0, <<<'JS'
await pause(100)
return items.some((_, i) => at(i)) && scroller.scrollLeft > 0
JS));
});
it('brings a partly hidden item into focus when it is pressed', function () {
$page = carouselShowcase()
->assertScript(onCarousel(0, 'return inset(4) > 0.5'));
$page->script(onCarousel(0, 'items[4].querySelector(\'[data-material-carousel-content]\').click()'));
$page->assertScript(onCarousel(0, 'return inset(4) < 0.5 && scroller.scrollLeft > 0'));
});
it('scrolls instantly and keeps content pinned to its mask under reduced motion', function () {
carouselShowcase(['reducedMotion' => 'reduce'])
->assertScript(onCarousel(0, <<<'JS'
root.querySelector('[aria-label="Next"]').click()
const arrived = at(1)
await pause(50)
const last = items.length - 1
return arrived && inset(last) > 0.5
&& surface(last).style.getPropertyValue('--material-carousel-pin') === surface(last).style.getPropertyValue('--material-carousel-inset')
JS));
});
it('mirrors in a right-to-left page', function () {
Route::middleware('web')->get('/carousel-rtl-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html dir="rtl">
<head>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body class="bg-surface">
<div class="p-4" style="width: 600px">
<x-carousel label="Right to left" item-width="200" controls>
@foreach (range(1, 6) as $number)
<x-carousel-item><div class="size-full bg-primary-container"></div></x-carousel-item>
@endforeach
</x-carousel>
</div>
@livewireScripts
</body>
</html>
BLADE));
$page = visit('/carousel-rtl-probe')->waitForEvent('networkidle')
->assertNoJavaScriptErrors()
->assertScript(onCarousel(0, 'return size > 0 && at(0) && inset(0) < 0.5 && inset(items.length - 1) > 0.5', 'body'));
$page->keys('[role="region"]', 'ArrowLeft')
->assertScript(onCarousel(0, <<<'JS'
return scroller.scrollLeft < -1 && at(1) && inset(0) > 0.5 && inset(1) < 0.5
&& parseFloat(surface(0).style.getPropertyValue('--material-carousel-shift')) < 0
JS, 'body'));
$page->click('button[aria-label="Next"]')
->assertScript(onCarousel(0, 'return at(2)', 'body'));
$page->keys('[role="region"]', 'ArrowRight')
->assertScript(onCarousel(0, 'return at(1)', 'body'));
});
it('measures itself again after a Livewire morph adds an item', function () {
Livewire::component('carousel-morph-probe', CarouselMorphProbe::class);
Route::middleware('web')->get('/carousel-morph-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body class="bg-surface">
<livewire:carousel-morph-probe />
@livewireScripts
</body>
</html>
BLADE));
$masked = "(() => { const root = document.querySelector('[x-data=\"materialCarousel\"]'); const items = [...root.querySelectorAll('[data-material-carousel-item]')]; return root.style.getPropertyValue('--material-carousel-slot').endsWith('px') && items.every((item) => item.querySelector('[data-material-carousel-surface]').style.getPropertyValue('--material-carousel-inset').endsWith('px')) && items.length })()";
$page = visit('/carousel-morph-probe')->waitForEvent('networkidle')
->assertNoJavaScriptErrors()
->assertScript($masked, 3)
->assertAttribute('[data-material-carousel-item] >> nth=2', 'aria-label', '3 of 3');
$page->click('Add')
->assertScript($masked, 4)
->assertAttribute('[data-material-carousel-item] >> nth=3', 'aria-label', '4 of 4');
});