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>
69 lines
2.3 KiB
TypeScript
69 lines
2.3 KiB
TypeScript
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;
|
|
}
|