153 lines
5.4 KiB
TypeScript
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>
|
|
);
|
|
}
|