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,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'
|
||||
|
||||
Reference in New Issue
Block a user