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
159 lines
7.9 KiB
PHP
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');
|
|
});
|