Files
livewire-material/tests/Feature/Components/CarouselTest.php
T
Andreas Reinhold / reiniandClaude Sonnet 5 58ef33dc95 Select on [dir='rtl'] now that no build rewrites :dir()
Plan step 39 (part 4). With Tailwind gone the Workbench's Vite build
still turns every `:dir(rtl)` into a long `:lang()` list (its own CSS
minifier, not Tailwind's doing — confirmed by rebuilding and grepping
the output). Every RTL mirror rule across the eleven stylesheets that
had one now selects `:is([dir='rtl'], [dir='rtl'] *)` instead, matching
`:dir(rtl)`'s own specificity (one pseudo-class) and its inherited
"this element or a descendant of one carrying the attribute" reach; the
built CSS now keeps the selector as written (no `:lang(` or `:dir(` left).

RTL browser tests drop the `lang="ar"` workaround the old rewrite
needed (ContainmentTest's side-sheet probe, LayoutTest's shared
layoutPage() helper) and set only dir="rtl"; CarouselTest's probe
already did. Feature tests asserting the selector's literal text
(Progress, Overlay, Menu, ListDetail, Icon, Carousel) updated to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 06:54:16 +02:00

268 lines
14 KiB
PHP

<?php
use Illuminate\Support\Facades\File;
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
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)
// The item's own interaction is the shared focus-ring class, never a hand-rolled one.
->and(substr_count($html, 'class="md-focus-ring"'))->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-md-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-md-padding="16"')
->toContain('data-md-padding-end="16"')
->toContain('data-md-carousel-item')
->not->toContain('data-md-centered');
$carousel = ComponentStylesheet::read('carousel');
$item = ComponentStylesheet::read('carousel-item');
// The specs table's 16dp leading/trailing and 8dp top/bottom padding, and one item per swipe.
expect($carousel->declarations("[data-md-carousel]:not([data-md-carousel='full-screen']) > [data-md-carousel-scroller]"))
->toHaveKey('overflow-x', 'auto')
->toHaveKey('padding-block', 'var(--md-sys-measurement-space100)')
->and($item->declarations('[data-md-carousel-item]'))
->toHaveKey('scroll-snap-align', 'start')
->toHaveKey('scroll-snap-stop', 'always')
->and($item->declarations("[data-md-carousel]:not([data-md-carousel='full-screen']):not([data-md-carousel='multi-aspect']) > [data-md-carousel-scroller] > [data-md-carousel-item]"))
->toHaveKey('width', 'var(--material-carousel-slot)')
->toHaveKey('border-radius', 'var(--md-sys-shape-corner-xl)');
});
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-md-padding="24"')
->and((string) $this->blade('<x-carousel layout="uncontained"><x-carousel-item>A</x-carousel-item></x-carousel>'))
->toContain('data-md-padding="16"')
->toContain('data-md-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-md-carousel="hero"')
->toContain('data-md-centered')
->toContain('--material-carousel-slot: calc(100% - 64px)')
->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-md-centered');
$carousel = ComponentStylesheet::read('carousel');
// multi-browse and hero are the two snap-scrolling layouts (uncontained and multi-aspect
// keep default scrolling, M3's recommendation for each).
expect($carousel->declarations("[data-md-carousel='multi-browse'] > [data-md-carousel-scroller], [data-md-carousel='hero'] > [data-md-carousel-scroller]"))
->toBe(['scroll-snap-type' => 'x mandatory']);
});
it('leaves an uncontained carousel unsnapped, and scrolls a full-screen one down the page', function () {
$carousel = ComponentStylesheet::read('carousel');
$item = ComponentStylesheet::read('carousel-item');
expect((string) $this->blade('<x-carousel layout="uncontained" item-width="180"><x-carousel-item>A</x-carousel-item></x-carousel>'))
->toContain('data-md-carousel="uncontained"')
->toContain('--material-carousel-item-width: 180px')
->and($carousel->has("[data-md-carousel='uncontained'] > [data-md-carousel-scroller]"))->toBeFalse()
->and((string) $this->blade('<x-carousel layout="full-screen" centered controls><x-carousel-item label="Morning">A</x-carousel-item></x-carousel>'))
->toContain('data-md-carousel="full-screen"')
->toContain('data-md-padding="0" data-md-padding-end="0"')
->toContain('aria-label="Previous"')
->not->toContain('--material-carousel-slot')
->not->toContain('x-ref="probe"')
->not->toContain('data-md-centered')
->and((string) $this->blade('<x-carousel layout="sideways"><x-carousel-item>A</x-carousel-item></x-carousel>'))
->toContain('data-md-carousel="multi-browse"');
// M3: one edge-to-edge item at a time, scrolled vertically, 16px apart, no corner, no mask,
// and never wider than the medium window it is meant for (C-11).
expect($carousel->declarations("[data-md-carousel='full-screen'] > [data-md-carousel-scroller]"))
->toBe([
'margin-inline' => 'auto',
'height' => 'var(--material-carousel-height)',
'max-width' => '840px',
'flex-direction' => 'column',
'gap' => 'var(--md-sys-measurement-space200)',
'overflow-x' => 'hidden',
'overflow-y' => 'auto',
'overscroll-behavior-y' => 'contain',
'scroll-snap-type' => 'y mandatory',
])
->and($item->declarations("[data-md-carousel='full-screen'] > [data-md-carousel-scroller] > [data-md-carousel-item]"))
->toBe(['width' => '100%'])
// Only a masked layout (not full-screen, not multi-aspect) shifts and clips the surface.
->and($item->declarations("[data-md-carousel]:not([data-md-carousel='full-screen']):not([data-md-carousel='multi-aspect']) > [data-md-carousel-scroller] > [data-md-carousel-item] > [data-md-carousel-surface]"))
->toBe([
'border-radius' => 'var(--md-sys-shape-corner-xl)',
'translate' => 'var(--material-carousel-shift) 0',
'clip-path' => 'inset(0 var(--material-carousel-inset, 0px) round var(--md-sys-shape-corner-xl))',
]);
});
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('data-md-carousel-controls="auto"')
->toContain('data-md-carousel-previous')
->toContain('data-md-carousel-next')
->toContain('aria-label="Previous"')
->toContain('aria-label="Next"')
->toContain('x-on:click="previous()"')
->toContain('x-on:click="next()"')
->toContain("aria-controls=\"{$scroller[1]}\"")
->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('data-md-carousel-controls="always"')
->toContain('aria-label="Next"');
expect((string) $this->blade('<x-carousel :controls="false"><x-carousel-item>A</x-carousel-item></x-carousel>'))
->not->toContain('data-md-carousel-controls')
->not->toContain('aria-label="Previous"')
->not->toContain('aria-label="Next"');
$carousel = ComponentStylesheet::read('carousel');
expect($carousel->declarations("[data-md-carousel] > [data-md-carousel-controls='always']"))
->toBe(['display' => 'flex'])
->and($carousel->declarations("[data-md-carousel] > [data-md-carousel-controls='auto']", ['@media (pointer: fine)']))
->toBe(['display' => 'flex'])
// The button carries no class of its own, and mirrors whole in RTL rather than through
// an icon prop button.blade.php does not expose.
->and($carousel->declarations("[data-md-carousel]:not([data-md-carousel='full-screen']) > [data-md-carousel-controls] > [data-md-carousel-previous]:is([dir='rtl'], [dir='rtl'] *), [data-md-carousel]:not([data-md-carousel='full-screen']) > [data-md-carousel-controls] > [data-md-carousel-next]:is([dir='rtl'], [dir='rtl'] *)"))
->toBe(['transform' => 'scaleX(-1)']);
});
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-md-carousel-item')
->toContain('wire:key="lake"')
->toContain('data-md-carousel-label')
->toContain('data-md-carousel-label-text')
->toContain('Lake Constance')
->and((string) $this->blade('<x-carousel-item>A</x-carousel-item>'))
->not->toContain('data-md-carousel-label');
$item = ComponentStylesheet::read('carousel-item');
// The scrim under the label and the item's literal white ink over it are deliberate (C-25).
expect($item->declarations('[data-md-carousel-surface] > [data-md-carousel-label]'))
->toHaveKey('background', 'linear-gradient(to top, color-mix(in srgb, var(--md-sys-color-scrim) 60%, transparent), transparent)')
->and($item->declarations('[data-md-carousel-label] > [data-md-carousel-label-text]'))
->toBe([
'overflow' => 'hidden',
'color' => 'white',
'font' => 'var(--md-sys-typescale-title-md)',
'letter-spacing' => 'var(--md-sys-typescale-title-md-tracking)',
'text-overflow' => 'ellipsis',
'white-space' => 'nowrap',
'translate' => 'var(--material-carousel-label-shift) 0',
]);
});
it('lets a multi-aspect carousel size each item by its own ratio, between 9:16 and 16:9', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-carousel layout="multi-aspect" label="Clips" height="180">
<x-carousel-item aspect="16/9">A</x-carousel-item>
<x-carousel-item aspect="9:16">B</x-carousel-item>
<x-carousel-item>C</x-carousel-item>
<x-carousel-item aspect="4">D</x-carousel-item>
<x-carousel-item aspect="0.1">E</x-carousel-item>
</x-carousel>
BLADE);
expect($html)
->toContain('data-md-carousel="multi-aspect"')
->toContain('--material-carousel-pad: 16px')
->toContain('--material-carousel-height: 180px')
->toContain('data-md-padding="16" data-md-padding-end="0"')
// M3's own range holds whatever the item asks for: 16/9 at the top, 9/16 at the bottom.
->toContain('aspect-ratio: 1.7778')
->toContain('aspect-ratio: 0.5625')
->toContain('aspect-ratio: 1')
->not->toContain('--material-carousel-slot')
->not->toContain('x-ref="probe"')
->and(substr_count($html, 'aspect-ratio: 1.7778'))->toBe(2)
->and((string) $this->blade('<x-carousel><x-carousel-item aspect="16/9">A</x-carousel-item></x-carousel>'))
->not->toContain('aspect-ratio');
$carousel = ComponentStylesheet::read('carousel');
$item = ComponentStylesheet::read('carousel-item');
// Uncontained scrolling, and nothing masks an item whose width is its own.
expect($carousel->declarations("[data-md-carousel='multi-aspect'] > [data-md-carousel-scroller]"))
->toBe(['padding-inline-start' => 'var(--material-carousel-pad)'])
->and($item->declarations("[data-md-carousel='multi-aspect'] > [data-md-carousel-scroller] > [data-md-carousel-item]"))
->toBe(['width' => 'auto', 'border-radius' => 'var(--md-sys-shape-corner-xl)']);
});
it('rings a focused item 3px inside its edge, and reveals an item the row cuts off under reduced motion', function () {
expect(ComponentStylesheet::read('carousel-item')->declarations('[data-md-carousel-item]:focus-visible'))->toBe(['outline-offset' => '-3px'])
->and(File::get(__DIR__.'/../../../resources/js/carousel.js'))->toContain('if (state.measured || state.reducedMotion.matches) {');
});