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
184 lines
9.2 KiB
PHP
184 lines
9.2 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.
|
|
- `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.
|
|
`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. --}}
|
|
|
|
@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', 'full-screen'], true) ? $layout : 'multi-browse';
|
|
$vertical = $layout === 'full-screen';
|
|
$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,
|
|
};
|
|
// The full-screen item is `h-full w-full`: it takes the row, not a slot.
|
|
$slotWidth = match (true) {
|
|
$vertical => 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' ? 0.0 : $padding;
|
|
|
|
$attributes = $attributes
|
|
->class('relative')
|
|
->merge(array_filter([
|
|
'data-material-carousel' => $layout,
|
|
'data-centered' => $layout === 'hero' && $centered ? true : null,
|
|
'data-padding' => (string) $paddingStart,
|
|
'data-padding-end' => (string) $paddingEnd,
|
|
'style' => implode('; ', array_filter([
|
|
$preferredWidth ? "--material-carousel-item-width: {$preferredWidth}" : null,
|
|
$slotWidth ? "--material-carousel-slot: {$slotWidth}" : null,
|
|
'--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"
|
|
{{ new \Illuminate\View\ComponentAttributeBag(['wire:key' => 'material-carousel']) }}
|
|
id="{{ $scrollerId }}"
|
|
role="region"
|
|
aria-roledescription="{{ __('carousel') }}"
|
|
aria-label="{{ $label }}"
|
|
@class([
|
|
'flex',
|
|
'[scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
|
|
'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' => $vertical,
|
|
'h-[calc(var(--material-carousel-height)+1rem)] gap-2 overflow-x-auto overflow-y-hidden overscroll-x-contain py-2' => ! $vertical,
|
|
'snap-x snap-mandatory' => ! $vertical && $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-livewire-material::button
|
|
:icon="$vertical ? 'keyboard_arrow_up' : 'chevron_left'"
|
|
variant="tonal"
|
|
:tooltip="__('Previous')"
|
|
aria-controls="{{ $scrollerId }}"
|
|
x-ref="previous"
|
|
x-on:click="previous()"
|
|
:class="$vertical ? '' : 'rtl:-scale-x-100'"
|
|
/>
|
|
<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()"
|
|
:class="$vertical ? '' : 'rtl:-scale-x-100'"
|
|
/>
|
|
</div>
|
|
@endif
|
|
</div>
|