Files
livewire-material/tests/Feature/Components/OverlayTest.php
T
Andreas Reinhold / reiniandClaude Sonnet 5 f21943746e Delete Tailwind's half and build the Workbench without it
Plan step 39 (parts 1-3, folded into one commit: PHP tests read the
deleted files directly, so they cannot land apart from it). Tailwind
leaves the whole stack:

- Delete resources/css/tailwind.css, tokens/theme.css and
  tokens/utilities.css. Neither token file declared an --md-sys-*
  custom property of its own (both only referenced tokens declared
  elsewhere), so nothing loses a value; the md-* interaction and text
  classes already mirror utilities.css's declarations exactly
  (foundation/interaction.css, text.css).
- npm uninstall tailwindcss @tailwindcss/vite; vite.config.js drops the
  plugin and its import; composer.json drops the tailwindcss keyword
  (no lock change — keywords are outside Composer's content hash).
- The Workbench now builds one CSS entry, workbench/resources/css/app.css
  (all.css, showcase.css and the scheme; package.css is folded in and
  removed) instead of two, used by ErrorPage::assets() and every
  browser-test probe page's raw @vite() call; the showcase's own pages
  still take their CSS from the bundle route.
- TokensTest and StylesheetsTest: the two facts theme.css and
  utilities.css carried (every scheme role becomes a colour, resolved
  on the element; md-type-* matches the type-* utilities) are asserted
  directly against the scheme and text.css now that there is no second
  copy to cross-check; StylesheetsTest gained a full-tree scan (every
  .css file under resources/css/ is reached from all.css or
  showcase.css, no exclusions left for Tailwind); the Workbench-entry
  test and every "moved out of tailwind.css" assertion updated for the
  single entry and its removal.
- DesignGuard.php's comments and the development skill's setup section
  no longer name the deleted files or a second Tailwind entry.

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

325 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/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-');
});