"use client" /** * PageHeader — Full-width content area header. * * Sits at the top of a page's main content, BELOW the breadcrumb / topbar. * Uses Ivy Presto (Adobe Fonts) for the title via the `--font-heading` * CSS variable. * * **Variant `collaboration`** — optional access line + collaborator faces * (or **Add collaborator** when the roster is empty) ahead of the primary * `actions` slot. The full **Invite people** flow lives in the consuming * page's `actions` overflow menu — this primitive only renders the face row. * * WCAG 2.1 AA: * - `

` landmark — one per page (WCAG 1.3.1) * - Sufficient colour contrast >= 4.5:1 on title + subtitle (SC 1.4.3) * - Face row: `role="group"` + aggregate `aria-label`; each face has a * `Tooltip` name (SC 4.1.2) * * Promotion note: this file lived at `apps/web/components/page-header.tsx` * until 2026-05-20. It moved into `@exxatdesignux/ui` so other apps (and * future docs sites) can compose hub headers without duplicating the * collaboration variant, h1 styling, or face-row a11y wiring. The * `CollaboratorAccessRole` union is duplicated here as a narrow string * literal so the primitive stays free of `apps/web/lib/` couplings — the * authoritative role labels / icons / capability helpers continue to live * in `apps/web/lib/collaborator-access.ts`. */ import * as React from "react" import { createPortal } from "react-dom" import { usePageHeaderScrollActionsSlot, usePageHeaderScrollRelocateEnabled } from "../shell/page-header-scroll-actions" import { useScrollStuck } from "../../hooks/use-scroll-stuck" import { Avatar, AvatarFallback, AvatarImage } from "./avatar" import { Button } from "./button" import { RESPONSIVE_ACTIONS_COMPACT_MAX_WIDTH_PX, RESPONSIVE_ACTIONS_MAX_VISIBLE, RESPONSIVE_ACTIONS_OVERFLOW_ONLY_MAX_WIDTH_PX, ResponsiveActionRow, useResponsiveActionWidth, } from "./responsive-action-row" import type { ResponsiveAction } from "./responsive-action-row" import { Separator } from "./separator" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "./tooltip" import { cn } from "../../lib/utils" /** * Library access role for shared hubs. Mirrors * `apps/web/lib/collaborator-access.ts > CollaboratorAccessRole` — kept * structurally identical so the apps/web type and this type are mutually * assignable wherever consumers pass collaborator rosters to `PageHeader`. */ export type PageHeaderCollaboratorAccessRole = | "owner" | "editor" | "commenter" | "viewer" export type PageHeaderVariant = "default" | "collaboration" /** * The action-row limits now live with the row itself * (`responsive-action-row.tsx`), since the table toolbar answers to the same * three. Re-exported under the old names because consumers and tests import * them from here. */ export const PAGE_HEADER_MAX_VISIBLE_ACTIONS = RESPONSIVE_ACTIONS_MAX_VISIBLE export const PAGE_HEADER_COMPACT_ACTIONS_MAX_WIDTH_PX = RESPONSIVE_ACTIONS_COMPACT_MAX_WIDTH_PX export const PAGE_HEADER_OVERFLOW_ONLY_MAX_WIDTH_PX = RESPONSIVE_ACTIONS_OVERFLOW_ONLY_MAX_WIDTH_PX /** * Type for the page `

`: the display serif, and the only place it is used. * * 18 rising to 20, down from 20 rising to 24. A page title is the one heading a * reader never has to hunt for, since it sits alone at the top of the column with * the actions opposite it, so it was buying prominence it did not need and paying * for it in the space between itself and the first row of content. Ivy's large * x-height means it still reads a size above the surrounding sans at 18. * * Exported because the record switcher on detail routes renders its own `

` * (`PageTitleRecordSwitcher`) and had this string copied out by hand, which is two * places for one type scale to drift, and the two sit on sibling routes where the * difference would show as a title that changes size when you drill in. */ export const PAGE_TITLE_TYPE_CLASS = "text-lg font-semibold leading-tight tracking-tight text-foreground sm:text-xl font-heading" /** * A page action. Structurally identical to the shared {@link ResponsiveAction} * the table toolbar takes, so a command can be moved between the two without * being rewritten. Field-level docs live on that type. */ export type PageHeaderActionItem = ResponsiveAction export interface PageHeaderCollaborator { id: string name: string imageUrl?: string | null initials?: string email?: string access?: PageHeaderCollaboratorAccessRole /** Org / directory role tags (e.g. Faculty, Program coordinator). */ roles?: string[] } export interface PageHeaderProps { /** Primary page title — rendered as `

` in Ivy Presto serif, or pass a custom node (e.g. record switcher). */ title: React.ReactNode /** Short descriptor or date shown below the title (and below `accessInfo` when set). */ subtitle?: React.ReactNode /** Layout preset — `collaboration` enables access line + face row ahead of `actions`. */ variant?: PageHeaderVariant /** * Role / access copy or badges — rendered between the title and subtitle * when `variant="collaboration"` (e.g. lock icon + "Editors can modify"). */ accessInfo?: React.ReactNode /** People with access — shown as a horizontal row of faces when `variant="collaboration"`. */ collaborators?: PageHeaderCollaborator[] /** Max faces before a `+N` chip — default 3. */ collaboratorDisplayLimit?: number /** Opens the invite collaborators sheet when a face, overflow chip, or empty-state CTA is activated. */ onCollaboratorsOpen?: () => void /** Label for the empty-roster header control — default `"Add collaborator"`. */ addCollaboratorLabel?: string /** Optional slot for right-aligned actions (buttons, selectors, etc.). */ actions?: React.ReactNode /** * Responsive page actions. At most three **row** actions render beside the * title (`placement="row"`, default). Items with `placement="overflow"` are * tertiary and **always** live under More (Invite people, Export, …). * Below the compact width (or on mobile) every visible row action is * icon-only with Tip. During WCAG reflow / overflow-only width, **row** * secondaries also move into More; the primary CTA stays on the row as * icon-only (never in More). * * Prefer this over `actions` when the controls can be represented as * labelled commands. `actions` remains for custom composites. */ actionItems?: PageHeaderActionItem[] /** Extra className for the outer wrapper. */ className?: string /** When false, the title + subtitle are visually hidden (actions remain). */ showTitleBlock?: boolean /** Keep h1 on routes. Use h2 only when demonstrating the header inside an existing document. */ headingLevel?: "h1" | "h2" } function PageHeaderCollaborationAccess({ people, limit, onOpenCollaborators, addCollaboratorLabel, countOnly = false, }: { people: PageHeaderCollaborator[] limit: number onOpenCollaborators?: () => void addCollaboratorLabel: string /** Compact header — one count control instead of face stack. */ countOnly?: boolean }) { if (people.length === 0) { if (countOnly) { return (