Initial commit
This commit is contained in:
139
resources/js/layouts/TwoPaneLayout.tsx
Normal file
139
resources/js/layouts/TwoPaneLayout.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user