Plan step 36 (containment group): <x-modal>'s class lists move into resources/css/components/modal.css, keyed on data-md-modal (with data-md-fullscreen) and data-md-modal-box/-bar/-head/-title/-subtitle/ -body/-content/-actions. Sizes stay px (560/280/48px matching DialogTokens.kt/FullScreenDialogTokens.kt and the 56px full-screen header, C-17), spacing the measurement tokens, breakpoints a literal 600px range query. The phone-only header bar and the pinned head/actions keep their existing responsive behaviour (a subtitle or an icon keeps the head on screen below 600px; only the bar's own divider-eligibility needs a computed flag, since it alone can sit beside a head that is also on screen) — reproduced with :has() against data-md-icon/ data-md-modal-subtitle wherever that is self-contained, and with an explicit data-md-modal-divider attribute where it is not. The scroll-driven dividers rename data-dialog-head/-actions -> data-md-modal-head/-actions, data-overflow-top/-bottom -> data-md-overflow-top/-bottom (resources/js/dialog.js); the dialog-dividers Alpine directive keeps its name, since it isn't a data-md-* hook. resources/css/components/dialog.css (Tailwind-era) is replaced by modal.css, imported from the Containment block of components.css; its import leaves resources/css/tailwind.css. No other component renders <x-modal> or dialog.css's rules (checked: datepicker, timepicker and search draw their own dialog-shaped surfaces independently). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
159 lines
7.9 KiB
PHP
159 lines
7.9 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", FullScreenDialogTokens), with a 56px
|
|
close-and-title bar clear of the notch (docs/audits/m3-alignment/containment.md, C-17).
|
|
`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, C-06): 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/modal.css draws the rules, keyed
|
|
on `data-md-modal-divider` (only where a body exists to divide, the bar's own flag computed in
|
|
PHP because it alone can sit beside a header that is *also* on screen) and `data-md-separator`;
|
|
`x-dialog-dividers` (resources/js/dialog.js), on the body, watches it and the element wrapped
|
|
around the slot, so content a Livewire render adds counts, and marks the `<dialog>` with
|
|
`data-md-overflow-top`/`data-md-overflow-bottom`.
|
|
|
|
`alert` is M3's "on web, basic dialogs should have the alert dialog role" (C-20): 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.
|
|
|
|
The root renders `data-md-modal` and `data-md-fullscreen`; its parts are `data-md-modal-*`,
|
|
drawn by resources/css/components/modal.css: `-box`, `-bar` (the phone-only header bar),
|
|
`-head`, `-title`, `-subtitle`, `-body`, `-content` (the resize-observed wrapper around the
|
|
slot), `-actions`. `box-class` lands on the box. --}}
|
|
|
|
@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'])->merge(array_filter([
|
|
'id' => $id,
|
|
'data-md-modal' => true,
|
|
'data-md-fullscreen' => $fullscreen ? true : null,
|
|
], fn ($value): bool => $value !== null)) }}
|
|
>
|
|
<div data-md-modal-box @if (filled($boxClass)) class="{{ $boxClass }}" @endif>
|
|
@if ($fullscreen)
|
|
<div
|
|
data-md-modal-bar
|
|
@if ($body && $barAboveBody) data-md-modal-divider @if ($separator) data-md-separator @endif @endif
|
|
>
|
|
<x-livewire-material::button icon="close" :tooltip="__('Close')" x-on:click="close()" />
|
|
|
|
@if (filled($title))
|
|
<span data-md-modal-bar-title>{{ $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
|
|
data-md-modal-head
|
|
@if ($body) data-md-modal-divider @if ($separator) data-md-separator @endif @endif
|
|
>
|
|
@if ($icon)
|
|
<x-livewire-material::icon :name="$icon" size="24" />
|
|
@endif
|
|
|
|
@if (filled($title))
|
|
<h2 id="{{ $id }}-title" data-md-modal-title>{{ $title }}</h2>
|
|
@endif
|
|
|
|
@if (filled($subtitle))
|
|
<p id="{{ $id }}-subtitle" data-md-modal-subtitle>{{ $subtitle }}</p>
|
|
@endif
|
|
</div>
|
|
@endif
|
|
|
|
@if ($body)
|
|
<div id="{{ $id }}-body" data-md-modal-body x-dialog-dividers>
|
|
<div data-md-modal-content>{{ $slot }}</div>
|
|
</div>
|
|
@endif
|
|
|
|
@isset($actions)
|
|
<div
|
|
data-md-modal-actions
|
|
@if ($body) data-md-modal-divider @if ($separator) data-md-separator @endif @endif
|
|
>
|
|
{{ $actions }}
|
|
</div>
|
|
@endisset
|
|
</div>
|
|
</dialog>
|