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
@@ -472,16 +472,16 @@ 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`. `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 row answers with the state layer and one step of elevation; its corner does not move. A card or list item that opens something is a **row**: `data-md-list-row` on it and `data-md-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 ```blade
<x-card variant="outlined" data-list-row wire:key="share-{{ $share->id }}"> <x-card variant="outlined" data-md-list-row wire:key="share-{{ $share->id }}">
<a href="{{ route('shares.show', $share) }}" data-list-open wire:navigate class="type-title-md">{{ $share->name }}</a> <a href="{{ route('shares.show', $share) }}" data-md-list-open wire:navigate class="type-title-md">{{ $share->name }}</a>
<x-slot:actions><x-button label="Copy link" wire:click="copy({{ $share->id }})" /></x-slot:actions> <x-slot:actions><x-button label="Copy link" wire:click="copy({{ $share->id }})" /></x-slot:actions>
</x-card> </x-card>
``` ```
`data-dragged` on the card draws M3's dragged state — elevation 8dp elevated / 6dp filled and outlined, under the 16% dragged state layer. The application sets it when its drag starts and removes it on drop; pair any drag with a single-pointer alternative (a menu with the same actions), as M3 requires. `data-md-dragged` on the card draws M3's dragged state — elevation 8dp elevated / 6dp filled and outlined, under the 16% dragged state layer. The application sets it when its drag starts and removes it on drop; pair any drag with a single-pointer alternative (a menu with the same actions), as M3 requires.
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. 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.
@@ -1079,7 +1079,7 @@ A choice of colour profile (see Colour profiles): a swatch per generated profile
### `<x-table>`, `<x-sort-header>` ### `<x-table>`, `<x-sort-header>`
A data table: write plain `<thead>`, `<tr>`, `<th>`, `<td>` inside `<x-table>`; cell utilities (`text-end`, `whitespace-nowrap`) always win. Rows are 52px — a target a finger can hit. `dense` tightens them to 36px and `size="xs"` is for a table inside a panel inside a panel (32px rows); M3 says density is always an opt-in, so neither is a default and both are yours to justify. Scrolling is yours: wrap it in `<div class="overflow-x-auto">`. A row that opens something is `data-list-row` with one `data-list-open` control; a selected row is `aria-selected="true"`. A data table: write plain `<thead>`, `<tr>`, `<th>`, `<td>` inside `<x-table>`; cell utilities (`text-end`, `whitespace-nowrap`) always win. Rows are 52px — a target a finger can hit. `dense` tightens them to 36px and `size="xs"` is for a table inside a panel inside a panel (32px rows); M3 says density is always an opt-in, so neither is a default and both are yours to justify. Scrolling is yours: wrap it in `<div class="overflow-x-auto">`. A row that opens something is `data-md-list-row` with one `data-md-list-open` control; a selected row is `aria-selected="true"`.
`<x-sort-header column="size" :sort-by="$sortBy">Size</x-sort-header>` sorts through the Livewire property `sortBy` (`['column' => …, 'direction' => 'asc'|'desc']`; `model` names another), with `aria-sort`. `<x-sort-header column="size" :sort-by="$sortBy">Size</x-sort-header>` sorts through the Livewire property `sortBy` (`['column' => …, 'direction' => 'asc'|'desc']`; `model` names another), with `aria-sort`.
@@ -1089,8 +1089,8 @@ A data table: write plain `<thead>`, `<tr>`, `<th>`, `<td>` inside `<x-table>`;
<thead><tr><x-sort-header column="name" :sort-by="$sortBy">Name</x-sort-header><th class="text-end">Size</th></tr></thead> <thead><tr><x-sort-header column="name" :sort-by="$sortBy">Name</x-sort-header><th class="text-end">Size</th></tr></thead>
<tbody> <tbody>
@foreach ($shares as $share) @foreach ($shares as $share)
<tr data-list-row wire:key="share-{{ $share->id }}"> <tr data-md-list-row wire:key="share-{{ $share->id }}">
<td><a href="{{ route('shares.show', $share) }}" data-list-open wire:navigate>{{ $share->name }}</a></td> <td><a href="{{ route('shares.show', $share) }}" data-md-list-open wire:navigate>{{ $share->name }}</a></td>
<td class="text-end tabular-nums">{{ $share->size }}</td> <td class="text-end tabular-nums">{{ $share->size }}</td>
</tr> </tr>
@endforeach @endforeach
+1
View File
@@ -58,5 +58,6 @@
/* Containment */ /* Containment */
@import './components/divider.css'; @import './components/divider.css';
@import './components/collapse.css'; @import './components/collapse.css';
@import './components/card.css';
/* Navigation */ /* Navigation */
+177
View File
@@ -0,0 +1,177 @@
/*
* <x-card>: M3's three card variants (ElevatedCardTokens.kt, FilledCardTokens.kt,
* OutlinedCardTokens.kt, androidx Compose Material 3, Apache-2.0; docs/reference/m3
* § Cards → Specs) — filled (surface-container-highest), elevated (surface-container-low at
* elevation 1) and outlined (surface, an outline-variant edge); one 12dp corner for all three,
* which never morphs (M3 specifies shape morph for buttons, FABs and list items, never cards —
* docs/audits/m3-alignment/containment.md, C-23).
*
* A row (`data-md-list-row`, `resources/js/list-rows.js`) renders the shared `md-state-layer` and
* `md-focus-ring` classes (foundation/interaction.css) for its tint and its ring; this file adds
* only what a card needs beyond them:
*
* - per-state *elevation*, which the shared class has no opinion on: elevated 1dp rest → 2dp
* hover → 1dp focus/pressed; filled and outlined 0dp rest → 1dp hover → 0dp focus/pressed
* (the cross-check table's Hover/Focus/Pressed rows). Pressed and focus fall back to the rest
* level, so the `:active` rule (declared after `:hover`, same specificity) resets it even
* while still hovering;
* - the *press/hover exclusion*: a card's own nested button (the "Copy link" in a card that is
* also a row) should light only itself, never the row's tint or elevation. `material.components`
* out-layers `material.base`, so these two rules win over the shared class's plain `:hover`/
* `:active` without needing extra specificity;
* - the *opener-focused ring*: a non-actionable row's focus never lands on the card itself (its
* opener carries the tab stop), so `md-focus-ring`'s own `:focus-visible` never fires for it;
* `:has([data-md-list-open]:focus-visible)` draws the same ring from here instead. An
* actionable card (`tabindex="0"`) is focused directly, which `md-focus-ring` already covers.
*
* `data-md-dragged` (an application sets it while its drag is in progress) is the top of the
* elevation scale — Level4 (8dp) elevated, Level3 (6dp) filled and outlined — under the shared
* class's own 16% dragged tint, which already reads `data-md-dragged` without any help from here.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './divider.css';
@layer material.components {
[data-md-card] {
position: relative;
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: var(--md-sys-shape-corner-md);
color: var(--md-sys-color-on-surface);
}
[data-md-card='filled'] {
background-color: var(--md-sys-color-surface-container-highest);
}
[data-md-card='elevated'] {
background-color: var(--md-sys-color-surface-container-low);
box-shadow: var(--md-sys-elevation-1);
}
[data-md-card='outlined'] {
border: 1px solid var(--md-sys-color-outline-variant);
background-color: var(--md-sys-color-surface);
}
[data-md-card][data-md-list-row] {
cursor: pointer;
transition-property: box-shadow;
transition-duration: var(--md-sys-motion-spatial-default-duration);
transition-timing-function: var(--md-sys-motion-spatial-default);
}
@media (hover: hover) {
[data-md-card][data-md-list-row]:not([data-md-dragged]):hover:not(:has(:is(a, button, input, select, textarea, label, summary):not([data-md-list-open]):hover)) {
box-shadow: var(--md-sys-elevation-1);
}
[data-md-card='elevated'][data-md-list-row]:not([data-md-dragged]):hover:not(:has(:is(a, button, input, select, textarea, label, summary):not([data-md-list-open]):hover)) {
box-shadow: var(--md-sys-elevation-2);
}
[data-md-card][data-md-list-row]:not([data-md-dragged]):hover:has(:is(a, button, input, select, textarea, label, summary):not([data-md-list-open]):hover)::before {
opacity: 0;
}
}
[data-md-card][data-md-list-row]:not([data-md-dragged]):active:not(:has(:is(a, button, input, select, textarea, label, summary):not([data-md-list-open]):active)) {
box-shadow: none;
}
[data-md-card='elevated'][data-md-list-row]:not([data-md-dragged]):active:not(:has(:is(a, button, input, select, textarea, label, summary):not([data-md-list-open]):active)) {
box-shadow: var(--md-sys-elevation-1);
}
[data-md-card][data-md-list-row]:not([data-md-dragged]):has(:is(a, button, input, select, textarea, label, summary):not([data-md-list-open]):active)::before {
opacity: 0;
}
[data-md-card][data-md-dragged] {
box-shadow: var(--md-sys-elevation-3);
}
[data-md-card='elevated'][data-md-dragged] {
box-shadow: var(--md-sys-elevation-4);
}
[data-md-card][data-md-list-row]:has([data-md-list-open]:focus-visible) {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: 2px;
}
[data-md-card][data-md-list-row] [data-md-list-open]:focus-visible {
outline: none;
}
[data-md-card-figure] {
flex-shrink: 0;
overflow: hidden;
& > img {
width: 100%;
object-fit: cover;
}
}
[data-md-card-body] {
display: flex;
flex: 1 1 0%;
flex-direction: column;
gap: var(--md-sys-measurement-space200);
padding: var(--md-sys-measurement-space200);
}
[data-md-card-header-row] {
display: flex;
align-items: flex-start;
gap: var(--md-sys-measurement-space100);
}
[data-md-card-heading] {
min-width: 0;
flex: 1 1 0%;
}
[data-md-card-title] {
font: var(--md-sys-typescale-title-md);
letter-spacing: var(--md-sys-typescale-title-md-tracking);
}
[data-md-card-subtitle] {
margin-top: var(--md-sys-measurement-space25);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-md);
letter-spacing: var(--md-sys-typescale-body-md-tracking);
}
[data-md-card-menu] {
display: flex;
flex-shrink: 0;
align-items: center;
gap: var(--md-sys-measurement-space50);
margin-block-start: calc(-1 * var(--md-sys-measurement-space100));
margin-inline-end: calc(-1 * var(--md-sys-measurement-space100));
}
[data-md-card-header] [data-md-divider] {
margin-top: var(--md-sys-measurement-space200);
}
[data-md-card-content] {
flex: 1 1 0%;
font: var(--md-sys-typescale-body-md);
letter-spacing: var(--md-sys-typescale-body-md-tracking);
}
[data-md-card-actions] {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: var(--md-sys-measurement-space100);
}
}
+2 -2
View File
@@ -7,8 +7,8 @@
* always wins, whatever layer it comes from. Header cells in title-small on-surface-variant over an * always wins, whatever layer it comes from. Header cells in title-small on-surface-variant over an
* outline-variant rule, body cells in body-medium between outline-variant rules — the role itself, * outline-variant rule, body cells in body-medium between outline-variant rules — the role itself,
* never a fraction of it, since M3 reserves opacity for state layers and disabled. A selected row * never a fraction of it, since M3 reserves opacity for state layers and disabled. A selected row
* (`aria-selected="true"`) is secondary-container. A row that opens something is `data-list-row` and * (`aria-selected="true"`) is secondary-container. A row that opens something is `data-md-list-row`
* answers a pointer as a list row does (components/list.css). * and answers a pointer as a list row does (components/list.css).
* *
* A row is 52px: 16px above and below a body-medium line, 12px beside each cell. Density is never * A row is 52px: 16px above and below a body-medium line, 12px beside each cell. Density is never
* applied by itself — "don't apply density by default; offer an explicit density opt-in instead, * applied by itself — "don't apply density by default; offer an explicit density opt-in instead,
+1 -1
View File
@@ -22,7 +22,7 @@ import { upTo } from './breakpoints.js'
const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
const CHOOSES = 'a[href], button:not([disabled]), [data-list-open]' const CHOOSES = 'a[href], button:not([disabled]), [data-md-list-open]'
// A close hands focus back to the input: Escape does, and so does the full-screen view's focus trap // A close hands focus back to the input: Escape does, and so does the full-screen view's focus trap
// when it lets go, a moment later. Focus arriving this soon after a close is that, not someone // when it lets go, a moment later. Focus arriving this soon after a close is that, not someone
+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 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). (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 Both are a row: give the card `data-md-list-row` and one `data-md-list-open` control inside
title link or a button), and a press anywhere on it reaches that control while its other (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 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`. 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 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. 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, A row (`data-md-list-row`, `actionable` or not) renders the shared `md-state-layer` and
filled and outlined 0 1); its corner does not move, because M3 gives a card one shape. `md-focus-ring` classes (foundation/interaction.css), which draw the tint and the ring for a
Never a stretched link, and never a whole-card `<a>` around buttons. 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 `data-md-dragged` is M3's dragged card: the top of a card's elevation scale 8dp elevated,
filled and outlined under the 16% dragged state layer, and it holds while the pointer is 6dp filled and outlined under the shared class's own 16% dragged tint (it already reads
down rather than falling back to the press state. Nothing in the browser tells a card it is `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 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 drop. M3 requires a single-pointer alternative beside any drag, so keep the same reorder or
delete actions in a menu on the card delete actions in a menu on the card
(docs/reference/m3/components-actions-communication-containment.md § Cards → Specs, (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 Do not pass a `bg-*` class to change its fill — the package's own rule for the variant sits in
order; use `variant`, or colour a wrapper inside. --}} `material.components`, and an application's unlayered CSS is the place to override it; use
`variant`, or colour a wrapper inside. --}}
@props([ @props([
'title' => null, 'title' => null,
@@ -49,60 +57,59 @@
@php @php
$variant = in_array($variant, ['filled', 'elevated', 'outlined'], true) ? $variant : 'filled'; $variant = in_array($variant, ['filled', 'elevated', 'outlined'], true) ? $variant : 'filled';
$role = $actionable ? ($attributes->get('role') ?: 'button') : null; $role = $actionable ? ($attributes->get('role') ?: 'button') : null;
$isRow = $actionable || $attributes->has('data-md-list-row');
$attributes = $actionable ? $attributes->except('role') : $attributes; $attributes = $actionable ? $attributes->except('role') : $attributes;
@endphp @endphp
<div <div
data-card="{{ $variant }}" {{ $attributes->merge(array_filter([
@if ($actionable) 'data-md-card' => $variant,
data-list-row 'data-md-list-row' => $actionable ? true : null,
data-list-actionable 'data-md-list-actionable' => $actionable ? true : null,
tabindex="0" 'tabindex' => $actionable ? '0' : null,
role="{{ $role }}" 'role' => $role,
@if (filled($title)) aria-label="{{ $title }}" @endif 'aria-label' => $actionable && filled($title) ? $title : null,
@endif ], fn ($value): bool => $value !== null))->class([
{{ $attributes->class([ 'md-state-layer' => $isRow,
'relative flex flex-col overflow-hidden rounded-corner-md text-on-surface', 'md-focus-ring' => $isRow,
'bg-surface-container-highest' => $variant === 'filled',
'bg-surface-container-low shadow-elevation-1' => $variant === 'elevated',
'border border-outline-variant bg-surface' => $variant === 'outlined',
]) }} ]) }}
> >
@isset($figure) @isset($figure)
<div class="shrink-0 overflow-hidden [&>img]:w-full [&>img]:object-cover">{{ $figure }}</div> <div data-md-card-figure>{{ $figure }}</div>
@endisset @endisset
<div class="flex flex-1 flex-col gap-4 p-4"> <div data-md-card-body>
@if ($title || $subtitle || isset($menu)) @if ($title || $subtitle || isset($menu))
<div> <div data-md-card-header>
<div class="flex items-start gap-2"> <div data-md-card-header-row>
<div class="min-w-0 flex-1"> <div data-md-card-heading>
@if ($title) @if ($title)
<h3 class="type-title-md">{{ $title }}</h3> <h3 data-md-card-title>{{ $title }}</h3>
@endif @endif
@if ($subtitle) @if ($subtitle)
<p class="mt-0.5 type-body-md text-on-surface-variant">{{ $subtitle }}</p> <p data-md-card-subtitle>{{ $subtitle }}</p>
@endif @endif
</div> </div>
@isset($menu) @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 @endisset
</div> </div>
@if ($separator) @if ($separator)
<x-livewire-material::divider class="mt-4" /> <x-livewire-material::divider />
@endif @endif
</div> </div>
@endif @endif
@if ($slot->isNotEmpty()) @if ($slot->isNotEmpty())
<div class="flex-1 type-body-md">{{ $slot }}</div> <div data-md-card-content>{{ $slot }}</div>
@endif @endif
@isset($actions) @isset($actions)
<div class="flex flex-wrap items-center justify-end gap-2">{{ $actions }}</div> <div data-md-card-actions>{{ $actions }}</div>
@endisset @endisset
</div> </div>
</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"> <div class="grid grid-cols-1 gap-3 medium:grid-cols-2 expanded:grid-cols-3">
@foreach ($entries as $key => $entry) @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"> <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"> <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']" /> <x-livewire-material::icon :name="$entry['icon']" />
</span> </span>
<div class="min-w-0"> <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> <p class="mt-1 type-body-md text-on-surface-variant">{{ $entry['description'] }}</p>
</div> </div>
</div> </div>
@@ -18,8 +18,8 @@
</div> </div>
BLADE, BLADE,
'A card that opens from anywhere on it' => <<<'BLADE' 'A card that opens from anywhere on it' => <<<'BLADE'
<x-card variant="outlined" data-list-row class="w-full max-w-sm"> <x-card variant="outlined" data-md-list-row class="w-full max-w-sm">
<a href="#containment" data-list-open class="type-title-md">holiday-photos.zip</a> <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> <p class="mt-1 text-on-surface-variant">248 MB · expires in 3 days</p>
<x-slot:actions> <x-slot:actions>
<x-button label="Copy link" icon="content_copy" x-on:click="materialToast('Link copied', { type: 'success' })" /> <x-button label="Copy link" icon="content_copy" x-on:click="materialToast('Link copied', { type: 'success' })" />
@@ -28,7 +28,7 @@
BLADE, BLADE,
'A directly actionable card: the card is the tab stop' => <<<'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"> <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> <p class="mt-1 text-on-surface-variant">1.2 MB · downloaded twice</p>
<x-slot:actions> <x-slot:actions>
<x-button label="Copy link" icon="content_copy" x-on:click="materialToast('Link copied', { type: 'success' })" /> <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' 'A card being dragged' => <<<'BLADE'
<div x-data="{ dragged: false }" class="w-full max-w-sm space-y-4"> <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-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. Elevation 4 and the 16% state layer, for as long as the application says the card is being carried.
</x-card> </x-card>
</div> </div>
@@ -12,20 +12,20 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr data-list-row> <tr data-md-list-row>
<td><a href="#data" data-list-open>contract.pdf</a></td> <td><a href="#data" data-md-list-open>contract.pdf</a></td>
<td class="text-end tabular-nums">1.2 MB</td> <td class="text-end tabular-nums">1.2 MB</td>
<td>in 1 hour</td> <td>in 1 hour</td>
<td class="text-end"><x-button icon="more_vert" tooltip="More" /></td> <td class="text-end"><x-button icon="more_vert" tooltip="More" /></td>
</tr> </tr>
<tr data-list-row aria-selected="true"> <tr data-md-list-row aria-selected="true">
<td><a href="#data" data-list-open>design-review.mp4</a></td> <td><a href="#data" data-md-list-open>design-review.mp4</a></td>
<td class="text-end tabular-nums">3.4 GB</td> <td class="text-end tabular-nums">3.4 GB</td>
<td>in 7 days</td> <td>in 7 days</td>
<td class="text-end"><x-button icon="more_vert" tooltip="More" /></td> <td class="text-end"><x-button icon="more_vert" tooltip="More" /></td>
</tr> </tr>
<tr data-list-row> <tr data-md-list-row>
<td><a href="#data" data-list-open>holiday-photos.zip</a></td> <td><a href="#data" data-md-list-open>holiday-photos.zip</a></td>
<td class="text-end tabular-nums">248 MB</td> <td class="text-end tabular-nums">248 MB</td>
<td>in 3 days</td> <td>in 3 days</td>
<td class="text-end"><x-button icon="more_vert" tooltip="More" /></td> <td class="text-end"><x-button icon="more_vert" tooltip="More" /></td>
+4 -4
View File
@@ -215,9 +215,9 @@ 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 () { it('opens a row\'s opener from a press anywhere on the row, but not from its own buttons', function () {
$page = containment(); $page = containment();
$row = '#containment [data-card][data-list-row]:not([data-list-actionable])'; $row = '#containment [data-md-card][data-md-list-row]:not([data-md-list-actionable])';
$page->script("window.__opened = 0; document.querySelector('{$row} [data-list-open]').addEventListener('click', (event) => { event.preventDefault(); window.__opened++ })"); $page->script("window.__opened = 0; document.querySelector('{$row} [data-md-list-open]').addEventListener('click', (event) => { event.preventDefault(); window.__opened++ })");
$page->click("{$row} p") $page->click("{$row} p")
->assertScript('window.__opened === 1'); ->assertScript('window.__opened === 1');
@@ -227,11 +227,11 @@ it('opens a row\'s opener from a press anywhere on the row, but not from its own
}); });
it('makes a directly actionable card the one tab stop, answering Enter', function () { it('makes a directly actionable card the one tab stop, answering Enter', function () {
$card = '#containment [data-card][data-list-actionable]'; $card = '#containment [data-md-card][data-md-list-actionable]';
$page = containment(); $page = containment();
$page->script("window.__opened = 0; document.querySelector('{$card} [data-list-open]').addEventListener('click', (event) => { event.preventDefault(); window.__opened++ })"); $page->script("window.__opened = 0; document.querySelector('{$card} [data-md-list-open]').addEventListener('click', (event) => { event.preventDefault(); window.__opened++ })");
$page->script("document.querySelector('{$card}').focus()"); $page->script("document.querySelector('{$card}').focus()");
+48 -34
View File
@@ -1,20 +1,17 @@
<?php <?php
it('draws M3\'s three cards, each naming its variant for its per-state elevation', function (string $variant, string $classes) { use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
expect((string) $this->blade("<x-card variant=\"{$variant}\">Body</x-card>"))
->toContain($classes) it('draws M3\'s three cards, each naming its variant for its own colour and per-state elevation', function (string $variant) {
->toContain('rounded-corner-md') $html = (string) $this->blade("<x-card variant=\"{$variant}\">Body</x-card>");
->toContain("data-card=\"{$variant}\""); $css = ComponentStylesheet::read('card');
})->with([
'filled' => ['filled', 'bg-surface-container-highest'], expect($html)->toContain("data-md-card=\"{$variant}\"")
'elevated' => ['elevated', 'bg-surface-container-low shadow-elevation-1'], ->and($css->has("[data-md-card='{$variant}']"))->toBeTrue();
'outlined' => ['outlined', 'border border-outline-variant bg-surface'], })->with(['filled', 'elevated', 'outlined']);
]);
it('is filled unless it knows the variant', function () { it('is filled unless it knows the variant', function () {
expect((string) $this->blade('<x-card variant="glass">Body</x-card>')) expect((string) $this->blade('<x-card variant="glass">Body</x-card>'))->toContain('data-md-card="filled"');
->toContain('bg-surface-container-highest')
->toContain('data-card="filled"');
}); });
it('lays out a header, menu, media, body and actions', function () { it('lays out a header, menu, media, body and actions', function () {
@@ -28,50 +25,67 @@ it('lays out a header, menu, media, body and actions', function () {
BLADE); BLADE);
expect($html) expect($html)
->toContain('data-md-card-figure')
->toContain('<img src="/x.png" alt="">') ->toContain('<img src="/x.png" alt="">')
->toContain('<h3 class="type-title-md">holiday-photos.zip</h3>') ->toContain('<h3 data-md-card-title>holiday-photos.zip</h3>')
->toContain('data-md-card-menu')
->toContain('248 MB') ->toContain('248 MB')
->toContain('<button>⋮</button>') ->toContain('<button>⋮</button>')
->toContain('role="separator"') ->toContain('role="separator"')
->toContain('data-md-card-content')
->toContain('Expires in 3 days.') ->toContain('Expires in 3 days.')
->toContain('data-md-card-actions')
->toContain('<button>Copy link</button>') ->toContain('<button>Copy link</button>')
->and(strpos($html, 'Expires'))->toBeLessThan(strpos($html, 'Copy link')); ->and(strpos($html, 'Expires'))->toBeLessThan(strpos($html, 'Copy link'));
}); });
it('passes row attributes through', function () { it('passes row attributes through and renders the shared interaction classes for a row', function () {
expect((string) $this->blade('<x-card data-list-row><a href="/s/1" data-list-open>Open</a></x-card>')) $html = (string) $this->blade('<x-card data-md-list-row><a href="/s/1" data-md-list-open>Open</a></x-card>');
->toContain('data-list-row')
->toContain('data-list-open'); expect($html)
->toContain('data-md-list-row')
->toContain('data-md-list-open')
->toContain('class="md-state-layer md-focus-ring"')
->and((string) $this->blade('<x-card title="Share">Body</x-card>'))
->not->toContain('md-state-layer')
->not->toContain('md-focus-ring');
}); });
it('makes a directly actionable card the one tab stop, named by its title', function () { 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>'); $html = (string) $this->blade('<x-card actionable title="holiday-photos.zip"><a href="/s/1" data-md-list-open tabindex="-1">Open</a></x-card>');
expect($html) expect($html)
->toContain('data-list-row') ->toContain('data-md-list-row')
->toContain('data-list-actionable') ->toContain('data-md-list-actionable')
->toContain('tabindex="0"') ->toContain('tabindex="0"')
->toContain('role="button"') ->toContain('role="button"')
->toContain('aria-label="holiday-photos.zip"') ->toContain('aria-label="holiday-photos.zip"')
->toContain('class="md-state-layer md-focus-ring"')
->and((string) $this->blade('<x-card actionable role="link" title="Share">Body</x-card>')) ->and((string) $this->blade('<x-card actionable role="link" title="Share">Body</x-card>'))
->toContain('role="link"') ->toContain('role="link"')
->and(substr_count((string) $this->blade('<x-card actionable role="link" title="Share">Body</x-card>'), 'role="link"'))->toBe(1) ->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>')) ->and((string) $this->blade('<x-card title="Share">Body</x-card>'))
->not->toContain('data-list-actionable') ->not->toContain('data-md-list-actionable')
->not->toContain('tabindex'); ->not->toContain('tabindex');
}); });
it('draws M3\'s dragged state while the application says the card is being carried', function () { it('draws M3\'s per-state elevation and dragged state from the tokens, never a hand-made shadow', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/list.css'); $css = ComponentStylesheet::read('card');
expect((string) $this->blade('<x-card data-dragged variant="elevated">Body</x-card>')) expect((string) $this->blade('<x-card data-md-dragged variant="elevated">Body</x-card>'))->toContain('data-md-dragged')
->toContain('data-dragged') ->and($css->declarations('[data-md-card][data-md-dragged]'))->toBe(['box-shadow' => 'var(--md-sys-elevation-3)'])
->and($css) ->and($css->declarations("[data-md-card='elevated'][data-md-dragged]"))->toBe(['box-shadow' => 'var(--md-sys-elevation-4)'])
->toContain('[data-card][data-dragged] {') ->and($css->declarations("[data-md-card='elevated']"))->toHaveKey('box-shadow', 'var(--md-sys-elevation-1)')
->toContain('var(--md-sys-elevation-3),') ->and($css->has('[data-md-card][data-md-list-row]:not([data-md-dragged]):hover:not(:has(:is(a, button, input, select, textarea, label, summary):not([data-md-list-open]):hover))', ['@media (hover: hover)']))->toBeTrue()
->toContain("[data-card='elevated'][data-dragged] {") ->and($css->declarations("[data-md-card='elevated'][data-md-list-row]:not([data-md-dragged]):hover:not(:has(:is(a, button, input, select, textarea, label, summary):not([data-md-list-open]):hover))", ['@media (hover: hover)']))->toBe(['box-shadow' => 'var(--md-sys-elevation-2)'])
->toContain('var(--md-sys-elevation-4),') ->and($css->declarations('[data-md-card][data-md-list-row]:not([data-md-dragged]):hover:has(:is(a, button, input, select, textarea, label, summary):not([data-md-list-open]):hover)::before', ['@media (hover: hover)']))->toBe(['opacity' => '0']);
->toContain('calc(var(--md-sys-state-dragged-state-layer-opacity) * 100%)') });
->toContain(':not([data-dragged]):hover')
->toContain(':not([data-dragged]):active'); it('rings the row from the card itself when actionable, or from a has() when its opener is focused', function () {
$css = ComponentStylesheet::read('card');
expect($css->declarations('[data-md-card][data-md-list-row]:has([data-md-list-open]:focus-visible)'))
->toBe(['outline' => '3px solid var(--md-sys-color-secondary)', 'outline-offset' => '2px'])
->and($css->declarations('[data-md-card][data-md-list-row] [data-md-list-open]:focus-visible'))
->toBe(['outline' => 'none']);
}); });
@@ -18,6 +18,7 @@ use NoNameWeb\LivewireMaterial\Tests\Support\ViewClasses;
dataset('containment components', [ dataset('containment components', [
'divider', 'divider',
'collapse', 'collapse',
'card',
]); ]);
/** /**