Give a card per-state elevation and M3's two keyboard models

Plan step 19, containment.md C-13, C-23 and the directly actionable card
of C-13/C-18. A card had one elevation and never changed it, and its
hover replaced the elevated card's shadow with the state layer; it now
names its variant in `data-card` and moves a step on hover (elevated
1 to 2, filled and outlined 0 to 1) with the layer over it. The 12 to 16
corner morph on hover is gone: M3 gives a card one shape and lists shape
morph for buttons, FABs and list items only. New `actionable` prop for
M3's directly actionable card — the card is the one tab stop, with
`role="button"` (or `link`), its `title` as the name, and Enter or Space
reaching the opener from resources/js/list-rows.js.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 06:07:14 +02:00
co-authored by Claude Opus 5
parent 26a9230a48
commit 675cfc806a
7 changed files with 131 additions and 18 deletions
@@ -420,7 +420,7 @@ The `illustration` slot draws the application's own artwork in place of the shap
`variant`: `filled` (default, surface-container-highest), `elevated`, `outlined`; medium corner. Props `title`, `subtitle`, `separator`; slots `figure` (full-bleed media), `menu` (top-end), `actions` (end-aligned). Do not pass `bg-*`; use `variant`.
A card or list item that opens something is a **row**: `data-list-row` on it and `data-list-open` on its one opener (the title link or a button). A press anywhere else on the row reaches the opener; its other controls keep their own presses. Never wrap a card in `<a>` or use a stretched link.
A card or list item that opens something is a **row**: `data-list-row` on it and `data-list-open` on its one opener (the title link or a button). A press anywhere else on the row reaches the opener; its other controls keep their own presses. Never wrap a card in `<a>` or use a stretched link. A row answers with the state layer and one step of elevation; its corner does not move.
```blade
<x-card variant="outlined" data-list-row wire:key="share-{{ $share->id }}">
@@ -429,6 +429,8 @@ A card or list item that opens something is a **row**: `data-list-row` on it and
</x-card>
```
That is M3's *non-actionable card with actionable elements*: Tab walks the controls inside. For M3's *directly actionable card*, where Tab lands on the card and then moves to the next card, add `actionable` (and `role="link"` where it goes somewhere) and put `tabindex="-1"` on the opener — the card is then the tab stop, named by its `title`, and Enter or Space on it reaches the opener while the card's other actions follow it.
### `<x-list>`, `<x-list-item>`
`<x-list>`: `label`, `dividers`, `segmented` (M3 Expressive: separate tiles 2px apart). `<x-list-item>`: `title` (or slot), `overline`, `description`, leading `icon` / `avatar` (image URL or initials) / `image` / `leading` slot, trailing `trailing` text / `icon-right` / `end` slot, `link` (the whole item becomes a row that opens it), `selected`, `disabled`. One-, two- and three-line heights follow from the content, and a three-line item top-aligns as M3 asks. Its icons are 24px, 20px in a `segmented` list.
+20 -5
View File
@@ -71,17 +71,27 @@
color: var(--md-sys-color-on-secondary-container);
}
/* A card that opens answers with its container one tone up and its corner opening a step. */
/*
* A card that opens answers with M3's state layer and one step of elevation, both written as one
* box-shadow because the elevated card's rest level is a `shadow-elevation-1` utility that only
* an unlayered rule can replace. Per-state elevation from the token files (the specs page's
* Hovered / Focused / Pressed rows): elevated 1 → 2 hovered → 1 focused and pressed; filled and
* outlined 0 → 1 hovered → 0. Its corner does not move: M3 gives a card one shape and specifies
* shape morph for buttons, FABs and list items, never for cards.
*/
[data-card][data-list-row] {
transition-property: border-radius, background-color, box-shadow;
transition-property: background-color, box-shadow;
transition-duration: var(--md-sys-motion-spatial-default-duration);
transition-timing-function: var(--md-sys-motion-spatial-default);
}
@media (hover: hover) {
[data-card][data-list-row]:hover:not(:has(:is(a, button, input, select, textarea, label, summary):not([data-list-open]):hover)) {
border-radius: var(--md-sys-shape-corner-lg);
box-shadow: inset 0 0 0 100vmax color-mix(in srgb, var(--md-sys-color-on-surface) 8%, transparent);
box-shadow: var(--md-sys-elevation-1), inset 0 0 0 100vmax color-mix(in srgb, var(--md-sys-color-on-surface) 8%, transparent);
}
[data-card='elevated'][data-list-row]:hover:not(:has(:is(a, button, input, select, textarea, label, summary):not([data-list-open]):hover)) {
box-shadow: var(--md-sys-elevation-2), inset 0 0 0 100vmax color-mix(in srgb, var(--md-sys-color-on-surface) 8%, transparent);
}
}
@@ -89,7 +99,12 @@
box-shadow: inset 0 0 0 100vmax color-mix(in srgb, var(--md-sys-color-on-surface) 10%, transparent);
}
[data-card][data-list-row]:has([data-list-open]:focus-visible) {
[data-card='elevated'][data-list-row]:active:not(:has(:is(a, button, input, select, textarea, label, summary):not([data-list-open]):active)) {
box-shadow: var(--md-sys-elevation-1), inset 0 0 0 100vmax color-mix(in srgb, var(--md-sys-color-on-surface) 10%, transparent);
}
/* Focus is on the card itself when the card is the tab stop (`<x-card actionable>`). */
[data-card][data-list-row]:is(:focus-visible, :has([data-list-open]:focus-visible)) {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
+26
View File
@@ -11,6 +11,11 @@
* Not a stretched link (`::after { inset: 0 }`): Safari makes no containing block of a <tr>, so in
* a table every overlay would cover the whole table; and not one <button> around the row, which
* could hold no other buttons. The listeners sit on `document` and are added once.
*
* A row that is also `data-list-actionable` is M3's **directly actionable card** (`<x-card
* actionable>`): the card is the one tab stop, so Enter and Space on the card reach the opener,
* and the card's other actions follow it in the tab order with their own keys. The opener itself
* carries `tabindex="-1"`, which the card's caller writes.
*/
/** Anything that answers a click itself — the row's own controls, and the opener. */
@@ -99,6 +104,27 @@ document.addEventListener('click', (event) => {
opener.click()
})
// A directly actionable card is a control, so it answers Enter and Space the way a
// button does — by reaching the opener, which owns the href or the wire:click.
document.addEventListener('keydown', (event) => {
if (event.key !== 'Enter' && event.key !== ' ') {
return
}
if (event.defaultPrevented || !(event.target instanceof Element) || !event.target.matches('[data-list-actionable]')) {
return
}
const opener = event.target.querySelector('[data-list-open]')
if (!opener) {
return
}
event.preventDefault()
opener.click()
})
// The middle button is not a `click`, and on a row that goes somewhere it means
// what it means on a link.
document.addEventListener('auxclick', (event) => {
+29 -6
View File
@@ -7,11 +7,24 @@
`separator` (a divider under the header), and the `menu` (top-end, beside the title),
`figure` (full-bleed media on top) and `actions` (end-aligned, under the content) slots.
A card that opens something is a row: give it `data-list-row` and one `data-list-open`
control inside (the title link or a button), and a press anywhere on it reaches that control
while its other buttons keep their own (resources/js/list-rows.js). It answers with a state
layer and its corner opening a step. Never a stretched link, and never a whole-card `<a>`
around buttons.
M3 draws two kinds of card a person can press, and they differ in the keyboard: on a
**non-actionable card with actionable elements** Tab moves through each control inside before
the next card; on a **directly actionable card** Tab moves to the card, then to the next card
(docs/reference/m3/components-actions-communication-containment.md § Cards Accessibility).
Both are a row: give the card `data-list-row` and one `data-list-open` control inside (the
title link or a button), and a press anywhere on it reaches that control while its other
buttons keep their own (resources/js/list-rows.js). That is the first kind the opener is
the tab stop, the accessible name and the owner of the `href` or the `wire:click`.
`actionable` makes it the second: the card takes `tabindex="0"` and `role="button"` (pass
`role="link"` where it goes somewhere), is named by its `title`, and Enter or Space on it
reaches the opener. Put `tabindex="-1"` on that opener so the card is the one stop, and leave
the card's other actions as they are — they follow it in the tab order.
A row answers with M3's state layer and one step of elevation (elevated 1 2 hovered,
filled and outlined 0 1); its corner does not move, because M3 gives a card one shape.
Never a stretched link, and never a whole-card `<a>` around buttons.
Do not pass a `bg-*` class to change its fill it races the card's own in Tailwind's emit
order; use `variant`, or colour a wrapper inside. --}}
@@ -21,14 +34,24 @@
'subtitle' => null,
'variant' => 'filled',
'separator' => false,
'actionable' => false,
])
@php
$variant = in_array($variant, ['filled', 'elevated', 'outlined'], true) ? $variant : 'filled';
$role = $actionable ? ($attributes->get('role') ?: 'button') : null;
$attributes = $actionable ? $attributes->except('role') : $attributes;
@endphp
<div
data-card
data-card="{{ $variant }}"
@if ($actionable)
data-list-row
data-list-actionable
tabindex="0"
role="{{ $role }}"
@if (filled($title)) aria-label="{{ $title }}" @endif
@endif
{{ $attributes->class([
'relative flex flex-col overflow-hidden rounded-corner-md text-on-surface',
'bg-surface-container-highest' => $variant === 'filled',
@@ -26,6 +26,15 @@
</x-slot:actions>
</x-card>
BLADE,
'A directly actionable card: the card is the tab stop' => <<<'BLADE'
<x-card variant="elevated" actionable role="link" title="contract.pdf" class="w-full max-w-sm">
<a href="#containment" data-list-open tabindex="-1" class="type-title-md">contract.pdf</a>
<p class="mt-1 text-on-surface-variant">1.2 MB · downloaded twice</p>
<x-slot:actions>
<x-button label="Copy link" icon="content_copy" x-on:click="materialToast('Link copied', { type: 'success' })" />
</x-slot:actions>
</x-card>
BLADE,
'Lists' => <<<'BLADE'
<div class="grid w-full gap-6 medium:grid-cols-2">
<x-list dividers label="Files">
+19 -3
View File
@@ -214,12 +214,28 @@ it('dismisses a bottom sheet dragged down past a quarter of its height', functio
it('opens a row\'s opener from a press anywhere on the row, but not from its own buttons', function () {
$page = containment();
$page->script("window.__opened = 0; document.querySelector('#containment [data-card][data-list-row] [data-list-open]').addEventListener('click', (event) => { event.preventDefault(); window.__opened++ })");
$row = '#containment [data-card][data-list-row]:not([data-list-actionable])';
$page->click('#containment [data-card][data-list-row] p')
$page->script("window.__opened = 0; document.querySelector('{$row} [data-list-open]').addEventListener('click', (event) => { event.preventDefault(); window.__opened++ })");
$page->click("{$row} p")
->assertScript('window.__opened === 1');
$page->click('#containment [data-card][data-list-row] button:has-text("Copy link")')
$page->click("{$row} button:has-text(\"Copy link\")")
->assertScript('window.__opened === 1');
});
it('makes a directly actionable card the one tab stop, answering Enter', function () {
$card = '#containment [data-card][data-list-actionable]';
$page = containment();
$page->script("window.__opened = 0; document.querySelector('{$card} [data-list-open]').addEventListener('click', (event) => { event.preventDefault(); window.__opened++ })");
$page->script("document.querySelector('{$card}').focus()")
->assertScript("document.activeElement.matches('{$card}')");
$page->keys($card, 'Enter')
->assertScript('window.__opened === 1');
});
+25 -3
View File
@@ -1,7 +1,10 @@
<?php
it('draws M3\'s three cards', function (string $variant, string $classes) {
expect((string) $this->blade("<x-card variant=\"{$variant}\">Body</x-card>"))->toContain($classes)->toContain('rounded-corner-md')->toContain('data-card');
it('draws M3\'s three cards, each naming its variant for its per-state elevation', function (string $variant, string $classes) {
expect((string) $this->blade("<x-card variant=\"{$variant}\">Body</x-card>"))
->toContain($classes)
->toContain('rounded-corner-md')
->toContain("data-card=\"{$variant}\"");
})->with([
'filled' => ['filled', 'bg-surface-container-highest'],
'elevated' => ['elevated', 'bg-surface-container-low shadow-elevation-1'],
@@ -9,7 +12,9 @@ it('draws M3\'s three cards', function (string $variant, string $classes) {
]);
it('is filled unless it knows the variant', function () {
expect((string) $this->blade('<x-card variant="glass">Body</x-card>'))->toContain('bg-surface-container-highest');
expect((string) $this->blade('<x-card variant="glass">Body</x-card>'))
->toContain('bg-surface-container-highest')
->toContain('data-card="filled"');
});
it('lays out a header, menu, media, body and actions', function () {
@@ -38,3 +43,20 @@ it('passes row attributes through', function () {
->toContain('data-list-row')
->toContain('data-list-open');
});
it('makes a directly actionable card the one tab stop, named by its title', function () {
$html = (string) $this->blade('<x-card actionable title="holiday-photos.zip"><a href="/s/1" data-list-open tabindex="-1">Open</a></x-card>');
expect($html)
->toContain('data-list-row')
->toContain('data-list-actionable')
->toContain('tabindex="0"')
->toContain('role="button"')
->toContain('aria-label="holiday-photos.zip"')
->and((string) $this->blade('<x-card actionable role="link" title="Share">Body</x-card>'))
->toContain('role="link"')
->and(substr_count((string) $this->blade('<x-card actionable role="link" title="Share">Body</x-card>'), 'role="link"'))->toBe(1)
->and((string) $this->blade('<x-card title="Share">Body</x-card>'))
->not->toContain('data-list-actionable')
->not->toContain('tabindex');
});