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:
co-authored by
Claude Sonnet 5
parent
76abdd6242
commit
f04e31b3aa
@@ -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`.
|
||||
|
||||
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
|
||||
<x-card variant="outlined" data-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>
|
||||
<x-card variant="outlined" data-md-list-row wire:key="share-{{ $share->id }}">
|
||||
<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-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.
|
||||
|
||||
@@ -1079,7 +1079,7 @@ A choice of colour profile (see Colour profiles): a swatch per generated profile
|
||||
|
||||
### `<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`.
|
||||
|
||||
@@ -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>
|
||||
<tbody>
|
||||
@foreach ($shares as $share)
|
||||
<tr data-list-row wire:key="share-{{ $share->id }}">
|
||||
<td><a href="{{ route('shares.show', $share) }}" data-list-open wire:navigate>{{ $share->name }}</a></td>
|
||||
<tr data-md-list-row wire:key="share-{{ $share->id }}">
|
||||
<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>
|
||||
</tr>
|
||||
@endforeach
|
||||
|
||||
@@ -58,5 +58,6 @@
|
||||
/* Containment */
|
||||
@import './components/divider.css';
|
||||
@import './components/collapse.css';
|
||||
@import './components/card.css';
|
||||
|
||||
/* Navigation */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,8 @@
|
||||
* 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,
|
||||
* 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
|
||||
* answers a pointer as a list row does (components/list.css).
|
||||
* (`aria-selected="true"`) is secondary-container. A row that opens something is `data-md-list-row`
|
||||
* 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
|
||||
* applied by itself — "don't apply density by default; offer an explicit density opt-in instead,
|
||||
|
||||
@@ -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 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
|
||||
// when it lets go, a moment later. Focus arriving this soon after a close is that, not someone
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 () {
|
||||
$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")
|
||||
->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 () {
|
||||
$card = '#containment [data-card][data-list-actionable]';
|
||||
$card = '#containment [data-md-card][data-md-list-actionable]';
|
||||
|
||||
$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()");
|
||||
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
<?php
|
||||
|
||||
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'],
|
||||
'outlined' => ['outlined', 'border border-outline-variant bg-surface'],
|
||||
]);
|
||||
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
|
||||
|
||||
it('draws M3\'s three cards, each naming its variant for its own colour and per-state elevation', function (string $variant) {
|
||||
$html = (string) $this->blade("<x-card variant=\"{$variant}\">Body</x-card>");
|
||||
$css = ComponentStylesheet::read('card');
|
||||
|
||||
expect($html)->toContain("data-md-card=\"{$variant}\"")
|
||||
->and($css->has("[data-md-card='{$variant}']"))->toBeTrue();
|
||||
})->with(['filled', 'elevated', 'outlined']);
|
||||
|
||||
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')
|
||||
->toContain('data-card="filled"');
|
||||
expect((string) $this->blade('<x-card variant="glass">Body</x-card>'))->toContain('data-md-card="filled"');
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
expect($html)
|
||||
->toContain('data-md-card-figure')
|
||||
->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('<button>⋮</button>')
|
||||
->toContain('role="separator"')
|
||||
->toContain('data-md-card-content')
|
||||
->toContain('Expires in 3 days.')
|
||||
->toContain('data-md-card-actions')
|
||||
->toContain('<button>Copy link</button>')
|
||||
->and(strpos($html, 'Expires'))->toBeLessThan(strpos($html, 'Copy link'));
|
||||
});
|
||||
|
||||
it('passes row attributes through', function () {
|
||||
expect((string) $this->blade('<x-card data-list-row><a href="/s/1" data-list-open>Open</a></x-card>'))
|
||||
->toContain('data-list-row')
|
||||
->toContain('data-list-open');
|
||||
it('passes row attributes through and renders the shared interaction classes for a row', function () {
|
||||
$html = (string) $this->blade('<x-card data-md-list-row><a href="/s/1" data-md-list-open>Open</a></x-card>');
|
||||
|
||||
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 () {
|
||||
$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)
|
||||
->toContain('data-list-row')
|
||||
->toContain('data-list-actionable')
|
||||
->toContain('data-md-list-row')
|
||||
->toContain('data-md-list-actionable')
|
||||
->toContain('tabindex="0"')
|
||||
->toContain('role="button"')
|
||||
->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>'))
|
||||
->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('data-md-list-actionable')
|
||||
->not->toContain('tabindex');
|
||||
});
|
||||
|
||||
it('draws M3\'s dragged state while the application says the card is being carried', function () {
|
||||
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/list.css');
|
||||
it('draws M3\'s per-state elevation and dragged state from the tokens, never a hand-made shadow', function () {
|
||||
$css = ComponentStylesheet::read('card');
|
||||
|
||||
expect((string) $this->blade('<x-card data-dragged variant="elevated">Body</x-card>'))
|
||||
->toContain('data-dragged')
|
||||
->and($css)
|
||||
->toContain('[data-card][data-dragged] {')
|
||||
->toContain('var(--md-sys-elevation-3),')
|
||||
->toContain("[data-card='elevated'][data-dragged] {")
|
||||
->toContain('var(--md-sys-elevation-4),')
|
||||
->toContain('calc(var(--md-sys-state-dragged-state-layer-opacity) * 100%)')
|
||||
->toContain(':not([data-dragged]):hover')
|
||||
->toContain(':not([data-dragged]):active');
|
||||
expect((string) $this->blade('<x-card data-md-dragged variant="elevated">Body</x-card>'))->toContain('data-md-dragged')
|
||||
->and($css->declarations('[data-md-card][data-md-dragged]'))->toBe(['box-shadow' => 'var(--md-sys-elevation-3)'])
|
||||
->and($css->declarations("[data-md-card='elevated'][data-md-dragged]"))->toBe(['box-shadow' => 'var(--md-sys-elevation-4)'])
|
||||
->and($css->declarations("[data-md-card='elevated']"))->toHaveKey('box-shadow', 'var(--md-sys-elevation-1)')
|
||||
->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()
|
||||
->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)'])
|
||||
->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']);
|
||||
});
|
||||
|
||||
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', [
|
||||
'divider',
|
||||
'collapse',
|
||||
'card',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user