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:
co-authored by
Claude Opus 5
parent
26a9230a48
commit
675cfc806a
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user