import { useUniqueId } from "@charcuterie/logic" import type { ElevationStep, SurfaceRole, } from "@charcuterie/tokens" import type { ComponentPropsWithRef, ReactNode, } from "react" import { toClassName } from "../toClassName.ts" import type { CardAccentEdge } from "./cardAccentEdge.ts" import { getAccentEdgeClassName, getAccentEdgeStyle, } from "./cardAccentEdge.ts" export type CardSurface = Extract< SurfaceRole, "base" | "raised" | "sunken" > export type CardPadding = "lg" | "md" | "none" | "sm" export type CardProps = ComponentPropsWithRef<"section"> & { /** * A coloured bar down the leading edge, following the card's own * corners. * * `{ categorical: 3 }` for a colour a user picked; `{ color }` * for one an app computed — a hue hashed from a repo's name, say, * where ten indices are not enough. See `cardAccentEdge.ts` for * why it is a pseudo-element and not a border. */ accentEdge?: CardAccentEdge /** Buttons or a badge, laid out beside the heading. */ actions?: ReactNode elevation?: ElevationStep footer?: ReactNode heading?: string headingLevel?: 2 | 3 | 4 padding?: CardPadding surface?: CardSurface } const SURFACE_CLASS: Record = { base: "bg-surface-base", raised: "bg-surface-raised", sunken: "bg-surface-sunken", } /** * Exported only so the story can derive its `elevation` control from * the same map the component indexes — `ElevationStep` comes from * `@charcuterie/tokens`, which react-docgen will not follow, so the * options have to be stated and this is the one statement of them * that cannot drift. */ export const ELEVATION_CLASS: Record< ElevationStep, string > = { none: "shadow-none", low: "shadow-low", medium: "shadow-medium", high: "shadow-high", } const PADDING_CLASS: Record = { none: "p-0", sm: "p-3", md: "p-4", lg: "p-6", } /** * Five repos, and the reason this is a P0 rather than a nicety is * the `
`: give a card a heading and it becomes a landmark, * so `getByRole("region", { name: "Bay 3" })` scopes every * subsequent query to that card. Without it, an agent driving a * 16-bay tower has sixteen identical "Start" buttons and no way to * say which one it means. * * `aria-labelledby` rather than `aria-label`, because the heading is * on screen already and stating the name twice is how the two drift * apart. A card with **no** `heading` keeps the `aria-labelledby` * the call site passed, for the app that draws its own title row * inside the card — the name still points at text on screen, which * is the same rule one level out. * * **Density is not a prop here.** mux-magic's inventory turned up a * `CardDensityProvider` in the withdrawn bambuddy evidence, and it * is the wrong shape for this fleet: density is a global axis on * ``, so a compact bay list is `data-density="compact"` on the * page, not a context around the cards. `padding` remains a prop * because a card wrapping a poster grid genuinely has none, which is * a layout fact rather than a density one. */ export const Card = ({ "aria-labelledby": ariaLabelledBy, accentEdge, actions, children, className, elevation = "low", footer, heading, headingLevel = 2, padding = "md", style, surface = "raised", ...sectionProps }: CardProps): ReactNode => { const headingId = useUniqueId() // The caller's own `style` wins. An accent colour is one custom // property, and silently dropping whatever else the call site set // would be a worse trade than letting it override this one. const accentEdgeStyle = accentEdge == null ? undefined : getAccentEdgeStyle(accentEdge) const mergedStyle = accentEdgeStyle == null ? style : { ...accentEdgeStyle, ...style } // The caller's own pointer is the FALLBACK, not the loser. This // attribute is written *after* the spread, so `undefined` here // does not mean "leave whatever the call site passed": it // overwrites it, React then drops the attribute entirely, and the // card's accessible name falls back to its whole subtree with // nothing failing — not typecheck, not axe, not a render. A // `role="button"` tile in mail-sifter naming itself // `aria-labelledby="recent-tile-heading"` announced as "Recent // Mail 6 40 arrived in the last 24 hours, 34 done". // // `heading` still wins when it is given, because that is the name // the reader can see and this component renders it. Do not // "simplify" this back to a ternary ending in `undefined`. // // `aria-label` needs no equivalent: the component never writes // one, so the spread carries the caller's through untouched. const labelledById = heading ? headingId : ariaLabelledBy const Heading = `h${headingLevel}` as const return (
{heading || actions ? (
{heading ? ( {heading} ) : null} {actions ? (
{actions}
) : null}
) : null} {children} {footer ? ( ) : null}
) }