Plan step 12, containment.md C-05. Only the parallax half of M3's rule was honoured: the mask was still written every frame, so items kept growing and shrinking between the keylines. Under reduced motion the inset, the shift and the label fade are now all zero, so every item stays at the strategy's large size and the keylines only decide where the row snaps. `--material-carousel-pin` went with it: with no mask there is nothing to pin the content to. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
237 lines
10 KiB
PHP
237 lines
10 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 = 6;
|
|
|
|
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 leaves every item unmasked under reduced motion', function () {
|
|
carouselShowcase(['reducedMotion' => 'reduce'])
|
|
->assertScript(onCarousel(0, <<<'JS'
|
|
root.querySelector('[aria-label="Next"]').click()
|
|
const arrived = at(1)
|
|
await pause(50)
|
|
return arrived && items.every((_, i) => inset(i) === 0)
|
|
&& items.every((item) => Math.abs(item.getBoundingClientRect().width - size) < 1.5)
|
|
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, 6)
|
|
->assertAttribute('[data-material-carousel-item] >> nth=5', 'aria-label', '6 of 6');
|
|
|
|
$page->click('Add')
|
|
->assertScript($masked, 7)
|
|
->assertAttribute('[data-material-carousel-item] >> nth=6', 'aria-label', '7 of 7');
|
|
|
|
// Scrolled once the morph has settled, so only the row's own scroll listener can re-mask the
|
|
// items: a row the morph swapped for a copy scrolls with its items' masks left as they were.
|
|
$page->script(onCarousel(0, "await pause(300); scroller.style.scrollSnapType = 'none'; scroller.scrollTo({ left: 2 * (size + gap), behavior: 'instant' })", 'body'));
|
|
|
|
$page->assertScript(onCarousel(0, <<<'JS'
|
|
await pause(50)
|
|
return inset(0) > 0.5 && inset(2) < 0.5
|
|
JS, 'body'));
|
|
});
|