From 02d908c5264849adede2696c703fd9d75932bd71 Mon Sep 17 00:00:00 2001 From: Andreas Reinhold / reini Date: Mon, 14 Sep 2026 06:01:42 +0200 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9 --- .../livewire-material-development/SKILL.md | 2 +- resources/views/components/modal.blade.php | 87 +++++++++++++------ .../showcase/sections/containment.blade.php | 2 +- tests/Feature/Components/OverlayTest.php | 41 ++++++++- 4 files changed, 98 insertions(+), 34 deletions(-) diff --git a/resources/boost/skills/livewire-material-development/SKILL.md b/resources/boost/skills/livewire-material-development/SKILL.md index 7cd9c5d9..f591786c 100644 --- a/resources/boost/skills/livewire-material-development/SKILL.md +++ b/resources/boost/skills/livewire-material-development/SKILL.md @@ -475,7 +475,7 @@ An M3 dialog on native ``. Bind with `wire:model` to a flag or an id; cl ``` -Props: `title`, `subtitle`, `icon` (centred hero icon), `separator`, `persistent` (no Escape or scrim), `fullscreen` (whole screen on a compact window, below `medium`, for forms — M3 allows a full-screen dialog only there), `box-class`. Never remove its `wire:ignore.self` behaviour by re-rendering it conditionally with `@if`; toggle the bound property instead. +Props: `title`, `subtitle`, `icon` (centred hero icon), `separator` (a divider under the headline and above the actions), `persistent` (no Escape or scrim), `fullscreen` (whole screen on a compact window, below `medium`, for forms — M3 allows a full-screen dialog only there), `alert` (`role="alertdialog"` for a dialog that interrupts to say something important — not for forms), `box-class`. The headline and the action row are pinned and only the body between them scrolls, as M3 requires, so do not put `overflow` on `box-class`. Never remove its `wire:ignore.self` behaviour by re-rendering it conditionally with `@if`; toggle the bound property instead. ### `` diff --git a/resources/views/components/modal.blade.php b/resources/views/components/modal.blade.php index 190a539e..6be807d1 100644 --- a/resources/views/components/modal.blade.php +++ b/resources/views/components/modal.blade.php @@ -20,9 +20,21 @@ on-surface-variant, and the `actions` slot at the end. `icon` puts a secondary-coloured icon above a centred title, as M3 draws a dialog with a hero icon. `fullscreen` makes a dialog that holds a form take the whole screen on a compact window (below `medium`, 600px — M3 uses - full-screen dialogs "only in compact breakpoints"), with a close button and the title in a top bar - clear of the notch. `persistent` ignores Escape and the scrim, for a dialog that must be - answered. It opens on the fast spatial spring and closes at once, as M3's do. --}} + full-screen dialogs "only in compact breakpoints"), with a 56px close-and-title bar clear of + the notch. `persistent` ignores Escape and the scrim, for a dialog that must be + answered. It opens on the fast spatial spring and closes at once, as M3's do. + + "Dialog content generally shouldn't scroll; if it must, the title stays pinned at the top and + the buttons at the bottom" (docs/reference/m3/components-actions-communication-containment.md + § Dialogs → Behaviour): the header and the action row are their own rows of the flex column + and only the body between them scrolls, each with the 24dp padding the box used to carry. + `separator` draws M3's divider under the pinned header and above the pinned actions. + + `alert` is M3's "on web, basic dialogs should have the alert dialog role": it sets + `role="alertdialog"` and points `aria-describedby` at the body, for the dialog that + interrupts to say something important. It is opt-in, because ARIA-APG keeps `alertdialog` + for exactly that and a form dialog would over-announce with it. A `subtitle` is always the + dialog's description. --}} @props([ 'title' => null, @@ -31,12 +43,18 @@ 'separator' => false, 'persistent' => false, 'fullscreen' => false, + 'alert' => false, 'boxClass' => null, ]) @php $model = $attributes->wire('model')->value() ?: null; $id = $attributes->get('id') ?? 'material-dialog-'.substr(md5($model.'|'.$title), 0, 10); + $header = filled($title) || filled($subtitle) || $icon; + $describedBy = implode(' ', array_filter([ + filled($subtitle) ? $id.'-subtitle' : null, + $alert && $slot->isNotEmpty() ? $id.'-body' : null, + ])); @endphp whereDoesntStartWith('wire:model')->except(['id', 'class'])->merge(['id' => $id]) }} @class([ 'm-auto max-h-[calc(100dvh-3rem)] w-[calc(100vw-3rem)] max-w-[35rem] min-w-70 overflow-visible bg-transparent p-0 text-on-surface', @@ -64,12 +84,12 @@ ]) >
$fullscreen, + 'flex max-h-[inherit] flex-col overflow-hidden rounded-corner-xl bg-surface-container-high shadow-elevation-3', + 'max-medium:h-full max-medium:rounded-corner-none max-medium:pt-[var(--material-safe-top,env(safe-area-inset-top))]' => $fullscreen, $boxClass, ])> @if ($fullscreen) -
+
@if (filled($title)) @@ -78,35 +98,46 @@
@endif -
$fullscreen])> - @if (filled($title) || filled($subtitle) || $icon) - {{-- A full-screen dialog's bar names it on a phone, so only the subtitle stays there. --}} -
$fullscreen && ! $icon && blank($subtitle), 'text-center' => $icon])> - @if ($icon) - - @endif + @if ($header) + {{-- A full-screen dialog's bar names it on a phone, so only the subtitle stays there. --}} +
$fullscreen && ! $icon && blank($subtitle), 'text-center' => $icon])> + @if ($icon) + + @endif - @if (filled($title)) -

$fullscreen && ! $icon])>{{ $title }}

- @endif + @if (filled($title)) +

$fullscreen && ! $icon])>{{ $title }}

+ @endif - @if (filled($subtitle)) -

filled($title) || $icon, 'max-medium:mt-0' => $fullscreen && ! $icon])>{{ $subtitle }}

- @endif + @if (filled($subtitle)) +

filled($title) || $icon, 'max-medium:mt-0' => $fullscreen && ! $icon])>{{ $subtitle }}

+ @endif - @if ($separator) - - @endif -
- @endif + @if ($separator) + + @endif +
+ @endif -
{{ $slot }}
-
+ @if ($slot->isNotEmpty()) +
$header, + 'pt-6' => ! $header, + 'pb-6' => ! isset($actions), + ])> + {{ $slot }} +
+ @endif @isset($actions) + @if ($separator) + + @endif +
$fullscreen, + 'flex shrink-0 flex-wrap items-center justify-end gap-2 px-6 pt-6 pb-6', + 'max-medium:min-h-14 max-medium:border-t max-medium:border-outline-variant max-medium:pt-2 max-medium:pb-2' => $fullscreen, ])> {{ $actions }}
diff --git a/resources/views/showcase/sections/containment.blade.php b/resources/views/showcase/sections/containment.blade.php index 45080614..1c810806 100644 --- a/resources/views/showcase/sections/containment.blade.php +++ b/resources/views/showcase/sections/containment.blade.php @@ -78,7 +78,7 @@ 'Dialogs' => <<<'BLADE'
- + diff --git a/tests/Feature/Components/OverlayTest.php b/tests/Feature/Components/OverlayTest.php index 932bbe1c..76733274 100644 --- a/tests/Feature/Components/OverlayTest.php +++ b/tests/Feature/Components/OverlayTest.php @@ -49,10 +49,43 @@ it('keeps a full-screen dialog\'s subtitle on a phone, where its bar carries the expect($html) ->toMatch('/

/') - ->toContain('

Scan the code

') - ->not->toContain('
') - ->and((string) $this->blade('Text'))->toContain('
') - ->and((string) $this->blade('Text'))->toContain('

Only a subtitle

'); + ->toMatch('/

Scan the code<\/p>/') + ->not->toContain('

') + ->and((string) $this->blade('Text'))->toContain('
') + ->and((string) $this->blade('Text'))->toMatch('/

Only a subtitle<\/p>/'); +}); + +it('pins a dialog\'s headline and actions and scrolls only the body between them', function () { + $html = (string) $this->blade('Body'); + + expect($html) + ->toContain('overflow-hidden rounded-corner-xl bg-surface-container-high shadow-elevation-3') + ->not->toContain('overflow-y-auto rounded-corner-xl') + ->toContain('

') + ->toMatch('/
/') + ->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('This cannot be undone.'); + + 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('Body')) + ->not->toContain('role="alertdialog"') + ->toMatch('/aria-describedby="material-dialog-[a-z0-9]+-subtitle"/') + ->and((string) $this->blade('Body')) + ->not->toContain('aria-describedby'); +}); + +it('gives a full-screen dialog M3\'s 56px header and action bar', function () { + expect((string) $this->blade('Body')) + ->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 () {