Files
livewire-material/resources/views/components/carousel.blade.php
T
Andreas Reinhold / reiniandClaude Opus 5 8640d815c8 Keep open menus and rich tooltips open through a Livewire render
<x-menu>, <x-fab-menu> and <x-rich-tooltip> gave their popover an id
that is new with every render, and Livewire's morph matches an element
without a wire:key by its id. So every render of the component around
them swapped the popover for a closed copy: an open menu closed, its
listeners stayed behind on the old element (Escape or a press outside
then left aria-expanded="true" on the trigger and focus unreturned), a
keep-open item's wire:click closed its menu when the response came, and
a rich tooltip went on showing the detached bubble, so it never opened
again. <x-carousel>'s row had the same kind of id: after a render it
scrolled without its listeners, and its items stopped re-masking.

The popover, the bubble and the row now carry a wire:key, so the morph
patches them in place and changes the id and anchor name together with
the trigger's, as it already did for everything else. The key goes
through an attribute bag: Livewire compiles a wire:key written in a
template into the key of the loop iteration around it, which would have
given every child component after the menu in a row the same key.

A morph also removes the menu button's ARIA attributes, which only
script writes. menu.js now writes them again after every morph, so the
button of a menu that stays open, and a FAB menu's close look, still
say it is open, and aria-controls names the popover's new id.

A second press on an open menu's button opened it again, render or not:
the popover closes on the press, and the guard against the click that
follows was timed from the toggle event, which is queued and arrives
after that click. It is timed from beforetoggle now.

Browser tests with Livewire probes in Chromium, Firefox and WebKit, and
a render test for the keys and the keys of the child components after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RHoXZSHc8gGpZjFmA5fPc2
2026-09-13 18:50:14 +02:00

163 lines
7.4 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 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. 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' => 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"
{{ new \Illuminate\View\ComponentAttributeBag(['wire:key' => 'material-carousel']) }}
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-livewire-material::button
icon="chevron_left"
variant="tonal"
:tooltip="__('Previous')"
aria-controls="{{ $scrollerId }}"
x-ref="previous"
x-on:click="previous()"
class="rtl:-scale-x-100"
/>
<x-livewire-material::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>