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
229 lines
9.7 KiB
PHP
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', $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');
|
|
});
|