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
+1
View File
@@ -61,5 +61,6 @@
@import './components/card.css'; @import './components/card.css';
@import './components/list-item.css'; @import './components/list-item.css';
@import './components/list.css'; @import './components/list.css';
@import './components/modal.css';
/* Navigation */ /* Navigation */
-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;
}
}
-3
View File
@@ -17,9 +17,6 @@
@import './tokens/theme.css'; @import './tokens/theme.css';
@import './tokens/utilities.css'; @import './tokens/utilities.css';
/* Containment — leaving in step 36 */
@import './components/dialog.css';
/* Navigation — leaving in step 36 */ /* Navigation — leaving in step 36 */
@import './components/tabs.css'; @import './components/tabs.css';
@import './components/app-bar.css'; @import './components/app-bar.css';
+7 -7
View File
@@ -1,7 +1,7 @@
/** /**
* `x-dialog-dividers`, on `<x-modal>`'s body — the one part of a dialog that scrolls: marks the * `x-dialog-dividers`, on `<x-modal>`'s body — the one part of a dialog that scrolls: marks the
* `<dialog>` with `data-overflow-top` while the body is scrolled away from its top, and * `<dialog>` with `data-md-overflow-top` while the body is scrolled away from its top, and
* `data-overflow-bottom` while more of it is below. resources/css/components/dialog.css draws * `data-md-overflow-bottom` while more of it is below. resources/css/components/modal.css draws
* those as the dividers under the pinned header and over the pinned actions; a body that fits * those as the dividers under the pinned header and over the pinned actions; a body that fits
* marks neither. * marks neither.
* *
@@ -25,14 +25,14 @@ document.addEventListener('alpine:init', () => {
// A pixel of slack: at a fractional device pixel ratio the end of a scroll can stop a // A pixel of slack: at a fractional device pixel ratio the end of a scroll can stop a
// fraction short of `scrollHeight`, which is rounded. // fraction short of `scrollHeight`, which is rounded.
const measure = () => { const measure = () => {
dialog.toggleAttribute('data-overflow-top', el.scrollTop >= 1) dialog.toggleAttribute('data-md-overflow-top', el.scrollTop >= 1)
dialog.toggleAttribute('data-overflow-bottom', el.scrollHeight - el.clientHeight - el.scrollTop >= 1) dialog.toggleAttribute('data-md-overflow-bottom', el.scrollHeight - el.clientHeight - el.scrollTop >= 1)
} }
const resizes = new ResizeObserver(measure) const resizes = new ResizeObserver(measure)
resizes.observe(el) resizes.observe(el)
const content = el.querySelector(':scope > [data-dialog-content]') const content = el.querySelector(':scope > [data-md-modal-content]')
if (content) { if (content) {
resizes.observe(content) resizes.observe(content)
@@ -43,8 +43,8 @@ document.addEventListener('alpine:init', () => {
cleanup(() => { cleanup(() => {
resizes.disconnect() resizes.disconnect()
el.removeEventListener('scroll', measure) el.removeEventListener('scroll', measure)
dialog.removeAttribute('data-overflow-top') dialog.removeAttribute('data-md-overflow-top')
dialog.removeAttribute('data-overflow-bottom') dialog.removeAttribute('data-md-overflow-bottom')
}) })
}) })
}) })
+38 -48
View File
@@ -20,14 +20,16 @@
on-surface-variant, and the `actions` slot at the end. `icon` puts a secondary-coloured icon 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 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 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 full-screen dialogs "only in compact breakpoints", FullScreenDialogTokens), with a 56px
the notch. `persistent` ignores Escape and the scrim, for a dialog that must be close-and-title bar clear of the notch (docs/audits/m3-alignment/containment.md, C-17).
answered. It opens on the fast spatial spring and closes at once, as M3's do. `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 "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 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 § Dialogs → Behaviour, C-06): the header and the action row are their own rows of the flex
and only the body between them scrolls, each with the 24dp padding the box used to carry. 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" 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 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 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 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 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; is nothing to divide, and no rule. resources/css/components/modal.css draws the rules, keyed
`x-dialog-dividers` (resources/js/dialog.js), on the body, says which of them show, watching on `data-md-modal-divider` (only where a body exists to divide, the bar's own flag computed in
the body and the one element wrapped around the slot, so content a Livewire render adds PHP because it alone can sit beside a header that is *also* on screen) and `data-md-separator`;
counts. `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 `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` 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 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([ @props([
'title' => null, 'title' => null,
@@ -91,29 +100,22 @@
@if ($alert) role="alertdialog" @endif @if ($alert) role="alertdialog" @endif
@if (filled($title)) aria-labelledby="{{ $id }}-title" @endif @if (filled($title)) aria-labelledby="{{ $id }}-title" @endif
@if (filled($describedBy)) aria-describedby="{{ $describedBy }}" @endif @if (filled($describedBy)) aria-describedby="{{ $describedBy }}" @endif
{{ $attributes->whereDoesntStartWith('wire:model')->except(['id', 'class'])->merge(['id' => $id]) }} {{ $attributes->whereDoesntStartWith('wire:model')->except(['id'])->merge(array_filter([
@class([ 'id' => $id,
'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', 'data-md-modal' => true,
'backdrop:bg-scrim/32', 'data-md-fullscreen' => $fullscreen ? true : null,
'opacity-100 scale-100 starting:opacity-0 starting:scale-95 transition-[opacity,scale] duration-(--md-sys-motion-spatial-fast-duration) ease-spatial-fast', ], fn ($value): bool => $value !== null)) }}
'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([ <div data-md-modal-box @if (filled($boxClass)) class="{{ $boxClass }}" @endif>
'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) @if ($fullscreen)
<div <div
@if ($body && $barAboveBody) data-dialog-head @if ($separator) data-separator @endif @endif data-md-modal-bar
class="flex h-14 shrink-0 items-center gap-1 px-1 medium:hidden" @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()" /> <x-livewire-material::button icon="close" :tooltip="__('Close')" x-on:click="close()" />
@if (filled($title)) @if (filled($title))
<span class="truncate type-title-lg">{{ $title }}</span> <span data-md-modal-bar-title>{{ $title }}</span>
@endif @endif
</div> </div>
@endif @endif
@@ -121,45 +123,33 @@
@if ($header) @if ($header)
{{-- A full-screen dialog's bar names it on a phone, so only the subtitle stays there. --}} {{-- A full-screen dialog's bar names it on a phone, so only the subtitle stays there. --}}
<div <div
@if ($body) data-dialog-head @if ($separator) data-separator @endif @endif data-md-modal-head
@class(['shrink-0 px-6 pt-6', 'pb-2' => $body, 'max-medium:hidden' => $fullscreen && ! $icon && blank($subtitle), 'text-center' => $icon]) @if ($body) data-md-modal-divider @if ($separator) data-md-separator @endif @endif
> >
@if ($icon) @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 @endif
@if (filled($title)) @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 @endif
@if (filled($subtitle)) @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 @endif
</div> </div>
@endif @endif
@if ($body) @if ($body)
<div id="{{ $id }}-body" data-dialog-body x-dialog-dividers @class([ <div id="{{ $id }}-body" data-md-modal-body x-dialog-dividers>
'min-h-0 flex-1 overflow-y-auto px-6 type-body-md text-on-surface-variant', <div data-md-modal-content>{{ $slot }}</div>
'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> </div>
@endif @endif
@isset($actions) @isset($actions)
<div <div
@if ($body) data-dialog-actions @if ($separator) data-separator @endif @endif data-md-modal-actions
@class([ @if ($body) data-md-modal-divider @if ($separator) data-md-separator @endif @endif
'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 }} {{ $actions }}
</div> </div>
@@ -21,6 +21,7 @@ dataset('containment components', [
'card', 'card',
'list', 'list',
'list-item', 'list-item',
'modal',
]); ]);
/** /**
+64 -58
View File
@@ -23,14 +23,15 @@ it('opens a native dialog entangled with a Livewire property, out of the morph\'
Livewire::test($component) Livewire::test($component)
->assertSeeHtml('<dialog') ->assertSeeHtml('<dialog')
->assertSeeHtml('data-md-modal')
->assertSeeHtml('wire:ignore.self') ->assertSeeHtml('wire:ignore.self')
->assertSeeHtml('open: window.Livewire.find(') ->assertSeeHtml('open: window.Livewire.find(')
->assertSeeHtml("entangle('confirming').live") ->assertSeeHtml("entangle('confirming').live")
->assertSeeHtml('close() { this.open = typeof this.open === \'boolean\' ? false : null }') ->assertSeeHtml('close() { this.open = typeof this.open === \'boolean\' ? false : null }')
->assertSeeHtml('rounded-corner-xl bg-surface-container-high') ->assertSeeHtml('data-md-modal-box')
->assertSeeHtml('type-headline-sm') ->assertSeeHtml('data-md-modal-title')
->assertSee('This cannot be undone.') ->assertSee('This cannot be undone.')
->assertSeeHtml('text-center') ->assertSeeHtml('data-md-icon')
->assertDontSeeHtml('wire:model="confirming"'); ->assertDontSeeHtml('wire:model="confirming"');
}); });
@@ -41,30 +42,36 @@ it('uses the surrounding Alpine scope without wire:model, and stays open when pe
->toContain('x-data="{ close() { this.open = false } }"') ->toContain('x-data="{ close() { this.open = false } }"')
->toContain('x-on:cancel.prevent=""') ->toContain('x-on:cancel.prevent=""')
->not->toContain('x-on:click.self') ->not->toContain('x-on:click.self')
->toContain('max-medium:h-dvh') ->toContain('data-md-fullscreen')
->toContain('aria-label="Close"'); ->toContain('aria-label="Close"');
}); });
it('keeps a full-screen dialog\'s subtitle on a phone, where its bar carries the title', function () { it('keeps a full-screen dialog\'s subtitle on a phone, where its bar carries the title, in the CSS', function () {
$html = (string) $this->blade('<x-modal title="Enable 2FA" subtitle="Scan the code" fullscreen>Text</x-modal>'); $html = (string) $this->blade('<x-modal title="Enable 2FA" subtitle="Scan the code" fullscreen>Text</x-modal>');
expect($html) expect($html)
->toMatch('/<h2 id="[^"]+-title" class="type-headline-sm max-medium:hidden">/') ->toMatch('/<h2 id="[^"]+-title" data-md-modal-title>Enable 2FA<\/h2>/')
->toMatch('/<p id="[^"]+-subtitle" class="type-body-md text-on-surface-variant mt-4 max-medium:mt-0">Scan the code<\/p>/') ->toMatch('/<p id="[^"]+-subtitle" data-md-modal-subtitle>Scan the code<\/p>/')
->not->toContain('class="shrink-0 px-6 pt-6 pb-2 max-medium:hidden"') ->and((string) $this->blade('<x-modal subtitle="Only a subtitle">Text</x-modal>'))
->and((string) $this->blade('<x-modal title="Help" fullscreen>Text</x-modal>'))->toContain('class="shrink-0 px-6 pt-6 pb-2 max-medium:hidden"') ->toMatch('/<p id="[^"]+-subtitle" data-md-modal-subtitle>Only a subtitle<\/p>/')
->and((string) $this->blade('<x-modal subtitle="Only a subtitle">Text</x-modal>'))->toMatch('/<p id="[^"]+-subtitle" class="type-body-md text-on-surface-variant">Only a subtitle<\/p>/'); ->not->toContain('data-md-modal-title');
// The markup is the same at every width; modal.css (ContainmentStylesheetsTest) decides which
// of the bar/head/title/subtitle show below 600px, keyed on the icon and the subtitle.
expect(File::get(__DIR__.'/../../../resources/css/components/modal.css'))
->toContain('[data-md-modal][data-md-fullscreen] [data-md-modal-head]:not(:has([data-md-icon])):not(:has([data-md-modal-subtitle]))')
->toContain('[data-md-modal][data-md-fullscreen]:not(:has([data-md-modal-head] [data-md-icon])) [data-md-modal-title]')
->toContain('[data-md-modal][data-md-fullscreen]:not(:has([data-md-modal-head] [data-md-icon])) [data-md-modal-subtitle]');
}); });
it('pins a dialog\'s headline and actions and scrolls only the body between them', function () { it('pins a dialog\'s headline and actions and scrolls only the body between them', function () {
$html = (string) $this->blade('<x-modal title="Terms" subtitle="Please read">Body<x-slot:actions><button>Agree</button></x-slot:actions></x-modal>'); $html = (string) $this->blade('<x-modal title="Terms" subtitle="Please read">Body<x-slot:actions><button>Agree</button></x-slot:actions></x-modal>');
expect($html) expect($html)
->toContain('overflow-hidden rounded-corner-xl bg-surface-container-high shadow-elevation-3') ->toContain('data-md-modal-box')
->not->toContain('overflow-y-auto rounded-corner-xl') ->toMatch('/<div\s+data-md-modal-head\s+data-md-modal-divider\s*>/')
->toMatch('/<div\s+data-dialog-head\s+class="shrink-0 px-6 pt-6 pb-2"\s*>/') ->toMatch('/<div id="[^"]+-body" data-md-modal-body x-dialog-dividers>\s*<div data-md-modal-content>Body<\/div>\s*<\/div>/')
->toMatch('/<div 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 pb-2">\s*<div data-dialog-content>Body<\/div>\s*<\/div>/') ->toMatch('/<div\s+data-md-modal-actions\s+data-md-modal-divider\s*>/');
->toMatch('/<div\s+data-dialog-actions\s+class="flex shrink-0 flex-wrap items-center justify-end gap-2 px-6 pb-6 pt-4"\s*>/');
}); });
it('divides a scrolling body from its header and actions only while content is hidden past them', function () { it('divides a scrolling body from its header and actions only while content is hidden past them', function () {
@@ -73,76 +80,74 @@ it('divides a scrolling body from its header and actions only while content is h
// The script marks the dialog, which a morph leaves alone, and nothing is marked before it runs. // The script marks the dialog, which a morph leaves alone, and nothing is marked before it runs.
expect($html) expect($html)
->toContain('x-dialog-dividers') ->toContain('x-dialog-dividers')
->not->toContain('data-overflow-top') ->not->toContain('data-md-overflow-top')
->not->toContain('data-overflow-bottom') ->not->toContain('data-md-overflow-bottom')
->not->toContain('data-separator') ->not->toContain('data-md-separator')
->not->toContain('role="separator"') ->not->toContain('role="separator"')
->and(substr_count($html, 'data-dialog-head'))->toBe(1) ->and(substr_count($html, 'data-md-modal-head'))->toBe(1)
->and(substr_count($html, 'data-dialog-actions'))->toBe(1); ->and(substr_count($html, 'data-md-modal-actions'))->toBe(1);
expect(File::get(__DIR__.'/../../../resources/css/components/dialog.css')) expect(File::get(__DIR__.'/../../../resources/css/components/modal.css'))
->toContain('dialog[data-overflow-top] > * > [data-dialog-head]::after') ->toContain('dialog[data-md-overflow-top] > [data-md-modal-box] > [data-md-modal-head][data-md-modal-divider]::after')
->toContain('dialog[data-overflow-bottom] > * > [data-dialog-actions]::before') ->toContain('dialog[data-md-overflow-bottom] > [data-md-modal-box] > [data-md-modal-actions][data-md-modal-divider]::before')
->toContain('background-color: var(--md-sys-color-outline-variant);') ->toContain('background-color: var(--md-sys-color-outline-variant);')
->toContain('height: 1px;') ->toContain('height: 1px;')
->toContain('position: absolute;') ->toContain('position: absolute;')
// A token that reduced motion sets to zero, so then the rule appears without a fade. // A token that reduced motion sets to zero, so there the rule simply appears.
->toContain('transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);') ->toContain('transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);')
->and(File::get(__DIR__.'/../../../resources/css/tailwind.css'))->toContain("@import './components/dialog.css';") ->and(File::get(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/modal.css';")
->and(File::get(__DIR__.'/../../../resources/css/tailwind.css'))->not->toContain('dialog.css')
->and(File::get(__DIR__.'/../../../resources/js/material.js'))->toContain("import './dialog.js'") ->and(File::get(__DIR__.'/../../../resources/js/material.js'))->toContain("import './dialog.js'")
->and(File::get(__DIR__.'/../../../resources/js/dialog.js')) ->and(File::get(__DIR__.'/../../../resources/js/dialog.js'))
->toContain("directive('dialog-dividers'") ->toContain("directive('dialog-dividers'")
->toContain('new ResizeObserver(measure)') ->toContain('new ResizeObserver(measure)')
->toContain('[data-dialog-content]'); ->toContain('[data-md-modal-content]');
}); });
it('draws the dividers whatever the scroll with separator, and only where a body has neighbours', function () { it('draws the dividers whatever the scroll with separator, and only where a body has neighbours', function () {
expect((string) $this->blade('<x-modal title="Terms" separator>Body<x-slot:actions><button>Agree</button></x-slot:actions></x-modal>')) expect((string) $this->blade('<x-modal title="Terms" separator>Body<x-slot:actions><button>Agree</button></x-slot:actions></x-modal>'))
->toMatch('/<div\s+data-dialog-head\s+data-separator\s+class="shrink-0 px-6 pt-6 pb-2"/') ->toMatch('/<div\s+data-md-modal-head\s+data-md-modal-divider\s+data-md-separator\s*>/')
->toMatch('/<div\s+data-dialog-actions\s+data-separator\s+class="flex shrink-0/') ->toMatch('/<div\s+data-md-modal-actions\s+data-md-modal-divider\s+data-md-separator\s*>/')
->and(File::get(__DIR__.'/../../../resources/css/components/dialog.css')) ->and(File::get(__DIR__.'/../../../resources/css/components/modal.css'))
->toContain('[data-dialog-head][data-separator]::after') ->toContain('[data-md-modal-head][data-md-modal-divider][data-md-separator]::after')
->toContain('[data-dialog-actions][data-separator]::before'); ->toContain('[data-md-modal-actions][data-md-modal-divider][data-md-separator]::before');
// Without a body there is nothing to divide, and the header keeps M3's 24dp to the actions. // Without a body there is nothing to divide, and the header carries no divider hook.
expect((string) $this->blade('<x-modal title="Discard the draft?" separator><x-slot:actions><button>Discard</button></x-slot:actions></x-modal>')) expect((string) $this->blade('<x-modal title="Discard the draft?" separator><x-slot:actions><button>Discard</button></x-slot:actions></x-modal>'))
->not->toContain('data-dialog-head') ->toMatch('/<div\s+data-md-modal-head\s*>/')
->not->toContain('data-dialog-actions') ->not->toContain('data-md-modal-divider')
->not->toContain('data-separator') ->not->toContain('data-md-separator')
->not->toContain('x-dialog-dividers') ->not->toContain('x-dialog-dividers');
->toContain('class="shrink-0 px-6 pt-6"')
->toContain('flex shrink-0 flex-wrap items-center justify-end gap-2 px-6 pb-6 pt-6"');
// No header: the body keeps its 24dp top and only the actions carry a rule; no actions: its 24dp bottom. // No header: only the actions carry the divider hook; no actions: nothing does.
expect((string) $this->blade('<x-modal>Body<x-slot:actions><button>OK</button></x-slot:actions></x-modal>')) expect((string) $this->blade('<x-modal>Body<x-slot:actions><button>OK</button></x-slot:actions></x-modal>'))
->not->toContain('data-dialog-head') ->not->toContain('data-md-modal-head')
->toContain('data-dialog-actions') ->toMatch('/<div\s+data-md-modal-actions\s+data-md-modal-divider\s*>/')
->toContain('text-on-surface-variant pt-6 pb-2"')
->and((string) $this->blade('<x-modal title="About">Body</x-modal>')) ->and((string) $this->blade('<x-modal title="About">Body</x-modal>'))
->toContain('data-dialog-head') ->toMatch('/<div\s+data-md-modal-head\s+data-md-modal-divider\s*>/')
->not->toContain('data-dialog-actions') ->not->toContain('data-md-modal-actions');
->toContain('text-on-surface-variant pt-2 pb-6"');
}); });
it('puts a full-screen dialog\'s top divider under its bar on a phone, unless the header stays there', function () { it('puts a full-screen dialog\'s top divider under its bar on a phone, unless the header stays there', function () {
$titled = (string) $this->blade('<x-modal title="Share settings" fullscreen>Body<x-slot:actions><button>Save</button></x-slot:actions></x-modal>'); $titled = (string) $this->blade('<x-modal title="Share settings" fullscreen>Body<x-slot:actions><button>Save</button></x-slot:actions></x-modal>');
// Both carry the hook: the bar shows only below medium, and this header only from it. // Both carry the divider hook here (no icon, no subtitle): the bar shows only below medium,
// and this header only from it (modal.css).
expect($titled) expect($titled)
->toMatch('/<div\s+data-dialog-head\s+class="flex h-14 shrink-0 items-center gap-1 px-1 medium:hidden"/') ->toMatch('/<div\s+data-md-modal-bar\s+data-md-modal-divider\s*>/')
->toMatch('/<div\s+data-dialog-head\s+class="shrink-0 px-6 pt-6 pb-2 max-medium:hidden"/') ->toMatch('/<div\s+data-md-modal-head\s+data-md-modal-divider\s*>/')
->toContain('text-on-surface-variant pt-2 max-medium:pt-4 pb-2"') ->and(preg_match_all('/<div\s+data-md-modal-bar[\s>]/', $titled))->toBe(1)
->and(substr_count($titled, 'data-dialog-head'))->toBe(2); ->and(preg_match_all('/<div\s+data-md-modal-head[\s>]/', $titled))->toBe(1);
// A subtitle keeps the header on a phone, so the rule stays under it rather than under the bar. // A subtitle keeps the header on a phone, so the rule stays under it rather than under the bar.
$subtitled = (string) $this->blade('<x-modal title="Share settings" subtitle="Who can see it" fullscreen>Body</x-modal>'); $subtitled = (string) $this->blade('<x-modal title="Share settings" subtitle="Who can see it" fullscreen>Body</x-modal>');
expect($subtitled) expect($subtitled)
->toMatch('/<div\s+class="flex h-14 shrink-0 items-center gap-1 px-1 medium:hidden"/') ->toMatch('/<div\s+data-md-modal-bar\s*>/')
->toContain('text-on-surface-variant pt-2 pb-6"') ->not->toContain('data-md-modal-bar data-md-modal-divider')
->and(substr_count($subtitled, 'data-dialog-head'))->toBe(1) ->toMatch('/<div\s+data-md-modal-head\s+data-md-modal-divider\s*>/')
->and((string) $this->blade('<x-modal fullscreen>Body</x-modal>')) ->and((string) $this->blade('<x-modal fullscreen>Body</x-modal>'))
->toMatch('/<div\s+data-dialog-head\s+class="flex h-14 shrink-0/'); ->toMatch('/<div\s+data-md-modal-bar\s+data-md-modal-divider\s*>/');
}); });
it('is an alert dialog when it interrupts, and describes itself by its subtitle', function () { it('is an alert dialog when it interrupts, and describes itself by its subtitle', function () {
@@ -162,10 +167,11 @@ it('is an alert dialog when it interrupts, and describes itself by its subtitle'
it('gives a full-screen dialog M3\'s 56px header and action bar', function () { it('gives a full-screen dialog M3\'s 56px header and action bar', function () {
expect((string) $this->blade('<x-modal title="Share settings" fullscreen>Body<x-slot:actions><button>Save</button></x-slot:actions></x-modal>')) expect((string) $this->blade('<x-modal title="Share settings" fullscreen>Body<x-slot:actions><button>Save</button></x-slot:actions></x-modal>'))
->toContain('flex h-14 shrink-0 items-center gap-1 px-1 medium:hidden') ->toContain('data-md-modal-bar')
->toContain('max-medium:min-h-14 max-medium:pt-2 max-medium:pb-2') ->toContain('data-md-fullscreen')
// Its rule follows the scroll, like any dialog's, instead of always showing. ->and(File::get(__DIR__.'/../../../resources/css/components/modal.css'))
->not->toContain('max-medium:border-t'); ->toContain('height: var(--md-sys-measurement-space700);')
->toContain('min-height: var(--md-sys-measurement-space700);');
}); });
it('leaves a pane open on Escape unless it is asked to close then too', function () { it('leaves a pane open on Escape unless it is asked to close then too', function () {