349 lines · 10.8 KB · TypeScript
RawDownload
import * as React from "react";import * as RechartsPrimitive from "recharts";import type { TooltipValueType } from "recharts"; import { cn } from "../lib/utils"; // Format: { THEME_NAME: CSS_SELECTOR }const THEMES = { light: "", dark: ".dark" } as const; const INITIAL_DIMENSION = { width: 320, height: 200 } as const;type TooltipNameType = number | string; export type ChartConfig = Record<  string,  {    label?: React.ReactNode;    icon?: React.ComponentType;  } & (    | { color?: string; theme?: never }    | { color?: never; theme: Record<keyof typeof THEMES, string> }  )>; type ChartContextProps = {  config: ChartConfig;}; const cssIdentifierPattern = /^[A-Za-z_][A-Za-z0-9_-]*$/;const cssColorPattern =  /^(?:#[0-9A-Fa-f]{3,8}|(?:rgb|hsl)a?\(\s*[-+0-9.%]+\s*(?:,\s*[-+0-9.%]+\s*){2}(?:,\s*[-+0-9.]+\s*)?\)|(?:rgb|hsl)a?\(\s*[-+0-9.%]+\s+[-+0-9.%]+\s+[-+0-9.%]+(?:\s*\/\s*[-+0-9.%]+)?\s*\)|[A-Za-z]+|var\(--[A-Za-z0-9_-]+\))$/; export const chartCssVariableName = (key: string): string | null =>  cssIdentifierPattern.test(key) ? `--color-${key}` : null; export const chartCssColorValue = (color: string): string | null => {  const trimmed = color.trim();  return cssColorPattern.test(trimmed) ? trimmed : null;}; const ChartContext = React.createContext<ChartContextProps | null>(null); function useChart() {  const context = React.useContext(ChartContext);   if (!context) {    throw new Error("useChart must be used within a <ChartContainer />");  }   return context;} function ChartContainer({  id,  className,  children,  config,  initialDimension = INITIAL_DIMENSION,  ...props}: React.ComponentProps<"div"> & {  config: ChartConfig;  children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];  initialDimension?: {    width: number;    height: number;  };}) {  const uniqueId = React.useId();  const chartId = `chart-${id ?? uniqueId.replace(/:/g, "")}`;   return (    <ChartContext.Provider value={{ config }}>      <div        data-slot="chart"        data-chart={chartId}        className={cn(          "flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",          className,        )}        {...props}      >        <ChartStyle id={chartId} config={config} />        <RechartsPrimitive.ResponsiveContainer initialDimension={initialDimension}>          {children}        </RechartsPrimitive.ResponsiveContainer>      </div>    </ChartContext.Provider>  );} const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {  const colorConfig = Object.entries(config).filter(([, config]) => config.theme ?? config.color);   if (!colorConfig.length) {    return null;  }   return (    <style      dangerouslySetInnerHTML={{        __html: Object.entries(THEMES)          .map(            ([theme, prefix]) => `${prefix} [data-chart=${id}] {${colorConfig  .map(([key, itemConfig]) => {    const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ?? itemConfig.color;    const variableName = chartCssVariableName(key);    const colorValue = color ? chartCssColorValue(color) : null;    return variableName && colorValue ? `  ${variableName}: ${colorValue};` : null;  })  .filter((line): line is string => line !== null)  .join("\n")}}`,          )          .join("\n"),      }}    />  );}; const ChartTooltip = RechartsPrimitive.Tooltip; function ChartTooltipContent({  active,  payload,  className,  indicator = "dot",  hideLabel = false,  hideIndicator = false,  label,  labelFormatter,  labelClassName,  formatter,  color,  nameKey,  labelKey,}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &  React.ComponentProps<"div"> & {    hideLabel?: boolean;    hideIndicator?: boolean;    indicator?: "line" | "dot" | "dashed";    nameKey?: string;    labelKey?: string;  } & Omit<    RechartsPrimitive.DefaultTooltipContentProps<TooltipValueType, TooltipNameType>,    "accessibilityLayer"  >) {  const { config } = useChart();   const tooltipLabel = React.useMemo(() => {    if (hideLabel || !payload?.length) {      return null;    }     const [item] = payload;    const key = `${labelKey ?? item?.dataKey ?? item?.name ?? "value"}`;    const itemConfig = getPayloadConfigFromPayload(config, item, key);    const value =      !labelKey && typeof label === "string" ? (config[label]?.label ?? label) : itemConfig?.label;     if (labelFormatter) {      return (        <div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>      );    }     if (!value) {      return null;    }     return <div className={cn("font-medium", labelClassName)}>{value}</div>;  }, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);   if (!active || !payload?.length) {    return null;  }   const nestLabel = payload.length === 1 && indicator !== "dot";   return (    <div      className={cn(        "grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",        className,      )}    >      {!nestLabel ? tooltipLabel : null}      <div className="grid gap-1.5">        {payload          .filter((item) => item.type !== "none")          .map((item, index) => {            const key = `${nameKey ?? item.name ?? item.dataKey ?? "value"}`;            const itemConfig = getPayloadConfigFromPayload(config, item, key);            const indicatorColor = color ?? item.payload?.fill ?? item.color;             return (              <div                key={index}                className={cn(                  "flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",                  indicator === "dot" && "items-center",                )}              >                {formatter && item?.value !== undefined && item.name ? (                  formatter(item.value, item.name, item, index, item.payload)                ) : (                  <>                    {itemConfig?.icon ? (                      <itemConfig.icon />                    ) : (                      !hideIndicator && (                        <div                          className={cn(                            "shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",                            {                              "h-2.5 w-2.5": indicator === "dot",                              "w-1": indicator === "line",                              "w-0 border-[1.5px] border-dashed bg-transparent":                                indicator === "dashed",                              "my-0.5": nestLabel && indicator === "dashed",                            },                          )}                          style={                            {                              "--color-bg": indicatorColor,                              "--color-border": indicatorColor,                            } as React.CSSProperties                          }                        />                      )                    )}                    <div                      className={cn(                        "flex flex-1 justify-between leading-none",                        nestLabel ? "items-end" : "items-center",                      )}                    >                      <div className="grid gap-1.5">                        {nestLabel ? tooltipLabel : null}                        <span className="text-muted-foreground">                          {itemConfig?.label ?? item.name}                        </span>                      </div>                      {item.value != null && (                        <span className="font-mono font-medium text-foreground tabular-nums">                          {typeof item.value === "number"                            ? item.value.toLocaleString()                            : String(item.value)}                        </span>                      )}                    </div>                  </>                )}              </div>            );          })}      </div>    </div>  );} const ChartLegend = RechartsPrimitive.Legend; function ChartLegendContent({  className,  hideIcon = false,  payload,  verticalAlign = "bottom",  nameKey,}: React.ComponentProps<"div"> & {  hideIcon?: boolean;  nameKey?: string;} & RechartsPrimitive.DefaultLegendContentProps) {  const { config } = useChart();   if (!payload?.length) {    return null;  }   return (    <div      className={cn(        "flex items-center justify-center gap-4",        verticalAlign === "top" ? "pb-3" : "pt-3",        className,      )}    >      {payload        .filter((item) => item.type !== "none")        .map((item, index) => {          const key = `${nameKey ?? item.dataKey ?? "value"}`;          const itemConfig = getPayloadConfigFromPayload(config, item, key);           return (            <div              key={index}              className={cn(                "flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground",              )}            >              {itemConfig?.icon && !hideIcon ? (                <itemConfig.icon />              ) : (                <div                  className="h-2 w-2 shrink-0 rounded-[2px]"                  style={{                    backgroundColor: item.color,                  }}                />              )}              {itemConfig?.label}            </div>          );        })}    </div>  );} // Helper to extract item config from a payload.const isRecord = (value: unknown): value is Record<string, unknown> =>  typeof value === "object" && value !== null && !Array.isArray(value); function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {  if (!isRecord(payload)) {    return undefined;  }   const payloadPayload = isRecord(payload.payload) ? payload.payload : undefined;   let configLabelKey: string = key;   if (typeof payload[key] === "string") {    configLabelKey = payload[key];  } else if (payloadPayload && typeof payloadPayload[key] === "string") {    configLabelKey = payloadPayload[key];  }   return configLabelKey in config ? config[configLabelKey] : config[key];} export {  ChartContainer,  ChartTooltip,  ChartTooltipContent,  ChartLegend,  ChartLegendContent,  ChartStyle,};