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:
co-authored by
Claude Opus 5
parent
12cdeaaf67
commit
cbc0fa76d2
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user