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 () {