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
-76
View File
@@ -1,76 +0,0 @@
/*
* `<x-modal>`'s dividers: the rule under the pinned header and the rule over the pinned actions.
*
* M3 lists a divider in the anatomy of both dialogs, 1dp high (docs/reference/m3/
* components-actions-communication-containment.md § Dialogs → Anatomy, Specs), in outline-variant
* (DividerTokens: Color = OutlineVariant, Thickness = 1dp, androidx Compose Material 3,
* Apache-2.0). A scrolling dialog keeps its title and buttons pinned and scrolls only what is
* between them (§ Dialogs → Behaviour), and a rule there only says something while content is
* hidden on its far side: the one under the header shows once the body is scrolled away from its
* top, the one over the actions while more of the body is below. resources/js/dialog.js marks the
* `<dialog>` with `data-overflow-top` and `data-overflow-bottom`; a row with `data-separator` (the
* `separator` prop) draws its rule whatever the scroll.
*
* Each rule is a pseudo-element laid over the edge of its own row's padding, as Material Web's
* dialog lays its dividers at the bottom of the headline and the top of the actions
* (material-components/material-web, dialog/internal/_dialog.scss): it takes no room, so showing
* it moves nothing, and it lies outside the body's scrollport, so no content scrolls over it. The
* rule is full-bleed, the width of the dialog. Only the head and actions of a dialog that has a
* body carry the hooks (resources/views/components/modal.blade.php).
*
* The child combinators tie a mark to its own dialog's rows, never to those of a dialog opened
* from inside its body. The rule fades on the effects-fast token, which reduced motion sets to
* zero (resources/css/tokens/motion.css), so there it simply appears.
*/
@layer components {
[data-dialog-head],
[data-dialog-actions] {
position: relative;
}
[data-dialog-head]::after,
[data-dialog-actions]::before {
content: '';
position: absolute;
inset-inline: 0;
height: 1px;
background-color: var(--md-sys-color-outline-variant);
opacity: 0;
pointer-events: none;
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
[data-dialog-head]::after {
bottom: 0;
}
[data-dialog-actions]::before {
top: 0;
}
dialog[data-overflow-top] > * > [data-dialog-head]::after,
dialog[data-overflow-bottom] > * > [data-dialog-actions]::before,
[data-dialog-head][data-separator]::after,
[data-dialog-actions][data-separator]::before {
opacity: 1;
}
}
/*
* The scrolling body is a keyboard stop of its own when nothing inside it can take focus: Chrome
* makes a scroll container focusable so the keyboard can scroll it, and `showModal()` hands it the
* dialog's first focus. It then showed the browser's own ring. It takes M3's focus indicator
* instead — 3px in secondary (tokens/state.css) — drawn inside its edge, because the dialog's
* rounded, overflow-hidden box would clip a ring drawn 2px outside.
*/
@layer components {
[data-dialog-body] {
outline: none;
}
[data-dialog-body]:focus-visible {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: -3px;
}
}
+271
View File
@@ -0,0 +1,271 @@
/*
* `<x-modal>`: M3's basic and full-screen dialogs (DialogTokens.kt, FullScreenDialogTokens.kt,
* androidx Compose Material 3, Apache-2.0; docs/reference/m3/
* components-actions-communication-containment.md § Dialogs → Specs).
*
* [data-md-modal] the <dialog>; data-md-fullscreen while `fullscreen` holds
* [data-md-modal-box] surface-container-high, extra-large corner, elevation 3
* [data-md-modal-bar] the 56px phone-only header bar (fullscreen, below 600px)
* [data-md-modal-head] title/subtitle/icon, pinned
* [data-md-modal-body] the only part that scrolls; [data-md-modal-content] wraps the slot
* [data-md-modal-actions] pinned, trailing-aligned
*
* 560/280px width (min/max), 48px scrim margin, 28px corner and 24dp padding all match the specs
* page; a compact window's full-screen dialog swaps the box for the whole screen with a 56px bar
* (C-17: this used to be 64px) instead of the headline block. It opens on the fast spatial spring
* and closes at once — a native `<dialog>` makes an exit transition awkward, since
* `@starting-style` only ever supplies an entry.
*
* The dividers (§ Dialogs → Anatomy, C-06): a 1px outline-variant rule under the pinned head and
* over the pinned actions, each only while the body has more content hidden on its side.
* resources/js/dialog.js marks the `<dialog>` with data-md-overflow-top/-bottom as the body
* scrolls; `data-md-modal-divider` says an element takes part at all — the head and the actions
* always do while a body exists (structural: whichever follows or precedes it), the phone bar
* only when it is the row actually sitting above the body there (computed in the view, because a
* subtitle or an icon can keep the head on screen too, in which case the bar must not draw a rule
* the head already owns); `data-md-separator` (the `separator` prop) draws the rule whatever the
* scroll. The rule is a pseudo-element over the row's own edge, so showing it moves nothing.
*
* `[data-md-modal-body]:focus-visible` is Chrome's own focusable scroll container catching the
* dialog's first focus when nothing inside can take it — M3's 3px secondary indicator, drawn
* inside the edge because the box's rounded, overflow-hidden corner would clip one drawn outside.
*/
@layer material.reset, material.tokens, material.base, material.layout, material.components, material.text, material.visibility;
@import './icon.css';
@import './button.css';
@layer material.components {
[data-md-modal] {
margin: auto;
max-width: 560px;
min-width: 280px;
width: calc(100vw - 48px);
max-height: calc(100dvh - 48px);
overflow: visible;
padding: 0;
background-color: transparent;
color: var(--md-sys-color-on-surface);
opacity: 1;
scale: 1;
transition-property: opacity, scale;
transition-duration: var(--md-sys-motion-spatial-fast-duration);
transition-timing-function: var(--md-sys-motion-spatial-fast);
}
@starting-style {
[data-md-modal] {
opacity: 0;
scale: 0.95;
}
}
[data-md-modal]::backdrop {
background-color: color-mix(in srgb, var(--md-sys-color-scrim) 32%, transparent);
}
@media (width < 600px) {
[data-md-modal][data-md-fullscreen] {
margin: 0;
width: 100%;
max-width: none;
min-width: 0;
height: 100dvh;
max-height: none;
}
}
[data-md-modal-box] {
display: flex;
max-height: inherit;
flex-direction: column;
overflow: hidden;
border-radius: var(--md-sys-shape-corner-xl);
background-color: var(--md-sys-color-surface-container-high);
box-shadow: var(--md-sys-elevation-3);
}
@media (width < 600px) {
[data-md-modal][data-md-fullscreen] > [data-md-modal-box] {
height: 100%;
border-radius: var(--md-sys-shape-corner-none);
padding-top: var(--material-safe-top, env(safe-area-inset-top));
}
}
/* The phone-only header bar: M3's full-screen dialog header, 56dp (C-17). */
[data-md-modal-bar] {
display: flex;
height: var(--md-sys-measurement-space700);
flex-shrink: 0;
align-items: center;
gap: var(--md-sys-measurement-space50);
padding-inline: var(--md-sys-measurement-space50);
}
@media (width >= 600px) {
[data-md-modal-bar] {
display: none;
}
}
[data-md-modal-bar-title] {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font: var(--md-sys-typescale-title-lg);
letter-spacing: var(--md-sys-typescale-title-lg-tracking);
}
[data-md-modal-head] {
flex-shrink: 0;
padding-inline: var(--md-sys-measurement-space300);
padding-top: var(--md-sys-measurement-space300);
}
[data-md-modal-head]:has(+ [data-md-modal-body]) {
padding-bottom: var(--md-sys-measurement-space100);
}
[data-md-modal-head]:has([data-md-icon]) {
text-align: center;
}
/* On a phone the bar already names a plain dialog; the head hides unless an icon or a
subtitle keeps it there (the bar cannot hold either). */
@media (width < 600px) {
[data-md-modal][data-md-fullscreen] [data-md-modal-head]:not(:has([data-md-icon])):not(:has([data-md-modal-subtitle])) {
display: none;
}
}
[data-md-modal-head] > [data-md-icon] {
margin-inline: auto;
margin-bottom: var(--md-sys-measurement-space200);
color: var(--md-sys-color-secondary);
}
[data-md-modal-title] {
font: var(--md-sys-typescale-headline-sm);
letter-spacing: var(--md-sys-typescale-headline-sm-tracking);
}
@media (width < 600px) {
[data-md-modal][data-md-fullscreen]:not(:has([data-md-modal-head] [data-md-icon])) [data-md-modal-title] {
display: none;
}
}
[data-md-modal-subtitle] {
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-md);
letter-spacing: var(--md-sys-typescale-body-md-tracking);
}
[data-md-modal-title] ~ [data-md-modal-subtitle],
[data-md-modal-head] > [data-md-icon] ~ [data-md-modal-subtitle] {
margin-top: var(--md-sys-measurement-space200);
}
@media (width < 600px) {
[data-md-modal][data-md-fullscreen]:not(:has([data-md-modal-head] [data-md-icon])) [data-md-modal-subtitle] {
margin-top: 0;
}
}
[data-md-modal-body] {
min-height: 0;
flex: 1 1 0%;
overflow-y: auto;
padding-inline: var(--md-sys-measurement-space300);
padding-top: var(--md-sys-measurement-space300);
padding-bottom: var(--md-sys-measurement-space300);
color: var(--md-sys-color-on-surface-variant);
font: var(--md-sys-typescale-body-md);
letter-spacing: var(--md-sys-typescale-body-md-tracking);
outline: none;
}
[data-md-modal-body]:focus-visible {
outline: 3px solid var(--md-sys-color-secondary);
outline-offset: -3px;
}
[data-md-modal-head] ~ [data-md-modal-body] {
padding-top: var(--md-sys-measurement-space100);
}
@media (width < 600px) {
[data-md-modal][data-md-fullscreen] [data-md-modal-head]:not(:has([data-md-icon])):not(:has([data-md-modal-subtitle])) ~ [data-md-modal-body] {
padding-top: var(--md-sys-measurement-space200);
}
}
[data-md-modal-body]:has(~ [data-md-modal-actions]) {
padding-bottom: var(--md-sys-measurement-space100);
}
[data-md-modal-actions] {
display: flex;
flex-shrink: 0;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: var(--md-sys-measurement-space100);
padding-inline: var(--md-sys-measurement-space300);
padding-top: var(--md-sys-measurement-space300);
padding-bottom: var(--md-sys-measurement-space300);
}
[data-md-modal-body] ~ [data-md-modal-actions] {
padding-top: var(--md-sys-measurement-space200);
}
@media (width < 600px) {
[data-md-modal][data-md-fullscreen] [data-md-modal-actions] {
min-height: var(--md-sys-measurement-space700);
padding-top: var(--md-sys-measurement-space100);
padding-bottom: var(--md-sys-measurement-space100);
}
}
/* The pinned-edge dividers: a pseudo-element over the row's own padding, drawn only while
something is scrolled past it (or `separator` says always). */
[data-md-modal-head],
[data-md-modal-bar],
[data-md-modal-actions] {
position: relative;
}
[data-md-modal-head]::after,
[data-md-modal-bar]::after,
[data-md-modal-actions]::before {
content: '';
position: absolute;
inset-inline: 0;
height: 1px;
background-color: var(--md-sys-color-outline-variant);
opacity: 0;
pointer-events: none;
transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);
}
[data-md-modal-head]::after,
[data-md-modal-bar]::after {
bottom: 0;
}
[data-md-modal-actions]::before {
top: 0;
}
dialog[data-md-overflow-top] > [data-md-modal-box] > [data-md-modal-head][data-md-modal-divider]::after,
dialog[data-md-overflow-top] > [data-md-modal-box] > [data-md-modal-bar][data-md-modal-divider]::after,
dialog[data-md-overflow-bottom] > [data-md-modal-box] > [data-md-modal-actions][data-md-modal-divider]::before,
[data-md-modal-head][data-md-modal-divider][data-md-separator]::after,
[data-md-modal-bar][data-md-modal-divider][data-md-separator]::after,
[data-md-modal-actions][data-md-modal-divider][data-md-separator]::before {
opacity: 1;
}
}