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
169 lines
8.7 KiB
PHP
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>
|