Plan step 36 (containment group): <x-modal>'s class lists move into resources/css/components/modal.css, keyed on data-md-modal (with data-md-fullscreen) and data-md-modal-box/-bar/-head/-title/-subtitle/ -body/-content/-actions. Sizes stay px (560/280/48px matching DialogTokens.kt/FullScreenDialogTokens.kt and the 56px full-screen header, C-17), spacing the measurement tokens, breakpoints a literal 600px range query. The phone-only header bar and the pinned head/actions keep their existing responsive behaviour (a subtitle or an icon keeps the head on screen below 600px; only the bar's own divider-eligibility needs a computed flag, since it alone can sit beside a head that is also on screen) — reproduced with :has() against data-md-icon/ data-md-modal-subtitle wherever that is self-contained, and with an explicit data-md-modal-divider attribute where it is not. The scroll-driven dividers rename data-dialog-head/-actions -> data-md-modal-head/-actions, data-overflow-top/-bottom -> data-md-overflow-top/-bottom (resources/js/dialog.js); the dialog-dividers Alpine directive keeps its name, since it isn't a data-md-* hook. resources/css/components/dialog.css (Tailwind-era) is replaced by modal.css, imported from the Containment block of components.css; its import leaves resources/css/tailwind.css. No other component renders <x-modal> or dialog.css's rules (checked: datepicker, timepicker and search draw their own dialog-shaped surfaces independently). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
282 lines
16 KiB
PHP
282 lines
16 KiB
PHP
<?php
|
|
|
|
use Illuminate\Support\Facades\File;
|
|
use Livewire\Component;
|
|
use Livewire\Livewire;
|
|
|
|
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-head]:not(:has([data-md-icon])):not(:has([data-md-modal-subtitle]))')
|
|
->toContain('[data-md-modal][data-md-fullscreen]:not(:has([data-md-modal-head] [data-md-icon])) [data-md-modal-title]')
|
|
->toContain('[data-md-modal][data-md-fullscreen]:not(:has([data-md-modal-head] [data-md-icon])) [data-md-modal-subtitle]');
|
|
});
|
|
|
|
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/components.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('leaves a pane open on Escape unless it is asked to close then too', function () {
|
|
expect((string) $this->blade('<x-drawer pane>Body</x-drawer>'))
|
|
->toContain('x-on:keydown.window.escape="if (open && ! wide) close()"')
|
|
->and((string) $this->blade('<x-drawer pane pane-close-on-escape>Body</x-drawer>'))
|
|
->toContain('x-on:keydown.window.escape="if (open) close()"')
|
|
->and((string) $this->blade('<x-drawer pane pane-close-on-escape :close-on-escape="false">Body</x-drawer>'))
|
|
->not->toContain('keydown.window.escape');
|
|
});
|
|
|
|
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 pane :with-close-button="false">Body</x-drawer>'))->toContain('aria-label="Close"')
|
|
->and((string) $this->blade('<x-drawer pane pane-close-on-escape :with-close-button="false">Body</x-drawer>'))->not->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>'))
|
|
->toContain('flex min-h-18 shrink-0 flex-wrap items-center justify-start gap-2 pt-4');
|
|
});
|
|
|
|
it('slides a side sheet in from either edge, and is a pane from expanded when asked', function () {
|
|
expect((string) $this->blade('<x-drawer title="Details" with-close-button>Body</x-drawer>'))
|
|
->toContain('x-trap.inert.noscroll="open && ! wide"')
|
|
->toContain('end-0 medium:rounded-s-corner-lg')
|
|
->toContain('bg-surface-container-low')
|
|
->toContain('role="dialog"')
|
|
->toContain('aria-label="Close"')
|
|
->and((string) $this->blade('<x-drawer side="start">Body</x-drawer>'))->toContain('start-0 medium:rounded-e-corner-lg')
|
|
->and((string) $this->blade('<x-drawer pane pane-width="28rem">Body</x-drawer>'))
|
|
->toContain('data-pane')
|
|
->toContain('--pane-width: 28rem')
|
|
->toContain("matchMedia('(width >= 52.5rem)')");
|
|
});
|
|
|
|
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('bg-scrim/32')
|
|
->toContain('x-trap.inert.noscroll="open"')
|
|
->toContain('rounded-t-corner-xl bg-surface-container-low')
|
|
->toContain('data-drag-handle')
|
|
->toContain('aria-modal="true"')
|
|
->toContain('py-5.5')
|
|
->toContain('touch-target h-1 w-8 rounded-corner-full bg-on-surface-variant ')
|
|
->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('bg-scrim/32')
|
|
->not->toContain('aria-modal')
|
|
->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-standard')
|
|
->toContain('expanded:sticky expanded:top-0 expanded:h-dvh expanded:shrink-0 expanded:self-start')
|
|
->toContain('expanded:relative expanded:top-0 expanded:z-auto expanded:h-full expanded:rounded-corner-none expanded:bg-surface expanded:shadow-none')
|
|
->toContain('expanded:border-s expanded:border-outline-variant')
|
|
->toContain('--sheet-width: min(25rem, 25rem)')
|
|
->toContain("matchMedia('(width >= 52.5rem)')")
|
|
->toContain('x-trap.inert.noscroll="open && ! wide"')
|
|
->toContain('bg-scrim/32 expanded:hidden')
|
|
->toContain('aria-label="Close"')
|
|
->and((string) $this->blade('<x-drawer standard side="start" width="30rem">Body</x-drawer>'))
|
|
->toContain('expanded:border-e expanded:border-outline-variant')
|
|
->toContain('--sheet-width: min(30rem, 25rem)')
|
|
->and((string) $this->blade('<x-drawer standard pane>Body</x-drawer>'))
|
|
->toContain('data-pane')
|
|
->not->toContain('data-standard')
|
|
->and((string) $this->blade('<x-drawer title="Filters">Body</x-drawer>'))
|
|
->not->toContain('data-standard')
|
|
->not->toContain('expanded:hidden');
|
|
});
|
|
|
|
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('h-(--sheet-max-height) transition-[height]')
|
|
->toContain('x-ref="probe"')
|
|
->toContain('aria-label="Change the sheet height"')
|
|
->toContain('x-bind:aria-label="handleLabel"')
|
|
->toContain('x-on:click="activate()"')
|
|
->toContain('<span class="sr-only" 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('sr-only')
|
|
->toContain('aria-label="Close"');
|
|
});
|