diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 8f408372..b999744c 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -499,7 +499,7 @@ An M3 bottom sheet, bound like ``: modal by default (scrim, inert page, ``` -A row of items that change size between M3's keylines as it scrolls (native scroll snap; items are masked, content keeps its size). ``: `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, **16** — M3's specs table; leading only for `uncontained`, none for `full-screen`), `centered` (hero), `label` (the region's name, "Carousel" by default), `controls` (previous/next buttons: default fine pointers only, `true` always, `false` never). ``: slot is an `` (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. +A row of items that change size between M3's keylines as it scrolls (native scroll snap; items are masked, content keeps its size). ``: `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, **16** — M3's specs table; leading only for `uncontained`, none for `full-screen`), `centered` (hero), `label` (the region's name, "Carousel" by default), `controls` (previous/next buttons: default fine pointers only, `true` always, `false` never). ``: slot is an `` (fills and crops) or an element sized `size-full`; `label` overlays a line of text. A `region` of `slide` groups named "n of m", each item a tab stop and the row itself not one, as M3 asks; from a focused item the arrow keys move one item, Home/End go to the ends and Space/Enter opens one that is not fully in view. Works after a Livewire morph, in RTL and under reduced motion. Give items a `wire:key` in a loop. ### `` diff --git a/resources/js/carousel.js b/resources/js/carousel.js index afaaaa60..dc7086b8 100644 --- a/resources/js/carousel.js +++ b/resources/js/carousel.js @@ -1067,31 +1067,58 @@ document.addEventListener('alpine:init', () => { }) }, - /** The row's own keys, while the row itself has focus: a control inside an item keeps its keys. */ + /** + * The items' keys, while an item itself has focus — M3: "Tab or Arrows moves to the + * previous or next carousel item; Space or Enter activates the focused carousel item". + * A control inside an item keeps its own keys, because focus is then on the control + * and not on the item. + */ navigate(event) { - if (event.target !== this.$refs.scroller || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) { + if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) { + return + } + + const index = state.items.findIndex((item) => item.element === event.target) + + if (index < 0) { + return + } + + if (event.key === 'Enter' || event.key === ' ') { + if (this.isMasked(index)) { + event.preventDefault() + this.scrollToItem(index) + } + return } const forward = state.rtl ? 'ArrowLeft' : 'ArrowRight' const backward = state.rtl ? 'ArrowRight' : 'ArrowLeft' - const action = { - [forward]: () => this.next(), - [backward]: () => this.previous(), - Home: () => this.scrollToItem(0), - End: () => this.scrollToItem(state.items.length - 1), + const to = { + [forward]: index + 1, + [backward]: index - 1, + Home: 0, + End: state.items.length - 1, }[event.key] - if (action) { - event.preventDefault() - action() + const target = to === undefined ? undefined : state.items[to] + + if (!target) { + return } + + // The browser would jump the row to the newly focused item; the smooth scroll to + // its snap position is this script's. + event.preventDefault() + target.element.focus({ preventScroll: true }) + this.scrollToItem(to) }, /** Focus inside an item brings that item into focus, as Compose's bring-into-view does. */ reveal(event) { - const element = event.target === this.$refs.scroller ? null : event.target.closest(ITEM) + const element = event.target.closest?.(ITEM) const index = state.items.findIndex((item) => item.element === element) if (index >= 0 && this.isMasked(index)) { diff --git a/resources/views/components/carousel-item.blade.php b/resources/views/components/carousel-item.blade.php index f4a530e9..eefe0e0c 100644 --- a/resources/views/components/carousel-item.blade.php +++ b/resources/views/components/carousel-item.blade.php @@ -11,8 +11,11 @@ for under text on an image; the text itself is `inverse-on-surface`, a role a scheme and a contrast profile follow, rather than a literal white. - 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: + A focusable `group` with `aria-roledescription="slide"`, named "n of m" by the carousel + around it. M3 puts the tab stop on the item, not on the row: Tab reaches the first item, the + arrow keys move between them and Space or Enter opens the focused one + (docs/reference/m3/components-actions-communication-containment.md § Carousel → + Accessibility). 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 @@ -24,13 +27,14 @@
class([ - 'relative h-full w-(--material-carousel-slot) max-w-full shrink-0 snap-start snap-always', + 'focus-ring relative h-full w-(--material-carousel-slot) max-w-full shrink-0 snap-start snap-always rounded-corner-xl focus-visible:-outline-offset-3', ]) ->merge([ 'role' => 'group', 'aria-roledescription' => __('slide'), 'aria-label' => '[material-carousel-position]', 'data-material-carousel-item' => true, + 'tabindex' => '0', ]) }}>
$layout === 'full-screen', 'h-[calc(var(--material-carousel-height)+1rem)] py-2' => $layout !== 'full-screen', diff --git a/resources/views/showcase/sections/carousel.blade.php b/resources/views/showcase/sections/carousel.blade.php index fb8e6d6e..bd87e761 100644 --- a/resources/views/showcase/sections/carousel.blade.php +++ b/resources/views/showcase/sections/carousel.blade.php @@ -78,7 +78,7 @@

<x-carousel> and <x-carousel-item>: 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. + Swipe, scroll with Shift and the wheel, or tab to an item and use the arrow keys.

@foreach ($examples as $title => $code) diff --git a/tests/Browser/CarouselTest.php b/tests/Browser/CarouselTest.php index 2ea928c1..ddf8022d 100644 --- a/tests/Browser/CarouselTest.php +++ b/tests/Browser/CarouselTest.php @@ -60,7 +60,7 @@ function onCarousel(int $index, string $body, string $scope = '#carousel'): stri JS; } -const FIRST_SCROLLER = '#carousel [role="region"] >> nth=0'; +const FIRST_ITEM = '#carousel [data-material-carousel-item] >> nth=0'; function carouselShowcase(array $options = []) { @@ -110,19 +110,21 @@ it('moves one item with the next and previous buttons', function () { 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)')); + // M3 puts the tab stop on the item, so the keys are the focused item's and each one moves + // focus to the item it scrolls to. + $page->keys(FIRST_ITEM, 'ArrowRight') + ->assertScript(onCarousel(0, 'return at(1) && document.activeElement === items[1]')); - $page->keys(FIRST_SCROLLER, 'ArrowRight') + $page->keys(':focus', 'ArrowRight') ->assertScript(onCarousel(0, 'return at(2)')); - $page->keys(FIRST_SCROLLER, 'ArrowLeft') + $page->keys(':focus', 'ArrowLeft') ->assertScript(onCarousel(0, 'return at(1)')); - $page->keys(FIRST_SCROLLER, 'End') + $page->keys(':focus', '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') + $page->keys(':focus', 'Home') ->assertScript(onCarousel(0, 'return at(0)')); }); @@ -183,7 +185,7 @@ it('mirrors in a right-to-left page', function () { ->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') + $page->keys('[data-material-carousel-item] >> nth=0', '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 @@ -192,7 +194,7 @@ it('mirrors in a right-to-left page', function () { $page->click('button[aria-label="Next"]') ->assertScript(onCarousel(0, 'return at(2)', 'body')); - $page->keys('[role="region"]', 'ArrowRight') + $page->keys('[data-material-carousel-item] >> nth=2', 'ArrowRight') ->assertScript(onCarousel(0, 'return at(1)', 'body')); }); diff --git a/tests/Feature/Components/CarouselTest.php b/tests/Feature/Components/CarouselTest.php index d9b31c10..8fba442f 100644 --- a/tests/Feature/Components/CarouselTest.php +++ b/tests/Feature/Components/CarouselTest.php @@ -1,6 +1,6 @@ blade(<<<'BLADE' A lake @@ -17,14 +17,16 @@ it('is a focusable carousel region of slides named n of m', function () { ->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('A lake') ->not->toContain('[material-carousel-position]') - ->and(substr_count($html, 'role="group"'))->toBe(3); + ->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('/])*>/'); }); it('counts the slides of a carousel inside a slide on their own', function () {