Files
Andreas Reinhold / reiniandClaude Sonnet 5 a149970dd0 Rewrite the carousel without Tailwind
Plan step 36 (containment group): <x-carousel>'s and <x-carousel-item>'s
class lists move into resources/css/components/carousel.css and
carousel-item.css, keyed on data-md-carousel (its value is the layout)
and the parts' data-md-carousel-* hooks (-probe, -scroller, -controls
with "auto"/"always", -previous/-next, -item, -surface, -content,
-label/-label-text). Every selector uses a `>` combinator rather than a
bare descendant one, because a carousel item can itself hold a nested
carousel whose own root would otherwise match its parent's layout
rules too (list.css already solves the same problem for segmented
list rows).

Behaviour is unchanged: resources/js/carousel.js (the keyline maths,
C-05's reduced-motion fix, C-11's vertical full-screen layout, C-12's
padding, C-18's item-as-tab-stop, the multi-aspect layout) is touched
only where it reads or writes the renamed hooks and dataset
properties; every inline custom property it writes
(--material-carousel-*) is untouched. The item renders the shared
md-focus-ring class (foundation/interaction.css) instead of a
hand-rolled ring, refined to an inset offset since an outward one
would draw under the neighbouring item. The previous/next buttons
mirror whole in RTL from carousel.css rather than through
<x-icon mirror-rtl>, which <x-button icon> has no prop to reach (a
component outside this batch); the technique matches how the
Tailwind-era markup already mirrored the whole button. The overlay
label's literal white ink over the scrim (C-25) is kept, with the
same reasoning as before.

Hooks renamed data-material-carousel(-item/-surface/-content/-label)
-> data-md-carousel(-item/-surface/-content/-label), data-padding(-end)
-> data-md-padding(-end), data-centered -> data-md-centered, updated in
the same commit: resources/js/carousel.js, tests/Feature/Components/
CarouselTest.php (rewritten on data-md-* and ComponentStylesheet) and
tests/Browser/CarouselTest.php. Browser tests owed by
docs/plans/material-3-browser-tests.md, added but not run: the
multi-aspect carousel's previous/next, arrow keys, Home and End
(scoped by data-md-carousel="multi-aspect" rather than a position in
the showcase, so reordering its examples cannot silently mis-target
the wrong carousel); a reduced-motion click on an item cut off only by
the row's own edge, which documents rather than fixes a real gap —
isMasked()'s inset check is always false once C-05 zeroes every
item's inset, so the click-to-reveal affordance does not fire there
(found by the Chromium baseline, step 32; fixing it is outside a hook
rename).

Imported from the Containment block of components.css.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 21:44:38 +02:00

199 lines
11 KiB
PHP

{{-- 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.
- `multi-aspect`: M3's **uncontained multi-aspect-ratio** layout, added November 2025
"same as Uncontained but items vary in width, ranging from a 9:16 minimum to a 16:9 maximum
aspect ratio", and "only use this layout if the items have various widths". Each
`<x-carousel-item aspect="16/9">` keeps its own ratio at the row's fixed `height`, so the
widths follow from the art rather than from a keyline. Uncontained scrolling, 16px of
leading padding, 8px between items, the extra-large corner.
- `full-screen`: one edge-to-edge item at a time, scrolled **vertically** — M3: "this layout
works best with content that is taller than it is wide, and scrolls vertically. It only
works in portrait orientation in compact and medium breakpoints. Don't use this layout in
landscape orientation." Items have no corner and no mask, 16px apart, no end padding, and
the row is never wider than the 840px medium window it is meant for. `item-width` and
`padding` do not apply; `height` is the height of each item, so give it the room a
portrait image wants.
The keyline machinery fits every layout but `multi-aspect`: an Arrangement is a count of
large, medium and small items of **one** size each, and the snap positions and masks it
produces all follow from that one size, so a row of items of different widths has no
arrangement to fit. That layout is therefore a plain flex row — the browser scrolls it and
each item is laid out at its own aspect ratio, unmasked — and only the parts of
resources/js/carousel.js that need no Strategy work on it: the previous and next buttons, the
arrow keys, Home and End, and bringing a clipped item into view, all from resting positions
measured off the DOM rather than computed from keylines.
`item-width` takes pixels or any CSS length. `height` is the items' height (205px, Compose's
sample). `padding` is Compose's `contentPadding` in pixels: the first and last items rest
that far in from the edges while items in between scroll to them. M3's specs table gives
every layout 16dp of it — `uncontained` at the leading edge only, `full-screen` none — so
that is the default, with the 8dp above and below the row that goes with it. 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.
The row is a `region` with `aria-roledescription="carousel"`, named by `label` ("Carousel"
by default); each item is a focusable `group` with `aria-roledescription="slide"` named
"n of m". M3: "Use Tab to place initial focus on the first carousel item" and "avoid
focusing on the carousel container", so the items are the tab stops and the row is not one.
From a focused item the arrow keys move one item, Home and End go to the ends, and Space or
Enter opens an item that is not fully open; 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 nothing is
masked: every item stays at its large size, which is M3's rule for a carousel under reduced
motion (docs/reference/m3/styles.md § Motion → Accessibility requirements). 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. The row's id, which the buttons control, is new with every render; the row
carries a `wire:key` (see `<x-menu>`), so a morph patches it in place — its scroll position
and listeners kept — rather than swapping in a copy.
Every part below is drawn by resources/css/components/carousel.css and carousel-item.css,
keyed on `data-md-carousel` (its value is the layout) and the parts' own `data-md-carousel-*`
hooks; the script keeps writing its masks and sizes as inline custom properties
(`--material-carousel-*`), which those stylesheets read. The previous/next buttons carry no
class of their own — `<x-button>` already draws its interaction states — and mirror in RTL by
scaling the whole button from carousel.css, since `icon` has no prop to reach `mirror-rtl`. --}}
@props([
'layout' => 'multi-browse',
'itemWidth' => null,
'height' => null,
'padding' => 16,
'centered' => false,
'label' => null,
'controls' => null,
])
@php
$layout = in_array($layout, ['multi-browse', 'hero', 'uncontained', 'multi-aspect', 'full-screen'], true) ? $layout : 'multi-browse';
$vertical = $layout === 'full-screen';
$multiAspect = $layout === 'multi-aspect';
$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),
'multi-aspect', 'full-screen' => null,
};
// The full-screen item fills the row and a multi-aspect one is as wide as its own ratio makes
// it: neither takes a slot.
$slotWidth = match (true) {
$vertical, $multiAspect => null,
$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,
);
// The specs table's leading and trailing padding: 16dp for multi-browse and hero, leading
// only for uncontained, none for full-screen, which is edge to edge.
$padding = max(0, (float) $padding);
$paddingStart = $vertical ? 0.0 : $padding;
$paddingEnd = $vertical || $layout === 'uncontained' || $multiAspect ? 0.0 : $padding;
$attributes = $attributes->merge(array_filter([
'data-md-carousel' => $layout,
'data-md-centered' => $layout === 'hero' && $centered ? true : null,
'data-md-padding' => (string) $paddingStart,
'data-md-padding-end' => (string) $paddingEnd,
'style' => implode('; ', array_filter([
$preferredWidth ? "--material-carousel-item-width: {$preferredWidth}" : null,
$slotWidth ? "--material-carousel-slot: {$slotWidth}" : null,
// Without keylines there is nothing to anchor the first item in from the edge, so
// the multi-aspect row carries the specs table's leading padding itself.
$multiAspect ? "--material-carousel-pad: {$paddingStart}px" : null,
'--material-carousel-height: '.($cssLength($height) ?? '205px'),
])),
], fn ($value): bool => $value !== null));
@endphp
<div x-data="materialCarousel" {{ $attributes }}>
@if ($preferredWidth)
<div x-ref="probe" data-md-carousel-probe aria-hidden="true"></div>
@endif
<div
x-ref="scroller"
{{ new \Illuminate\View\ComponentAttributeBag(['wire:key' => 'material-carousel']) }}
id="{{ $scrollerId }}"
role="region"
aria-roledescription="{{ __('carousel') }}"
aria-label="{{ $label }}"
data-md-carousel-scroller
>
{!! $slides !!}
</div>
@if ($controls !== false)
<div data-md-carousel-controls="{{ $controls === true ? 'always' : 'auto' }}">
<x-livewire-material::button
:icon="$vertical ? 'keyboard_arrow_up' : 'chevron_left'"
variant="tonal"
:tooltip="__('Previous')"
aria-controls="{{ $scrollerId }}"
x-ref="previous"
x-on:click="previous()"
data-md-carousel-previous
/>
<x-livewire-material::button
:icon="$vertical ? 'keyboard_arrow_down' : 'chevron_right'"
variant="tonal"
:tooltip="__('Next')"
aria-controls="{{ $scrollerId }}"
x-ref="next"
x-on:click="next()"
data-md-carousel-next
/>
</div>
@endif
</div>