Plan step 23, containment.md § Missing ("Carousel: the uncontained
multi-aspect-ratio layout"). `<x-carousel layout="multi-aspect">` is the
layout M3 added in November 2025: each `<x-carousel-item aspect="…">`
keeps its own ratio at the row's fixed height, held inside M3's 9:16 to
16:9 range (a square by default), with 16dp leading padding, 8dp gaps,
the extra-large corner and uncontained (default) scrolling.
The keyline machinery does not fit it: an Arrangement counts items of
one size each, and every snap position and mask follows from that size.
So the layout is a plain flex row, unmasked, and carousel.js only
measures each item's resting position off the DOM, which keeps the
previous/next buttons, the arrow keys, Home/End and bring-into-view
working. The header and SKILL.md say so.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
191 lines
9.5 KiB
PHP
191 lines
9.5 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-white')
|
|
->toContain('[&>img]:size-full [&>img]:object-cover')
|
|
->and((string) $this->blade('<x-carousel-item>A</x-carousel-item>'))
|
|
->not->toContain('data-material-carousel-label');
|
|
});
|
|
|
|
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-material-carousel="multi-aspect"')
|
|
->toContain('--material-carousel-pad: 16px')
|
|
->toContain('--material-carousel-height: 180px')
|
|
->toContain('ps-(--material-carousel-pad)')
|
|
->toContain('data-padding="16" data-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')
|
|
->toContain('w-auto rounded-corner-xl')
|
|
// Uncontained scrolling, and nothing masks an item whose width is its own.
|
|
->not->toContain('snap-mandatory')
|
|
->not->toContain('--material-carousel-slot')
|
|
->not->toContain('clip-path')
|
|
->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');
|
|
});
|