Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 22 additions & 19 deletions src/components/layout/RootLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { SidebarProvider } from "@/components/ui/sidebar";
import { useDocumentTitle } from "@/hooks/useDocumentTitle";
import { BackendProvider } from "@/providers/BackendProvider";
import { ComponentSpecProvider } from "@/providers/ComponentSpecProvider";
import { DialogProvider } from "@/providers/DialogProvider/DialogProvider";

import AppFooter from "./AppFooter";
import AppMenu from "./AppMenu";
Expand All @@ -15,25 +16,27 @@ const RootLayout = () => {

return (
<BackendProvider>
<SidebarProvider>
<ComponentSpecProvider>
<ToastContainer />

<div className="App flex flex-col min-h-screen w-full">
<AppMenu />

<main className="flex-1 grid">
<Outlet />
</main>

<AppFooter />

{import.meta.env.VITE_ENABLE_ROUTER_DEVTOOLS === "true" && (
<TanStackRouterDevtools />
)}
</div>
</ComponentSpecProvider>
</SidebarProvider>
<DialogProvider>
<SidebarProvider>
<ComponentSpecProvider>
<ToastContainer />

<div className="App flex flex-col min-h-screen w-full">
<AppMenu />

<main className="flex-1 grid">
<Outlet />
</main>

<AppFooter />

{import.meta.env.VITE_ENABLE_ROUTER_DEVTOOLS === "true" && (
<TanStackRouterDevtools />
)}
</div>
</ComponentSpecProvider>
</SidebarProvider>
</DialogProvider>
</BackendProvider>
);
};
Expand Down
129 changes: 129 additions & 0 deletions src/components/ui/animated-height.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";

import { cn } from "@/lib/utils";

interface AnimatedHeightProps {
children: React.ReactNode;
className?: string;
/** Duration of the height transition in milliseconds */
duration?: number;
/** Easing function for the transition */
easing?: string;
/** Key that changes when content changes - triggers re-measurement */
contentKey?: string | number;
}

/**
* A component that smoothly animates its height based on content changes.
* Uses ResizeObserver to detect content size changes and applies CSS transitions.
* During shrink transitions, overflow is visible to prevent content clipping.
*/
export function AnimatedHeight({
children,
className,
duration = 200,
easing = "ease-out",
contentKey,
}: AnimatedHeightProps) {
const contentRef = useRef<HTMLDivElement>(null);
const [height, setHeight] = useState<number | null>(null);
const [enableTransition, setEnableTransition] = useState(false);
const [isShrinking, setIsShrinking] = useState(false);
const prevHeightRef = useRef<number | null>(null);
const shrinkTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isFirstMeasurementRef = useRef(true);
const isTransitioningRef = useRef(false);

const measureHeight = useCallback(
(force = false) => {
if (isTransitioningRef.current && !force) return;

const contentEl = contentRef.current;
if (!contentEl) return;

const newHeight = contentEl.offsetHeight;
const prevHeight = prevHeightRef.current;

if (newHeight !== prevHeight && newHeight > 0) {
const shrinking = prevHeight !== null && newHeight < prevHeight;

if (shrinkTimeoutRef.current) {
clearTimeout(shrinkTimeoutRef.current);
shrinkTimeoutRef.current = null;
}

if (shrinking) {
setIsShrinking(true);
shrinkTimeoutRef.current = setTimeout(() => {
setIsShrinking(false);
}, duration);
}

prevHeightRef.current = newHeight;
setHeight(newHeight);

if (isFirstMeasurementRef.current) {
isFirstMeasurementRef.current = false;
requestAnimationFrame(() => {
setEnableTransition(true);
});
}
}
},
[duration],
);

// Handle contentKey changes synchronously before paint
useLayoutEffect(() => {
isTransitioningRef.current = true;

// Measure synchronously - useLayoutEffect runs after DOM update but before paint
measureHeight(true);

isTransitioningRef.current = false;
}, [contentKey, measureHeight]);

// ResizeObserver for dynamic content changes (not during key transitions)
useEffect(() => {
const contentEl = contentRef.current;
if (!contentEl) return;

const resizeObserver = new ResizeObserver(() => {
if (!isTransitioningRef.current) {
measureHeight();
}
});

resizeObserver.observe(contentEl);

return () => {
resizeObserver.disconnect();
if (shrinkTimeoutRef.current) {
clearTimeout(shrinkTimeoutRef.current);
}
};
}, [measureHeight]);

return (
<div
className={cn(
isShrinking ? "overflow-visible" : "overflow-hidden",
className,
)}
style={{
height: height !== null ? `${height}px` : "auto",
transition: enableTransition
? `height ${duration}ms ${easing}`
: undefined,
}}
>
<div ref={contentRef}>{children}</div>
</div>
);
}
Loading