Put the carousel's tab stop on its items, as M3 asks
Plan step 19, containment.md C-18. The row was the focusable `region` and the items were not focusable at all, which is the thing M3's accessibility page draws a Don't for: "use Tab to place initial focus on the first carousel item", "avoid focusing on the carousel container". Each item is now `tabindex="0"` with the focus ring drawn inside it, the row is out of the tab order, and from a focused item the arrows move one item (moving focus with them), Home and End go to the ends, and Space or Enter opens one that is not fully in view. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
co-authored by
Claude Opus 5
parent
ad37862a9f
commit
9875ba156e
@@ -499,7 +499,7 @@ An M3 bottom sheet, bound like `<x-modal>`: modal by default (scrim, inert page,
|
|||||||
</x-carousel>
|
</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, **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). `<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.
|
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, **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). `<x-carousel-item>`: slot is an `<img>` (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.
|
||||||
|
|
||||||
### `<x-chip>`
|
### `<x-chip>`
|
||||||
|
|
||||||
|
|||||||
+38
-11
@@ -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) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const forward = state.rtl ? 'ArrowLeft' : 'ArrowRight'
|
const forward = state.rtl ? 'ArrowLeft' : 'ArrowRight'
|
||||||
const backward = state.rtl ? 'ArrowRight' : 'ArrowLeft'
|
const backward = state.rtl ? 'ArrowRight' : 'ArrowLeft'
|
||||||
|
|
||||||
const action = {
|
const to = {
|
||||||
[forward]: () => this.next(),
|
[forward]: index + 1,
|
||||||
[backward]: () => this.previous(),
|
[backward]: index - 1,
|
||||||
Home: () => this.scrollToItem(0),
|
Home: 0,
|
||||||
End: () => this.scrollToItem(state.items.length - 1),
|
End: state.items.length - 1,
|
||||||
}[event.key]
|
}[event.key]
|
||||||
|
|
||||||
if (action) {
|
const target = to === undefined ? undefined : state.items[to]
|
||||||
event.preventDefault()
|
|
||||||
action()
|
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. */
|
/** Focus inside an item brings that item into focus, as Compose's bring-into-view does. */
|
||||||
reveal(event) {
|
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)
|
const index = state.items.findIndex((item) => item.element === element)
|
||||||
|
|
||||||
if (index >= 0 && this.isMasked(index)) {
|
if (index >= 0 && this.isMasked(index)) {
|
||||||
|
|||||||
@@ -11,8 +11,11 @@
|
|||||||
for under text on an image; the text itself is `inverse-on-surface`, a role a scheme and a
|
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.
|
contrast profile follow, rather than a literal white.
|
||||||
|
|
||||||
A `group` with `aria-roledescription="slide"`, named "n of m" by the carousel around it
|
A focusable `group` with `aria-roledescription="slide"`, named "n of m" by the carousel
|
||||||
(WAI-ARIA's carousel pattern). The item is laid out at the carousel's large size and masked:
|
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
|
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
|
extra-large corner (28px, CarouselDefaults' item shape) and moved by
|
||||||
`--material-carousel-shift`, both written by resources/js/carousel.js. Without script the
|
`--material-carousel-shift`, both written by resources/js/carousel.js. Without script the
|
||||||
@@ -24,13 +27,14 @@
|
|||||||
|
|
||||||
<div {{ $attributes
|
<div {{ $attributes
|
||||||
->class([
|
->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([
|
->merge([
|
||||||
'role' => 'group',
|
'role' => 'group',
|
||||||
'aria-roledescription' => __('slide'),
|
'aria-roledescription' => __('slide'),
|
||||||
'aria-label' => '[material-carousel-position]',
|
'aria-label' => '[material-carousel-position]',
|
||||||
'data-material-carousel-item' => true,
|
'data-material-carousel-item' => true,
|
||||||
|
'tabindex' => '0',
|
||||||
]) }}>
|
]) }}>
|
||||||
<div
|
<div
|
||||||
data-material-carousel-surface
|
data-material-carousel-surface
|
||||||
|
|||||||
@@ -36,10 +36,12 @@
|
|||||||
frame, content at full size, so items change size between the keylines. Without script
|
frame, content at full size, so items change size between the keylines. Without script
|
||||||
the row still scrolls and snaps, unmasked.
|
the row still scrolls and snaps, unmasked.
|
||||||
|
|
||||||
WAI-ARIA's carousel pattern: the row is a focusable `region` with
|
The row is a `region` with `aria-roledescription="carousel"`, named by `label` ("Carousel"
|
||||||
`aria-roledescription="carousel"`, named by `label` ("Carousel" by default); each item is a
|
by default); each item is a focusable `group` with `aria-roledescription="slide"` named
|
||||||
`group` with `aria-roledescription="slide"` named "n of m". With the row focused the arrow
|
"n of m". M3: "Use Tab to place initial focus on the first carousel item" and "avoid
|
||||||
keys move one item, Home and End to the ends; focus moving into an item, or a press on one
|
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
|
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);
|
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
|
`true` always, `false` never. Under reduced motion they scroll instantly and nothing is
|
||||||
@@ -134,9 +136,8 @@
|
|||||||
role="region"
|
role="region"
|
||||||
aria-roledescription="{{ __('carousel') }}"
|
aria-roledescription="{{ __('carousel') }}"
|
||||||
aria-label="{{ $label }}"
|
aria-label="{{ $label }}"
|
||||||
tabindex="0"
|
|
||||||
@class([
|
@class([
|
||||||
'focus-ring flex gap-2 overflow-x-auto overflow-y-hidden overscroll-x-contain',
|
'flex gap-2 overflow-x-auto overflow-y-hidden overscroll-x-contain',
|
||||||
'[scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
|
'[scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
|
||||||
'h-(--material-carousel-height)' => $layout === 'full-screen',
|
'h-(--material-carousel-height)' => $layout === 'full-screen',
|
||||||
'h-[calc(var(--material-carousel-height)+1rem)] py-2' => $layout !== 'full-screen',
|
'h-[calc(var(--material-carousel-height)+1rem)] py-2' => $layout !== 'full-screen',
|
||||||
|
|||||||
@@ -78,7 +78,7 @@
|
|||||||
|
|
||||||
<p class="max-w-3xl type-body-md text-on-surface-variant">
|
<p class="max-w-3xl type-body-md text-on-surface-variant">
|
||||||
<code><x-carousel></code> and <code><x-carousel-item></code>: items that change size between M3's keylines as they scroll.
|
<code><x-carousel></code> and <code><x-carousel-item></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.
|
Swipe, scroll with Shift and the wheel, or tab to an item and use the arrow keys.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@foreach ($examples as $title => $code)
|
@foreach ($examples as $title => $code)
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ function onCarousel(int $index, string $body, string $scope = '#carousel'): stri
|
|||||||
JS;
|
JS;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FIRST_SCROLLER = '#carousel [role="region"] >> nth=0';
|
const FIRST_ITEM = '#carousel [data-material-carousel-item] >> nth=0';
|
||||||
|
|
||||||
function carouselShowcase(array $options = [])
|
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 () {
|
it('moves one item with the arrow keys, and to the ends with Home and End', function () {
|
||||||
$page = carouselShowcase();
|
$page = carouselShowcase();
|
||||||
|
|
||||||
$page->keys(FIRST_SCROLLER, 'ArrowRight')
|
// M3 puts the tab stop on the item, so the keys are the focused item's and each one moves
|
||||||
->assertScript(onCarousel(0, 'return at(1)'));
|
// 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)'));
|
->assertScript(onCarousel(0, 'return at(2)'));
|
||||||
|
|
||||||
$page->keys(FIRST_SCROLLER, 'ArrowLeft')
|
$page->keys(':focus', 'ArrowLeft')
|
||||||
->assertScript(onCarousel(0, 'return at(1)'));
|
->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'));
|
->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)'));
|
->assertScript(onCarousel(0, 'return at(0)'));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -183,7 +185,7 @@ it('mirrors in a right-to-left page', function () {
|
|||||||
->assertNoJavaScriptErrors()
|
->assertNoJavaScriptErrors()
|
||||||
->assertScript(onCarousel(0, 'return size > 0 && at(0) && inset(0) < 0.5 && inset(items.length - 1) > 0.5', 'body'));
|
->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'
|
->assertScript(onCarousel(0, <<<'JS'
|
||||||
return scroller.scrollLeft < -1 && at(1) && inset(0) > 0.5 && inset(1) < 0.5
|
return scroller.scrollLeft < -1 && at(1) && inset(0) > 0.5 && inset(1) < 0.5
|
||||||
&& parseFloat(surface(0).style.getPropertyValue('--material-carousel-shift')) < 0
|
&& 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"]')
|
$page->click('button[aria-label="Next"]')
|
||||||
->assertScript(onCarousel(0, 'return at(2)', 'body'));
|
->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'));
|
->assertScript(onCarousel(0, 'return at(1)', 'body'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
it('is a focusable carousel region of slides named n of m', function () {
|
it('is a carousel region of focusable slides named n of m', function () {
|
||||||
$html = (string) $this->blade(<<<'BLADE'
|
$html = (string) $this->blade(<<<'BLADE'
|
||||||
<x-carousel label="Recent uploads">
|
<x-carousel label="Recent uploads">
|
||||||
<x-carousel-item><img src="/a.jpg" alt="A lake" /></x-carousel-item>
|
<x-carousel-item><img src="/a.jpg" alt="A lake" /></x-carousel-item>
|
||||||
@@ -17,14 +17,16 @@ it('is a focusable carousel region of slides named n of m', function () {
|
|||||||
->toContain('role="region"')
|
->toContain('role="region"')
|
||||||
->toContain('aria-roledescription="carousel"')
|
->toContain('aria-roledescription="carousel"')
|
||||||
->toContain('aria-label="Recent uploads"')
|
->toContain('aria-label="Recent uploads"')
|
||||||
->toContain('tabindex="0"')
|
|
||||||
->toContain('aria-roledescription="slide"')
|
->toContain('aria-roledescription="slide"')
|
||||||
->toContain('aria-label="1 of 3"')
|
->toContain('aria-label="1 of 3"')
|
||||||
->toContain('aria-label="2 of 3"')
|
->toContain('aria-label="2 of 3"')
|
||||||
->toContain('aria-label="3 of 3"')
|
->toContain('aria-label="3 of 3"')
|
||||||
->toContain('<img src="/a.jpg" alt="A lake" />')
|
->toContain('<img src="/a.jpg" alt="A lake" />')
|
||||||
->not->toContain('[material-carousel-position]')
|
->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('/<div\s+x-ref="scroller"(?:(?!tabindex)[^>])*>/');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('counts the slides of a carousel inside a slide on their own', function () {
|
it('counts the slides of a carousel inside a slide on their own', function () {
|
||||||
|
|||||||
Reference in New Issue
Block a user