diff --git a/apps/dashboard/src/components/layouts/dashboard-layout.tsx b/apps/dashboard/src/components/layouts/dashboard-layout.tsx index d1acc52..dd0cacf 100644 --- a/apps/dashboard/src/components/layouts/dashboard-layout.tsx +++ b/apps/dashboard/src/components/layouts/dashboard-layout.tsx @@ -1,5 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { getRouteApi, Outlet } from "@tanstack/react-router"; +import { motion } from "motion/react"; import { lazy, Suspense } from "react"; import { githubMyIssuesQueryOptions, @@ -7,9 +8,11 @@ import { } from "#/lib/github.query"; import { useGitHubRevalidation } from "#/lib/use-github-revalidation"; import { useHasMounted } from "#/lib/use-has-mounted"; +import { useMediaQuery } from "#/lib/use-media-query"; import { DashboardBottomBar } from "./dashboard-bottombar"; import { DashboardMobileNav } from "./dashboard-mobile-nav"; import { + SIDE_PANEL_WIDTH, SidePanelProvider, SidePanelSlot, SidePanelToggle, @@ -61,6 +64,8 @@ export function DashboardLayout() { const tabsReady = hasMounted && Boolean(pullsQuery.data && issuesQuery.data); const sidePanel = useSidePanelSlot(); + const isXl = useMediaQuery("(min-width: 1280px)"); + const showPanel = isXl && sidePanel.hasContent && !sidePanel.collapsed; return (
@@ -83,19 +88,29 @@ export function DashboardLayout() { toggle: sidePanel.toggle, }} > -
-
+ +
+ -
+ (null); + const ghostRef = useRef(null); + const exitTimer = useRef | null>(null); const refCallback = useCallback( (el: HTMLDivElement | null) => { @@ -86,31 +91,52 @@ export function SidePanelSlot({ }; check(); - const observer = new MutationObserver(check); + const observer = new MutationObserver((mutations) => { + const has = el.childNodes.length > 0; + + if (!has && ghostRef.current) { + // Content removed — clone into ghost for exit animation + ghostRef.current.innerHTML = ""; + for (const mutation of mutations) { + for (const node of mutation.removedNodes) { + ghostRef.current.appendChild(node.cloneNode(true)); + } + } + if (exitTimer.current) clearTimeout(exitTimer.current); + exitTimer.current = setTimeout(() => { + if (ghostRef.current) ghostRef.current.innerHTML = ""; + }, 500); + } else if (has && ghostRef.current) { + // Content added — clear ghost + ghostRef.current.innerHTML = ""; + if (exitTimer.current) clearTimeout(exitTimer.current); + } + + setHasChildren(has); + onHasContent(has); + }); observer.observe(el, { childList: true }); el.addEventListener("sidepanel-content", check); return () => { observer.disconnect(); el.removeEventListener("sidepanel-content", check); + if (exitTimer.current) clearTimeout(exitTimer.current); }; }, [onHasContent]); const show = hasChildren && !collapsed; return ( - +
+
- +
); } diff --git a/apps/dashboard/src/lib/use-media-query.ts b/apps/dashboard/src/lib/use-media-query.ts new file mode 100644 index 0000000..64d49e3 --- /dev/null +++ b/apps/dashboard/src/lib/use-media-query.ts @@ -0,0 +1,15 @@ +import { useEffect, useState } from "react"; + +export function useMediaQuery(query: string) { + const [matches, setMatches] = useState(false); + + useEffect(() => { + const mq = window.matchMedia(query); + setMatches(mq.matches); + const handler = (e: MediaQueryListEvent) => setMatches(e.matches); + mq.addEventListener("change", handler); + return () => mq.removeEventListener("change", handler); + }, [query]); + + return matches; +}