220 lines
7.0 KiB
TypeScript
220 lines
7.0 KiB
TypeScript
import type { ReactNode } from "react";
|
|
import { Link } from "react-router";
|
|
import { FeatherArrowLeft } from "@subframe/core";
|
|
import { Button } from "~/ui/components/Button";
|
|
import { cn } from "~/lib/utils";
|
|
|
|
/**
|
|
* Shared page chrome for the HiveGrid app screens.
|
|
*
|
|
* The header bar markup is the one shipped with the Subframe screens for
|
|
* /automations and /forms: a full-bleed bar (own padding + bottom border) that
|
|
* carries icon, heading and optional actions. Every app screen composes the same
|
|
* three primitives so the pages stay visually aligned and each page renders
|
|
* exactly one <h1>.
|
|
*
|
|
* Layout constraint (see screens/subframe-layout-boundary.test.ts): never combine
|
|
* `h-full`+`overflow-hidden` or `basis-0`+`overflow-y-auto` in here — the app
|
|
* shell's <main> has no definite height and such a screen collapses to 0px.
|
|
*/
|
|
|
|
/** Canonical icon styling for PageHeader icons — keeps icon size/tone aligned across screens. */
|
|
export const PAGE_HEADER_ICON_CLASS = "text-heading- font-heading- text-icon-neutral-tertiary";
|
|
|
|
export function PageShell({
|
|
uiId,
|
|
className,
|
|
sseHref,
|
|
children,
|
|
}: {
|
|
uiId?: string;
|
|
className?: string;
|
|
/** Optional realtime hint mirrored onto the root as `data-sse-href` (SSE-backed screens). */
|
|
sseHref?: string | null;
|
|
/** Optional so not-ready/empty screen states can render a bare shell. */
|
|
children?: ReactNode;
|
|
}): React.JSX.Element {
|
|
return (
|
|
<div
|
|
className={cn("flex w-full flex-col items-start", className)}
|
|
data-ui-id={uiId}
|
|
data-sse-href={sseHref ?? undefined}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function PageHeader({
|
|
icon,
|
|
title,
|
|
subtitle,
|
|
subtitleClassName,
|
|
subtitleTone = "tertiary",
|
|
actions,
|
|
dense = false,
|
|
titleTrailing,
|
|
uiId,
|
|
}: {
|
|
icon?: ReactNode;
|
|
title: ReactNode;
|
|
subtitle?: ReactNode;
|
|
subtitleClassName?: string;
|
|
subtitleTone?: "secondary" | "tertiary";
|
|
actions?: ReactNode;
|
|
/**
|
|
* Compact header bar: smaller bar height and a smaller heading, for a screen
|
|
* whose own controls (not a description) say what is being shown. A subtitle
|
|
* still fits — it sits directly under the heading and keeps the bar to two
|
|
* short lines.
|
|
*/
|
|
dense?: boolean;
|
|
/** Controls that belong to the heading itself, split off by a vertical rule (dense only). */
|
|
titleTrailing?: ReactNode;
|
|
uiId?: string;
|
|
}): React.JSX.Element {
|
|
const heading = (
|
|
<div className="flex items-center gap-2">
|
|
{icon}
|
|
<h1
|
|
className={
|
|
dense
|
|
? "text-body-large font-body-large text-text-neutral-primary"
|
|
: "text-subtitle font-subtitle text-text-neutral-primary mobile:text-heading- mobile:font-heading-"
|
|
}
|
|
>
|
|
{title}
|
|
</h1>
|
|
</div>
|
|
);
|
|
|
|
if (dense) {
|
|
return (
|
|
<div
|
|
className="flex w-full items-center gap-4 border-b border-solid border-border-default bg-surface-neutral-secondary px-6 py-3 mobile:flex-col mobile:items-start mobile:px-4"
|
|
data-ui-id={uiId}
|
|
>
|
|
{/* `gap-1.5`: the subtitle of a dense header is a chip, not a text line —
|
|
without air between the two it reads as part of the heading. */}
|
|
<div className="flex grow shrink-0 basis-0 flex-col items-start gap-1.5">
|
|
<div className="flex w-full items-center gap-3">
|
|
{heading}
|
|
{titleTrailing ? (
|
|
<>
|
|
<div className="flex h-6 w-px flex-none flex-col items-center bg-border-default mobile:hidden" />
|
|
{titleTrailing}
|
|
</>
|
|
) : null}
|
|
</div>
|
|
{subtitle ? (
|
|
<p
|
|
className={`text-body-small-regular-0 font-body-small-regular-0 ${
|
|
subtitleClassName ?? (subtitleTone === "secondary" ? "text-text-neutral-secondary" : "text-text-neutral-tertiary")
|
|
}`}
|
|
>
|
|
{subtitle}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
{actions ? <div className="flex items-center gap-2 mobile:w-full mobile:flex-none mobile:flex-wrap">{actions}</div> : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="flex w-full items-start gap-4 border-b border-solid border-border-default bg-surface-neutral-secondary px-6 py-5 mobile:flex-col mobile:px-4"
|
|
data-ui-id={uiId}
|
|
>
|
|
<div className="flex grow shrink-0 basis-0 flex-col items-start gap-1">
|
|
{heading}
|
|
{subtitle ? (
|
|
<p
|
|
className={`text-body-small-regular-150 font-body-small-regular-150 ${
|
|
subtitleClassName ?? (subtitleTone === "secondary" ? "text-text-neutral-secondary" : "text-text-neutral-tertiary")
|
|
}`}
|
|
>
|
|
{subtitle}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
{actions ? <div className="flex items-center gap-2 mobile:w-full mobile:flex-none mobile:flex-wrap">{actions}</div> : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Full-bleed filter/toolbar strip directly below the PageHeader.
|
|
*
|
|
* Same chrome as the header bar (own padding, bottom border, secondary surface)
|
|
* so filter controls read as page chrome rather than as content. Must be a
|
|
* sibling of PageHeader, NOT nested in PageBody — PageBody adds the 24px content
|
|
* padding and would break the full-bleed edge.
|
|
*/
|
|
export function PageToolbar({
|
|
className,
|
|
uiId,
|
|
children,
|
|
}: {
|
|
className?: string;
|
|
uiId?: string;
|
|
children: ReactNode;
|
|
}): React.JSX.Element {
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"flex w-full flex-wrap items-end gap-4 border-b border-solid border-border-default bg-surface-neutral-secondary px-6 py-3 mobile:px-4",
|
|
className
|
|
)}
|
|
data-ui-id={uiId}
|
|
>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Canonical back navigation for subpages: tertiary button with a left arrow, placed at the top of PageBody. */
|
|
export function PageBackLink({ to, label, uiId }: { to: string; label: ReactNode; uiId?: string }): React.JSX.Element {
|
|
return (
|
|
<div>
|
|
<Link to={to} className="contents no-underline" data-ui-id={uiId}>
|
|
<Button variant="tertiary" size="small" icon={<FeatherArrowLeft />}>
|
|
{label}
|
|
</Button>
|
|
</Link>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Description line for a screen that renders inside a settings hub (AK-SET-01).
|
|
*
|
|
* A hub already owns the page's single <h1> and its tab bar names the section,
|
|
* so an embedded screen must not render a second PageHeader. Its description is
|
|
* still worth keeping — this is where it goes.
|
|
*/
|
|
export function PageIntro({ children, uiId }: { children: ReactNode; uiId?: string }): React.JSX.Element {
|
|
return (
|
|
<p className="text-body-small-regular-150 font-body-small-regular-150 text-text-neutral-tertiary" data-ui-id={uiId}>
|
|
{children}
|
|
</p>
|
|
);
|
|
}
|
|
|
|
/** Content area below the header bar: 24px page padding (16px on mobile, matching the header edge). */
|
|
export function PageBody({
|
|
className,
|
|
uiId,
|
|
children,
|
|
}: {
|
|
className?: string;
|
|
uiId?: string;
|
|
children: ReactNode;
|
|
}): React.JSX.Element {
|
|
return (
|
|
<div className={cn("flex w-full flex-col gap-6 p-6 mobile:p-4", className)} data-ui-id={uiId}>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|