Math.round(px * dpr) / dpr
const surface =
@@ -20,7 +73,8 @@ type Face
= {
}
// The composer and the docks that stand in for it as one surface: a new face grows the region to its height on one
-// spring while the old face hands over in place. Faces present on mount rest immediately.
+// spring while the old face closes in its own box above or below it, so the two never draw over each other. Faces
+// present on mount rest immediately.
export function ComposerMorph(props: {
face: T
children: (face: Accessor) => JSX.Element
@@ -38,21 +92,62 @@ export function ComposerMorph(props: {
root?.removeAttribute("data-moving")
root?.removeAttribute("data-dock-border-underlay")
root?.style.removeProperty("height")
- list.forEach((item) =>
- ["opacity", "transform", "filter", "mask-image"].forEach((prop) => item.el?.style.removeProperty(prop)),
- )
+ list.forEach((item) => {
+ const content = item.el?.firstElementChild instanceof HTMLElement ? item.el.firstElementChild : undefined
+ ;["position", "inset-inline", "top", "transform", "clip-path", "opacity"].forEach((prop) =>
+ item.el?.style.removeProperty(prop),
+ )
+ ;["transform", "filter", "mask-image"].forEach((prop) => content?.style.removeProperty(prop))
+ })
setFaces(list.filter((item) => !item.leaving()))
return false
}
+ // Face heights come from a ResizeObserver; this one read happens only when a flight starts.
const measure = () => {
for (const item of untrack(faces)) if (item.el) item.height = item.el.getBoundingClientRect().height
}
- const paintFace = (item: Face, shown: number) => {
- if (!item.el) return
- const p = Math.max(0, Math.min(1, item.presence.value))
- styleFace(item.el, p, item.leaving() ? 2 : 6, item.height - shown)
+ // Each face gets a box: a closing face keeps its presence's share of its height, and the face coming in takes what is
+ // left of the region. During a flight the faces are stacked with transforms and cut to their boxes with clip-path, so
+ // only the region's own height changes layout. Under reduced motion the closing faces fade out in full first, then
+ // the new face fades in.
+ const paintFaces = (list: Face[], shown: number) => {
+ const reduced = prefersReducedMotion()
+ const leaving = list.filter((item) => item.leaving())
+ const waiting = reduced && leaving.some((item) => item.presence.value > 0.01)
+ const boxes = new Map(
+ leaving.map((item) => [
+ item,
+ reduced ? (waiting ? item.height : 0) : snap(item.height * Math.max(0, Math.min(1, item.presence.value))),
+ ]),
+ )
+ const used = [...boxes.values()].reduce((sum, box) => sum + box, 0)
+ let offset = 0
+ list.forEach((item) => {
+ if (!item.el) return
+ const box = boxes.get(item) ?? (waiting ? 0 : Math.max(0, Math.min(item.height, shown - used)))
+ const content = item.el.firstElementChild instanceof HTMLElement ? item.el.firstElementChild : item.el
+ item.el.style.position = "absolute"
+ item.el.style.insetInline = "0"
+ item.el.style.top = "0"
+ item.el.style.transform = `translateY(${offset}px)`
+ item.el.style.clipPath = `inset(0 0 calc(100% - ${box.toFixed(2)}px) 0)`
+ offset += box
+ const p = item.leaving() ? (reduced ? item.presence.value : box / Math.max(1, item.height)) : item.presence.value
+ styleFace(item.el, Math.max(0, Math.min(1, p)), {
+ dy: item.leaving() ? 0 : 6,
+ out: item.leaving(),
+ hidden: item.height - box,
+ content,
+ })
+ })
+ }
+
+ const regionHeight = (list: Face[], spring: Spring) => {
+ if (!prefersReducedMotion()) return snap(spring.value)
+ const leaving = list.filter((item) => item.leaving() && item.presence.value > 0.01)
+ return leaving.length ? leaving.reduce((sum, item) => sum + item.height, 0) : spring.target
}
const paint = (dt: number) => {
@@ -60,20 +155,25 @@ export function ComposerMorph(props: {
const entering = list.find((item) => !item.leaving())
if (!root || !height || !entering) return false
- if (entering.el && height.target !== entering.height) height.set(entering.height)
- if (prefersReducedMotion()) height.snap(entering.height)
+ // A face that just mounted has no height until its ResizeObserver reports one.
+ if (entering.height > 0 && height.target !== entering.height) height.set(entering.height)
+ const reduced = prefersReducedMotion()
+ if (reduced) height.snap(entering.height)
height.step(dt)
if (Math.abs(height.value - height.target) < 0.5 && Math.abs(height.velocity) < 5) height.snap(height.target)
+ const waiting = reduced && list.some((item) => item.leaving() && item.presence.value > 0.01)
list.forEach((item) => {
+ if (waiting && !item.leaving()) return
item.presence.step(dt)
if (Math.abs(item.presence.value - item.presence.target) < 0.01 && Math.abs(item.presence.velocity) < 0.05)
item.presence.snap(item.presence.target)
})
if (height.resting && list.every((item) => item.presence.resting)) return settle(list)
- const shown = snap(height.value)
+ const shown = regionHeight(list, height)
+ // Written every frame, never read back: the timeline inset follows the region's real height (as in #57559).
root.style.height = `${shown}px`
- list.forEach((item) => paintFace(item, shown))
+ paintFaces(list, shown)
return true
}
@@ -91,27 +191,33 @@ export function ComposerMorph(props: {
const back = next.key === "composer" ? list.find((item) => item.key === next.key) : undefined
const entering = back ?? face(next, 0)
const active = list.filter((item) => item === entering || item.presence.value >= 0.01)
+ // The closing box must start at the face's full height, which only a fresh measure knows.
+ measure()
active.forEach((item) => {
if (item === entering) return
item.leave(true)
if (item.el?.contains(document.activeElement) && document.activeElement instanceof HTMLElement)
document.activeElement.blur()
- tune(item.presence, reduced ? fade : exit).set(0)
+ // A closing face's box closes on the region's spring, so the boxes always add up to the region.
+ tune(item.presence, reduced ? fade : morph()).set(0)
})
entering.set(next)
entering.leave(false)
- tune(entering.presence, reduced ? fade : enter).set(1)
- height ??= Spring.fromDuration(from, enter)
- tune(height, enter)
- const shown = snap(height.value)
- const nextFaces = back ? active : [...active, entering]
+ tune(entering.presence, reduced ? fade : fadeIn()).set(1)
+ height ??= Spring.fromDuration(from, morph())
+ tune(height, morph())
+ // Docks always stack above the composer, whichever is coming or going.
+ const nextFaces = (back ? active : [...active, entering]).toSorted(
+ (a, b) => Number(a.key === "composer") - Number(b.key === "composer"),
+ )
+ const shown = regionHeight(nextFaces, height)
root.toggleAttribute("data-moving", true)
root.toggleAttribute("data-dock-border-underlay", true)
root.style.height = `${shown}px`
- nextFaces.forEach((item) => paintFace(item, shown))
+ paintFaces(nextFaces, shown)
setFaces(nextFaces)
- stop ??= frameLoop.addMeasured(measure, paint)
+ stop ??= frameLoop.add(paint)
},
{ defer: true },
),
@@ -127,13 +233,19 @@ export function ComposerMorph(props: {
{
item.el = el
- if (height) paintFace(item, snap(height.value))
+ const observer = new ResizeObserver((entries) => {
+ const size = entries[0]?.borderBoxSize[0]?.blockSize
+ if (size !== undefined) item.height = size
+ })
+ observer.observe(el)
+ onCleanup(() => observer.disconnect())
+ if (height) paintFaces(untrack(faces), snap(height.value))
}}
data-slot="composer-face"
inert={item.leaving()}
aria-hidden={item.leaving() || undefined}
class="shrink-0"
- classList={{ "pointer-events-none absolute inset-x-0 top-0": item.leaving() }}
+ classList={{ "pointer-events-none": item.leaving() }}
>
{props.children(item.value)}
@@ -155,11 +267,21 @@ function tune(spring: Spring, options: DurationOptions) {
return spring
}
-function styleFace(el: HTMLElement, p: number, dy: number, hidden = 0, content = el) {
- el.style.opacity = `${(p * p).toFixed(4)}`
- if (prefersReducedMotion()) return
- content.style.transform = `translateY(${((1 - p) * dy).toFixed(2)}px)`
- el.style.filter = p < 0.999 ? `blur(${((1 - p) * 4).toFixed(2)}px)` : ""
+// A face coming in eases its opacity in late. A face going out stays opaque until only the fade-out tail of its box is
+// left, then fades, so it is gone just as its box closes.
+function styleFace(
+ el: HTMLElement,
+ p: number,
+ input: { dy: number; out: boolean; hidden?: number; content?: HTMLElement },
+) {
+ const content = input.content ?? el
+ const hidden = input.hidden ?? 0
+ el.style.opacity = `${(input.out ? Math.min(1, p / fadeTail()) : p * p).toFixed(4)}`
+ // Movement, blur and the soft edge go on the content inside the clipping box, never on the box itself, so a blur
+ // can't bleed past it. Before the content mounts there is nothing to move.
+ if (prefersReducedMotion() || content === el) return
+ content.style.transform = `translateY(${((1 - p) * input.dy).toFixed(2)}px)`
+ content.style.filter = p < 0.999 && blur() > 0 ? `blur(${((1 - p) * blur()).toFixed(2)}px)` : ""
content.style.maskImage =
hidden > 0 ? `linear-gradient(to top, transparent ${hidden}px, #000 ${hidden + Math.min(24, hidden)}px)` : ""
}
@@ -171,6 +293,8 @@ export function Grow(props: {
fade?: boolean
card?: boolean
class?: string
+ /** The spring it closes on, when it closes alongside other motion that should share it. */
+ close?: () => DurationOptions
onClosed?: () => void
children: JSX.Element | ((item: NonNullable) => JSX.Element)
}) {
@@ -189,7 +313,7 @@ export function Grow(props: {
}
if (!el || (!entering && (!prev || prev[0] === when))) return
entering = false
- grow(el, when, props.fade, props.card, () => {
+ grow(el, when, props.fade, props.card, props.close?.(), () => {
setPresent(false)
props.onClosed?.()
})
@@ -206,22 +330,36 @@ export function Grow(props: {
>
{(() => {
const child = props.children
- return typeof child === "function" ? {child} : child
+ return typeof child === "function" ? (
+
+ {child}
+
+ ) : (
+ child
+ )
})()}
)
}
-const growing = new WeakMap