diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 6eb52ecf..7e52658b 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -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 `` 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 `` or use a stretched link. A row answers with the state layer and one step of elevation; its corner does not move. ```blade - - {{ $share->name }} + + {{ $share->name }} ``` -`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 ### ``, `` -A data table: write plain ``, ``, ``, `` inside ``; 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 `
`. 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 ``, ``, ``, `` inside ``; 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 `
`. A row that opens something is `data-md-list-row` with one `data-md-list-open` control; a selected row is `aria-selected="true"`. `Size` 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 ``, ``, ``, `` inside ``; NameSize @foreach ($shares as $share) - - {{ $share->name }} + + {{ $share->name }} {{ $share->size }} @endforeach diff --git a/resources/css/components.css b/resources/css/components.css index 6abf51ab..d4462cac 100644 --- a/resources/css/components.css +++ b/resources/css/components.css @@ -58,5 +58,6 @@ /* Containment */ @import './components/divider.css'; @import './components/collapse.css'; +@import './components/card.css'; /* Navigation */ diff --git a/resources/css/components/card.css b/resources/css/components/card.css new file mode 100644 index 00000000..bf6dffeb --- /dev/null +++ b/resources/css/components/card.css @@ -0,0 +1,177 @@ +/* + * : 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); + } +} diff --git a/resources/css/components/table.css b/resources/css/components/table.css index eb200f4f..afa4e225 100644 --- a/resources/css/components/table.css +++ b/resources/css/components/table.css @@ -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, diff --git a/resources/js/search.js b/resources/js/search.js index 85612782..2521a275 100644 --- a/resources/js/search.js +++ b/resources/js/search.js @@ -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 diff --git a/resources/views/components/card.blade.php b/resources/views/components/card.blade.php index f4a092bf..b898433e 100644 --- a/resources/views/components/card.blade.php +++ b/resources/views/components/card.blade.php @@ -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 `` 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
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) -
{{ $figure }}
+
{{ $figure }}
@endisset -
+
@if ($title || $subtitle || isset($menu)) -
-
-
+
+
+
@if ($title) -

{{ $title }}

+

{{ $title }}

@endif @if ($subtitle) -

{{ $subtitle }}

+

{{ $subtitle }}

@endif
@isset($menu) -
{{ $menu }}
+
{{ $menu }}
@endisset
@if ($separator) - + @endif
@endif @if ($slot->isNotEmpty()) -
{{ $slot }}
+
{{ $slot }}
@endif @isset($actions) -
{{ $actions }}
+
{{ $actions }}
@endisset
diff --git a/resources/views/showcase/index.blade.php b/resources/views/showcase/index.blade.php index 582b06af..3572db97 100644 --- a/resources/views/showcase/index.blade.php +++ b/resources/views/showcase/index.blade.php @@ -17,13 +17,13 @@
@foreach ($entries as $key => $entry) - +
- {{ $entry['title'] }} + {{ $entry['title'] }}

{{ $entry['description'] }}

diff --git a/resources/views/showcase/sections/containment.blade.php b/resources/views/showcase/sections/containment.blade.php index 138b4778..ac1f5fc8 100644 --- a/resources/views/showcase/sections/containment.blade.php +++ b/resources/views/showcase/sections/containment.blade.php @@ -18,8 +18,8 @@
BLADE, 'A card that opens from anywhere on it' => <<<'BLADE' - - holiday-photos.zip + + holiday-photos.zip

248 MB · expires in 3 days

@@ -28,7 +28,7 @@ BLADE, 'A directly actionable card: the card is the tab stop' => <<<'BLADE' - contract.pdf + contract.pdf

1.2 MB · downloaded twice

@@ -38,7 +38,7 @@ 'A card being dragged' => <<<'BLADE'
- + Elevation 4 and the 16% state layer, for as long as the application says the card is being carried.
diff --git a/resources/views/showcase/sections/data.blade.php b/resources/views/showcase/sections/data.blade.php index 4f459e7f..387fe327 100644 --- a/resources/views/showcase/sections/data.blade.php +++ b/resources/views/showcase/sections/data.blade.php @@ -12,20 +12,20 @@ - - contract.pdf + + contract.pdf 1.2 MB in 1 hour - - design-review.mp4 + + design-review.mp4 3.4 GB in 7 days - - holiday-photos.zip + + holiday-photos.zip 248 MB in 3 days diff --git a/tests/Browser/ContainmentTest.php b/tests/Browser/ContainmentTest.php index 19a134b4..1f1c297a 100644 --- a/tests/Browser/ContainmentTest.php +++ b/tests/Browser/ContainmentTest.php @@ -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()"); diff --git a/tests/Feature/Components/CardTest.php b/tests/Feature/Components/CardTest.php index 9785767c..cfdd7834 100644 --- a/tests/Feature/Components/CardTest.php +++ b/tests/Feature/Components/CardTest.php @@ -1,20 +1,17 @@ blade("Body")) - ->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("Body"); + $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('Body')) - ->toContain('bg-surface-container-highest') - ->toContain('data-card="filled"'); + expect((string) $this->blade('Body'))->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('') - ->toContain('

holiday-photos.zip

') + ->toContain('

holiday-photos.zip

') + ->toContain('data-md-card-menu') ->toContain('248 MB') ->toContain('') ->toContain('role="separator"') + ->toContain('data-md-card-content') ->toContain('Expires in 3 days.') + ->toContain('data-md-card-actions') ->toContain('') ->and(strpos($html, 'Expires'))->toBeLessThan(strpos($html, 'Copy link')); }); -it('passes row attributes through', function () { - expect((string) $this->blade('Open')) - ->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('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('Body')) + ->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('Open'); + $html = (string) $this->blade('Open'); 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('Body')) ->toContain('role="link"') ->and(substr_count((string) $this->blade('Body'), 'role="link"'))->toBe(1) ->and((string) $this->blade('Body')) - ->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('Body')) - ->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('Body'))->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']); }); diff --git a/tests/Feature/Components/ContainmentStylesheetsTest.php b/tests/Feature/Components/ContainmentStylesheetsTest.php index d7327f6d..9dab850f 100644 --- a/tests/Feature/Components/ContainmentStylesheetsTest.php +++ b/tests/Feature/Components/ContainmentStylesheetsTest.php @@ -18,6 +18,7 @@ use NoNameWeb\LivewireMaterial\Tests\Support\ViewClasses; dataset('containment components', [ 'divider', 'collapse', + 'card', ]); /**