Pin a dialog's headline and actions, and let it be an alert dialog

Plan step 19, containment.md C-06, C-17 and C-20. The whole box scrolled,
so a long dialog scrolled its headline and its buttons away; the header,
the body and the action row are now separate rows of the flex column,
each carrying the 24dp padding the box used to hold, and only the body
scrolls. `separator` draws the divider under the header and above the
actions, where it now stays put. The full-screen header is M3's 56dp
(was 64) and its action bar 56 (was ~72). New `alert` prop for M3's "on
web, basic dialogs should have the alert dialog role", and a `subtitle`
is always the dialog's `aria-describedby`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 06:01:42 +02:00
co-authored by Claude Opus 5
parent f895b553b6
commit 02d908c526
4 changed files with 98 additions and 34 deletions
+37 -4
View File
@@ -49,10 +49,43 @@ it('keeps a full-screen dialog\'s subtitle on a phone, where its bar carries the
expect($html)
->toMatch('/<h2 id="[^"]+-title" class="type-headline-sm max-medium:hidden">/')
->toContain('<p class="type-body-md text-on-surface-variant mt-4 max-medium:mt-0">Scan the code</p>')
->not->toContain('<div class="mb-4 max-medium:hidden">')
->and((string) $this->blade('<x-modal title="Help" fullscreen>Text</x-modal>'))->toContain('<div class="mb-4 max-medium:hidden">')
->and((string) $this->blade('<x-modal subtitle="Only a subtitle">Text</x-modal>'))->toContain('<p class="type-body-md text-on-surface-variant">Only a subtitle</p>');
->toMatch('/<p id="[^"]+-subtitle" class="type-body-md text-on-surface-variant mt-4 max-medium:mt-0">Scan the code<\/p>/')
->not->toContain('<div class="shrink-0 px-6 pt-6 max-medium:hidden">')
->and((string) $this->blade('<x-modal title="Help" fullscreen>Text</x-modal>'))->toContain('<div class="shrink-0 px-6 pt-6 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>/');
});
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" separator>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')
->toContain('<div class="shrink-0 px-6 pt-6">')
->toMatch('/<div id="[^"]+-body" class="min-h-0 flex-1 overflow-y-auto px-6 type-body-md text-on-surface-variant pt-4">/')
->toContain('flex shrink-0 flex-wrap items-center justify-end gap-2 px-6 pt-6 pb-6')
->and(substr_count($html, 'role="separator"'))->toBe(2);
});
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('flex h-14 shrink-0 items-center gap-1 px-1 medium:hidden')
->toContain('max-medium:min-h-14 max-medium:border-t max-medium:border-outline-variant max-medium:pt-2 max-medium:pb-2');
});
it('leaves a pane open on Escape unless it is asked to close then too', function () {