v3: inline edit forms and the narrow-viewport search row ease open instead of popping in
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 41s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-19 20:26:18 +00:00
parent 5e5f984d87
commit 8c3efe6c30
15 changed files with 202 additions and 28 deletions

View File

@@ -0,0 +1,68 @@
import { useCallback, useLayoutEffect, useRef } from "react";
const FALLBACK_MS = 250;
/**
* Eases an element's height whenever `watch` changes, so an interaction that
* swaps part of its content (a view for an edit form, a reply box opening…)
* moves the surrounding layout smoothly instead of jumping.
*
* Returns a callback ref for the element whose height should transition.
* Only the box is animated; give the content that appears the `animate-in`
* class so it fades in as the box grows. Duration follows `--dur-reveal`.
*/
export function useHeightTransition<T extends HTMLElement>(watch: unknown) {
const elRef = useRef<T | null>(null);
// Last rendered height, kept current through a ResizeObserver so that by
// the time the swap commits we still know where the box *was* — including
// mid-animation, when a second toggle should start from the current frame.
const lastHeight = useRef<number | null>(null);
const lastWatch = useRef(watch);
const ref = useCallback((el: T | null) => {
if (!el) return;
elRef.current = el;
lastHeight.current = el.getBoundingClientRect().height;
const ro = new ResizeObserver(() => {
lastHeight.current = el.getBoundingClientRect().height;
});
ro.observe(el);
return () => {
ro.disconnect();
elRef.current = null;
lastHeight.current = null;
};
}, []);
useLayoutEffect(() => {
if (Object.is(lastWatch.current, watch)) return;
lastWatch.current = watch;
const el = elRef.current;
const from = lastHeight.current;
if (!el || from === null) return;
for (const a of el.getAnimations()) {
if (a.id === "height-transition") a.cancel();
}
const to = el.getBoundingClientRect().height;
lastHeight.current = to;
if (
Math.abs(from - to) < 1 ||
matchMedia("(prefers-reduced-motion: reduce)").matches
) return;
const style = getComputedStyle(el);
const duration = parseFloat(style.getPropertyValue("--dur-reveal")) *
1000 || FALLBACK_MS;
const easing = style.getPropertyValue("--ease-out").trim() || "ease-out";
el.animate(
[
{ height: `${from}px`, boxSizing: "border-box", overflow: "clip" },
{ height: `${to}px`, boxSizing: "border-box", overflow: "clip" },
],
{ duration, easing, id: "height-transition" },
);
}, [watch]);
return ref;
}