deadhead-space-freight-tycoon/apps/web/src/ui/Sheet.tsx

72 lines
2.2 KiB
TypeScript

import type { ReactNode } from "react";
import { createPortal } from "react-dom";
import type { Tone } from "./Panel";
const TITLE_TONES: Partial<Record<Tone, string>> = {
amber: "text-amber-300",
emerald: "text-emerald-300",
cyan: "text-cyan-200",
};
const BORDER_TONES: Partial<Record<Tone, string>> = {
amber: "border-amber-400/50",
emerald: "border-emerald-400/40",
cyan: "border-cyan-400/40",
};
/**
* A modal dialog: a bottom sheet on phones, a centred card from `sm` up.
* Labelled by its title, which it renders as an `h2` with `titleId`. Portalled
* to `body`: a transformed ancestor (the screens' rise-in) would otherwise
* become the containing block of `position: fixed`.
*/
export function Sheet({
titleId,
title,
eyebrow,
subtitle,
tone = "default",
layer = "z-20",
children,
}: {
titleId: string;
title: ReactNode;
eyebrow?: ReactNode;
subtitle?: ReactNode;
tone?: Tone;
/** Stacking layer, e.g. `z-30` for a sheet over other sheets. */
layer?: string;
children: ReactNode;
}) {
return createPortal(
<div
className={`fixed inset-0 ${layer} flex items-end justify-center bg-space-950/75 backdrop-blur-sm sm:items-center sm:p-4`}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className={`relative max-h-[88dvh] w-full max-w-md animate-sheet overflow-y-auto rounded-t-2xl border bg-space-900/95 p-5 pb-[max(1.25rem,env(safe-area-inset-bottom))] shadow-panel sm:rounded-2xl ${BORDER_TONES[tone] ?? "border-space-600"}`}
>
<div
aria-hidden="true"
className="mx-auto -mt-2 mb-3 h-1 w-10 rounded-full bg-space-600 sm:hidden"
/>
{eyebrow && (
<div className="font-display text-[11px] font-semibold tracking-[0.2em] text-cyan-300/80 uppercase">
{eyebrow}
</div>
)}
<h2
id={titleId}
className={`font-display text-xl font-bold tracking-wide ${TITLE_TONES[tone] ?? "text-ink-50"}`}
>
{title}
</h2>
{subtitle && <div className="mt-1 text-sm text-ink-300">{subtitle}</div>}
{children}
</div>
</div>,
document.body,
);
}