Files
livewire-material/resources/views/components/modal.blade.php
T
Andreas Reinhold / reiniandClaude Opus 5 ff16b12b3b Draw M3's focus ring on a dialog's scrolling body
Chrome makes a scroll container with nothing focusable inside a keyboard stop, and
showModal() gives it the dialog's first focus, which showed the browser's own ring.
The body now takes M3's 3px secondary indicator, inset so the dialog's rounded,
overflow-hidden box does not clip it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-14 11:04:40 +02:00

169 lines
8.7 KiB
PHP

{{-- An M3 dialog, on the native `<dialog>` opened with `showModal()`.
Native because it gets the hard parts right on its own: the top layer above everything, the
rest of the page inert, focus moved in and handed back, Escape. The open state is the Livewire
property in `wire:model` (entangled live) a flag (`$confirmingDelete`) or an id
(`$deletingShareId`) and closing writes back whichever "closed" means for it, `false` or
`null`. Without `wire:model` it reads and writes `open` in the Alpine scope around it:
<div x-data="{ open: false }">
<x-button label="Delete" x-on:click="open = true" />
<x-modal title="Delete this share?"></x-modal>
</div>
`wire:ignore.self`, because `showModal()` sets the `open` attribute, which the server's HTML
does not have: without it the next Livewire render morphs the attribute away and the dialog
shuts under the person using it. The contents still morph.
M3's basic dialog (DialogTokens, androidx Compose Material 3, Apache-2.0): surface-container-
high, extra-large corner, elevation 3, a headline-small `title`, body-medium `subtitle` in
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 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.
Between that body and the rows pinned around it go M3's optional dividers (the 1dp "Divider"
in both dialogs' anatomy, § Dialogs → Anatomy, Specs), each only while content is hidden on
its far side: the rule under the header once the body is scrolled away from its top, the rule
over the actions while more of the body is below, so a body that fits draws neither. On a
phone a full-screen dialog's top rule lies under its close-and-title bar, unless a subtitle
or an icon keeps the header there, and its action bar's rule follows the scroll like any
other. `separator` draws both rules whatever the scroll. The gaps M3 gives are split around
the rules — 8px above and 8px below the top one (16dp title to body), 8px above and 16px below
the bottom one (24dp body to actions) — so the scrolling body keeps room inside its edges for
a floating label or a focus ring, and a rule that appears moves nothing. Without a body there
is nothing to divide, and no rule. resources/css/components/dialog.css draws the rules;
`x-dialog-dividers` (resources/js/dialog.js), on the body, says which of them show, watching
the body and the one element wrapped around the slot, so content a Livewire render adds
counts.
`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,
'subtitle' => null,
'icon' => null,
'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;
$body = $slot->isNotEmpty();
// On a phone a full-screen dialog's bar names it, and the header hides unless an icon or a subtitle keeps it.
$barAboveBody = $fullscreen && (! $header || (! $icon && blank($subtitle)));
$describedBy = implode(' ', array_filter([
filled($subtitle) ? $id.'-subtitle' : null,
$alert && $body ? $id.'-body' : null,
]));
@endphp
<dialog
wire:ignore.self
@if ($model !== null)
x-data="{
open: @entangle($attributes->wire('model')).live,
close() { this.open = typeof this.open === 'boolean' ? false : null },
}"
@else
x-data="{ close() { this.open = false } }"
@endif
x-effect="open ? ($el.open || $el.showModal()) : ($el.open && $el.close())"
x-on:cancel.prevent="{{ $persistent ? '' : 'close()' }}"
x-on:close="if (open) close()"
@if (! $persistent) x-on:click.self="close()" @endif
@if ($alert) role="alertdialog" @endif
@if (filled($title)) aria-labelledby="{{ $id }}-title" @endif
@if (filled($describedBy)) aria-describedby="{{ $describedBy }}" @endif
{{ $attributes->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',
'backdrop:bg-scrim/32',
'opacity-100 scale-100 starting:opacity-0 starting:scale-95 transition-[opacity,scale] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast',
'max-medium:m-0 max-medium:h-dvh max-medium:max-h-none max-medium:w-full max-medium:max-w-none max-medium:min-w-0' => $fullscreen,
$attributes->get('class'),
])
>
<div @class([
'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)
<div
@if ($body && $barAboveBody) data-dialog-head @if ($separator) data-separator @endif @endif
class="flex h-14 shrink-0 items-center gap-1 px-1 medium:hidden"
>
<x-livewire-material::button icon="close" :tooltip="__('Close')" x-on:click="close()" />
@if (filled($title))
<span class="truncate type-title-lg">{{ $title }}</span>
@endif
</div>
@endif
@if ($header)
{{-- A full-screen dialog's bar names it on a phone, so only the subtitle stays there. --}}
<div
@if ($body) data-dialog-head @if ($separator) data-separator @endif @endif
@class(['shrink-0 px-6 pt-6', 'pb-2' => $body, 'max-medium:hidden' => $fullscreen && ! $icon && blank($subtitle), 'text-center' => $icon])
>
@if ($icon)
<x-livewire-material::icon :name="$icon" class="mx-auto mb-4 size-6 text-secondary" />
@endif
@if (filled($title))
<h2 id="{{ $id }}-title" @class(['type-headline-sm', 'max-medium:hidden' => $fullscreen && ! $icon])>{{ $title }}</h2>
@endif
@if (filled($subtitle))
<p id="{{ $id }}-subtitle" @class(['type-body-md text-on-surface-variant', 'mt-4' => filled($title) || $icon, 'max-medium:mt-0' => $fullscreen && ! $icon])>{{ $subtitle }}</p>
@endif
</div>
@endif
@if ($body)
<div id="{{ $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' => $header,
'max-medium:pt-4' => $header && $barAboveBody,
'pt-6' => ! $header,
'pb-2' => isset($actions),
'pb-6' => ! isset($actions),
])>
<div data-dialog-content>{{ $slot }}</div>
</div>
@endif
@isset($actions)
<div
@if ($body) data-dialog-actions @if ($separator) data-separator @endif @endif
@class([
'flex shrink-0 flex-wrap items-center justify-end gap-2 px-6 pb-6',
'pt-4' => $body,
'pt-6' => ! $body,
'max-medium:min-h-14 max-medium:pt-2 max-medium:pb-2' => $fullscreen,
])
>
{{ $actions }}
</div>
@endisset
</div>
</dialog>