Add cards, lists, dialogs, sheets and the rest of M3's containment
tests / lint (push) Failing after 2m6s
tests / feature (8.4) (push) Successful in 1m3s
tests / feature (8.5) (push) Successful in 1m7s
tests / browser (safari, webkit) (push) Successful in 3m14s
tests / browser (chrome, chromium) (push) Successful in 2m9s
tests / browser (firefox, firefox) (push) Successful in 2m29s

<x-card> (filled, elevated, outlined), <x-list> and <x-list-item>
(plain or M3 Expressive's segmented list), <x-divider>, <x-collapse> on
<details>, <x-modal> on the native <dialog>, <x-drawer> as a side sheet or
list-detail pane, and <x-bottom-sheet> with drag to dismiss. Dialogs and
sheets bind to a Livewire flag or id and write back false or null on close,
or use the surrounding Alpine scope. Rows open from anywhere on them through
data-list-row and data-list-open. DesignGuard now also reports Blade
directives written inside a component tag, where they do not compile.

Browser test helpers wait for a complete document with Alpine and Livewire
running: in Firefox, networkidle alone could return before a repeated visit
had loaded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
This commit is contained in:
Andreas Reinhold / reini
2026-09-13 07:28:05 +02:00
co-authored by Claude Opus 5
parent e57063b0f4
commit fef20a9178
30 changed files with 1490 additions and 16 deletions
+2 -1
View File
@@ -4,7 +4,8 @@ const MORE = '#menus [aria-label="More"]';
function showcase()
{
return visit('/material')->waitForEvent('networkidle');
return visit('/material')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
}
function focused(string $expression): string
+6 -3
View File
@@ -3,7 +3,8 @@
const SNACKBAR = "document.querySelector('[x-data=\"materialSnackbar\"] [aria-live]')";
it('shows a toast as a snackbar, then the next in turn', function () {
$page = visit('/material')->waitForEvent('networkidle');
$page = visit('/material')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
$page->script("materialToast('First', { type: 'success', timeout: 600 }); materialToast('Second', { type: 'error' })");
@@ -18,7 +19,7 @@ it('shows a toast as a snackbar, then the next in turn', function () {
it('shows a toast dispatched as a browser event, as the Toasts concern does', function () {
$page = visit('/material')->waitForEvent('networkidle')
->assertScript("typeof window.Livewire !== 'undefined' && typeof window.Alpine !== 'undefined'");
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
// Through Livewire, in the page's own realm: an event built inside Playwright's evaluate
// carries a detail object Firefox's sandbox will not let the page read.
@@ -28,7 +29,8 @@ it('shows a toast dispatched as a browser event, as the Toasts concern does', fu
});
it('runs a toast\'s action and dismisses it', function () {
$page = visit('/material')->waitForEvent('networkidle');
$page = visit('/material')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
$page->script("window.__undone = false; materialToast('Share deleted', { action: { label: 'Undo', handler: () => window.__undone = true } })");
@@ -41,6 +43,7 @@ it('opens a persistent rich tooltip on press', function () {
$bubble = "document.querySelector('#communication [role=\"dialog\"][popover]')";
visit('/material')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'")
->click('#communication button:has-text("Press for details")')
->assertScript("{$bubble}.matches(':popover-open')");
});
+157
View File
@@ -0,0 +1,157 @@
<?php
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Route;
use Livewire\Component;
use Livewire\Livewire;
class OverlayProbe extends Component
{
public bool $confirming = false;
public ?int $deletingId = null;
public int $renders = 0;
public function touch(): void
{
$this->renders++;
}
public function render(): string
{
return <<<'BLADE'
<div class="space-y-4 p-4">
<p>confirming: <span id="confirming">{{ var_export($confirming, true) }}</span></p>
<p>deleting: <span id="deleting">{{ var_export($deletingId, true) }}</span></p>
<p>renders: <span id="renders">{{ $renders }}</span></p>
<x-button label="Confirm" wire:click="$set('confirming', true)" />
<x-button label="Delete 7" wire:click="$set('deletingId', 7)" />
<x-modal wire:model="confirming" title="Are you sure?">
<x-slot:actions>
<x-button label="Re-render" wire:click="touch" />
</x-slot:actions>
</x-modal>
<x-modal wire:model="deletingId" title="Delete share?" />
</div>
BLADE;
}
}
function overlayProbe()
{
Livewire::component('overlay-probe', OverlayProbe::class);
Route::middleware('web')->get('/overlay-probe', fn () => Blade::render(<<<'BLADE'
<!DOCTYPE html>
<html>
<head>
<x-theme-script />
@vite(config('livewire-material.showcase.vite'))
@livewireStyles
</head>
<body class="bg-surface">
<livewire:overlay-probe />
@livewireScripts
</body>
</html>
BLADE));
return visit('/overlay-probe')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
}
function containment()
{
return visit('/material')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
}
it('writes back what closed means: false for a flag, null for an id', function () {
$page = overlayProbe();
$page->click('button:has-text("Confirm")')
->assertScript("[...document.querySelectorAll('dialog')].some((d) => d.open && d.textContent.includes('Are you sure?'))");
$page->click('dialog[open] button:has-text("Re-render")')
->assertSeeIn('#renders', '1')
->assertScript("[...document.querySelectorAll('dialog')].some((d) => d.open && d.textContent.includes('Are you sure?'))");
$page->keys('dialog[open] button:has-text("Re-render")', 'Escape')
->assertScript("! [...document.querySelectorAll('dialog')].some((d) => d.open)")
->assertSeeIn('#confirming', 'false');
$page->click('button:has-text("Delete 7")')
->assertSeeIn('#deleting', '7')
->assertScript("[...document.querySelectorAll('dialog')].some((d) => d.open && d.textContent.includes('Delete share?'))");
$page->script("document.querySelector('dialog[open]').dispatchEvent(new Event('cancel', { cancelable: true }))");
$page->assertSeeIn('#deleting', 'NULL');
});
it('closes a showcase dialog on Escape and gives focus back to its trigger', function () {
// Opened from the keyboard: Safari does not focus a button on click, so after a click there
// is nothing for the dialog to hand focus back to.
$page = containment();
$page->script("[...document.querySelectorAll('#containment button')].find((b) => b.textContent.trim() === 'Basic dialog').focus()");
$page->keys(':focus', 'Enter')
->assertScript("document.querySelector('#containment dialog').open");
$page->keys('#containment dialog[open] button:has-text("Cancel")', 'Escape')
->assertScript("! document.querySelector('#containment dialog').open")
->assertScript("document.activeElement.textContent.trim() === 'Basic dialog'");
});
it('slides a side sheet in, traps the page, and closes on the scrim', function () {
$sheet = "document.querySelector('#containment aside[role=\"dialog\"]')";
$page = containment()
->click('#containment button:has-text("Side sheet")')
->assertScript("getComputedStyle({$sheet}).display !== 'none'")
->assertScript("document.querySelector('header').closest('[aria-hidden=\"true\"]') !== null");
$page->script("document.querySelector('[data-sheet] > [aria-hidden=\"true\"]').click()");
$page->assertScript("getComputedStyle({$sheet}).display === 'none'")
->assertScript("document.querySelector('header').closest('[aria-hidden=\"true\"]') === null");
});
it('dismisses a bottom sheet dragged down past a quarter of its height', function () {
$sheet = "document.querySelector('#containment section[role=\"dialog\"]')";
$page = containment()
->click('#containment button:has-text("Bottom sheet")')
->assertScript("getComputedStyle({$sheet}).display !== 'none'")
->wait(0.5);
$page->script(<<<JS
(() => {
const handle = {$sheet}.querySelector('[data-drag-handle]');
const box = handle.getBoundingClientRect();
const x = box.x + box.width / 2, y = box.y + box.height / 2;
handle.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, clientX: x, clientY: y, button: 0 }));
window.dispatchEvent(new PointerEvent('pointermove', { clientX: x, clientY: y + 400 }));
window.dispatchEvent(new PointerEvent('pointerup', { clientX: x, clientY: y + 400 }));
})()
JS);
$page->assertScript("getComputedStyle({$sheet}).display === 'none'");
});
it('opens a row\'s opener from a press anywhere on the row, but not from its own buttons', function () {
$page = containment();
$page->script("window.__opened = 0; document.querySelector('#containment [data-card][data-list-row] [data-list-open]').addEventListener('click', (event) => { event.preventDefault(); window.__opened++ })");
$page->click('#containment [data-card][data-list-row] p')
->assertScript('window.__opened === 1');
$page->click('#containment [data-card][data-list-row] button:has-text("Copy link")')
->assertScript('window.__opened === 1');
});
+19 -6
View File
@@ -38,6 +38,9 @@ function onProgress(string $label, string $body): string
return <<<JS
(async () => {
const el = document.querySelector('#progress [aria-label="{$label}"]')
// Writes go through the page's own realm: Firefox runs this script in a sandbox whose
// assignments do not reach Alpine's reactive proxies.
const set = (value) => window.eval(`Alpine.\$data(document.querySelector('#progress [aria-label="{$label}"]')).progress = \${JSON.stringify(value)}`)
el.scrollIntoView({ block: 'center' })
const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const active = () => el.querySelector('path[stroke="currentColor"]')
@@ -50,7 +53,10 @@ function onProgress(string $label, string $body): string
function progressShowcase(array $options = [])
{
return visit('/material', $options)->waitForEvent('networkidle');
// networkidle alone can return before a repeated visit has even loaded in Firefox; the
// assertion retries until the page is complete and Alpine has started.
return visit('/material', $options)->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
}
it('draws a linear indicator to its value', function () {
@@ -85,9 +91,16 @@ it('moves to a new bound value instead of jumping', function () {
progressShowcase()
->assertScript(onProgress('Bound', <<<'JS'
const before = reach()
Alpine.$data(el).progress = 80
await pause(80)
const during = reach()
set(80)
// The first frame that shows movement, not a timer: under load a timer fires late enough
// that the 330ms spring has nearly arrived, and a jump would look the same as a fast move.
// A jump's first moved frame is already at the target; a move's is on the way.
const frame = () => new Promise((resolve) => requestAnimationFrame(resolve))
let during = reach()
for (let i = 0; i < 60 && during <= before + 0.01; i++) {
await frame()
during = reach()
}
return Math.abs(before - 0.3) < 0.01 && during > before + 0.01 && during < 0.79
JS))
->wait(1)
@@ -96,13 +109,13 @@ it('moves to a new bound value instead of jumping', function () {
it('turns indeterminate when a bound value is null, and back', function () {
progressShowcase()->assertScript(onProgress('Bound', <<<'JS'
Alpine.$data(el).progress = null
set(null)
await pause(100)
const indeterminate = !el.hasAttribute('aria-valuenow') && !el.hasAttribute('data-value')
const first = active().getAttribute('d')
await pause(200)
const moving = active().getAttribute('d') !== first
Alpine.$data(el).progress = 50
set(50)
await pause(100)
return indeterminate && moving && el.getAttribute('aria-valuenow') === '50' && Math.abs(reach() - 0.5) < 0.01
JS));
+40
View File
@@ -0,0 +1,40 @@
<?php
it('draws M3\'s three cards', function (string $variant, string $classes) {
expect((string) $this->blade("<x-card variant=\"{$variant}\">Body</x-card>"))->toContain($classes)->toContain('rounded-corner-md')->toContain('data-card');
})->with([
'filled' => ['filled', 'bg-surface-container-highest'],
'elevated' => ['elevated', 'bg-surface-container-low shadow-elevation-1'],
'outlined' => ['outlined', 'border border-outline-variant bg-surface'],
]);
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');
});
it('lays out a header, menu, media, body and actions', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-card title="holiday-photos.zip" subtitle="248 MB" separator>
<x-slot:figure><img src="/x.png" alt=""></x-slot:figure>
<x-slot:menu><button></button></x-slot:menu>
Expires in 3 days.
<x-slot:actions><button>Copy link</button></x-slot:actions>
</x-card>
BLADE);
expect($html)
->toContain('<img src="/x.png" alt="">')
->toContain('<h3 class="type-title-md">holiday-photos.zip</h3>')
->toContain('248 MB')
->toContain('<button>⋮</button>')
->toContain('role="separator"')
->toContain('Expires in 3 days.')
->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');
});
+14
View File
@@ -0,0 +1,14 @@
<?php
it('discloses on the native details element, kept open through a morph', function () {
$html = (string) $this->blade('<x-collapse title="How long do links last?" icon="schedule" open variant="filled">Until the expiry.</x-collapse>');
expect($html)
->toContain('<details')
->toContain('wire:ignore.self')
->toContain(' open ')
->toContain('rounded-corner-lg bg-surface-container')
->toContain('How long do links last?')
->toContain('Until the expiry.')
->toContain('group-open/collapse:rotate-180');
});
+8
View File
@@ -0,0 +1,8 @@
<?php
it('separates horizontally or vertically, inset on request', function () {
expect((string) $this->blade('<x-divider />'))->toContain('role="separator"')->toContain('aria-orientation="horizontal"')->toContain('h-px')->toContain('bg-outline-variant')
->and((string) $this->blade('<x-divider vertical />'))->toContain('aria-orientation="vertical"')->toContain('w-px')
->and((string) $this->blade('<x-divider inset />'))->toContain('ms-4')
->and((string) $this->blade('<x-divider decorative />'))->toContain('aria-hidden="true"')->not->toContain('role="separator"');
});
+46
View File
@@ -0,0 +1,46 @@
<?php
it('draws a plain list, with dividers on request', function () {
expect((string) $this->blade('<x-list label="Files" dividers><x-list-item title="a.zip" /></x-list>'))
->toContain('role="list"')
->toContain('aria-label="Files"')
->toContain('data-list="plain"')
->toContain('divide-y divide-outline-variant')
->toContain('role="listitem"');
});
it('draws M3 Expressive\'s segmented list', function () {
expect((string) $this->blade('<x-list segmented><x-list-item title="a.zip" /></x-list>'))
->toContain('data-list="segmented"')
->toContain('gap-0.5');
});
it('grows an item from one to three lines', function () {
expect((string) $this->blade('<x-list-item title="a.zip" />'))->toContain('min-h-14')
->and((string) $this->blade('<x-list-item title="a.zip" description="248 MB" />'))->toContain('min-h-18')->toContain('248 MB')
->and((string) $this->blade('<x-list-item title="a.zip" overline="Protected" description="248 MB" />'))->toContain('min-h-22')->toContain('Protected');
});
it('leads with an icon, an avatar or an image, and trails with text or an icon', function () {
expect((string) $this->blade('<x-list-item title="a" icon="folder_zip" trailing="3 days" icon-right="chevron_right" />'))
->toContain('3 days')
->and(substr_count((string) $this->blade('<x-list-item title="a" icon="folder_zip" icon-right="chevron_right" />'), '<svg'))->toBe(2)
->and((string) $this->blade('<x-list-item title="Anna" avatar="AM" />'))->toContain('bg-primary-container')->toContain('>AM</span>')
->and((string) $this->blade('<x-list-item title="Anna" avatar="/anna.jpg" />'))->toContain('<img src="/anna.jpg"')
->and((string) $this->blade('<x-list-item title="Photo" image="/p.jpg" />'))->toContain('size-14');
});
it('makes a linked item a row that opens from anywhere', function () {
expect((string) $this->blade('<x-list-item title="Settings" link="/settings" />'))
->toContain('data-list-row')
->toContain('href="/settings"')
->toContain('data-list-open')
->toContain('wire:navigate');
});
it('marks a selected item and takes controls in its slots', function () {
expect((string) $this->blade('<x-list-item title="Anna" :selected="true"><x-slot:leading><input type="checkbox"></x-slot:leading><x-slot:end><button>⋮</button></x-slot:end></x-list-item>'))
->toContain('data-selected')
->toContain('<input type="checkbox">')
->toContain('<button>⋮</button>');
});
+73
View File
@@ -0,0 +1,73 @@
<?php
use Livewire\Component;
use Livewire\Livewire;
it('opens a native dialog entangled with a Livewire property, out of the morph\'s reach', function () {
$component = new class extends Component
{
public bool $confirming = false;
public function render(): string
{
return <<<'BLADE'
<div>
<x-modal wire:model="confirming" title="Delete this share?" subtitle="This cannot be undone." icon="delete">
<x-slot:actions><button>Delete</button></x-slot:actions>
</x-modal>
</div>
BLADE;
}
};
Livewire::test($component)
->assertSeeHtml('<dialog')
->assertSeeHtml('wire:ignore.self')
->assertSeeHtml('open: window.Livewire.find(')
->assertSeeHtml("entangle('confirming').live")
->assertSeeHtml('close() { this.open = typeof this.open === \'boolean\' ? false : null }')
->assertSeeHtml('rounded-corner-xl bg-surface-container-high')
->assertSeeHtml('type-headline-sm')
->assertSee('This cannot be undone.')
->assertSeeHtml('text-center')
->assertDontSeeHtml('wire:model="confirming"');
});
it('uses the surrounding Alpine scope without wire:model, and stays open when persistent', function () {
$html = (string) $this->blade('<x-modal title="Help" persistent fullscreen>Text</x-modal>');
expect($html)
->toContain('x-data="{ close() { this.open = false } }"')
->toContain('x-on:cancel.prevent=""')
->not->toContain('x-on:click.self')
->toContain('max-sm:h-dvh')
->toContain('aria-label="Close"');
});
it('slides a side sheet in from either edge, and is a pane from xl when asked', function () {
expect((string) $this->blade('<x-drawer title="Details" with-close-button>Body</x-drawer>'))
->toContain('x-trap.inert.noscroll="open && ! wide"')
->toContain('end-0 sm:rounded-s-corner-lg')
->toContain('bg-surface-container-low')
->toContain('role="dialog"')
->toContain('aria-label="Close"')
->and((string) $this->blade('<x-drawer side="start">Body</x-drawer>'))->toContain('start-0 sm:rounded-e-corner-lg')
->and((string) $this->blade('<x-drawer pane pane-width="28rem">Body</x-drawer>'))
->toContain('data-pane')
->toContain('--pane-width: 28rem')
->toContain("matchMedia('(min-width: 80rem)')");
});
it('draws a modal bottom sheet with a drag handle, or a standard one without a scrim', function () {
expect((string) $this->blade('<x-bottom-sheet title="Share via">Body</x-bottom-sheet>'))
->toContain('...materialBottomSheet(false)')
->toContain('bg-scrim/32')
->toContain('x-trap.inert.noscroll="open"')
->toContain('rounded-t-corner-xl bg-surface-container-low')
->toContain('data-drag-handle')
->toContain('aria-modal="true"')
->and((string) $this->blade('<x-bottom-sheet standard>Body</x-bottom-sheet>'))
->toContain('...materialBottomSheet(true)')
->not->toContain('bg-scrim/32')
->not->toContain('aria-modal');
});
+1
View File
@@ -20,6 +20,7 @@ it('finds what compiles to nothing', function () {
'views/page.blade.php:6 daisyUI class `btn-primary`',
'views/page.blade.php:4 unknown Material Symbol `o-home`',
'views/page.blade.php:5 unknown Material Symbol `not_a_symbol`',
'views/page.blade.php:9 Blade directive `@class` inside a component tag, where it does not compile',
'views/page.blade.php:2 maryUI component `<x-mary-button`',
'views/page.blade.php:3 colour the theme does not declare `bg-base-200`',
'views/page.blade.php:6 colour the theme does not declare `text-red-600`',
@@ -6,4 +6,5 @@
<span @class(['btn-primary' => $active, 'select-none'])>text-red-600</span>
<p class="text-tertiary table collapse link focus-ring">Tailwind's and ours</p>
<x-icon :name="$dynamic" />
<x-icon name="home" @class(['size-4']) />
</div>