Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/components/screens/shared/PageShell.tsx
T

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>
);
}