140 lines
4.3 KiB
TypeScript
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>
|
|
);
|
|
}
|