Files
livewire-material/tests/Browser/CarouselTest.php
T
Andreas Reinhold / reiniandClaude Opus 5 889f8a8aa1
tests / lint (push) Has been cancelled
tests / feature (8.4) (push) Has been cancelled
tests / feature (8.5) (push) Has been cancelled
tests / browser (chrome, chromium) (push) Has been cancelled
tests / browser (firefox, firefox) (push) Has been cancelled
tests / browser (safari, webkit) (push) Has been cancelled
Put the showcase in the app shell, with a page per section
The showcase is now an overview and one page per section behind a
grouped navigation rail, moved between with wire:navigate, instead of
one long page under a scrolling top bar. The switch exposed a WebKit
bug in the menu: inside a focusable region, WebKit moves focus out of
the closing popover before its toggle event, so Escape no longer
returned focus to the trigger. The menu now reads it on beforetoggle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
2026-09-13 09:30:26 +02:00

229 lines
9.7 KiB
PHP

<?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/carousel', $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');
});