import * as React from "react"
import { cn } from "@/registry/lib/utils"
interface PageTitleRowProps extends Omit<React.ComponentProps<"div">, "title"> {
/** The page title. A string takes the row's own type treatment; pass a node
* (a Breadcrumb, a record picker) when the title IS a control and brings its
* own styling. Same slot rule DashboardCardHeader follows. */
title: React.ReactNode
/** Leading slot before the title, e.g. a back button on an editor sub-page.
* Centred against the title line. */
leading?: React.ReactNode
/** A count or a status pill inline beside the title: a Badge reading
* "39 items", "Live", "3 failing". It is an ANNOTATION on the title, sized
* and placed to be read as part of it, and it never sets the row's height.
* Not a control: a button, a menu, a segmented control, anything that can be
* pressed, goes in `action` at the other end of the row, which is where the
* eye goes looking for something to press. */
titleBadge?: React.ReactNode
/** Secondary text inline beside the title, e.g. "@username" or a record id.
* A PHRASE, not a sentence: it shares one line with the title and truncates.
* Explanatory prose belongs to the section it explains, or to `PageHeader`.
* TEXT, and only text. A Badge or any other pill goes in `titleBadge`. This
* slot's wrapper is a bare inline `<span>` carrying `truncate`, and overflow
* does not apply to a non-replaced inline box, so a pill dropped in here
* renders unclipped and misaligned in a wrapper built for a phrase. A real
* consumer put a row count here and it looked broken, which is what
* `titleBadge` exists to stop. */
subtitle?: React.ReactNode
/** Extra classes on the subtitle, e.g. `hidden sm:inline` to drop it on small
* screens rather than have it eat the title's width. */
subtitleClassName?: string
/** Centre slot. Its presence switches the row to a three-column grid (title,
* centre, actions) for sub-nav surfaces where a tab set sits between the
* title and the actions. Omit it for the ordinary two-end row. */
center?: React.ReactNode
/** Right slot: buttons, a segmented control, a badge, a menu. A badge here
* annotates the ROW, sitting with the controls; one that annotates the title
* and should be read with it goes in `titleBadge`. */
action?: React.ReactNode
/** @deprecated Use `action`. */
actions?: React.ReactNode
/** `page` (default) is the route's own title bar: an `h1` at `text-lg` in a
* 40px row. `section` titles a group INSIDE an app page, a "Pending" and a
* "Done" over their own tables: an `h2` one step down the type scale at
* `text-base`, in a 32px row. Same slots, same guarantee that a count never
* changes the row's height. */
size?: PageTitleRowSize
}
type PageTitleRowSize = "page" | "section"
// Each size moves the title ONE step down the type scale and keeps every
// interval around it, so the section row is the page row scaled rather than a
// second design.
//
// Title: 18 -> 16, the next step down. 14 would be two steps, and it is also
// the size a table's own cell text runs at, so the heading would be told apart
// from the rows under it by weight alone.
//
// Row: 40 -> 32, the control rung whose label sits the same distance below the
// title. Page: an 18px title over the `lg` rung's 14px label, two steps down.
// Section: a 16px title, two steps down is 13px, which is Button `default`, a
// 32px box. 32 is also the height of every control in the `TableToolbar` this
// row sits on top of, so a section action and the toolbar controls under it
// are one size. `min-h`, not `h`: a row with no action is the same height as
// one with a button in it, and the content under it starts on the same line.
//
// Subtitle: three steps under the title at both sizes (18 -> 13, 16 -> 12).
//
// The trim is the same at both sizes, so the zero-height badge wrapper below
// centres a count on the cap band exactly as it does at `page`.
const SIZE = {
page: { Heading: "h1", title: "text-lg", subtitle: "text-xs", row: "min-h-10" },
section: { Heading: "h2", title: "text-base", subtitle: "text-1xs", row: "min-h-8" },
} as const
/**
* The page's title bar: one line, the title at one end and the controls at the
* other. Every top-level route uses it, which is what keeps the bar's height,
* spacing and type identical from page to page.
*
* ┌──────────────────────────────────────────────┐
* │ Title [39] · subtitle [actions] │
* └──────────────────────────────────────────────┘
*
* With a centre slot, for a section that carries sub-nav:
*
* ┌──────────────────────────────────────────────┐
* │ Title [centre] [actions] │
* └──────────────────────────────────────────────┘
*
* NOT the same component as `PageHeader`, and the choice between them is about
* the surface rather than the heading level. `PageHeader` stacks a title over a
* description and belongs to a page that is READ, where the description earns
* its line. This is chrome for a page that is OPERATED: an app or admin route
* whose title is a label on a working surface, where a paragraph under it is
* read once and then skipped forever while costing vertical space on every
* visit. A title here that needs a sentence of explanation is usually a section
* below it that needs the sentence instead.
*
* The gap under the row depends on what it titles. Over ONE surface (a table,
* a list), `<Stack gap="cluster">`: the surface is the thing the title names,
* so the two bind. Over a GROUP of blocks (a metric grid, cards, several
* sections), the heading step for the title: `heading-lg` at `page`,
* `heading-md` at `section`, so the title balances the weight of what it
* opens. Never `region`, which opens a hole between a title and its content.
*
* `size="section"` is the same row one level down, for a titled group inside
* an app page: a review queue's "Pending" and "Done", each with a count in
* `titleBadge` and its own `Table` under it, spaced as above. Space one
* section from the next with `<Stack gap="peer">`, one
* rung under the `region` the page's own blocks sit on, so the sections read
* as one group under the page title rather than as more page-level blocks.
*
* It ships NO bottom margin, the same rule PageHeader and TableToolbar follow:
* a child never sets a margin to make room for a sibling, because a flex gap
* and a child margin do not collapse and the seam renders at the sum of the
* two. Put the row and the page body in a `Stack` and let the container own it.
*/
// @use-when a page's title bar: the title on one line with controls at the far
// end, plus an optional back button, count, or status pill. For an app page
// that is operated. `size="section"` titles a group inside that page (a
// "Pending" and a "Done", each with a count and its own table). Not
// PageHeader, which is a title over a description.
function PageTitleRow({
title,
leading,
titleBadge,
subtitle,
subtitleClassName,
center,
action: actionProp,
actions,
size = "page",
className,
...props
}: PageTitleRowProps) {
const action = actionProp ?? actions
const { Heading, ...sized } = SIZE[size]
const titleCluster = (
<div className="flex min-w-0 items-center gap-2">
{leading ? <div className="flex shrink-0 items-center">{leading}</div> : null}
{/* `items-baseline`, not `items-center`: the title carries `cap-trim`, so
its box is the cap height and a centred subtitle beside it sits low.
The baseline is the line the eye reads both from. */}
<div className="flex min-w-0 items-baseline gap-3">
{/* NO `truncate` here, and that is load-bearing rather than an
omission. `cap-trim` ends the box at the ALPHABETIC BASELINE, so a
descender paints below it by design; `truncate` brings
`overflow: hidden`, which would then clip the tail off every g, y
and p in a title. This is the only place in the kit the two would
have met, and "Playground" is what showed it.
The title wraps instead, which `min-w-0` on the wrapper allows. A
title long enough to wrap in a bar this size is a title that wanted
to be shorter, and wrapping says so where a clean ellipsis hides it.
The SUBTITLE still truncates: it carries no trim, and it is the part
that is genuinely expendable when the row runs out of room. */}
{typeof title === "string" ? (
// `leading-snug` is stated at both sizes: `text-base` ships body
// leading (1.5), and a title that wraps wants the heading value.
<Heading className={cn("cap-trim font-semibold leading-snug", sized.title)}>
{title}
</Heading>
) : (
title
)}
{/* A DIRECT child of the cluster, and deliberately not baseline-aligned
with it. A pill is a box around text, not text, and the two things
the baseline would do to it are both wrong.
Height first. A baseline-aligned row is as tall as
`max(above-baseline) + max(below-baseline)`, and `cap-trim` took the
title's below-baseline extent to zero, which hands that edge to
whatever else is in the row. A Badge seated on the baseline hangs
its bottom padding and its descent underneath, so the cluster grows
the moment a count appears. The cluster is centred in a `min-h-10`
row, so the TITLE would then sit higher on a page that carries a
count than on one that does not, and holding that line still from
page to page is the whole job of this component.
Position second. Sharing a baseline is right for text beside text,
which is why the subtitle does it. Line a pill's inner baseline up
with a title nearly twice its size and the box hangs low against the
letterforms. A pill beside a title wants the CAP BAND, centred on
the word rather than seated on it.
The zero-height wrapper answers both, and it is the same wrapper
`MetricCardValue` puts its delta Badge in, for the same reason: it
contributes nothing to the cluster's cross size and overflows it
instead, centred on the box the title's trim leaves behind. With a
subtitle riding along, that box is a hair taller than the cap band
(the subtitle carries no trim, so its descender counts), and the
pill drops with it by a pixel or so. That is far smaller than either
thing the baseline would have done, and it costs no clipping: the
overflow is a few px each way inside a 40px row.
`shrink-0` because the title wraps rather than truncating, so
without it a long title would squeeze the count into a lozenge. */}
{titleBadge ? (
<span className="flex h-0 shrink-0 items-center self-center">{titleBadge}</span>
) : null}
{subtitle ? (
<span
className={cn("truncate text-muted-foreground", sized.subtitle, subtitleClassName)}
>
{subtitle}
</span>
) : null}
</div>
</div>
)
if (center !== undefined) {
// The row answers to its OWN width, not the viewport's: a title row in a
// narrow pane on a wide screen is as cramped as one on a phone. Below the
// `lg` container width (32rem) the title and actions share the first line
// and the centre slot takes the next one, full width. From `lg` up it is
// three columns, the outer two splitting what the centre leaves, so the
// centre stays centred whatever the title and actions measure. The grid
// sits on an inner element because a container query never matches the
// container itself.
return (
<div
data-slot="page-title-row"
data-size={size}
className={cn("@container/page-title-row shrink-0", className)}
{...props}
>
<div className="flex flex-wrap items-center justify-between gap-(--spacing-rhythm-bind) @lg/page-title-row:grid @lg/page-title-row:grid-cols-[1fr_auto_1fr] @lg/page-title-row:gap-(--spacing-rhythm-cluster)">
{titleCluster}
{action ? (
<div className="flex items-center justify-end gap-(--spacing-rhythm-bind) @lg/page-title-row:col-start-3 @lg/page-title-row:row-start-1">
{action}
</div>
) : null}
<div className="order-last flex w-full min-w-0 justify-start @lg/page-title-row:order-none @lg/page-title-row:col-start-2 @lg/page-title-row:row-start-1 @lg/page-title-row:w-auto @lg/page-title-row:justify-center">
{center}
</div>
</div>
</div>
)
}
return (
<div
data-slot="page-title-row"
data-size={size}
// `min-h-10` (`min-h-8` at `section`) is a rung on the control ladder, so
// a row with a button in it and a row with nothing in it are the same
// height and the page body starts on the same line either way.
className={cn(
"@container/page-title-row flex shrink-0 flex-wrap items-center justify-between gap-(--spacing-rhythm-bind)",
sized.row,
className
)}
{...props}
>
{titleCluster}
{/* Under the kit's `xl` container width (42rem) the actions grow into
the space the title leaves and a search field grows with them, from
a 224px floor, the same move TableToolbarControls makes. So a search
and a button share the title's line when they fit and take the next
line together when they do not, the field filling it beside the
button instead of pushing it past the row's edge. Above `xl` the
field keeps the width its call site gave it. */}
{action ? (
<div className="flex min-w-0 max-w-full items-center justify-end gap-(--spacing-rhythm-bind) @max-xl/page-title-row:grow @max-xl/page-title-row:[&>input,&>:has(input:not([type=checkbox]):not([type=radio]):not([hidden]))]:min-w-0 @max-xl/page-title-row:[&>input,&>:has(input:not([type=checkbox]):not([type=radio]):not([hidden]))]:grow @max-xl/page-title-row:[&>input,&>:has(input:not([type=checkbox]):not([type=radio]):not([hidden]))]:basis-56">
{action}
</div>
) : null}
</div>
)
}
export { PageTitleRow }