count++; } public function render(): string { return <<<'BLADE'
@foreach (range(1, $count) as $number)
@endforeach
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 << { 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' @vite(config('livewire-material.showcase.vite')) @livewireStyles
@foreach (range(1, 6) as $number)
@endforeach
@livewireScripts 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' @vite(config('livewire-material.showcase.vite')) @livewireStyles @livewireScripts 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'); });