Rewrite the card without Tailwind

Plan step 36 (containment group): <x-card>'s class lists move into
resources/css/components/card.css, keyed on data-md-card (per variant),
data-md-card-figure/-body/-header/-heading/-title/-subtitle/-menu/
-content/-actions. A row (data-md-list-row) renders the shared
md-state-layer and md-focus-ring classes for its tint and ring
(foundation/interaction.css) instead of copying their rules; card.css
adds only the per-state elevation the shared class has no opinion on
(ElevatedCardTokens.kt/FilledCardTokens.kt/OutlinedCardTokens.kt), the
press/hover exclusion for a card's own nested buttons, and the ring for
a non-actionable row whose focus lands on its opener rather than the
card. data-md-dragged is read automatically by the shared class's own
16% tint; card.css only adds its elevation levels 3/4.

Hooks renamed data-list-row -> data-md-list-row, data-list-open ->
data-md-list-open, data-list-actionable -> data-md-list-actionable,
data-dragged -> data-md-dragged, updated in the same commit:
resources/js/search.js and list-rows.js's consumers, table.css's
comment, the showcase (index, data and containment sections),
tests/Browser/ContainmentTest.php, and the development skill's card
and table examples.

Imported from the Containment block of components.css.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 20:30:14 +02:00
co-authored by Claude Sonnet 5
parent 76abdd6242
commit f04e31b3aa
12 changed files with 295 additions and 95 deletions
+42 -35
View File
@@ -12,8 +12,8 @@
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
Both are a row: give the card `data-md-list-row` and one `data-md-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`.
@@ -22,21 +22,29 @@
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.
A row (`data-md-list-row`, `actionable` or not) renders the shared `md-state-layer` and
`md-focus-ring` classes (foundation/interaction.css), which draw the tint and the ring for a
card focused directly; resources/css/components/card.css refines their opacity when the
pointer or the keyboard is on one of the card's *own* buttons instead (which should light only
itself), and adds the `:has([data-md-list-open]:focus-visible)` ring for the non-actionable
row, whose focus never lands on the card itself. Elevation is separate from the tint M3
gives each variant its own per-state box-shadow (elevated 1dp rest 2dp hover 1dp
focus/pressed; filled and outlined 0dp rest 1dp hover 0dp focus/pressed) so it is drawn
directly on `[data-md-card]`, never inside the shared class. Its corner does not move, because
M3 gives a card one shape (docs/audits/m3-alignment/containment.md, C-13, C-23).
`data-dragged` is M3's dragged card: the top of a card's elevation scale 8dp elevated, 6dp
filled and outlined under the 16% dragged state layer, and it holds while the pointer is
down rather than falling back to the press state. Nothing in the browser tells a card it is
`data-md-dragged` is M3's dragged card: the top of a card's elevation scale 8dp elevated,
6dp filled and outlined under the shared class's own 16% dragged tint (it already reads
`data-md-dragged`, foundation/interaction.css). Nothing in the browser tells a card it is
being carried, so the application sets the attribute when its drag starts and takes it off on
drop. M3 requires a single-pointer alternative beside any drag, so keep the same reorder or
delete actions in a menu on the card
(docs/reference/m3/components-actions-communication-containment.md § Cards → Specs,
Accessibility; resources/css/components/list.css draws it).
Accessibility).
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. --}}
Do not pass a `bg-*` class to change its fill — the package's own rule for the variant sits in
`material.components`, and an application's unlayered CSS is the place to override it; use
`variant`, or colour a wrapper inside. --}}
@props([
'title' => null,
@@ -49,60 +57,59 @@
@php
$variant = in_array($variant, ['filled', 'elevated', 'outlined'], true) ? $variant : 'filled';
$role = $actionable ? ($attributes->get('role') ?: 'button') : null;
$isRow = $actionable || $attributes->has('data-md-list-row');
$attributes = $actionable ? $attributes->except('role') : $attributes;
@endphp
<div
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',
'bg-surface-container-low shadow-elevation-1' => $variant === 'elevated',
'border border-outline-variant bg-surface' => $variant === 'outlined',
{{ $attributes->merge(array_filter([
'data-md-card' => $variant,
'data-md-list-row' => $actionable ? true : null,
'data-md-list-actionable' => $actionable ? true : null,
'tabindex' => $actionable ? '0' : null,
'role' => $role,
'aria-label' => $actionable && filled($title) ? $title : null,
], fn ($value): bool => $value !== null))->class([
'md-state-layer' => $isRow,
'md-focus-ring' => $isRow,
]) }}
>
@isset($figure)
<div class="shrink-0 overflow-hidden [&>img]:w-full [&>img]:object-cover">{{ $figure }}</div>
<div data-md-card-figure>{{ $figure }}</div>
@endisset
<div class="flex flex-1 flex-col gap-4 p-4">
<div data-md-card-body>
@if ($title || $subtitle || isset($menu))
<div>
<div class="flex items-start gap-2">
<div class="min-w-0 flex-1">
<div data-md-card-header>
<div data-md-card-header-row>
<div data-md-card-heading>
@if ($title)
<h3 class="type-title-md">{{ $title }}</h3>
<h3 data-md-card-title>{{ $title }}</h3>
@endif
@if ($subtitle)
<p class="mt-0.5 type-body-md text-on-surface-variant">{{ $subtitle }}</p>
<p data-md-card-subtitle>{{ $subtitle }}</p>
@endif
</div>
@isset($menu)
<div class="-me-2 -mt-2 flex shrink-0 items-center gap-1">{{ $menu }}</div>
<div data-md-card-menu>{{ $menu }}</div>
@endisset
</div>
@if ($separator)
<x-livewire-material::divider class="mt-4" />
<x-livewire-material::divider />
@endif
</div>
@endif
@if ($slot->isNotEmpty())
<div class="flex-1 type-body-md">{{ $slot }}</div>
<div data-md-card-content>{{ $slot }}</div>
@endif
@isset($actions)
<div class="flex flex-wrap items-center justify-end gap-2">{{ $actions }}</div>
<div data-md-card-actions>{{ $actions }}</div>
@endisset
</div>
</div>
+2 -2
View File
@@ -17,13 +17,13 @@
<div class="grid grid-cols-1 gap-3 medium:grid-cols-2 expanded:grid-cols-3">
@foreach ($entries as $key => $entry)
<x-livewire-material::card variant="outlined" data-list-row wire:key="section-{{ $key }}">
<x-livewire-material::card variant="outlined" data-md-list-row wire:key="section-{{ $key }}">
<div class="flex items-start gap-4">
<span class="grid size-12 shrink-0 place-items-center rounded-corner-lg bg-secondary-container text-on-secondary-container">
<x-livewire-material::icon :name="$entry['icon']" />
</span>
<div class="min-w-0">
<a href="{{ route('livewire-material.section', $key) }}" wire:navigate data-list-open class="type-title-md">{{ $entry['title'] }}</a>
<a href="{{ route('livewire-material.section', $key) }}" wire:navigate data-md-list-open class="type-title-md">{{ $entry['title'] }}</a>
<p class="mt-1 type-body-md text-on-surface-variant">{{ $entry['description'] }}</p>
</div>
</div>
@@ -18,8 +18,8 @@
</div>
BLADE,
'A card that opens from anywhere on it' => <<<'BLADE'
<x-card variant="outlined" data-list-row class="w-full max-w-sm">
<a href="#containment" data-list-open class="type-title-md">holiday-photos.zip</a>
<x-card variant="outlined" data-md-list-row class="w-full max-w-sm">
<a href="#containment" data-md-list-open class="type-title-md">holiday-photos.zip</a>
<p class="mt-1 text-on-surface-variant">248 MB · expires in 3 days</p>
<x-slot:actions>
<x-button label="Copy link" icon="content_copy" x-on:click="materialToast('Link copied', { type: 'success' })" />
@@ -28,7 +28,7 @@
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>
<a href="#containment" data-md-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' })" />
@@ -38,7 +38,7 @@
'A card being dragged' => <<<'BLADE'
<div x-data="{ dragged: false }" class="w-full max-w-sm space-y-4">
<x-button label="Pick it up or put it down" variant="tonal" x-on:click="dragged = ! dragged" />
<x-card variant="elevated" title="holiday-photos.zip" subtitle="248 MB" x-bind:data-dragged="dragged || null">
<x-card variant="elevated" title="holiday-photos.zip" subtitle="248 MB" x-bind:data-md-dragged="dragged || null">
Elevation 4 and the 16% state layer, for as long as the application says the card is being carried.
</x-card>
</div>
@@ -12,20 +12,20 @@
</tr>
</thead>
<tbody>
<tr data-list-row>
<td><a href="#data" data-list-open>contract.pdf</a></td>
<tr data-md-list-row>
<td><a href="#data" data-md-list-open>contract.pdf</a></td>
<td class="text-end tabular-nums">1.2 MB</td>
<td>in 1 hour</td>
<td class="text-end"><x-button icon="more_vert" tooltip="More" /></td>
</tr>
<tr data-list-row aria-selected="true">
<td><a href="#data" data-list-open>design-review.mp4</a></td>
<tr data-md-list-row aria-selected="true">
<td><a href="#data" data-md-list-open>design-review.mp4</a></td>
<td class="text-end tabular-nums">3.4 GB</td>
<td>in 7 days</td>
<td class="text-end"><x-button icon="more_vert" tooltip="More" /></td>
</tr>
<tr data-list-row>
<td><a href="#data" data-list-open>holiday-photos.zip</a></td>
<tr data-md-list-row>
<td><a href="#data" data-md-list-open>holiday-photos.zip</a></td>
<td class="text-end tabular-nums">248 MB</td>
<td>in 3 days</td>
<td class="text-end"><x-button icon="more_vert" tooltip="More" /></td>