Files
livewire-material/tests/Feature/Components/OverlayTest.php
T
Andreas Reinhold / reiniandClaude Sonnet 5 925d9a4439 Fold layout.css and components.css into all.css
Plan step 37: all.css replaces the two interim import lists with one
entry for an application that wants everything — the foundation, every
layout stylesheet and every component stylesheet, grouped under the
same block comments components.css used, plus a Layout block. It also
directly imports the three files nothing imported by name before
(layout/spacing.css, layout/visibility.css, components/selection.css),
so every file under components/ and layout/ is now one @import away.

Every test that read a block of components.css or layout.css now reads
the matching block of all.css through one shared helper (allCssBlock(),
in tests/Pest.php so it loads for any test run) instead of repeating
the same substr() search in each file. StylesheetsTest.php's shape,
Tailwind-free and breakpoint checks, previously run twice (once for
the foundation, once for the layout tree), now run once over the whole
tree all.css reaches, since every component and layout stylesheet is
plain CSS after step 36; a new test asserts all.css imports everything
under components/ and layout/ exactly once. The Workbench imports
all.css in place of layout.css and components.css.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 02:49:29 +02:00

326 lines
19 KiB
PHP

<?php
use Illuminate\Support\Facades\File;
use Livewire\Component;
use Livewire\Livewire;
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
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('data-md-modal')
->assertSeeHtml('wire:ignore.self')
->assertSeeHtml('open: window.Livewire.find(')
->assertSeeHtml("entangle('confirming').live")
->assertSeeHtml('close() { this.open = typeof this.open === \'boolean\' ? false : null }')
->assertSeeHtml('data-md-modal-box')
->assertSeeHtml('data-md-modal-title')
->assertSee('This cannot be undone.')
->assertSeeHtml('data-md-icon')
->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('data-md-fullscreen')
->toContain('aria-label="Close"');
});
it('keeps a full-screen dialog\'s subtitle on a phone, where its bar carries the title, in the CSS', function () {
$html = (string) $this->blade('<x-modal title="Enable 2FA" subtitle="Scan the code" fullscreen>Text</x-modal>');
expect($html)
->toMatch('/<h2 id="[^"]+-title" data-md-modal-title>Enable 2FA<\/h2>/')
->toMatch('/<p id="[^"]+-subtitle" data-md-modal-subtitle>Scan the code<\/p>/')
->and((string) $this->blade('<x-modal subtitle="Only a subtitle">Text</x-modal>'))
->toMatch('/<p id="[^"]+-subtitle" data-md-modal-subtitle>Only a subtitle<\/p>/')
->not->toContain('data-md-modal-title');
// The markup is the same at every width; modal.css (ContainmentStylesheetsTest) decides which
// of the bar/head/title/subtitle show below 600px, keyed on the icon and the subtitle.
expect(File::get(__DIR__.'/../../../resources/css/components/modal.css'))
->toContain('[data-md-modal][data-md-fullscreen] > [data-md-modal-box] > [data-md-modal-head]:not(:has(> [data-md-icon], > [data-md-modal-subtitle]))')
->toContain('[data-md-modal][data-md-fullscreen] > [data-md-modal-box] > [data-md-modal-head]:not(:has(> [data-md-icon])) > [data-md-modal-title]')
->toContain('[data-md-modal][data-md-fullscreen] > [data-md-modal-box] > [data-md-modal-head]:not(:has(> [data-md-icon])) > [data-md-modal-subtitle]');
// Each through its own box, so a basic dialog opened inside a full-screen one's body keeps its
// title and padding on a phone.
expect(File::get(__DIR__.'/../../../resources/css/components/modal.css'))->not->toMatch('/\[data-md-fullscreen\](?::[^ ]+)? \[/');
});
it('pins a dialog\'s headline and actions and scrolls only the body between them', function () {
$html = (string) $this->blade('<x-modal title="Terms" subtitle="Please read">Body<x-slot:actions><button>Agree</button></x-slot:actions></x-modal>');
expect($html)
->toContain('data-md-modal-box')
->toMatch('/<div\s+data-md-modal-head\s+data-md-modal-divider\s*>/')
->toMatch('/<div id="[^"]+-body" data-md-modal-body x-dialog-dividers>\s*<div data-md-modal-content>Body<\/div>\s*<\/div>/')
->toMatch('/<div\s+data-md-modal-actions\s+data-md-modal-divider\s*>/');
});
it('divides a scrolling body from its header and actions only while content is hidden past them', function () {
$html = (string) $this->blade('<x-modal title="Terms">Body<x-slot:actions><button>Agree</button></x-slot:actions></x-modal>');
// The script marks the dialog, which a morph leaves alone, and nothing is marked before it runs.
expect($html)
->toContain('x-dialog-dividers')
->not->toContain('data-md-overflow-top')
->not->toContain('data-md-overflow-bottom')
->not->toContain('data-md-separator')
->not->toContain('role="separator"')
->and(substr_count($html, 'data-md-modal-head'))->toBe(1)
->and(substr_count($html, 'data-md-modal-actions'))->toBe(1);
expect(File::get(__DIR__.'/../../../resources/css/components/modal.css'))
->toContain('dialog[data-md-overflow-top] > [data-md-modal-box] > [data-md-modal-head][data-md-modal-divider]::after')
->toContain('dialog[data-md-overflow-bottom] > [data-md-modal-box] > [data-md-modal-actions][data-md-modal-divider]::before')
->toContain('background-color: var(--md-sys-color-outline-variant);')
->toContain('height: 1px;')
->toContain('position: absolute;')
// A token that reduced motion sets to zero, so there the rule simply appears.
->toContain('transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);')
->and(File::get(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/modal.css';")
->and(File::get(__DIR__.'/../../../resources/css/tailwind.css'))->not->toContain('dialog.css')
->and(File::get(__DIR__.'/../../../resources/js/material.js'))->toContain("import './dialog.js'")
->and(File::get(__DIR__.'/../../../resources/js/dialog.js'))
->toContain("directive('dialog-dividers'")
->toContain('new ResizeObserver(measure)')
->toContain('[data-md-modal-content]');
});
it('draws the dividers whatever the scroll with separator, and only where a body has neighbours', function () {
expect((string) $this->blade('<x-modal title="Terms" separator>Body<x-slot:actions><button>Agree</button></x-slot:actions></x-modal>'))
->toMatch('/<div\s+data-md-modal-head\s+data-md-modal-divider\s+data-md-separator\s*>/')
->toMatch('/<div\s+data-md-modal-actions\s+data-md-modal-divider\s+data-md-separator\s*>/')
->and(File::get(__DIR__.'/../../../resources/css/components/modal.css'))
->toContain('[data-md-modal-head][data-md-modal-divider][data-md-separator]::after')
->toContain('[data-md-modal-actions][data-md-modal-divider][data-md-separator]::before');
// Without a body there is nothing to divide, and the header carries no divider hook.
expect((string) $this->blade('<x-modal title="Discard the draft?" separator><x-slot:actions><button>Discard</button></x-slot:actions></x-modal>'))
->toMatch('/<div\s+data-md-modal-head\s*>/')
->not->toContain('data-md-modal-divider')
->not->toContain('data-md-separator')
->not->toContain('x-dialog-dividers');
// No header: only the actions carry the divider hook; no actions: nothing does.
expect((string) $this->blade('<x-modal>Body<x-slot:actions><button>OK</button></x-slot:actions></x-modal>'))
->not->toContain('data-md-modal-head')
->toMatch('/<div\s+data-md-modal-actions\s+data-md-modal-divider\s*>/')
->and((string) $this->blade('<x-modal title="About">Body</x-modal>'))
->toMatch('/<div\s+data-md-modal-head\s+data-md-modal-divider\s*>/')
->not->toContain('data-md-modal-actions');
});
it('puts a full-screen dialog\'s top divider under its bar on a phone, unless the header stays there', function () {
$titled = (string) $this->blade('<x-modal title="Share settings" fullscreen>Body<x-slot:actions><button>Save</button></x-slot:actions></x-modal>');
// Both carry the divider hook here (no icon, no subtitle): the bar shows only below medium,
// and this header only from it (modal.css).
expect($titled)
->toMatch('/<div\s+data-md-modal-bar\s+data-md-modal-divider\s*>/')
->toMatch('/<div\s+data-md-modal-head\s+data-md-modal-divider\s*>/')
->and(preg_match_all('/<div\s+data-md-modal-bar[\s>]/', $titled))->toBe(1)
->and(preg_match_all('/<div\s+data-md-modal-head[\s>]/', $titled))->toBe(1);
// A subtitle keeps the header on a phone, so the rule stays under it rather than under the bar.
$subtitled = (string) $this->blade('<x-modal title="Share settings" subtitle="Who can see it" fullscreen>Body</x-modal>');
expect($subtitled)
->toMatch('/<div\s+data-md-modal-bar\s*>/')
->not->toContain('data-md-modal-bar data-md-modal-divider')
->toMatch('/<div\s+data-md-modal-head\s+data-md-modal-divider\s*>/')
->and((string) $this->blade('<x-modal fullscreen>Body</x-modal>'))
->toMatch('/<div\s+data-md-modal-bar\s+data-md-modal-divider\s*>/');
});
it('is an alert dialog when it interrupts, and describes itself by its subtitle', function () {
$alert = (string) $this->blade('<x-modal title="Delete this share?" subtitle="Recipients lose access." alert>This cannot be undone.</x-modal>');
preg_match('/id="(material-dialog-[a-z0-9]+)"/', $alert, $id);
expect($alert)
->toContain('role="alertdialog"')
->toContain("aria-describedby=\"{$id[1]}-subtitle {$id[1]}-body\"")
->and((string) $this->blade('<x-modal title="Share settings" subtitle="Who can see it">Body</x-modal>'))
->not->toContain('role="alertdialog"')
->toMatch('/aria-describedby="material-dialog-[a-z0-9]+-subtitle"/')
->and((string) $this->blade('<x-modal title="Share settings">Body</x-modal>'))
->not->toContain('aria-describedby');
});
it('gives a full-screen dialog M3\'s 56px header and action bar', function () {
expect((string) $this->blade('<x-modal title="Share settings" fullscreen>Body<x-slot:actions><button>Save</button></x-slot:actions></x-modal>'))
->toContain('data-md-modal-bar')
->toContain('data-md-fullscreen')
->and(File::get(__DIR__.'/../../../resources/css/components/modal.css'))
->toContain('height: var(--md-sys-measurement-space700);')
->toContain('min-height: var(--md-sys-measurement-space700);');
});
it('always offers a way out of a side sheet, as M3 requires', function () {
expect((string) $this->blade('<x-drawer title="Details">Body</x-drawer>'))->toContain('aria-label="Close"')
->and((string) $this->blade('<x-drawer title="Details" :with-close-button="false">Body</x-drawer>'))->not->toContain('aria-label="Close"')
->and((string) $this->blade('<x-drawer title="Details" :with-close-button="false" :close-on-escape="false">Body</x-drawer>'))->toContain('aria-label="Close"')
->and((string) $this->blade('<x-drawer title="Details" :with-close-button="false" without-backdrop-close>Body</x-drawer>'))->toContain('aria-label="Close"')
->and((string) $this->blade('<x-drawer standard :with-close-button="false">Body</x-drawer>'))->toContain('aria-label="Close"');
});
it('puts a side sheet\'s actions on the left, in M3\'s 72dp row', function () {
expect((string) $this->blade('<x-drawer title="Details">Body<x-slot:actions><button>Delete</button></x-slot:actions></x-drawer>'))
->toMatch('/<div\s+data-md-drawer-actions\s*>/');
});
it('slides a side sheet in from either edge', function () {
expect((string) $this->blade('<x-drawer title="Details" with-close-button>Body</x-drawer>'))
->toContain('x-trap.inert.noscroll="open && ! wide"')
->toContain('data-md-side="end"')
->toContain('data-md-drawer-sheet')
->toContain('role="dialog"')
->toContain('aria-label="Close"')
->and((string) $this->blade('<x-drawer side="start">Body</x-drawer>'))->toContain('data-md-side="start"');
});
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, JSON.parse(')
->toContain('data-md-bottom-sheet-scrim')
->toContain('x-trap.inert.noscroll="open"')
->toContain('data-md-bottom-sheet-panel')
->toContain('data-md-bottom-sheet-handle')
->toContain('data-md-bottom-sheet-grip')
->toContain('aria-modal="true"')
->toContain('md-focus-ring md-touch-target')
->toContain('--sheet-max-height: min(50dvh, calc(100dvh - 72px))')
->and((string) $this->blade('<x-bottom-sheet standard>Body</x-bottom-sheet>'))
->toContain('...materialBottomSheet(true, JSON.parse(')
->not->toContain('data-md-bottom-sheet-scrim')
->not->toContain('aria-modal')
->toContain('data-md-standard')
->and((string) $this->blade('<x-bottom-sheet height="90dvh">Body</x-bottom-sheet>'))
->toContain('--sheet-max-height: min(90dvh, calc(100dvh - 72px))');
});
it('is M3\'s standard side sheet from expanded, and the modal one below', function () {
$html = (string) $this->blade('<x-drawer standard title="Filters">Body</x-drawer>');
expect($html)
->toContain('data-md-standard')
->toContain('--sheet-width: min(400px, 400px)')
->toContain("matchMedia('(width >= 840px)')")
->toContain('x-trap.inert.noscroll="open && ! wide"')
->toContain('aria-label="Close"')
->and((string) $this->blade('<x-drawer standard side="start" width="30rem">Body</x-drawer>'))
->toContain('data-md-side="start"')
->toContain('--sheet-width: min(30rem, 400px)')
->and((string) $this->blade('<x-drawer title="Filters">Body</x-drawer>'))
->not->toContain('data-md-standard');
$css = ComponentStylesheet::read('drawer');
$expanded = ['@media (width >= 840px)'];
expect($css->has('[data-md-drawer][data-md-standard]', $expanded))->toBeTrue()
->and($css->declarations('[data-md-drawer][data-md-standard][data-md-drawer-collapsed]', $expanded))->toBe(['display' => 'none'])
// The old view's `expanded:hidden` on a standard sheet's scrim.
->and($css->declarations('[data-md-drawer][data-md-standard] > [data-md-drawer-scrim]', $expanded))->toBe(['display' => 'none'])
->and($css->declarations('[data-md-drawer][data-md-standard] > [data-md-drawer-sheet]', $expanded))->toHaveKey('box-shadow', 'none')
->and($css->has("[data-md-drawer][data-md-standard] > [data-md-drawer-sheet][data-md-side='end']", $expanded))->toBeTrue()
->and($css->has("[data-md-drawer][data-md-standard] > [data-md-drawer-sheet][data-md-side='start']", $expanded))->toBeTrue();
});
it('slides a side sheet out as it slid in, from its own edge in either direction, and fades its scrim both ways', function () {
$css = ComponentStylesheet::read('drawer');
expect($css->declarations('[data-md-drawer-sheet]'))
->toHaveKey('translate', 'var(--sheet-offset) 0')
->toHaveKey('transition-behavior', 'allow-discrete')
->not->toHaveKey('opacity')
->and($css->declarations("[data-md-drawer-sheet][data-md-side='end']:dir(rtl)"))->toBe(['--sheet-offset' => '-100%'])
->and($css->declarations("[data-md-drawer-sheet][data-md-side='start']:dir(rtl)"))->toBe(['--sheet-offset' => '100%'])
->and($css->declarations('[data-md-drawer][data-md-open] > [data-md-drawer-sheet]', ['@starting-style']))->toBe(['translate' => 'var(--sheet-offset) 0'])
->and($css->declarations('[data-md-drawer-scrim]'))->toHaveKey('opacity', '0')
->and($css->declarations('[data-md-drawer][data-md-open] > [data-md-drawer-scrim]'))->toBe(['opacity' => '1']);
});
it('draws the drawer from a stylesheet imported from the containment block, with the pane prop gone', function () {
$view = File::get(__DIR__.'/../../../resources/views/components/drawer.blade.php');
expect($view)
->not->toContain("'pane'")
->not->toContain('paneWidth')
->not->toContain('paneCloseOnEscape')
->not->toContain('pane-width')
->not->toContain('pane-close-on-escape')
->not->toContain('data-pane')
->and(File::get(__DIR__.'/../../../resources/css/all.css'))
->toContain("@import './components/drawer.css';");
});
it('gives a bottom sheet M3\'s preset heights, cycled from the drag handle', function () {
$html = (string) $this->blade('<x-bottom-sheet title="Share via" snap>Body</x-bottom-sheet>');
expect($html)
->toContain('--sheet-stop: 50dvh; --sheet-max-height: min(var(--sheet-stop), calc(100dvh - 72px))')
->toContain('data-md-preset')
->toContain('x-ref="probe"')
->toContain('data-md-bottom-sheet-probe')
->toContain('aria-label="Change the sheet height"')
->toContain('x-bind:aria-label="handleLabel"')
->toContain('x-on:click="activate()"')
->toMatch('/<span data-md-bottom-sheet-announce class="md-visually-hidden" aria-live="polite" x-text="announcement"><\/span>/')
->toContain('25dvh')
->toContain('90dvh')
->toContain('Height 2 of 3')
->and((string) $this->blade('<x-bottom-sheet heights="30dvh,60dvh" height="60dvh">Body</x-bottom-sheet>'))
->toContain('--sheet-stop: 60dvh;')
->toContain('30dvh')
->and((string) $this->blade('<x-bottom-sheet :heights="[25, 50, 90]">Body</x-bottom-sheet>'))
->toContain('--sheet-stop: 50dvh;')
->and((string) $this->blade('<x-bottom-sheet heights="50dvh">Body</x-bottom-sheet>'))
->toContain('--sheet-max-height: min(50dvh, calc(100dvh - 72px))')
->not->toContain('--sheet-stop')
->not->toContain('data-md-preset')
->toContain('aria-label="Close"');
});
it('draws the bottom sheet from a stylesheet imported from the containment block and by the menu', function () {
expect(File::get(__DIR__.'/../../../resources/css/all.css'))
->toContain("@import './components/bottom-sheet.css';")
->and(File::get(__DIR__.'/../../../resources/css/components/menu.css'))
->toContain("@import './bottom-sheet.css';")
->and(File::get(__DIR__.'/../../../resources/js/bottom-sheet.js'))
->toContain('data-md-bottom-sheet-handle')
->not->toContain('data-drag-handle');
$css = ComponentStylesheet::read('bottom-sheet');
// The scrim fades out as well as in, a nested sheet keeps its own state, and the handle's ring
// stands 4px off its bar.
expect($css->declarations('[data-md-bottom-sheet-scrim]'))->toHaveKey('opacity', '0')
->and($css->declarations('[data-md-bottom-sheet][data-md-open] > [data-md-bottom-sheet-scrim]'))->toBe(['opacity' => '1'])
->and($css->declarations('[data-md-bottom-sheet][data-md-open] > [data-md-bottom-sheet-panel]', ['@starting-style']))->toBe(['translate' => '0 100%'])
->and($css->declarations('[data-md-bottom-sheet-grip]:focus-visible'))->toBe(['outline-offset' => '4px'])
->and($css->css)->not->toContain('[data-md-open] [data-md-');
});