"use client" /** * charts-core — ChartCard, ChartFigure, ChartDataTable shells + their minimal deps. * * This module deliberately carries ZERO gallery/demo code so that hub dashboards * (library, data-view, placements, etc.) that only need the chart frame can import * from here without pulling in the full gallery bundle. * * The gallery wiring lives in `charts-overview.tsx` which re-exports everything * from this module for back-compat. */ import * as React from "react" import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card" import { Tabs, TabsList, TabsTrigger, TabsTriggerIcon, TabsTriggerLabel, TabsContent, } from "@/components/ui/tabs" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select" import { AskLeoButton } from "@/components/ask-leo-button" import { isEditableTarget } from "@/lib/editable-target" import { cn } from "@/lib/utils" import { metricTrendTone, type MetricTrendPolarity } from "@/components/key-metrics" import { type ChartLeoInsight, type ChartLeoInsightAnchor, type ChartLeoInsightKind, } from "@/components/leo-insight-indicator" import { ChartLeoInsightOverlay, ChartLeoPlotInsightOverlay, ChartLeoPixelPlotInsightOverlay, } from "@/components/chart-leo-spotting" /* ── Re-export Leo types + components so callers can import from one place ── */ export type { ChartLeoInsight, ChartLeoInsightAnchor, ChartLeoInsightKind } export { ChartLeoPlotInsightOverlay, ChartLeoInsightOverlay, ChartLeoPixelPlotInsightOverlay } /* ════════════════════════════════════════════════════════════════════════════ Types ════════════════════════════════════════════════════════════════════════════ */ export type ChartCardVariant = "normal" | "tabs" | "selector" | "metrics-tabs" | "kpi-chart" export type MiniMetric = { label: string value: string trend?: "up" | "down" | "neutral" /** Same semantics as `MetricItem.trendPolarity` on `KeyMetrics`. */ trendPolarity?: MetricTrendPolarity } /* ════════════════════════════════════════════════════════════════════════════ ChartDataTable — Screen-reader data fallback for charts ════════════════════════════════════════════════════════════════════════════ */ /** 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}
) } /* ════════════════════════════════════════════════════════════════════════════ ChartFigure — Keyboard-focusable chart region ════════════════════════════════════════════════════════════════════════════ */ /** * 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
)}
) } /* ════════════════════════════════════════════════════════════════════════════ ChartCard — reusable chart frame (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", ) 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; icon?: 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. * * Pass a Font Awesome name (`fa-chart-line`) in `icon` wherever the option has * one, and the row can drop inactive labels before it starts moving whole * options into the overflow menu. Optional because these tabs are often a * data slice rather than a destination, and no glyph tells "By status" apart * from "By facility" — such a row is better shedding whole tabs to the menu, * which is what it does when the icon is absent. */ tabOptions?: { value: string; label: string; icon?: 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 = (
{/* A chart card is often the narrowest thing on a dashboard, so the row starts shedding inactive labels to the overflow menu very early. Using TabsTriggerIcon + TabsTriggerLabel instead of plain text gives the row the glyph it needs to represent a tab without a visible label. */}