The side sheet, the bottom sheet's panel and the docked search's scrim and view kept `display` alive through their exit with `transition-behavior: allow-discrete`. Firefox does not transition `display` (Chrome 117 and Safari 18 do), and `x-show` sets `display: none` in the frame the exit starts, so in Firefox the sheets vanished instead of sliding out and the search view and scrim vanished instead of fading. The docked search had a second problem in every engine: neither the view nor the scrim had a closed state to transition to, so where `display` was held (Chrome, Safari) the view stood at full opacity for its duration and then disappeared. Each element now carries `x-transition:enter`/`:leave="md-transition"`, the approach the two sheet scrims already took (renamed from `md-scrim-transition` to one name for all of them). The class only switches Alpine to CSS-transition mode, so `x-show` holds `display` for the element's computed transition-duration before hiding it, in every engine, and a reopen during the exit cancels the pending hide; nothing styles it. `display` and `allow-discrete` leave the transitions so Chrome and Safari do not hold a second time. Alpine reads the first `transition-duration` listed, which is the closing slide or fade in each list (the preset panel lists translate before height). The search view now closes back into the bar (opacity 0, `scale: 1 0.9`, the reverse of its `@starting-style` entry) and its scrim fades out on close and when the search turns full screen. Under reduced motion the durations are zero and every one of them closes at once. Four browser tests sample each exit mid-way in the page, in the same round trip as the close: the sheets part of the way to their closed offset, the search scrim and view part of the way faded, each still displayed, then `display: none`. All four fail on main in Firefox (the two search tests in Chrome too) and pass in Chrome, Firefox and Safari. OverlayTest pins the drawer's new transition and the view's markup. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
331 lines
19 KiB
PHP
331 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" wire:ignore.self 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');
|
|
|
|
// Alpine's CSS transition holds `display` through the exit (the view's `x-transition`), not
|
|
// `allow-discrete`, which Firefox does not honour for `display`.
|
|
expect($css->declarations('[data-md-drawer-sheet]'))
|
|
->toHaveKey('translate', 'var(--sheet-offset) 0')
|
|
->toHaveKey('transition-property', 'translate')
|
|
->not->toHaveKey('transition-behavior')
|
|
->not->toHaveKey('opacity')
|
|
->and($css->declarations("[data-md-drawer-sheet][data-md-side='end']:is([dir='rtl'], [dir='rtl'] *)"))->toBe(['--sheet-offset' => '-100%'])
|
|
->and($css->declarations("[data-md-drawer-sheet][data-md-side='start']:is([dir='rtl'], [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'])
|
|
->and((string) $this->blade('<x-drawer title="Filters">Body</x-drawer>'))
|
|
->toMatch('/<div data-md-drawer-scrim x-cloak x-show="open" x-transition:enter="md-transition" x-transition:leave="md-transition"/')
|
|
->toMatch('/<aside\s+x-cloak\s+x-show="open"\s+x-transition:enter="md-transition"\s+x-transition:leave="md-transition"/');
|
|
});
|
|
|
|
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-');
|
|
});
|