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
326 lines
19 KiB
PHP
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-');
|
|
});
|