Files
vkv/resources/js/layouts/TwoPaneLayout.tsx
Zdeněk Burda 41e3ce6f25 Initial commit
2026-01-09 21:26:40 +01:00

140 lines
4.3 KiB
TypeScript

import {
createContext,
useContext,
useEffect,
useRef,
useState,
type ReactNode,
} from "react";
import { Outlet, matchPath, useLocation } from "react-router-dom";
type TwoPaneLayoutConfig = {
left?: ReactNode;
rightTop?: ReactNode;
leftWidthClassName?: string;
leftCollapsedWidthClassName?: string;
};
const TwoPaneLayoutContext = createContext<TwoPaneLayoutConfig | null>(null);
const DEFAULT_LEFT_WIDTH_PX = 300;
const LEFT_RAIL_WIDTH_PX = 32;
const AUTO_HIDE_MS = 5000;
export function useTwoPaneLayout() {
const ctx = useContext(TwoPaneLayoutContext);
if (!ctx) throw new Error("TwoPaneLayoutContext missing");
return ctx;
}
export default function TwoPaneLayout({ value }: { value: TwoPaneLayoutConfig }) {
const {
left,
rightTop,
leftWidthClassName = "md:grid-cols-[300px_minmax(0,1fr)]",
leftCollapsedWidthClassName = "md:grid-cols-[10px_minmax(0,1fr)]",
} = value;
const location = useLocation();
const isContestsIndex = !!matchPath({ path: "/contests", end: true }, location.pathname);
const isContestDetail = !!matchPath({ path: "/contests/:contestId", end: true }, location.pathname);
const isRoundDetail = !!matchPath(
{ path: "/contests/:contestId/rounds/:roundId", end: true },
location.pathname,
);
const [leftCollapsed, setLeftCollapsed] = useState(!isContestsIndex);
const [leftPaneWidth, setLeftPaneWidth] = useState(DEFAULT_LEFT_WIDTH_PX);
const leftPaneRef = useRef<HTMLDivElement | null>(null);
const autoHideTimerRef = useRef<number | null>(null);
useEffect(() => {
setLeftCollapsed(!(isContestsIndex || isContestDetail));
}, [isContestsIndex, isContestDetail]);
useEffect(() => {
if (!leftPaneRef.current || leftCollapsed) return;
const width = leftPaneRef.current.getBoundingClientRect().width;
if (width > 0 && width !== leftPaneWidth) {
setLeftPaneWidth(width);
}
}, [leftCollapsed, leftPaneWidth, leftWidthClassName]);
const clearAutoHide = () => {
if (autoHideTimerRef.current) {
window.clearTimeout(autoHideTimerRef.current);
autoHideTimerRef.current = null;
}
};
const scheduleAutoHide = () => {
if (!isRoundDetail || leftCollapsed) return;
clearAutoHide();
autoHideTimerRef.current = window.setTimeout(() => {
setLeftCollapsed(true);
}, AUTO_HIDE_MS);
};
useEffect(() => {
if (!isRoundDetail || leftCollapsed) {
clearAutoHide();
return;
}
scheduleAutoHide();
return clearAutoHide;
}, [isRoundDetail, leftCollapsed]);
const handlePaneMouseEnter = () => {
clearAutoHide();
};
const handlePaneMouseLeave = () => {
scheduleAutoHide();
};
const translateX = leftCollapsed
? -(Math.max(0, leftPaneWidth - LEFT_RAIL_WIDTH_PX))
: 0;
const gridClassName = leftCollapsed ? leftCollapsedWidthClassName : leftWidthClassName;
return (
<TwoPaneLayoutContext.Provider value={value}>
<div className={`w-full grid grid-cols-1 ${gridClassName} gap-6`}>
<aside
className="min-w-0 relative"
onMouseEnter={handlePaneMouseEnter}
onMouseLeave={handlePaneMouseLeave}
>
<button
type="button"
onClick={() => setLeftCollapsed((prev) => !prev)}
className="absolute left-0 top-0 h-10 w-8 rounded-r bg-slate-200 text-slate-700 shadow hover:bg-slate-300 z-10"
aria-label="Toggle left panel"
onFocus={handlePaneMouseEnter}
onBlur={handlePaneMouseLeave}
>
{leftCollapsed ? ">" : "<"}
</button>
<div className="overflow-hidden pt-10">
<div
ref={leftPaneRef}
className={`min-w-0 transition-transform duration-300 ease-in-out${leftCollapsed ? " pointer-events-none" : ""}`}
style={{
width: leftCollapsed ? leftPaneWidth : undefined,
transform: `translateX(${translateX}px)`,
}}
aria-hidden={leftCollapsed || undefined}
onFocus={handlePaneMouseEnter}
onBlur={handlePaneMouseLeave}
>
{left}
</div>
</div>
</aside>
<section className="min-w-0 space-y-4">
{rightTop}
<Outlet />
</section>
</div>
</TwoPaneLayoutContext.Provider>
);
}