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
@@ -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