"use client" /** * Reusable `DataTable` / `HubTable` cell primitives — extracted from * `columns-showcase.tsx` so every hub composes its grid from the same set of * named, accessible, copy-paste-free renderers. * * **Why this module exists.** Without a shared home, each hub would re-derive * progress bars, currency formatting, rating stars, attachment chips, relative * times, etc. — drifting in spacing, color, and a11y treatment. These cells * pair color + glyph (WCAG 1.4.1), keep tabular numbers right-aligned, and * expose a focusable `Tip` for any glyph-only signal. * * **Composition only.** Every renderer is a pure composition of existing * primitives (`@/components/ui/*`, `StatusBadge`, * `Intl` formatters, Font Awesome icon classes). No new design tokens, no new * package surface — drop these into any `ColumnDef['cell']`. * * **Live catalog:** Design OS Columns (`/columns`) via consumer `columns-showcase.tsx` (hosted at * `/columns`) renders every export below as its own column so designers, * engineers, and AI agents can see the cell in situ before picking it. * * **Skill reference:** `.cursor/skills/exxat-token-economy/SKILL.md` §3 names * each export below in its "primitive aliases" table so the AI imports * directly instead of re-implementing. */ import * as React from "react" import { AvatarGroup, AvatarGroupCount, AvatarInitials } from "../ui/avatar" import { Badge } from "../ui/badge" import { Button } from "../ui/button" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "../ui/dropdown-menu" import { FAVORITE_HOVER_GROUP, FavoriteToggleButton } from "../ui/favorite-toggle-button" import { InlineInputEdit, InlineSelectEdit } from "../ui/inline-edit" import { STATUS_BADGE_SEMANTIC_MD_SHELL, STATUS_BADGE_SEMANTIC_SM_SHELL, STATUS_BADGE_TONE_CLASS, StatusBadge, type StatusBadgeSemanticSize, type StatusBadgeTone, } from "../ui/status-badge" import { Tip } from "../ui/tip" import { ToggleSwitch } from "../ui/toggle-switch" import { cn } from "../../lib/utils" export { FAVORITE_HOVER_GROUP } /* ────────────────────────────────────────────────────────────────────────── * * Shared helpers * ────────────────────────────────────────────────────────────────────────── */ /** Missing-value cell. Renders the word, not a glyph, so sighted and * screen-reader users read the same thing. Pass `label` when the column can * name what is missing ("No date", "No files"). */ export function EmptyCell({ label = "None" }: { label?: string }) { return {label} } /** * Text editor sized for a DataTable cell. It stops row activation while the * value is edited and keeps Enter and Escape local to the editor. */ export function InlineInputCell( props: React.ComponentProps, ) { return ( ) } /** * Select editor sized for a DataTable cell. The draft is explicit: choosing an * option does not commit it until Save is activated. */ export function InlineSelectCell( props: React.ComponentProps, ) { return } /* ────────────────────────────────────────────────────────────────────────── * * Numeric / monetary * ────────────────────────────────────────────────────────────────────────── */ /** * Right-aligned plain numeric cell. Use for counts where the grid benefits * from column-aligned digits (attempts, downloads, file size N). */ export function NumericCell({ value, fractionDigits = 0, className, }: { value: number | null | undefined fractionDigits?: number className?: string }) { if (value == null || Number.isNaN(value)) return return ( {Number(value).toLocaleString(undefined, { minimumFractionDigits: fractionDigits, maximumFractionDigits: fractionDigits, })} ) } /** * Currency cell — right-aligned, `tabular-nums`. `Intl.NumberFormat` honors * locale + currency; defaults to USD because the product is US-first. */ /* ────────────────────────────────────────────────────────────────────────── * * Currency * ────────────────────────────────────────────────────────────────────────── */ export function CurrencyCell({ value, currency = "USD", locale = "en-US", maximumFractionDigits = 2, }: { value: number | null | undefined currency?: string locale?: string maximumFractionDigits?: number }) { const fmt = React.useMemo( () => new Intl.NumberFormat(locale, { style: "currency", currency, maximumFractionDigits }), [locale, currency, maximumFractionDigits], ) if (value == null || Number.isNaN(value)) return return ( {fmt.format(value)} ) } /* ────────────────────────────────────────────────────────────────────────── * * Progress + signal * ────────────────────────────────────────────────────────────────────────── */ export type ProgressTone = "auto" | "success" | "warning" | "danger" | "info" /** * Progress bar — track + filled fill + numeric label. Auto-tones in thirds: * <34% destructive, <67% warning, ≥67% success. Pass an explicit `tone` to * override (e.g. "info" for non-judgmental quantity bars). */ export function ProgressCell({ value, max = 100, tone = "auto", label, className, }: { value: number | null | undefined max?: number tone?: ProgressTone /** Right-side label. Defaults to `${pct}%`. Pass `false` to hide. */ label?: React.ReactNode | false className?: string }) { if (value == null || Number.isNaN(value)) return const pct = Math.max(0, Math.min(100, Math.round((value / max) * 100))) const autoTone = pct < 34 ? "bg-destructive" : pct < 67 ? "bg-amber-500" : "bg-emerald-500" const toneClass = tone === "success" ? "bg-emerald-500" : tone === "warning" ? "bg-amber-500" : tone === "danger" ? "bg-destructive" : tone === "info" ? "bg-primary" : autoTone const labelNode = label === false ? null : label ?? {pct}% return (
{labelNode}
) } export type SignalTone = "success" | "warning" | "danger" | "info" | "neutral" /** * Three-bar signal indicator — same metaphor as Wi-Fi / cellular bars. Use * for ordinal scales (low/medium/high; easy/medium/hard). Color is *paired* * with bar count so the cell still communicates on monochrome + forced-colors. */ export function SignalBarsCell({ level, max = 3, tone = "info", label, }: { /** 1-indexed level. */ level: number /** Total number of bars. Default 3. */ max?: number tone?: SignalTone /** Accessible name; also used as the `Tip` content. */ label: string }) { const lvl = Math.max(0, Math.min(max, Math.round(level))) const toneClass = tone === "success" ? "bg-emerald-500" : tone === "warning" ? "bg-amber-500" : tone === "danger" ? "bg-destructive" : tone === "info" ? "bg-primary" : "bg-foreground" return ( {Array.from({ length: max }, (_, i) => { const bar = i + 1 const filled = bar <= lvl // Stair-step the heights so the metaphor reads visually. const heightClass = bar === 1 ? "h-2" : bar === 2 ? "h-3" : bar === 3 ? "h-4" : "h-5" return ( ) } /* ────────────────────────────────────────────────────────────────────────── * * People * ────────────────────────────────────────────────────────────────────────── */ export interface PersonStub { name: string initials: string } /** * Face rail — list of people with a `+N more` overflow chip. Each face gets a * `Tip` of the person's name; the overflow chip's tip lists the hidden names. * Uses non-overlapping avatars (gap, not negative margin) per Exxat DS rule. */ export function PeopleAvatarRailCell({ people, visibleMax = 3, size = "sm", emptyLabel = "No people", }: { people: PersonStub[] | undefined /** How many faces to show before `+N`. Default 3. */ visibleMax?: number size?: "sm" | "md" emptyLabel?: string }) { if (!people?.length) return const visible = people.slice(0, visibleMax) const overflow = people.length - visible.length const sizeClass = size === "md" ? "size-7 text-xs" : "size-6 text-xs" return ( {visible.map((p) => ( ))} {overflow > 0 && ( p.name).join(", ")}> +{overflow} )} ) } /* ────────────────────────────────────────────────────────────────────────── * * New / unread row affordance * ────────────────────────────────────────────────────────────────────────── */ /** * Subtle dot for unread/new table rows — pair with `row.isNew` + * `bg-dt-new-row-bg`. * * In a table row the dot is the **only** signal that the row is new, which * makes it informational rather than decorative: the swatch stays `aria-hidden` * and a sibling `sr-only` string carries the meaning, so a screen reader hears * "New" where a sighted user sees the dot (WCAG 1.4.1 / 1.1.1). * * Pass `label` to say something more specific ("Unread", "Added today"), or * `label={null}` when the surrounding context already establishes newness (a * "What's new" card, where every row is new and repeating it is just noise). */ /** * Reserved gutter for a leading row marker (`TableNewRowDot`). * * Two jobs. It reserves its width even when empty, so titles stay aligned down * the column instead of unmarked rows sliding left. And it is exactly one * `text-sm` line box tall, so `items-center` centers the marker on the middle of * the title's **first** line rather than on the whole (possibly wrapped) block. * * The height is derived from the type tokens rather than hard-coded to 20px: * `--text-sm` is `max(14px, 0.875rem)`, so the line box grows when the root font * is scaled up, and a fixed height would drift off-center exactly for the users * who scale text. */ export function TableRowMarkerGutter({ children, className, }: { children?: React.ReactNode className?: string }) { return ( {children} ) } export function TableNewRowDot({ tone = "info", label = "New", className, }: { tone?: "info" | "danger" /** Screen-reader text. `null` when the context already says "new". */ label?: string | null className?: string }) { return ( ) } /* ────────────────────────────────────────────────────────────────────────── * * Name + favorite * ────────────────────────────────────────────────────────────────────────── */ /** * Name/title column with an inline favorite star — the "identity + favorite" * pattern used by every hub with a favoritable primary column (question * bank, course forms, course reports, …). One shared composition so hubs * don't each re-derive the hover-reveal star, gap, and text treatment * (`exxat-reuse-before-custom.mdc`). Star is trailing (after the text) — * keep new call sites consistent rather than flipping the icon to lead. * * Row-state markers (`TableNewRowDot`) go in `leading`, not `badge`: a status * marker in front of the title is scannable down the column edge, while a * trailing one drifts with each title's length. */ export function FavoriteNameCell({ label, isFavorite, onToggleFavorite, subtitle, interactive = false, leading, badge, }: { label: string isFavorite: boolean onToggleFavorite: () => void /** Secondary line under the label (mono ID, meta, etc.). */ subtitle?: React.ReactNode /** Render `label` as a link-styled button (row opens its own detail view) instead of plain text. */ interactive?: boolean /** * Row-state marker before the title, e.g. ``. * * Pass `null` (not `undefined`) for rows without a marker: the gutter is * reserved whenever this prop is present at all, so titles stay aligned down * the column instead of the unmarked ones sliding left. */ leading?: React.ReactNode /** Trailing badge/chip after the label. Row state belongs in `leading`. */ badge?: React.ReactNode }) { return (
{leading !== undefined ? ( {leading} ) : null}
{interactive ? ( ) : ( {label} )} {badge}
{subtitle}
) } /* ────────────────────────────────────────────────────────────────────────── * * Status * ────────────────────────────────────────────────────────────────────────── */ /** One selectable state in an actionable `StatusCell` menu. */ export interface StatusCellOption { /** Stable key handed back to `onChange`. */ value: string label: string /** FA glyph without the family prefix, e.g. `"fa-circle-check"`. */ icon?: string tone?: StatusBadgeTone /** Escape hatch for hub-specific tints (`lib/list-status-badges.ts`). */ tintClassName?: string disabled?: boolean } /** * Workflow status — the canonical renderer for any column with * `cellKind: "status"`. Wraps `StatusBadge` so tone, size, and glyph stay * identical across hubs instead of each one re-deriving them. * * Pass `options` **and** `onChange` to make it actionable: the badge becomes a * menu trigger that advances the record in place, which is the whole point of a * status column. Omit either one and it renders the static badge, so a user who * cannot change status never sees an affordance that does nothing. Callers gate * on their own permission check rather than the cell reaching for one, keeping * authorization in a single place. * * The trigger clears the 24px target floor (`min-h-6`), paints its focus ring * inside the clipped cell, and stops row-click propagation so changing status * never also opens the record. */ export function StatusCell({ label, icon, tone, tintClassName, size = "sm", value, options, onChange, changeLabel, align = "start", }: { label: string /** FA glyph without the family prefix, e.g. `"fa-circle-check"`. */ icon?: string tone?: StatusBadgeTone tintClassName?: string size?: StatusBadgeSemanticSize /** Current option key. Defaults to `label` when the two are the same. */ value?: string /** Selectable states. With `onChange`, turns the badge into a menu trigger. */ options?: StatusCellOption[] onChange?: (next: string) => void /** Accessible name and tooltip for the trigger. Must describe the action. */ changeLabel?: string align?: "start" | "center" | "end" }) { const isActionable = Boolean(onChange && options?.length) if (!isActionable) { return ( ) } const tint = tintClassName ?? (tone ? STATUS_BADGE_TONE_CLASS[tone] : undefined) const triggerLabel = changeLabel ?? `Change status. Currently ${label}.` return (