Files
livewire-material/tests/Browser/ContainmentTest.php
T
Andreas Reinhold / reiniandClaude Sonnet 5 f04e31b3aa 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
2026-09-14 20:30:14 +02:00

280 lines
10 KiB
PHP

<?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;
}
}
class CollapseProbe extends Component
{
public bool $fineTuning = false;
public int $renders = 0;
public function touch(): void
{
$this->renders++;
}
public function expand(): void
{
$this->fineTuning = true;
}
public function collapse(): void
{
$this->fineTuning = false;
}
public function render(): string
{
return <<<'BLADE'
<div class="space-y-4 p-4">
<p>fine tuning: <span id="fine-tuning">{{ var_export($fineTuning, true) }}</span></p>
<p>renders: <span id="renders">{{ $renders }}</span></p>
<x-button label="Re-render" wire:click="touch" />
<x-button label="Open from the server" wire:click="expand" />
<x-button label="Close from the server" wire:click="collapse" />
<x-collapse id="fine-tuning-collapse" title="Fine-tuning" wire:model="fineTuning">Zones and paces.</x-collapse>
<div x-data="{ advanced: false }">
<p>advanced: <span id="advanced" x-text="advanced"></span></p>
<x-button label="Close from Alpine" x-on:click="advanced = false" />
<x-collapse id="advanced-collapse" title="Advanced" x-model="advanced">Everything else.</x-collapse>
</div>
</div>
BLADE;
}
}
function collapseProbe()
{
Livewire::component('collapse-probe', CollapseProbe::class);
Route::middleware('web')->get('/collapse-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:collapse-probe />
@livewireScripts
</body>
</html>
BLADE));
return visit('/collapse-probe')->waitForEvent('networkidle')
->assertScript("document.readyState === 'complete' && typeof window.Alpine !== 'undefined' && typeof window.Livewire !== 'undefined'");
}
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/containment')->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");
// This sheet's own scrim: the standard side sheet above it on the page is a `[data-sheet]` too.
$page->script("{$sheet}.parentElement.querySelector(':scope > [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(> span:text-is("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();
$row = '#containment [data-md-card][data-md-list-row]:not([data-md-list-actionable])';
$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');
$page->click("{$row} button:has-text(\"Copy link\")")
->assertScript('window.__opened === 1');
});
it('makes a directly actionable card the one tab stop, answering Enter', function () {
$card = '#containment [data-md-card][data-md-list-actionable]';
$page = containment();
$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->assertScript("document.activeElement.matches('{$card}')");
$page->keys($card, 'Enter')
->assertScript('window.__opened === 1');
});
it('binds a collapse to a Livewire property both ways', function () {
$collapse = "document.querySelector('#fine-tuning-collapse')";
$page = collapseProbe()
->assertScript("{$collapse}.open === false");
$page->click('#fine-tuning-collapse summary')
->assertScript("{$collapse}.open === true")
->click('button:has-text("Re-render")')
->assertSeeIn('#renders', '1')
->assertSeeIn('#fine-tuning', 'true')
->assertScript("{$collapse}.open === true");
$page->click('button:has-text("Close from the server")')
->assertSeeIn('#fine-tuning', 'false')
->assertScript("{$collapse}.open === false");
$page->click('button:has-text("Open from the server")')
->assertSeeIn('#fine-tuning', 'true')
->assertScript("{$collapse}.open === true");
});
it('binds a collapse to an Alpine property both ways', function () {
$collapse = "document.querySelector('#advanced-collapse')";
$page = collapseProbe()
->assertScript("{$collapse}.open === false");
$page->click('#advanced-collapse summary')
->assertScript("{$collapse}.open === true")
->assertSeeIn('#advanced', 'true');
$page->click('button:has-text("Close from Alpine")')
->assertSeeIn('#advanced', 'false')
->assertScript("{$collapse}.open === false");
});