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
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:
68
src/hooks/useHeightTransition.ts
Normal file
68
src/hooks/useHeightTransition.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user