Divide a dialog's scrolling body from its pinned header and actions

Plan step 23 (containment), containment.md § Missing: "no divider pinned
between a scrolling body and the header/actions". Every dialog now draws a
1px outline-variant rule under the header once the body is scrolled away
from its top, and over the actions while more of it is below; a body that
fits shows neither. The rules are pseudo-elements in the rows' own padding
(dialog.css), so showing one moves nothing, and x-dialog-dividers
(dialog.js) marks the wire:ignore.self <dialog>, which a morph leaves
alone, watching scroll and a ResizeObserver on the body and a wrapper
around the slot. The M3 gaps are split around the rules (8/8 under the
header, 8/16 over the actions). A full-screen dialog's rule sits under its
phone bar, and its action bar's always-on border follows the scroll too.
`separator` now means "draw both rules always" instead of rendering two
<x-divider> elements. The fade uses the effects-fast token, zero under
reduced motion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
Andreas Reinhold / reini
2026-09-14 11:03:05 +02:00
co-authored by Claude Opus 5
parent 12cdeaaf67
commit cbc0fa76d2
6 changed files with 240 additions and 28 deletions
+58
View File
@@ -0,0 +1,58 @@
/*
* `<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;
}
}
+1
View File
@@ -24,6 +24,7 @@
@import './components/groups.css';
@import './components/list.css';
@import './components/collapse.css';
@import './components/dialog.css';
@import './components/field.css';
@import './components/menu.css';
@import './components/selection.css';
+50
View File
@@ -0,0 +1,50 @@
/**
* `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
* `data-overflow-bottom` while more of it is below. resources/css/components/dialog.css draws
* those as the dividers under the pinned header and over the pinned actions; a body that fits
* marks neither.
*
* The marks go on the `<dialog>` rather than the body because the dialog is `wire:ignore.self`: a
* Livewire morph hands the body back the server's attributes, which would wipe a mark until the
* next scroll, but it never touches the dialog's own.
*
* Measured on scroll, and whenever the body or what is in it changes size: the body when the window
* or the dialog does (opening, too — a closed dialog has no size, and the observer reports the
* one it opens to), and the element the component wraps around the slot when a morph, an image or
* a disclosure makes the content taller or shorter while the body stays at its cap.
*/
document.addEventListener('alpine:init', () => {
window.Alpine.directive('dialog-dividers', (el, _, { cleanup }) => {
const dialog = el.closest('dialog')
if (!dialog) {
return
}
// 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.
const measure = () => {
dialog.toggleAttribute('data-overflow-top', el.scrollTop >= 1)
dialog.toggleAttribute('data-overflow-bottom', el.scrollHeight - el.clientHeight - el.scrollTop >= 1)
}
const resizes = new ResizeObserver(measure)
resizes.observe(el)
const content = el.querySelector(':scope > [data-dialog-content]')
if (content) {
resizes.observe(content)
}
el.addEventListener('scroll', measure, { passive: true })
cleanup(() => {
resizes.disconnect()
el.removeEventListener('scroll', measure)
dialog.removeAttribute('data-overflow-top')
dialog.removeAttribute('data-overflow-bottom')
})
})
})
+1
View File
@@ -17,6 +17,7 @@ import './rich-tooltip.js'
import './progress.js'
import './list-rows.js'
import './bottom-sheet.js'
import './dialog.js'
import './carousel.js'
import './chips.js'
import './field.js'
+42 -20
View File
@@ -28,7 +28,21 @@
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.
`separator` draws M3's divider under the pinned header and above the pinned actions.
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
@@ -51,9 +65,12 @@
$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 && $slot->isNotEmpty() ? $id.'-body' : null,
$alert && $body ? $id.'-body' : null,
]));
@endphp
@@ -89,7 +106,10 @@
$boxClass,
])>
@if ($fullscreen)
<div class="flex h-14 shrink-0 items-center gap-1 px-1 medium:hidden">
<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))
@@ -100,7 +120,10 @@
@if ($header)
{{-- A full-screen dialog's bar names it on a phone, so only the subtitle stays there. --}}
<div @class(['shrink-0 px-6 pt-6', 'max-medium:hidden' => $fullscreen && ! $icon && blank($subtitle), 'text-center' => $icon])>
<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
@@ -112,33 +135,32 @@
@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
@if ($separator)
<x-livewire-material::divider class="mt-4" />
@endif
</div>
@endif
@if ($slot->isNotEmpty())
<div id="{{ $id }}-body" @class([
@if ($body)
<div id="{{ $id }}-body" x-dialog-dividers @class([
'min-h-0 flex-1 overflow-y-auto px-6 type-body-md text-on-surface-variant',
'pt-4' => $header,
'pt-2' => $header,
'max-medium:pt-4' => $header && $barAboveBody,
'pt-6' => ! $header,
'pb-2' => isset($actions),
'pb-6' => ! isset($actions),
])>
{{ $slot }}
<div data-dialog-content>{{ $slot }}</div>
</div>
@endif
@isset($actions)
@if ($separator)
<x-livewire-material::divider class="shrink-0" />
@endif
<div @class([
'flex shrink-0 flex-wrap items-center justify-end gap-2 px-6 pt-6 pb-6',
'max-medium:min-h-14 max-medium:border-t max-medium:border-outline-variant max-medium:pt-2 max-medium:pb-2' => $fullscreen,
])>
<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