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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Livewire\Component;
|
||||
use Livewire\Livewire;
|
||||
|
||||
@@ -50,21 +51,98 @@ it('keeps a full-screen dialog\'s subtitle on a phone, where its bar carries the
|
||||
expect($html)
|
||||
->toMatch('/<h2 id="[^"]+-title" class="type-headline-sm max-medium:hidden">/')
|
||||
->toMatch('/<p id="[^"]+-subtitle" class="type-body-md text-on-surface-variant mt-4 max-medium:mt-0">Scan the code<\/p>/')
|
||||
->not->toContain('<div class="shrink-0 px-6 pt-6 max-medium:hidden">')
|
||||
->and((string) $this->blade('<x-modal title="Help" fullscreen>Text</x-modal>'))->toContain('<div class="shrink-0 px-6 pt-6 max-medium:hidden">')
|
||||
->not->toContain('class="shrink-0 px-6 pt-6 pb-2 max-medium:hidden"')
|
||||
->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"')
|
||||
->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>/');
|
||||
});
|
||||
|
||||
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" separator>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)
|
||||
->toContain('overflow-hidden rounded-corner-xl bg-surface-container-high shadow-elevation-3')
|
||||
->not->toContain('overflow-y-auto rounded-corner-xl')
|
||||
->toContain('<div class="shrink-0 px-6 pt-6">')
|
||||
->toMatch('/<div id="[^"]+-body" class="min-h-0 flex-1 overflow-y-auto px-6 type-body-md text-on-surface-variant pt-4">/')
|
||||
->toContain('flex shrink-0 flex-wrap items-center justify-end gap-2 px-6 pt-6 pb-6')
|
||||
->and(substr_count($html, 'role="separator"'))->toBe(2);
|
||||
->toMatch('/<div\s+data-dialog-head\s+class="shrink-0 px-6 pt-6 pb-2"\s*>/')
|
||||
->toMatch('/<div id="[^"]+-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-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 () {
|
||||
$html = (string) $this->blade('<x-modal title="Terms">Body<x-slot:actions><button>Agree</button></x-slot:actions></x-modal>');
|
||||
|
||||
// The script marks the dialog, which a morph leaves alone, and nothing is marked before it runs.
|
||||
expect($html)
|
||||
->toContain('x-dialog-dividers')
|
||||
->not->toContain('data-overflow-top')
|
||||
->not->toContain('data-overflow-bottom')
|
||||
->not->toContain('data-separator')
|
||||
->not->toContain('role="separator"')
|
||||
->and(substr_count($html, 'data-dialog-head'))->toBe(1)
|
||||
->and(substr_count($html, 'data-dialog-actions'))->toBe(1);
|
||||
|
||||
expect(File::get(__DIR__.'/../../../resources/css/components/dialog.css'))
|
||||
->toContain('dialog[data-overflow-top] > * > [data-dialog-head]::after')
|
||||
->toContain('dialog[data-overflow-bottom] > * > [data-dialog-actions]::before')
|
||||
->toContain('background-color: var(--md-sys-color-outline-variant);')
|
||||
->toContain('height: 1px;')
|
||||
->toContain('position: absolute;')
|
||||
// A token that reduced motion sets to zero, so then the rule appears without a fade.
|
||||
->toContain('transition: opacity var(--md-sys-motion-effects-fast-duration) var(--md-sys-motion-effects-fast);')
|
||||
->and(File::get(__DIR__.'/../../../resources/css/material.css'))->toContain("@import './components/dialog.css';")
|
||||
->and(File::get(__DIR__.'/../../../resources/js/material.js'))->toContain("import './dialog.js'")
|
||||
->and(File::get(__DIR__.'/../../../resources/js/dialog.js'))
|
||||
->toContain("directive('dialog-dividers'")
|
||||
->toContain('new ResizeObserver(measure)')
|
||||
->toContain('[data-dialog-content]');
|
||||
});
|
||||
|
||||
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>'))
|
||||
->toMatch('/<div\s+data-dialog-head\s+data-separator\s+class="shrink-0 px-6 pt-6 pb-2"/')
|
||||
->toMatch('/<div\s+data-dialog-actions\s+data-separator\s+class="flex shrink-0/')
|
||||
->and(File::get(__DIR__.'/../../../resources/css/components/dialog.css'))
|
||||
->toContain('[data-dialog-head][data-separator]::after')
|
||||
->toContain('[data-dialog-actions][data-separator]::before');
|
||||
|
||||
// Without a body there is nothing to divide, and the header keeps M3's 24dp to the actions.
|
||||
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')
|
||||
->not->toContain('data-dialog-actions')
|
||||
->not->toContain('data-separator')
|
||||
->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.
|
||||
expect((string) $this->blade('<x-modal>Body<x-slot:actions><button>OK</button></x-slot:actions></x-modal>'))
|
||||
->not->toContain('data-dialog-head')
|
||||
->toContain('data-dialog-actions')
|
||||
->toContain('text-on-surface-variant pt-6 pb-2"')
|
||||
->and((string) $this->blade('<x-modal title="About">Body</x-modal>'))
|
||||
->toContain('data-dialog-head')
|
||||
->not->toContain('data-dialog-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 () {
|
||||
$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.
|
||||
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-dialog-head\s+class="shrink-0 px-6 pt-6 pb-2 max-medium:hidden"/')
|
||||
->toContain('text-on-surface-variant pt-2 max-medium:pt-4 pb-2"')
|
||||
->and(substr_count($titled, 'data-dialog-head'))->toBe(2);
|
||||
|
||||
// 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>');
|
||||
|
||||
expect($subtitled)
|
||||
->toMatch('/<div\s+class="flex h-14 shrink-0 items-center gap-1 px-1 medium:hidden"/')
|
||||
->toContain('text-on-surface-variant pt-2 pb-6"')
|
||||
->and(substr_count($subtitled, 'data-dialog-head'))->toBe(1)
|
||||
->and((string) $this->blade('<x-modal fullscreen>Body</x-modal>'))
|
||||
->toMatch('/<div\s+data-dialog-head\s+class="flex h-14 shrink-0/');
|
||||
});
|
||||
|
||||
it('is an alert dialog when it interrupts, and describes itself by its subtitle', function () {
|
||||
@@ -85,7 +163,9 @@ 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 () {
|
||||
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('max-medium:min-h-14 max-medium:border-t max-medium:border-outline-variant max-medium:pt-2 max-medium:pb-2');
|
||||
->toContain('max-medium:min-h-14 max-medium:pt-2 max-medium:pb-2')
|
||||
// Its rule follows the scroll, like any dialog's, instead of always showing.
|
||||
->not->toContain('max-medium:border-t');
|
||||
});
|
||||
|
||||
it('leaves a pane open on Escape unless it is asked to close then too', function () {
|
||||
|
||||
Reference in New Issue
Block a user