"use client" /** * ChartsOverview — Dashboard chart gallery * * ── ChartCard variants ─────────────────────────────────────────────────────── * normal — plain card with Ask Leo * tabs — "Chart" | "Trend (Line)" tabs + Ask Leo * selector — quick-filter Select + Ask Leo * metrics-tabs — metric cells ARE the tab triggers (label + value + trend) * * ── ASK LEO ICON GUIDELINE ─────────────────────────────────────────────────── * Always use: * Never use: fa-wand-magic-sparkles (retired, inconsistent) * Size: text-xs (12px via --text-xs) with aria-hidden="true" * Label: "Ask Leo" (never truncate or omit the text label) * Applies to: ALL Ask Leo buttons across the entire app — * ChartCard headers, KeyMetrics card, GreetingWidget, NavUser, etc. * * ── WCAG AA STANDARDS FOR GRAPHS ───────────────────────────────────────────── * 1. Container landmark * • Wrap each chart in a
(or div with role="figure") + * aria-label="" + aria-describedby="" * • Add a visually-hidden
with a plain-text * summary of the key trend (e.g. "Placements rose 12% in Q1 2026"). * * 2. Keyboard navigation * • The ChartContainer wrapper must have tabIndex={0} so it receives focus. * • On focus, announce title + summary via aria-label / aria-describedby. * • Arrow keys (←/→) cycle through data points; announce value via * a live region (role="status" aria-live="polite"). * • Esc clears the selection and returns focus to the container. * * 3. Accessible data table (hidden fallback) * • Immediately after the SVG/canvas, render a wrapped in * (visually hidden, in DOM). * • Columns mirror the chart axes; each data point is a
. * • Screen-reader users can navigate data with standard table shortcuts. * * 4. Colour & contrast * • Chart series colours must achieve ≥ 3:1 contrast against the card bg. * • Never use colour as the ONLY differentiator — pair with: * - Dashed vs solid line strokes * - Direct inline labels on lines/segments * - Shape markers on data points (circle vs square vs triangle) * • Text labels inside charts: ≥ 4.5:1 on their local background. * * 5. Focus ring on data points * • Active/focused data point: 3px outline, ≥ 3:1 contrast, distinct * from the hover state (use outline-offset to separate). * * 6. Tooltip accessibility * • Tooltips must appear on keyboard focus, not only on mouse hover. * • Tooltip content must be announced to the live region. * • Tooltip must remain visible while it has focus (no auto-dismiss). * ───────────────────────────────────────────────────────────────────────────── */ import * as React from "react" import { Area, AreaChart, Bar, BarChart, CartesianGrid, Cell, ComposedChart, Funnel, FunnelChart, LabelList, Line, LineChart, Pie, PieChart, PolarAngleAxis, PolarGrid, PolarRadiusAxis, Radar, RadarChart, RadialBar, RadialBarChart, Scatter, ScatterChart, Sector, Trapezoid, XAxis, YAxis, ZAxis, type DotItemDotProps, } from "recharts" import type { PieSectorDataItem } from "recharts/types/polar/Pie" import { QuotaLinearProgressCardBody, QuotaRadialChartInner, } from "@/components/dashboard-quota-progress-card" import { DASHBOARD_STUDENT_SCORES, formatBandScore, type StudentScoreRadial, } from "@/lib/mock/dashboard" import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card" import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, chartTooltipKeyboardSyncProps, ChartTooltipContent, type ChartConfig, } from "@/components/ui/chart" import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select" import { Button } from "@/components/ui/button" import { AskLeoButton } from "@/components/ask-leo-button" import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip" import { isEditableTarget } from "@/lib/editable-target" import { chartLineStrokeDash } from "@/lib/chart-line-dash" import { CHART_AXIS_TICK, CHART_TICK_FONT_SIZE, } from "@/lib/chart-typography" import { CHART_DASHBOARD_CELL_CLASS, CHART_DASHBOARD_PLOT_MIN_CLASS, CHART_DASHBOARD_ROW_GRID_CLASS, CHART_DASHBOARD_ROW_GRID_THIRDS_CLASS, } from "@/lib/chart-dashboard-layout" import { chartLeoPeakAnchor } from "@/lib/chart-leo-spotting" import { CHART_KBD_ACTIVE_PIE_SHAPE, } from "@/lib/chart-keyboard-selection" import { cn } from "@/lib/utils" import { metricTrendTone, type MetricTrendPolarity } from "@/components/key-metrics" /** Recharts passes `index` into Line `dot` renderers; keep the callback typed against the v3 dot contract. */ type LineDotRenderProps = DotItemDotProps & { index?: number } function donutLeoSector(props: PieSectorDataItem) { const isPeak = props.name === "confirmed" const isActive = (props as PieSectorDataItem & { isActive?: boolean }).isActive return ( ) } function radialLeoSector( props: React.ComponentProps & { payload?: { name?: string }; isActive?: boolean }, ) { const isPeak = props.payload?.name === "nursing" return ( ) } function funnelLeoShape( props: React.ComponentProps & { payload?: { name?: string } }, ) { const isPeak = props.payload?.name === "Applied" return } const SCATTER_LEO_PEAK = { x: 90, y: 97 } as const const activeIndexProps = (activeIndex: number | null) => activeIndex == null ? {} : ({ activeIndex } as Record) type MiniMetric = { label: string value: string trend?: "up" | "down" | "neutral" /** Same semantics as `MetricItem.trendPolarity` on `KeyMetrics`. */ trendPolarity?: MetricTrendPolarity } /* ── Colour tokens ────────────────────────────────────────────────────────── */ const BRAND = "var(--brand-color)" const CHART_1 = "var(--color-chart-1)" const CHART_2 = "var(--color-chart-2)" const CHART_3 = "var(--color-chart-3)" const CHART_4 = "var(--color-chart-4)" const CHART_5 = "var(--color-chart-5)" const SUCCESS = "var(--chart-2)" const WARNING = "var(--chart-4)" const DESTRUCTIVE = "var(--destructive)" /* ── Period filter options (reused across selector cards) ─────────────────── */ const PERIOD_OPTIONS = [ { value: "7d", label: "Last 7 days" }, { value: "30d", label: "Last 30 days" }, { value: "90d", label: "Last quarter" }, { value: "1y", label: "Last year" }, ] const PROGRAM_OPTIONS = [ { value: "all", label: "All programs" }, { value: "nursing", label: "Nursing" }, { value: "pt", label: "PT" }, { value: "ot", label: "OT" }, { value: "pharmacy", label: "Pharmacy" }, ] /* ════════════════════════════════════════════════════════════════════════════ REUSABLE ChartCard — supports 3 variants ════════════════════════════════════════════════════════════════════════════ */ export type ChartCardVariant = "normal" | "tabs" | "selector" | "metrics-tabs" | "kpi-chart" /** Shared line tab chrome — metrics-tabs is canonical; tabs variant uses the same shell. */ const chartCardLineTabsListClass = "h-auto w-full gap-0 rounded-none justify-start !items-end" const chartCardLineTabTriggerBaseClass = "h-auto min-w-0 flex-none px-3 pt-2 pb-3 text-muted-foreground data-active:text-foreground" const chartCardTabTriggerClass = cn( chartCardLineTabTriggerBaseClass, "flex-row items-center gap-2", ) const chartCardMetricTabTriggerClass = cn( chartCardLineTabTriggerBaseClass, "flex-col items-start gap-1", ) import { type ChartLeoInsight, type ChartLeoInsightAnchor, type ChartLeoInsightKind, } from "@/components/leo-insight-indicator" import { ChartLeoInsightOverlay, ChartLeoPlotInsightOverlay, } from "@/components/chart-leo-spotting" export type { ChartLeoInsight, ChartLeoInsightAnchor, ChartLeoInsightKind } export { ChartLeoPlotInsightOverlay, ChartLeoInsightOverlay, ChartLeoPixelPlotInsightOverlay } from "@/components/chart-leo-spotting" /** Screen-reader data fallback for charts — shared with list-page dashboards. */ export function ChartDataTable({ caption, headers, rows, }: { caption: string headers: string[] rows: (string | number)[][] }) { return ( {headers.map((h) => )} {rows.map((row, i) => ( {row.map((cell, j) => )} ))}
{caption}
{h}
{cell}
) } /** * Keyboard-focusable chart region (arrow keys, Escape) + live announcement when a point is selected. * Shared by the `/dashboard` gallery and **Data** view dashboards (Placements / Team / Compliance): same * interaction model; visual differences come from `ChartCard` chrome and per-chart renderers (bar vs pie), * not from a separate chart implementation. */ export function ChartFigure({ label, summary, dataLength, leoInsight, children, }: { label: string summary: string dataLength: number /** Optional Ask-Leo insight context for chart bodies (same as `ChartCard`). */ leoInsight?: ChartLeoInsight | null children: (activeIndex: number | null) => React.ReactNode }) { const [activeIndex, setActiveIndex] = React.useState(null) const ref = React.useRef(null) const prevActiveIndexRef = React.useRef(null) React.useEffect(() => { const prev = prevActiveIndexRef.current prevActiveIndexRef.current = activeIndex if (prev === null || activeIndex !== null) return const wrapper = ref.current?.querySelector(".recharts-wrapper") if (!wrapper) return wrapper.dispatchEvent( new MouseEvent("mouseleave", { bubbles: true, cancelable: true }), ) }, [activeIndex]) const navigateKeys = React.useCallback( (e: React.KeyboardEvent) => { if (!dataLength) return if (isEditableTarget(e.target)) return switch (e.key) { case "ArrowRight": case "ArrowDown": e.preventDefault() e.stopPropagation() setActiveIndex((i) => (i === null ? 0 : Math.min(i + 1, dataLength - 1))) break case "ArrowLeft": case "ArrowUp": e.preventDefault() e.stopPropagation() setActiveIndex((i) => (i === null ? dataLength - 1 : Math.max(i - 1, 0))) break case "Escape": e.preventDefault() e.stopPropagation() setActiveIndex(null) ref.current?.blur() break default: break } }, [dataLength], ) /** Clicks on Recharts SVG do not focus this node — focus so Arrow keys work without extra Tab stops. */ function handlePointerDownCapture(e: React.PointerEvent) { if (!dataLength) return const root = ref.current if (!root?.contains(e.target as Node)) return const el = e.target as HTMLElement | null if (el?.closest?.("button, a, [role='tab'], [role='option'], input, select, textarea, [contenteditable='true']")) return queueMicrotask(() => root.focus()) } return (
{ if (!ref.current?.contains(e.target as Node)) return if (isEditableTarget(e.target)) return if ( e.key === "ArrowRight" || e.key === "ArrowDown" || e.key === "ArrowLeft" || e.key === "ArrowUp" || e.key === "Escape" ) { navigateKeys(e) } }} onPointerDownCapture={handlePointerDownCapture} className="flex min-h-0 flex-1 flex-col outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 rounded-sm" > {children(activeIndex)} {activeIndex !== null && (
Data point {activeIndex + 1} of {dataLength} selected
)}
) } function ChartCardHeader({ title, description, variant, filterOptions, filter, onFilter, }: { title: string description: string variant: ChartCardVariant filterOptions?: { value: string; label: string }[] filter?: string onFilter?: (v: string) => void }) { const isSelector = variant === "selector" && Array.isArray(filterOptions) && filterOptions.length > 0 return (
{title} {description}
{/* Reveal on card hover/focus — pointer-events guarded so the hidden button is not reachable */} {isSelector && filterOptions && onFilter && ( )}
) } function resolveChartCardFilter( variant: ChartCardVariant, defaultFilter: string | undefined, filterOptions: { value: string; label: string }[] | undefined, miniMetrics: MiniMetric[] | undefined, tabOptions: { value: string; label: string }[] | undefined, ): string { if (variant === "metrics-tabs" && miniMetrics && miniMetrics.length > 0) { const labels = new Set(miniMetrics.map((m) => m.label)) if (defaultFilter && labels.has(defaultFilter)) return defaultFilter return miniMetrics[0]!.label } if (variant === "kpi-chart" && miniMetrics && miniMetrics.length > 0) { return miniMetrics[0]!.label } if (variant === "tabs" && tabOptions && tabOptions.length > 0) { if (defaultFilter && tabOptions.some((t) => t.value === defaultFilter)) return defaultFilter return tabOptions[0]!.value } if (variant === "selector" && filterOptions && filterOptions.length > 0) { if (defaultFilter && filterOptions.some((o) => o.value === defaultFilter)) return defaultFilter return filterOptions[0]!.value } return defaultFilter ?? "" } export function ChartCard({ title, description, children, className = "", variant = "normal", trendContent, filterOptions, defaultFilter, onFilterChange, miniMetrics, tabOptions, leoInsight, }: { title: string description: string children: React.ReactNode | ((filter: string) => React.ReactNode) className?: string variant?: ChartCardVariant /** "tabs" / "metrics-tabs" variant: content shown in the "Trend" tab */ trendContent?: React.ReactNode /** "selector" variant: options for the filter dropdown */ filterOptions?: { value: string; label: string }[] defaultFilter?: string onFilterChange?: (value: string) => void /** "metrics-tabs" variant: compact KPI strip shown above the chart */ miniMetrics?: MiniMetric[] /** "tabs" variant: override the default Chart/Trend tabs with custom options. * The selected value is passed to the children function. */ tabOptions?: { value: string; label: string }[] /** * Smart Leo summary: opens a popover + Ask Leo CTA. * With `anchor`, mount `ChartLeoPlotInsightOverlay` beside `ChartContainer` for on-plot guide + marker. */ leoInsight?: ChartLeoInsight | null }) { const [filter, setFilter] = React.useState(() => resolveChartCardFilter(variant, defaultFilter, filterOptions, miniMetrics, tabOptions), ) // Reconcile when variant or option sets change (catalog variant switcher, prop updates). React.useEffect(() => { setFilter(resolveChartCardFilter(variant, defaultFilter, filterOptions, miniMetrics, tabOptions)) }, [variant, defaultFilter, filterOptions, miniMetrics, tabOptions]) const handleFilter = (v: string) => { setFilter(v); onFilterChange?.(v) } const resolvedChildren = typeof children === "function" ? children(filter) : children const chartCardShellClass = cn("flex h-full min-h-0 flex-col", className) /* ── Default Chart / Trend tabs (no custom tabOptions) ───────────────────── */ const defaultTabsBlock = (