{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "header", "title": "Header", "description": "Top-of-page banner landmark with brand lockup, site name, version badge and a slot for header controls, in four WCAG 2.2 AAA surface colours.", "dependencies": [ "class-variance-authority", "clsx", "tailwind-merge" ], "registryDependencies": [ "https://ui.digital.nsw.gov.au/registry/r/theme.json", "https://ui.digital.nsw.gov.au/registry/r/badge.json", "https://ui.digital.nsw.gov.au/registry/r/link.json", "https://ui.digital.nsw.gov.au/registry/r/logo.json" ], "files": [ { "path": "src/components/header.tsx", "content": "'use client'\n\nimport { cva, type VariantProps } from 'class-variance-authority'\nimport React from 'react'\n\nimport { cn } from '@/lib/utils'\n\nimport { Badge, type BadgeProps } from '@/components/badge'\nimport { Link } from '@/components/link'\nimport { Logo } from '@/components/logo'\n\n/**\n * Surface colours the header can be themed with, sharing the Masthead's and\n * SkipLink's four-name vocabulary (`dark`, `light`, `white`, `grey`) — the three\n * components stack at the top of the page and a service picks one word for the\n * whole chrome.\n *\n * Every pair is WCAG 2.2 AAA (1.4.6, 7:1), computed from the masterbrand oklch\n * tokens. Light mode: grey-800 on white 15.1:1, grey-800 on grey-100 13.8:1,\n * white on primary-800 14.4:1, white on grey-800 15.1:1. Dark mode deepens each\n * surface onto the same family's dark step and clears AAA more comfortably\n * still (17.2:1–20.2:1).\n *\n * Unlike the Masthead — a brand strip whose light/white/grey surfaces are frozen\n * across themes — the header is the page's own surface, so all four follow the\n * theme. Pair `Header color=\"dark\"` with `Masthead color=\"dark\"` when a service\n * wants the two to match in both modes.\n */\nconst headerColors = {\n // Ink values use the RAW masterbrand tokens (--grey-800) rather than\n // Tailwind's --color-* bridge aliases: Tailwind v4 tree-shakes an\n // unreferenced @theme key, and referencing one from inside an arbitrary\n // property is not a usage signal. Same reasoning as footer.tsx.\n // (--color-white is safe: `text-white` below is a real utility.)\n dark: 'bg-primary-800 text-white [--header-ink:var(--color-white)] dark:bg-primary-950',\n light:\n 'bg-grey-100 text-grey-800 [--header-ink:var(--grey-800)] dark:bg-grey-850 dark:text-white dark:[--header-ink:var(--color-white)]',\n white:\n 'bg-white text-grey-800 [--header-ink:var(--grey-800)] dark:bg-grey-900 dark:text-white dark:[--header-ink:var(--color-white)]',\n grey: 'bg-grey-800 text-white [--header-ink:var(--color-white)] dark:bg-grey-950',\n}\n\ntype HeaderColor = keyof typeof headerColors\n\n// Shared by cva's defaultVariants, the data-color attribute and the logo\n// context, so the four can't drift apart.\nconst DEFAULT_HEADER_COLOR: HeaderColor = 'white'\n\nconst headerVariants = cva(\n [\n 'w-full',\n // The hairline rule derives from the surface's own ink, so it follows every\n // colour variant and both themes from one value. Mirrors the color-mix\n // derivation in footer.tsx (--footer-border) and link.tsx (--link-halo).\n '[--header-border:color-mix(in_oklch,var(--header-ink)_15%,transparent)]',\n 'duration-300 motion-safe:transition-shadow',\n ],\n {\n variants: {\n color: headerColors,\n },\n defaultVariants: {\n color: DEFAULT_HEADER_COLOR,\n },\n },\n)\n\nconst headerContainerVariants = cva(\n [\n 'mx-auto flex w-full flex-wrap items-center justify-between gap-x-4 gap-y-3 max-sm:py-3 sm:max-lg:py-4 lg:py-5',\n // Lateral padding funnels through --header-padding-x so an app can retune it\n // once (via the `style` prop or a utility class) without a new variant. Same\n // mechanism, and the same 16px → 24px → 48px rhythm, as Masthead and Footer.\n 'px-(--header-padding-x)',\n '[--header-padding-x:--spacing(4)] sm:[--header-padding-x:--spacing(6)] lg:[--header-padding-x:--spacing(12)]',\n ],\n {\n variants: {\n container: {\n // Full-bleed (nswds-app parity). --header-max-width is still read so a\n // shell app can constrain the inner wrapper without switching variants.\n fluid: 'max-w-[var(--header-max-width,none)]',\n // Centred content column, legacy nsw-container parity (1200px).\n contained: 'max-w-[var(--header-max-width,75rem)]',\n },\n },\n defaultVariants: {\n container: 'fluid',\n },\n },\n)\n\n/**\n * Lets `HeaderBrand` adapt its artwork to the surface it is drawn on — the\n * default `Logo` paints its wordmark `nsw-blue-800` and the version `Badge`\n * paints itself `primary-800`, both of which disappear on the `dark`\n * (primary-800) surface. A `logo` node supplied by the consumer overrides the\n * lockup entirely; at that point the choice is theirs.\n */\nconst HeaderColorContext = React.createContext(DEFAULT_HEADER_COLOR)\n\n/** Surfaces dark enough to need light-on-dark artwork. */\nconst DARK_SURFACE_COLORS = new Set(['dark', 'grey'])\n\ntype HeaderBrandProps = React.ComponentPropsWithoutRef<'div'> & {\n /** Home-page target for the brand link. Defaults to `/`. */\n href?: React.ComponentPropsWithoutRef['href']\n /** Service or site name shown beside the logo. */\n sitename?: React.ReactNode\n /**\n * Heading level for `sitename`. Omit (the default) to render a ``,\n * which is usually right: the page's own `

` belongs to its main content,\n * and a site name repeated in the header on every page is not the heading\n * that describes this page. Supply a level only when a service deliberately\n * places the site name in the document outline.\n *\n * `1` is excluded for the reason above; `6` is the deepest heading HTML\n * defines.\n */\n headingLevel?: 2 | 3 | 4 | 5 | 6\n /**\n * Version string shown in a Badge beside the brand. Sits outside the link,\n * so it never becomes part of the home link's accessible name.\n */\n version?: React.ReactNode\n /**\n * Visually-hidden prefix announced before `version`, giving the bare number\n * context for screen readers (\"2.1.0\" → \"Version 2.1.0\"). Pass an empty\n * string to suppress.\n */\n versionLabel?: string\n /**\n * Props forwarded to the version `Badge` — `size`, `color`, `variant`,\n * `className` and the rest of its API. Use it when the badge has to meet a\n * house rule the default does not, most often a minimum type size:\n *\n * ```tsx\n * \n * ```\n *\n * An explicit value always wins — including `color`, whose surface-aware\n * default exists to stop the badge painting itself the same colour as a dark\n * header. Overriding it is the same trade as supplying your own `logo` node:\n * the contrast is then yours to check.\n *\n * `undefined` and `null` do *not* win: they fall back to the defaults, so a\n * conditional like `{ color: cond ? 'accent' : undefined }` keeps the\n * surface-aware colour on the falsy branch rather than dropping the badge to\n * cva's own `primary` default.\n *\n * `children` is excluded — the badge's content is `version` and\n * `versionLabel`.\n */\n badgeProps?: Omit\n /**\n * `true` (default) renders the NSW Government waratah, `false` omits it, and\n * a node replaces it — pass an agency lockup here.\n */\n logo?: boolean | React.ReactNode\n /**\n * Accessible name for the brand link. Left unset by default: the link's name\n * is then its visible content — \"NSW Government\" (from `Logo`'s\n * visually-hidden text) plus `sitename` — which keeps the visible label\n * inside the accessible name (WCAG 2.2, 2.5.3 Label in Name). Overriding it\n * with something like \"Home page\" would break that, so set it only when the\n * brand renders no text at all.\n */\n label?: string\n ref?: React.Ref\n}\n\n/**\n * Dev-only guard, mirroring the icon-only Button check in button.tsx: the\n * default `Logo` carries a visually-hidden \"NSW Government\", so a brand with\n * neither it nor a `sitename` renders a link with no text at all, and reaches\n * assistive tech unnamed (WCAG 2.2, 4.1.2 Name, Role, Value). A consumer's own\n * logo node is assumed to carry its own text. No-op in production.\n */\nfunction warnIfBrandUnlabelled({\n logo,\n sitename,\n label,\n}: Pick) {\n if (process.env.NODE_ENV === 'production') {\n return\n }\n if (!logo && !sitename && !label) {\n console.warn(\n '[nswds/ui] HeaderBrand has no logo and no sitename, so its home link has no accessible name — pass `label`.',\n )\n }\n}\n\n/**\n * Logo, site name and optional version badge, linked to the home page. Sits at\n * the start of the `Header` row.\n *\n * The link renders through `Link`, so it picks up the framework link component\n * from `LinkProvider` (next/link et al) rather than hardcoding one.\n */\nfunction HeaderBrand({\n className,\n href = '/',\n sitename,\n headingLevel,\n version,\n versionLabel = 'Version',\n badgeProps,\n logo = true,\n label,\n children,\n ref,\n ...props\n}: HeaderBrandProps) {\n const color = React.useContext(HeaderColorContext)\n const onDarkSurface = DARK_SURFACE_COLORS.has(color)\n const Sitename = headingLevel ? (`h${headingLevel}` as const) : 'span'\n\n warnIfBrandUnlabelled({ logo, sitename, label })\n\n const logoNode =\n logo === true ? (\n \n ) : (\n // `false` / `null` fall through as the falsy nodes they are; anything\n // else is the consumer's own lockup.\n logo\n )\n\n return (\n \n {/* variant='unstyled' — a brand lockup takes no underline or link\n colour. outline-current resolves to the surface's ink on every\n variant, so the focus indicator contrasts with the header\n (WCAG 2.2, 2.4.13 Focus Appearance). The negative margin keeps the\n indicator from nudging the row. */}\n \n {logoNode}\n {sitename ? (\n // Brand-blue ink on the light surfaces (14.4:1 on white, 13.2:1 on\n // grey-100 — both WCAG 2.2 AAA). The `dark` surface *is* primary-800\n // and `grey` is nearly as deep, so those keep the header's own white\n // ink; dark mode does the same, matching the `dark:text-white` the\n // light/white variants already set on the surface.\n \n {sitename}\n \n ) : null}\n \n {version ? (\n // Badge's `primary` ink is primary-800 — the `dark` header's own\n // surface — so dark surfaces take the white badge instead.\n \n {versionLabel ? {versionLabel} : null}\n {version}\n \n ) : null}\n {children}\n \n )\n}\n\ntype HeaderActionsProps = React.ComponentPropsWithoutRef<'div'> & {\n ref?: React.Ref\n}\n\n/**\n * Trailing cluster of header controls — search, theme switcher, sign-in, and\n * the like. Deliberately unopinionated about what goes in it: site search and\n * theme switching are app concerns (they need routing and a theme provider),\n * so the design system supplies the slot and its spacing rather than the\n * widgets.\n *\n * Not a `nav` landmark: a row of controls is not a set of navigation links, and\n * an extra landmark on every page buries the ones that matter. Wrap genuine\n * navigation in your own `