Files
livewire-material/tests/Feature/Components/CarouselTest.php
T
Andreas Reinhold / reiniandClaude Opus 5 5e58dd5449 Scroll the full-screen carousel down the page, as M3's does
Plan step 19, containment.md C-11. The full-screen layout was another
horizontal row with a 28px corner and a mask, where M3's "shows one
edge-to-edge large item at a time and scrolls vertically". It is now a
vertical scroll-snap column: items fill the row with no corner and no
mask, 16dp apart, no end padding, the previous/next buttons point up and
down and the arrow keys are Up and Down. The row is capped at the 840px
medium window, which with the portrait rule in the header comment is as
far as CSS can hold M3's "compact and medium, portrait only". The ported
FullScreenCarouselStrategy keylines go with it: nothing measures them
any more.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 06:17:31 +02:00

159 lines
7.9 KiB
PHP

<?php
it('is a carousel region of focusable slides named n of m', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-carousel label="Recent uploads">
<x-carousel-item><img src="/a.jpg" alt="A lake" /></x-carousel-item>
<x-carousel-item><img src="/b.jpg" alt="A hill" /></x-carousel-item>
<x-carousel-item><img src="/c.jpg" alt="A town" /></x-carousel-item>
</x-carousel>
BLADE);
preg_match('/id="(material-carousel-[a-z0-9]+)"/', $html, $scroller);
expect($scroller)->not->toBeEmpty()
->and($html)
->toContain('x-data="materialCarousel"')
->toContain('role="region"')
->toContain('aria-roledescription="carousel"')
->toContain('aria-label="Recent uploads"')
->toContain('aria-roledescription="slide"')
->toContain('aria-label="1 of 3"')
->toContain('aria-label="2 of 3"')
->toContain('aria-label="3 of 3"')
->toContain('<img src="/a.jpg" alt="A lake" />')
->not->toContain('[material-carousel-position]')
->and(substr_count($html, 'role="group"'))->toBe(3)
// M3 puts the tab stop on each item and tells you not to focus the container.
->and(substr_count($html, 'tabindex="0"'))->toBe(3)
->and($html)->toMatch('/<div\s+x-ref="scroller"(?:(?!tabindex)[^>])*>/');
});
it('counts the slides of a carousel inside a slide on their own', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-carousel label="Outer">
<x-carousel-item>
<x-carousel label="Inner">
<x-carousel-item>1</x-carousel-item>
<x-carousel-item>2</x-carousel-item>
<x-carousel-item>3</x-carousel-item>
</x-carousel>
</x-carousel-item>
<x-carousel-item>Two</x-carousel-item>
</x-carousel>
BLADE);
expect($html)
->toContain('aria-label="1 of 3"')
->toContain('aria-label="3 of 3"')
->toContain('aria-label="1 of 2"')
->toContain('aria-label="2 of 2"');
});
it('browses many by default: 186px items that snap one at a time, 205px high', function () {
$html = (string) $this->blade('<x-carousel><x-carousel-item>A</x-carousel-item></x-carousel>');
expect($html)
->toContain('data-material-carousel="multi-browse"')
->toContain('aria-label="Carousel"')
->toContain('--material-carousel-item-width: 186px; --material-carousel-slot: 186px; --material-carousel-height: 205px')
->toContain('x-ref="probe"')
->toContain('data-padding="16"')
->toContain('data-padding-end="16"')
->toContain('h-[calc(var(--material-carousel-height)+1rem)] gap-2 overflow-x-auto overflow-y-hidden overscroll-x-contain py-2')
->toContain('snap-x snap-mandatory')
->toContain('snap-start snap-always')
->toContain('rounded-corner-xl')
->toContain('[clip-path:inset(0_var(--material-carousel-inset,0px)_round_var(--md-sys-shape-corner-xl))]')
->not->toContain('data-centered');
});
it('takes the item width in pixels or as a CSS length, and a height and padding', function () {
expect((string) $this->blade('<x-carousel item-width="220" height="18rem" padding="24"><x-carousel-item>A</x-carousel-item></x-carousel>'))
->toContain('--material-carousel-item-width: 220px; --material-carousel-slot: 220px; --material-carousel-height: 18rem')
->toContain('data-padding="24"')
->and((string) $this->blade('<x-carousel layout="uncontained"><x-carousel-item>A</x-carousel-item></x-carousel>'))
->toContain('data-padding="16"')
->toContain('data-padding-end="0"')
->and((string) $this->blade('<x-carousel item-width="40%"><x-carousel-item>A</x-carousel-item></x-carousel>'))
->toContain('--material-carousel-item-width: 40%;');
});
it('lays out a hero that fills the width unless capped, centred on request', function () {
$filled = (string) $this->blade('<x-carousel layout="hero" centered><x-carousel-item>A</x-carousel-item></x-carousel>');
expect($filled)
->toContain('data-material-carousel="hero"')
->toContain('data-centered')
->toContain('--material-carousel-slot: calc(100% - 64px)')
->toContain('snap-x snap-mandatory')
->not->toContain('x-ref="probe"')
->not->toContain('--material-carousel-item-width')
->and((string) $this->blade('<x-carousel layout="hero" item-width="600"><x-carousel-item>A</x-carousel-item></x-carousel>'))
->toContain('--material-carousel-item-width: 600px')
->toContain('x-ref="probe"')
->not->toContain('data-centered');
});
it('leaves an uncontained carousel unsnapped, and scrolls a full-screen one down the page', function () {
expect((string) $this->blade('<x-carousel layout="uncontained" item-width="180"><x-carousel-item>A</x-carousel-item></x-carousel>'))
->toContain('data-material-carousel="uncontained"')
->toContain('--material-carousel-item-width: 180px')
->not->toContain('snap-mandatory')
->and((string) $this->blade('<x-carousel layout="full-screen" centered controls><x-carousel-item label="Morning">A</x-carousel-item></x-carousel>'))
->toContain('data-material-carousel="full-screen"')
// M3: one edge-to-edge item at a time, scrolled vertically, 16px apart, no end padding,
// no corner, no mask, and never wider than the medium window it is meant for.
->toContain('mx-auto h-(--material-carousel-height) max-w-210 snap-y snap-mandatory flex-col gap-4 overflow-x-hidden overflow-y-auto overscroll-y-contain')
->toContain('data-padding="0" data-padding-end="0"')
->toContain('aria-label="Previous"')
->not->toContain('--material-carousel-slot')
->not->toContain('snap-x')
->not->toContain('rounded-corner-xl')
->not->toContain('clip-path')
->not->toContain('rtl:-scale-x-100')
->not->toContain('x-ref="probe"')
->not->toContain('data-centered')
->and((string) $this->blade('<x-carousel layout="sideways"><x-carousel-item>A</x-carousel-item></x-carousel>'))
->toContain('data-material-carousel="multi-browse"');
});
it('offers previous and next buttons for fine pointers, always, or never', function () {
$auto = (string) $this->blade('<x-carousel><x-carousel-item>A</x-carousel-item></x-carousel>');
preg_match('/id="(material-carousel-[a-z0-9]+)"/', $auto, $scroller);
expect($auto)
->toContain('hidden pointer-fine:flex')
->toContain('aria-label="Previous"')
->toContain('aria-label="Next"')
->toContain('x-on:click="previous()"')
->toContain('x-on:click="next()"')
->toContain("aria-controls=\"{$scroller[1]}\"")
->toContain('rtl:-scale-x-100')
->and(substr_count($auto, "aria-controls=\"{$scroller[1]}\""))->toBe(2);
expect((string) $this->blade('<x-carousel controls><x-carousel-item>A</x-carousel-item></x-carousel>'))
->toContain('aria-label="Next"')
->not->toContain('pointer-fine:flex');
expect((string) $this->blade('<x-carousel :controls="false"><x-carousel-item>A</x-carousel-item></x-carousel>'))
->not->toContain('aria-label="Previous"')
->not->toContain('aria-label="Next"');
});
it('lays a label over an item on a scrim, and passes attributes to the item', function () {
$html = (string) $this->blade('<x-carousel-item label="Lake Constance" wire:key="lake"><img src="/a.jpg" alt="" /></x-carousel-item>');
expect($html)
->toContain('data-material-carousel-item')
->toContain('wire:key="lake"')
->toContain('data-material-carousel-label')
->toContain('Lake Constance')
->toContain('from-scrim/60')
->toContain('type-title-md text-inverse-on-surface')
->toContain('[&>img]:size-full [&>img]:object-cover')
->and((string) $this->blade('<x-carousel-item>A</x-carousel-item>'))
->not->toContain('data-material-carousel-label');
});