Add the M3 Expressive carousel
tests / browser (chrome, chromium) (push) Successful in 2m24s
tests / browser (firefox, firefox) (push) Successful in 2m48s
tests / browser (safari, webkit) (push) Successful in 3m42s
tests / lint (push) Successful in 1m0s
tests / feature (8.4) (push) Successful in 1m7s
tests / feature (8.5) (push) Successful in 1m2s

Multi-browse, hero, uncontained and full-screen layouts on a native
scroll-snap row, with Compose's keyline maths masking each item as it
scrolls. Completes Phase 5.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
This commit is contained in:
Andreas Reinhold / reini
2026-09-13 07:40:30 +02:00
co-authored by Claude Opus 5
parent fef20a9178
commit ad724662ca
12 changed files with 1792 additions and 1 deletions
+4
View File
@@ -17,6 +17,10 @@ M3 Expressive loading indicator (resources/svg/loading-indicator)
recorded at the top of bin/loading-indicator.mjs. recorded at the top of bin/loading-indicator.mjs.
Copyright The Android Open Source Project. Apache License 2.0. Copyright The Android Open Source Project. Apache License 2.0.
M3 Expressive carousel keylines (resources/js/carousel.js)
Ported from androidx Compose Material 3 (carousel/*.kt), as recorded at the top of the file.
Copyright The Android Open Source Project. Apache License 2.0.
Google Sans Flex (resources/fonts/google-sans-flex) Google Sans Flex (resources/fonts/google-sans-flex)
Copyright Google LLC. SIL Open Font License 1.1 (resources/fonts/google-sans-flex/OFL.txt). Copyright Google LLC. SIL Open Font License 1.1 (resources/fonts/google-sans-flex/OFL.txt).
Subset: Latin and Latin Extended, weight 400700, roundness 0100. Subset: Latin and Latin Extended, weight 400700, roundness 0100.
+19
View File
@@ -427,6 +427,25 @@ tokens, `training-row`, `map-shell`, `map-chip`, `product-shot`, `star-rating`,
standard; `pane` for list-detail from `xl`; `width` prop), `carousel` (multi-browse, uncontained, hero, standard; `pane` for list-detail from `xl`; `width` prop), `carousel` (multi-browse, uncontained, hero,
full-screen on CSS scroll-snap), `collapse`. full-screen on CSS scroll-snap), `collapse`.
**Phase 5 is done (2026-09-13).** What changed from the steps above:
- **`<x-carousel>` was built by a separate agent in its own worktree**, porting Compose's keyline
maths (`Arrangement`, `Keylines`, `Strategy`, `KeylineSnapPosition`, androidx
`7ac433e44e797de53af85226797862687f37735f`; checked against 43 values from androidx's unit tests).
Items are laid out at the large size on a native scroll-snap row and masked each frame with a
`clip-path` inset; snap positions are `scroll-margin-inline-start`. Full-screen follows
material-components-android, which Compose lacks; one item per swipe is `scroll-snap-stop`, not
fling physics. Hooks are `data-material-carousel*`: the design guard rejects `carousel*` classes
as daisyUI's.
- **The drawer is the side sheet**, with `pane` for list-detail from `xl`, and the bottom sheet is its
own component (modal or `standard`) with drag-to-dismiss.
- **`<x-list-item>`'s trailing slot is `end`**: a slot named like the `trailing` prop replaced it.
- **The design guard also rejects Blade directives inside component tags** (`@class` on
`<x-icon>`), which reach the browser as text.
- **`x-trap.inert` hides the page with `aria-hidden`**, not `inert`; tests assert what it sets.
- **WebKit returns focus from a dialog only to an element that had focus**, and a click does not
focus a button there: tests open dialogs from the keyboard.
### Phase 6 — Text inputs and selection ### Phase 6 — Text inputs and selection
25. `form`, `field` (the shared shell: **outlined and filled**, floating label via `:has()`, 25. `form`, `field` (the shared shell: **outlined and filled**, floating label via `:has()`,
@@ -347,6 +347,20 @@ An M3 side sheet, bound like `<x-modal>`; `close()` in scope. Props: `title`, `s
An M3 bottom sheet, bound like `<x-modal>`: modal by default (scrim, inert page, drag the handle down or press Escape to close), `standard` for one that is part of the page. Props: `title`, `height` (`90dvh`), `actions` slot. An M3 bottom sheet, bound like `<x-modal>`: modal by default (scrim, inert page, drag the handle down or press Escape to close), `standard` for one that is part of the page. Props: `title`, `height` (`90dvh`), `actions` slot.
### `<x-carousel>`, `<x-carousel-item>`
```blade
<x-carousel label="Recent uploads" item-width="220">
@foreach ($photos as $photo)
<x-carousel-item :label="$photo->title" wire:key="photo-{{ $photo->id }}">
<img src="{{ $photo->url }}" alt="{{ $photo->alt }}" />
</x-carousel-item>
@endforeach
</x-carousel>
```
A row of items that change size between M3's keylines as it scrolls (native scroll snap; items are masked, content keeps its size). `<x-carousel>`: `layout` (`multi-browse` default, `hero`, `uncontained`, `full-screen`), `item-width` (px or any CSS length; the large size multi-browse aims for, the fixed size uncontained keeps, the cap for hero; 186 by default), `height` (205px), `padding` (px at the ends, 0), `centered` (hero), `label` (the region's name, "Carousel" by default), `controls` (previous/next buttons: default fine pointers only, `true` always, `false` never). `<x-carousel-item>`: slot is an `<img>` (fills and crops) or an element sized `size-full`; `label` overlays a line of text. A focusable `region` of `slide` groups named "n of m"; arrow keys move one item while the row has focus, Home/End to the ends. Works after a Livewire morph, in RTL and under reduced motion. Give items a `wire:key` in a loop.
## Testing the design ## Testing the design
```php ```php
File diff suppressed because it is too large Load Diff
+1
View File
@@ -16,3 +16,4 @@ import './rich-tooltip.js'
import './progress.js' import './progress.js'
import './list-rows.js' import './list-rows.js'
import './bottom-sheet.js' import './bottom-sheet.js'
import './carousel.js'
@@ -0,0 +1,50 @@
{{-- One item of an `<x-carousel>`: a slide of art an `<img>`, which fills and crops to the
item, or any element sized `size-full`.
<x-carousel-item label="Lake Constance">
<img src="{{ $photo->url }}" alt="Lake Constance at dusk" />
</x-carousel-item>
`label` lays a short line of text over the bottom of the art, on a scrim, pinned to the
item's visible edge and fading out as the item narrows — Compose's carousel sample, which
fades its label chip in once the mask is wide enough to hold it.
A `group` with `aria-roledescription="slide"`, named "n of m" by the carousel around it
(WAI-ARIA's carousel pattern). The item is laid out at the carousel's large size and masked:
the surface inside is clipped by `--material-carousel-inset` from both sides with M3's
extra-large corner (28px, CarouselDefaults' item shape) and moved by
`--material-carousel-shift`, both written by resources/js/carousel.js. Without script the
item shows unmasked, at its `item-width`. Only inside `<x-carousel>`. --}}
@props([
'label' => null,
])
<div {{ $attributes
->class([
'relative h-full w-(--material-carousel-slot) max-w-full shrink-0 snap-start snap-always',
])
->merge([
'role' => 'group',
'aria-roledescription' => __('slide'),
'aria-label' => '[material-carousel-position]',
'data-material-carousel-item' => true,
]) }}>
<div
data-material-carousel-surface
class="relative size-full overflow-hidden rounded-corner-xl bg-surface-container-highest text-on-surface translate-x-(--material-carousel-shift) [clip-path:inset(0_var(--material-carousel-inset,0px)_round_var(--md-sys-shape-corner-xl))]"
>
<div data-material-carousel-content class="size-full translate-x-(--material-carousel-pin) [&>img]:size-full [&>img]:object-cover">
{{ $slot }}
</div>
@if (filled($label))
<div
data-material-carousel-label
class="pointer-events-none absolute inset-x-0 bottom-0 flex bg-linear-to-t from-scrim/60 to-transparent px-4 pt-10 pb-4 opacity-(--material-carousel-label)"
>
<span class="truncate type-title-md text-white translate-x-(--material-carousel-label-shift)">{{ $label }}</span>
</div>
@endif
</div>
</div>
@@ -0,0 +1,159 @@
{{-- M3 Expressive's carousel: a row of `<x-carousel-item>`s that grow and shrink as they scroll.
<x-carousel label="Recent uploads" item-width="220">
@foreach ($photos as $photo)
<x-carousel-item :label="$photo->title">
<img src="{{ $photo->url }}" alt="{{ $photo->alt }}" />
</x-carousel-item>
@endforeach
</x-carousel>
`layout` is one of M3's four:
- `multi-browse` (the default): large items at the start, then a medium and a small one, for
browsing many HorizontalMultiBrowseCarousel. `item-width` is the width large items
would like to be (186px, Compose's sample); the carousel adjusts it so a whole
arrangement fits, small items between 40 and 56px.
- `hero`: one large item and a small one after it, `centered` between two small ones
HorizontalCenteredHeroCarousel and material-components-android's start-aligned hero. The
large item fills the width unless `item-width` caps it, and more large items fit when it
does.
- `uncontained`: items keep `item-width`; the one cut off at the end narrows as it leaves
HorizontalUncontainedCarousel. No snapping, as Compose's uncontained fling.
- `full-screen`: one item the width of the carousel at a time
(FullScreenCarouselStrategy).
`item-width` takes pixels or any CSS length. `height` is the items' height (205px, Compose's
sample). `padding` is Compose's `contentPadding` in pixels (0): the first and last items
rest that far in from the edges while items in between scroll to them. Items are 8px apart
with M3's extra-large corner.
The row is a native scroll container with CSS scroll snap, one item per swipe, as Compose's
single-advance fling; touch, trackpad and Shift with the wheel scroll it. resources/js/
carousel.js ports Compose's keylines (Arrangement, Keylines, KeylineList, Strategy,
KeylineSnapPosition and Carousel.kt at androidx commit
7ac433e44e797de53af85226797862687f37735f, Apache-2.0) and masks each item on every scroll
frame, content at full size, so items change size between the keylines. Without script
the row still scrolls and snaps, unmasked.
WAI-ARIA's carousel pattern: the row is a focusable `region` with
`aria-roledescription="carousel"`, named by `label` ("Carousel" by default); each item is a
`group` with `aria-roledescription="slide"` named "n of m". With the row focused the arrow
keys move one item, Home and End to the ends; focus moving into an item, or a press on one
that is not fully open, brings it into focus. `controls` adds previous and next icon
buttons under the row by default only where the pointer is fine (a mouse or trackpad);
`true` always, `false` never. Under reduced motion they scroll instantly and content no
longer slides inside its mask. RTL mirrors the keylines, keys and buttons.
Re-measures itself when resized, when a Livewire morph resets its styles and when items
come and go. --}}
@props([
'layout' => 'multi-browse',
'itemWidth' => null,
'height' => null,
'padding' => 0,
'centered' => false,
'label' => null,
'controls' => null,
])
@php
$layout = in_array($layout, ['multi-browse', 'hero', 'uncontained', 'full-screen'], true) ? $layout : 'multi-browse';
$controls = $controls === null ? null : filter_var($controls, FILTER_VALIDATE_BOOL);
$label ??= __('Carousel');
$scrollerId = 'material-carousel-'.\Illuminate\Support\Str::lower(\Illuminate\Support\Str::random(10));
$cssLength = fn (mixed $value): ?string => match (true) {
$value === null, $value === '' => null,
is_numeric($value) => ((float) $value).'px',
default => (string) $value,
};
// What the keylines are asked for, and the width items take before (or without) script.
$preferredWidth = match ($layout) {
'multi-browse', 'uncontained' => $cssLength($itemWidth) ?? '186px',
'hero' => $cssLength($itemWidth),
'full-screen' => null,
};
$slotWidth = match (true) {
$layout === 'full-screen' => '100%',
$preferredWidth === null => 'calc(100% - 64px)',
default => $preferredWidth,
};
// "n of m": every item rendered in the slot leaves a placeholder for its position. An inner
// carousel has already replaced its own by the time this one renders.
$slides = $slot->toHtml();
$slideCount = substr_count($slides, '[material-carousel-position]');
$slidePosition = 0;
$slides = preg_replace_callback(
'/\[material-carousel-position\]/',
function () use (&$slidePosition, $slideCount): string {
$slidePosition++;
return e(__(':position of :count', ['position' => $slidePosition, 'count' => $slideCount]));
},
$slides,
);
$attributes = $attributes
->class('relative')
->merge(array_filter([
'data-material-carousel' => $layout,
'data-centered' => $layout === 'hero' && $centered ? true : null,
'data-padding' => (string) max(0, (float) $padding),
'style' => implode('; ', array_filter([
$preferredWidth ? "--material-carousel-item-width: {$preferredWidth}" : null,
"--material-carousel-slot: {$slotWidth}",
'--material-carousel-height: '.($cssLength($height) ?? '205px'),
])),
], fn ($value): bool => $value !== null));
@endphp
<div x-data="materialCarousel" {{ $attributes }}>
@if ($preferredWidth)
<div x-ref="probe" aria-hidden="true" class="pointer-events-none invisible absolute start-0 top-0 h-0 w-(--material-carousel-item-width)"></div>
@endif
<div
x-ref="scroller"
id="{{ $scrollerId }}"
role="region"
aria-roledescription="{{ __('carousel') }}"
aria-label="{{ $label }}"
tabindex="0"
@class([
'focus-ring flex h-(--material-carousel-height) gap-2 overflow-x-auto overflow-y-hidden overscroll-x-contain',
'[scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
'snap-x snap-mandatory' => $layout !== 'uncontained',
])
>
{!! $slides !!}
</div>
@if ($controls !== false)
<div @class([
'mt-3 justify-end gap-2',
'hidden pointer-fine:flex' => $controls === null,
'flex' => $controls === true,
])>
<x-button
icon="chevron_left"
variant="tonal"
:tooltip="__('Previous')"
aria-controls="{{ $scrollerId }}"
x-ref="previous"
x-on:click="previous()"
class="rtl:-scale-x-100"
/>
<x-button
icon="chevron_right"
variant="tonal"
:tooltip="__('Next')"
aria-controls="{{ $scrollerId }}"
x-ref="next"
x-on:click="next()"
class="rtl:-scale-x-100"
/>
</div>
@endif
</div>
+1
View File
@@ -17,5 +17,6 @@
@include('livewire-material::showcase.sections.communication') @include('livewire-material::showcase.sections.communication')
@include('livewire-material::showcase.sections.progress') @include('livewire-material::showcase.sections.progress')
@include('livewire-material::showcase.sections.containment') @include('livewire-material::showcase.sections.containment')
@include('livewire-material::showcase.sections.carousel')
</main> </main>
@endsection @endsection
+1 -1
View File
@@ -18,7 +18,7 @@
<a href="{{ route('livewire-material.showcase') }}" class="shrink-0 type-title-lg max-sm:hidden">Livewire Material</a> <a href="{{ route('livewire-material.showcase') }}" class="shrink-0 type-title-lg max-sm:hidden">Livewire Material</a>
<nav class="-my-2 flex min-w-0 flex-1 gap-x-4 overflow-x-auto py-2 whitespace-nowrap type-label-lg text-on-surface-variant [scrollbar-width:none]" aria-label="Sections"> <nav class="-my-2 flex min-w-0 flex-1 gap-x-4 overflow-x-auto py-2 whitespace-nowrap type-label-lg text-on-surface-variant [scrollbar-width:none]" aria-label="Sections">
@foreach (['colour' => 'Colour', 'type' => 'Type', 'shape' => 'Shape', 'elevation' => 'Elevation', 'motion' => 'Motion', 'icons' => 'Icons', 'buttons' => 'Buttons', 'menus' => 'Menus', 'communication' => 'Communication', 'progress' => 'Progress', 'containment' => 'Containment'] as $anchor => $section) @foreach (['colour' => 'Colour', 'type' => 'Type', 'shape' => 'Shape', 'elevation' => 'Elevation', 'motion' => 'Motion', 'icons' => 'Icons', 'buttons' => 'Buttons', 'menus' => 'Menus', 'communication' => 'Communication', 'progress' => 'Progress', 'containment' => 'Containment', 'carousel' => 'Carousel'] as $anchor => $section)
<a href="#{{ $anchor }}" class="rounded-corner-xs hover:text-on-surface focus-ring">{{ $section }}</a> <a href="#{{ $anchor }}" class="rounded-corner-xs hover:text-on-surface focus-ring">{{ $section }}</a>
@endforeach @endforeach
</nav> </nav>
@@ -0,0 +1,87 @@
@php
$examples = [
'Multi-browse' => <<<'BLADE'
<x-carousel label="Shapes" item-width="220">
@foreach ([
['Cookie', 'cookie-9', 'bg-primary-container text-on-primary-container'],
['Clover', 'clover-4', 'bg-tertiary-container text-on-tertiary-container'],
['Burst', 'soft-burst', 'bg-secondary-container text-on-secondary-container'],
['Flower', 'flower', 'bg-primary text-on-primary'],
['Gem', 'gem', 'bg-surface-container-highest text-primary'],
['Puffy', 'puffy', 'bg-tertiary text-on-tertiary'],
['Sunny', 'sunny', 'bg-secondary text-on-secondary'],
['Heart', 'heart', 'bg-inverse-surface text-inverse-primary'],
] as [$name, $shape, $colours])
<x-carousel-item :label="$name">
<div class="grid size-full place-items-center {{ $colours }}">
<x-shape :name="$shape" class="size-36" />
</div>
</x-carousel-item>
@endforeach
</x-carousel>
BLADE,
'Hero, centred' => <<<'BLADE'
<x-carousel layout="hero" centered label="Featured" height="280">
@foreach ([
['Arch', 'arch', 'bg-secondary-container text-on-secondary-container'],
['Pentagon', 'pentagon', 'bg-primary-container text-on-primary-container'],
['Boom', 'soft-boom', 'bg-tertiary-container text-on-tertiary-container'],
['Bun', 'bun', 'bg-surface-container-highest text-secondary'],
['Ghost', 'ghostish', 'bg-primary text-on-primary'],
] as [$name, $shape, $colours])
<x-carousel-item :label="$name">
<div class="grid size-full place-items-center {{ $colours }}">
<x-shape :name="$shape" class="size-48" />
</div>
</x-carousel-item>
@endforeach
</x-carousel>
BLADE,
'Uncontained' => <<<'BLADE'
<x-carousel layout="uncontained" label="Albums" item-width="180" height="180">
@foreach ([
['cookie-6', 'bg-tertiary-container text-on-tertiary-container'],
['diamond', 'bg-primary-container text-on-primary-container'],
['clover-8', 'bg-secondary-container text-on-secondary-container'],
['pixel-circle', 'bg-surface-container-highest text-tertiary'],
['burst', 'bg-secondary text-on-secondary'],
['oval', 'bg-primary text-on-primary'],
] as [$shape, $colours])
<x-carousel-item>
<div class="grid size-full place-items-center {{ $colours }}">
<x-shape :name="$shape" class="size-24" />
</div>
</x-carousel-item>
@endforeach
</x-carousel>
BLADE,
'Full-screen' => <<<'BLADE'
<x-carousel layout="full-screen" label="Wallpapers" height="240" :controls="true">
@foreach ([
['Morning', 'very-sunny', 'bg-linear-to-br from-primary-container to-tertiary-container text-on-primary-container'],
['Noon', 'clam-shell', 'bg-linear-to-br from-secondary-container to-primary-container text-on-secondary-container'],
['Night', 'puffy-diamond', 'bg-linear-to-br from-inverse-surface to-primary text-inverse-primary'],
] as [$name, $shape, $colours])
<x-carousel-item :label="$name">
<div class="grid size-full place-items-center {{ $colours }}">
<x-shape :name="$shape" class="size-40" />
</div>
</x-carousel-item>
@endforeach
</x-carousel>
BLADE,
];
@endphp
<section id="carousel" class="scroll-mt-24 space-y-6">
<h2 class="type-headline-md">Carousel</h2>
<p class="max-w-3xl type-body-md text-on-surface-variant">
<code>&lt;x-carousel&gt;</code> and <code>&lt;x-carousel-item&gt;</code>: items that change size between M3's keylines as they scroll.
Swipe, scroll with Shift and the wheel, or focus a row and use the arrow keys.
</p>
@foreach ($examples as $title => $code)
<x-showcase::example :$title :$code stack />
@endforeach
</section>
+228
View File
@@ -0,0 +1,228 @@
<?php
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Route;
use Livewire\Component;
use Livewire\Livewire;
/**
* A Livewire component whose items only the server changes, to watch a carousel through a morph.
*/
class CarouselMorphProbe extends Component
{
public int $count = 3;
public function add(): void
{
$this->count++;
}
public function render(): string
{
return <<<'BLADE'
<div class="p-4" style="width: 600px">
<x-carousel label="Server" item-width="200">
@foreach (range(1, $count) as $number)
<x-carousel-item wire:key="item-{{ $number }}"><div class="size-full bg-primary-container"></div></x-carousel-item>
@endforeach
</x-carousel>
<button type="button" wire:click="add">Add</button>
</div>
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 <<<JS
(async () => {
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'
<!DOCTYPE html>
<html dir="rtl">
<head>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body class="bg-surface">
<div class="p-4" style="width: 600px">
<x-carousel label="Right to left" item-width="200" controls>
@foreach (range(1, 6) as $number)
<x-carousel-item><div class="size-full bg-primary-container"></div></x-carousel-item>
@endforeach
</x-carousel>
</div>
@livewireScripts
</body>
</html>
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'
<!DOCTYPE html>
<html>
<head>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body class="bg-surface">
<livewire:carousel-morph-probe />
@livewireScripts
</body>
</html>
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');
});
+143
View File
@@ -0,0 +1,143 @@
<?php
it('is a focusable carousel region of 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('tabindex="0"')
->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);
});
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="0"')
->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="16"><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="16"')
->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 a full-screen one a page wide', 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><x-carousel-item>A</x-carousel-item></x-carousel>'))
->toContain('data-material-carousel="full-screen"')
->toContain('--material-carousel-slot: 100%')
->toContain('snap-mandatory')
->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');
});