deadhead-space-freight-tycoon/apps/web/src/layout/PanelHost.tsx

153 lines
5.4 KiB
TypeScript

import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { useLocation } from "react-router";
import { useMapFrame } from "../map/camera";
type Detent = "peek" | "half" | "full";
/** Screens that open tall on phones (decision 0.5.166). */
const OPEN_FULL = new Set(["/settings", "/logbook", "/pilots", "/hqs", "/locations", "/market"]);
/** Screens with extra columns: a wider panel on desktop. */
const WIDE = new Set(["/contracts", "/logbook"]);
const PEEK_PX = 96;
const HALF = 0.56;
const HEADER_PX = 64;
const NAV_PX = 64;
const detentPx = (detent: Detent, height: number) =>
detent === "peek"
? PEEK_PX
: detent === "half"
? Math.round(height * HALF)
: height - HEADER_PX - NAV_PX;
export const panelIsWide = (pathname: string) => WIDE.has(pathname);
/**
* The glass panel screens live in (decision 0.5.166): a bottom sheet with drag
* detents on phones, a right-hand column from `md`. On `/map` it holds only the
* selected place or ship. It tells the map where the uncovered area is.
*/
export function PanelHost({ children }: { children: ReactNode }) {
const { pathname } = useLocation();
const onMap = pathname === "/map";
const [detent, setDetent] = useState<Detent>(OPEN_FULL.has(pathname) ? "full" : "half");
const [dragPx, setDragPx] = useState<number | null>(null);
const hostRef = useRef<HTMLElement>(null);
const drag = useRef<{ startY: number; startPx: number; moved: number } | null>(null);
// Each screen opens at its own height.
useEffect(() => {
setDetent(OPEN_FULL.has(pathname) ? "full" : "half");
}, [pathname]);
// Keep the map centered in what the panel leaves showing.
useLayoutEffect(() => {
const host = hostRef.current;
if (!host) return;
const measure = () => {
const rect = host.getBoundingClientRect();
const { innerWidth: width, innerHeight: height } = window;
const desktop = width >= 768;
const left = desktop ? 96 : 0;
const right = desktop ? rect.left : width;
const top = desktop ? 0 : HEADER_PX;
const bottom = desktop ? height : onMap ? height - NAV_PX : rect.top;
useMapFrame.setState({
offsetX: (left + right) / 2 - width / 2,
offsetY: (top + bottom) / 2 - height / 2,
covered: !desktop && bottom - top < 40,
});
};
measure();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(measure);
observer.observe(host);
window.addEventListener("resize", measure);
return () => {
observer.disconnect();
window.removeEventListener("resize", measure);
};
}, [onMap, pathname]);
const onPointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
e.currentTarget.setPointerCapture?.(e.pointerId);
drag.current = {
startY: e.clientY,
startPx: detentPx(detent, window.innerHeight),
moved: 0,
};
};
const onPointerMove = (e: React.PointerEvent<HTMLButtonElement>) => {
if (!drag.current) return;
const delta = drag.current.startY - e.clientY;
drag.current.moved = Math.max(drag.current.moved, Math.abs(delta));
if (drag.current.moved > 4) {
const max = detentPx("full", window.innerHeight);
setDragPx(Math.max(PEEK_PX, Math.min(max, drag.current.startPx + delta)));
}
};
const onPointerUp = () => {
if (dragPx !== null) {
const details: Detent[] = ["peek", "half", "full"];
const nearest = details.reduce((best, d) =>
Math.abs(detentPx(d, window.innerHeight) - dragPx) <
Math.abs(detentPx(best, window.innerHeight) - dragPx)
? d
: best,
);
setDetent(nearest);
}
setDragPx(null);
drag.current = null;
};
// A tap or the keyboard steps through the heights, so dragging is never required.
const step = () => {
if (drag.current && drag.current.moved > 4) return;
setDetent((d) => (d === "half" ? "full" : d === "full" ? "peek" : "half"));
};
const wide = WIDE.has(pathname);
if (onMap) {
return (
<main
ref={hostRef}
className="pointer-events-none absolute inset-x-2 bottom-[calc(var(--nav-h)+0.5rem)] z-10 md:top-4 md:right-4 md:bottom-auto md:left-auto md:w-(--panel-w)"
>
{children}
</main>
);
}
return (
<main
ref={hostRef}
style={
{
"--sheet-h": `${dragPx ?? detentPx(detent, globalThis.innerHeight ?? 800)}px`,
} as React.CSSProperties
}
className={`absolute inset-x-0 bottom-(--nav-h) z-10 flex h-(--sheet-h) flex-col rounded-t-3xl border-t border-glass-line bg-space-850/97 shadow-panel md:bg-glass md:top-4 md:right-4 md:bottom-auto md:left-auto md:h-auto md:max-h-[calc(100%-2rem)] md:w-(--panel-w) md:rounded-3xl md:border md:backdrop-blur-xl ${
dragPx === null ? "transition-[height] duration-300 ease-out" : ""
}`}
>
<button
type="button"
aria-label={`Panel height: ${detent}. Change panel height`}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onClick={step}
className="-mb-4 flex h-11 shrink-0 touch-none items-start justify-center pt-2.5 md:hidden"
>
<span aria-hidden="true" className="h-1 w-9 rounded-full bg-white/30" />
</button>
<div
className={`@container min-h-0 flex-1 overflow-y-auto overscroll-contain ${wide ? "" : ""}`}
>
{children}
</div>
</main>
);
}