"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/*`, `@/components/list-hub-status-badge`, * `Intl` formatters, Font Awesome icon classes). No new design tokens, no new * package surface — drop these into any `ColumnDef['cell']`. * * **Live catalog:** `apps/web/components/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 "@/components/ui/avatar" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" import { Tip } from "@/components/ui/tip" import { ToggleSwitch } from "@/components/ui/toggle-switch" import { cn } from "@/lib/utils" /* ────────────────────────────────────────────────────────────────────────── * * Shared helpers * ────────────────────────────────────────────────────────────────────────── */ const EMPTY_DASH = ( ) /** Truthy-only dash with an accessible label so screen-reader users get a hint * for "no value" cells across every hub. */ function EmptyCell({ label = "No value" }: { label?: string }) { 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. */ export function CurrencyCell({ value, currency = "USD", locale = "en-US", maximumFractionDigits = 2, }: { value: number | null | undefined currency?: string locale?: string maximumFractionDigits?: number }) { if (value == null || Number.isNaN(value)) return const fmt = new Intl.NumberFormat(locale, { style: "currency", currency, maximumFractionDigits, }) 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} )} ) } /* ────────────────────────────────────────────────────────────────────────── * * Pills + chips * ────────────────────────────────────────────────────────────────────────── */ /** * Outlined pill with a leading FA icon — the "Type" pattern. Use for * single-select categorical fields where color isn't carrying meaning * (otherwise reach for `ListHubStatusBadge`). */ export function PillCell({ label, icon, iconClassName, className, }: { label: React.ReactNode /** FA glyph name without the family prefix, e.g. `"fa-list-check"`. */ icon?: string iconClassName?: string className?: string }) { return ( {icon ? (