Rewrite the modal (dialog) without Tailwind

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
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 21:01:05 +02:00
co-authored by Claude Sonnet 5
parent 9ff5a7a809
commit 3c235ecc1e
8 changed files with 382 additions and 192 deletions
+38 -48
View File
@@ -20,14 +20,16 @@
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.
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): 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.
§ 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
@@ -39,16 +41,23 @@
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.
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": it sets
`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. --}}
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,
@@ -91,29 +100,22 @@
@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'),
])
{{ $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 @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,
])>
<div data-md-modal-box @if (filled($boxClass)) class="{{ $boxClass }}" @endif>
@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"
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 class="truncate type-title-lg">{{ $title }}</span>
<span data-md-modal-bar-title>{{ $title }}</span>
@endif
</div>
@endif
@@ -121,45 +123,33 @@
@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])
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" class="mx-auto mb-4 size-6 text-secondary" />
<x-livewire-material::icon :name="$icon" size="24" />
@endif
@if (filled($title))
<h2 id="{{ $id }}-title" @class(['type-headline-sm', 'max-medium:hidden' => $fullscreen && ! $icon])>{{ $title }}</h2>
<h2 id="{{ $id }}-title" data-md-modal-title>{{ $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>
<p id="{{ $id }}-subtitle" data-md-modal-subtitle>{{ $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 id="{{ $id }}-body" data-md-modal-body x-dialog-dividers>
<div data-md-modal-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,
])
data-md-modal-actions
@if ($body) data-md-modal-divider @if ($separator) data-md-separator @endif @endif
>
{{ $actions }}
</div>