Rewrite the modal (dialog) without Tailwind

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
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 21:01:05 +02:00
co-authored by Claude Sonnet 5
parent 9ff5a7a809
commit 3c235ecc1e
8 changed files with 382 additions and 192 deletions
@@ -21,6 +21,7 @@ dataset('containment components', [
'card',
'list',
'list-item',
'modal',
]);
/**
+64 -58
View File
@@ -23,14 +23,15 @@ it('opens a native dialog entangled with a Livewire property, out of the morph\'
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('rounded-corner-xl bg-surface-container-high')
->assertSeeHtml('type-headline-sm')
->assertSeeHtml('data-md-modal-box')
->assertSeeHtml('data-md-modal-title')
->assertSee('This cannot be undone.')
->assertSeeHtml('text-center')
->assertSeeHtml('data-md-icon')
->assertDontSeeHtml('wire:model="confirming"');
});
@@ -41,30 +42,36 @@ it('uses the surrounding Alpine scope without wire:model, and stays open when pe
->toContain('x-data="{ close() { this.open = false } }"')
->toContain('x-on:cancel.prevent=""')
->not->toContain('x-on:click.self')
->toContain('max-medium:h-dvh')
->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', function () {
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" class="type-headline-sm max-medium:hidden">/')
->toMatch('/<p id="[^"]+-subtitle" class="type-body-md text-on-surface-variant mt-4 max-medium:mt-0">Scan the code<\/p>/')
->not->toContain('class="shrink-0 px-6 pt-6 pb-2 max-medium:hidden"')
->and((string) $this->blade('<x-modal title="Help" fullscreen>Text</x-modal>'))->toContain('class="shrink-0 px-6 pt-6 pb-2 max-medium:hidden"')
->and((string) $this->blade('<x-modal subtitle="Only a subtitle">Text</x-modal>'))->toMatch('/<p id="[^"]+-subtitle" class="type-body-md text-on-surface-variant">Only a subtitle<\/p>/');
->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('overflow-hidden rounded-corner-xl bg-surface-container-high shadow-elevation-3')
->not->toContain('overflow-y-auto rounded-corner-xl')
->toMatch('/<div\s+data-dialog-head\s+class="shrink-0 px-6 pt-6 pb-2"\s*>/')
->toMatch('/<div id="[^"]+-body" data-dialog-body x-dialog-dividers class="min-h-0 flex-1 overflow-y-auto px-6 type-body-md text-on-surface-variant pt-2 pb-2">\s*<div data-dialog-content>Body<\/div>\s*<\/div>/')
->toMatch('/<div\s+data-dialog-actions\s+class="flex shrink-0 flex-wrap items-center justify-end gap-2 px-6 pb-6 pt-4"\s*>/');
->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 () {
@@ -73,76 +80,74 @@ it('divides a scrolling body from its header and actions only while content is h
// 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-overflow-top')
->not->toContain('data-overflow-bottom')
->not->toContain('data-separator')
->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-dialog-head'))->toBe(1)
->and(substr_count($html, 'data-dialog-actions'))->toBe(1);
->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/dialog.css'))
->toContain('dialog[data-overflow-top] > * > [data-dialog-head]::after')
->toContain('dialog[data-overflow-bottom] > * > [data-dialog-actions]::before')
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 then the rule appears without a fade.
// 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/tailwind.css'))->toContain("@import './components/dialog.css';")
->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-dialog-content]');
->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-dialog-head\s+data-separator\s+class="shrink-0 px-6 pt-6 pb-2"/')
->toMatch('/<div\s+data-dialog-actions\s+data-separator\s+class="flex shrink-0/')
->and(File::get(__DIR__.'/../../../resources/css/components/dialog.css'))
->toContain('[data-dialog-head][data-separator]::after')
->toContain('[data-dialog-actions][data-separator]::before');
->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 keeps M3's 24dp to the actions.
// 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>'))
->not->toContain('data-dialog-head')
->not->toContain('data-dialog-actions')
->not->toContain('data-separator')
->not->toContain('x-dialog-dividers')
->toContain('class="shrink-0 px-6 pt-6"')
->toContain('flex shrink-0 flex-wrap items-center justify-end gap-2 px-6 pb-6 pt-6"');
->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: the body keeps its 24dp top and only the actions carry a rule; no actions: its 24dp bottom.
// 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-dialog-head')
->toContain('data-dialog-actions')
->toContain('text-on-surface-variant pt-6 pb-2"')
->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>'))
->toContain('data-dialog-head')
->not->toContain('data-dialog-actions')
->toContain('text-on-surface-variant pt-2 pb-6"');
->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 hook: the bar shows only below medium, and this header only from it.
// 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-dialog-head\s+class="flex h-14 shrink-0 items-center gap-1 px-1 medium:hidden"/')
->toMatch('/<div\s+data-dialog-head\s+class="shrink-0 px-6 pt-6 pb-2 max-medium:hidden"/')
->toContain('text-on-surface-variant pt-2 max-medium:pt-4 pb-2"')
->and(substr_count($titled, 'data-dialog-head'))->toBe(2);
->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+class="flex h-14 shrink-0 items-center gap-1 px-1 medium:hidden"/')
->toContain('text-on-surface-variant pt-2 pb-6"')
->and(substr_count($subtitled, 'data-dialog-head'))->toBe(1)
->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-dialog-head\s+class="flex h-14 shrink-0/');
->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 () {
@@ -162,10 +167,11 @@ it('is an alert dialog when it interrupts, and describes itself by its subtitle'
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('flex h-14 shrink-0 items-center gap-1 px-1 medium:hidden')
->toContain('max-medium:min-h-14 max-medium:pt-2 max-medium:pb-2')
// Its rule follows the scroll, like any dialog's, instead of always showing.
->not->toContain('max-medium:border-t');
->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 () {