1 estrella en GitHub = 1 componente nuevo.

Dar una estrella
Gráficos y Estadísticaschartsvga11yfoundationtooltip

Chart Frame

Contenedor accesible compartido por todos los gráficos SVG: tarjeta, cabecera, leyenda, tooltip y tabla de datos alternativa.

Vista Previa en Vivo

Chart Frame

Uso

Copia y pega este código directamente en tu componente React:

tsx
import { ChartFrame } from 'react-component-kit-ui/charts';

<ChartFrame title="Custom visual" subtitle="Any SVG you like" legend={[{ label: 'A', color: 'var(--chart-1)' }, { label: 'B', color: 'var(--chart-2)' }]} table={{ headers: ['Item', 'Value'], rows: [['A', 10], ['B', 20]] }}>
  <svg viewBox="0 0 640 200" role="img" aria-label="Custom visual">…</svg>
</ChartFrame>

Instalación Manual

Si no estás usando el paquete npm, crea un archivo nuevo y pega el siguiente código: src/components/ChartFrame.tsx + ChartFrame.css:

ChartFrame.tsx
'use client';

import { useEffect, useRef } from 'react';
import type { CSSProperties, ReactNode } from 'react';
import './ChartFrame.css';

/** class:list → className */
function cx(...args: unknown[]): string {
  const out: string[] = [];
  const walk = (v: unknown) => {
    if (!v) return;
    if (typeof v === 'string' || typeof v === 'number') out.push(String(v));
    else if (Array.isArray(v)) v.forEach(walk);
    else if (typeof v === 'object') for (const [k, on] of Object.entries(v as Record<string, unknown>)) if (on) out.push(k);
  };
  args.forEach(walk);
  return out.join(' ');
}

/** "a: b; --c: d" → style object */
function st(css: string | undefined): CSSProperties {
  const o: Record<string, string> = {};
  for (const decl of String(css ?? '').split(';')) {
    const i = decl.indexOf(':');
    if (i < 0) continue;
    const k = decl.slice(0, i).trim();
    if (k) o[k.startsWith('--') ? k : k.replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = decl.slice(i + 1).trim();
  }
  return o as CSSProperties;
}

/**
 * ChartFrame — Shared accessible shell for every SVG chart in the kit.
 * Renders the card surface, header, legend, hover tooltip and a
 * "View data" table fallback so no chart relies on color or hover alone.
 *
 * @param {string} title - Chart heading (names the series when there is only one).
 * @param {string} subtitle - Supporting description under the title.
 * @param {string} value - Optional headline figure shown on the right of the header.
 * @param {string} delta - Optional change label next to the headline figure.
 * @param {boolean} deltaPositive - Good news (green ▲) or bad (red ▼); omit for a neutral caption.
 * @param {Array<{label: string, color: string}>} legend - Legend items (shown when ≥ 2 series).
 * @param {{headers: string[], rows: Array<Array<string|number>>}} table - Data for the table view.
 * @param {string} variant - Surface style (glass|solid|ghost).
 * @param {string} size - Density (sm|md|lg).
 * @param {string} className - Extra CSS class.
 *
 * Accessibility: the figure is labelled by its title, every mark is focusable via
 * the chart itself, and a native <details> data table is always available.
 */
export interface ChartFrameProps {
  title?: string;
  subtitle?: string;
  value?: string;
  delta?: string;
  deltaPositive?: boolean;
  legend?: Array<{ label: string; color: string }>;
  table?: { headers: string[]; rows: Array<Array<string | number>> };
  variant?: 'glass' | 'solid' | 'ghost';
  size?: 'sm' | 'md' | 'lg';
  className?: string;
  children?: ReactNode;
  footer?: ReactNode;
}

export function ChartFrame({
  title,
  subtitle,
  value,
  delta,
  deltaPositive,
  legend = [],
  table,
  variant = 'glass',
  size = 'md',
  className = '',
  children,
  footer
}: ChartFrameProps) {
  const titleId = `cf-${Math.random().toString(36).slice(2, 9)}`;

  // Ported from the Astro <script>: runs once after mount (DOM logic kept as in the Astro kit).
  const mounted = useRef(false);
  useEffect(() => {
    if (mounted.current) return;
    mounted.current = true;
    import('./chart-tooltip').then((m) => m.initChartTooltips());
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <>
      <figure className={`cf cf--${variant} cf--${size} ${className}`} data-chart aria-labelledby={title ? titleId : undefined} data-rk-0ca607="">
        {(title || value) && (
          <header className="cf__header" data-rk-0ca607="">
            <div className="cf__heading" data-rk-0ca607="">
              {title && <figcaption id={titleId} className="cf__title" data-rk-0ca607="">{title}</figcaption>}
              {subtitle && <p className="cf__subtitle" data-rk-0ca607="">{subtitle}</p>}
            </div>
            {value && (
              <div className="cf__headline" data-rk-0ca607="">
                <span className="cf__value" data-rk-0ca607="">{value}</span>
                {delta && (
                  <span className={cx(['cf__delta', { 'cf__delta--pos': deltaPositive === true, 'cf__delta--neg': deltaPositive === false, 'cf__delta--neutral': deltaPositive === undefined }])} data-rk-0ca607="">
                    {deltaPositive !== undefined && (
                      <svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true" data-rk-0ca607="">
                        <path d={deltaPositive ? 'M5 1 L9 7 H1 Z' : 'M5 9 L9 3 H1 Z'} fill="currentColor" data-rk-0ca607="" />
                      </svg>
                    )}
                    {delta}
                  </span>
                )}
              </div>
            )}
          </header>
        )}
      
        {legend.length > 1 && (
          <ul className="cf__legend" aria-label="Legend" data-rk-0ca607="">
            {legend.map((item, __i: number) => (
              <li key={__i} className="cf__legend-item" data-rk-0ca607="">
                <span className="cf__swatch" style={st(`background:${item.color}`)} aria-hidden="true" data-rk-0ca607=""></span>
                {item.label}
              </li>
            ))}
          </ul>
        )}
      
        <div className="cf__plot" data-rk-0ca607="">
          {children}
        </div>
      
        {footer}
      
        {table && table.rows.length > 0 && (
          <details className="cf__data" data-rk-0ca607="">
            <summary data-rk-0ca607="">View data</summary>
            <div className="cf__table-wrap" data-rk-0ca607="">
              <table data-rk-0ca607="">
                <thead data-rk-0ca607="">
                  <tr data-rk-0ca607="">{table.headers.map((h, __i: number) => <th key={__i} scope="col" data-rk-0ca607="">{h}</th>)}</tr>
                </thead>
                <tbody data-rk-0ca607="">
                  {table.rows.map((row, __i: number) => (
                    <tr key={__i} data-rk-0ca607="">{row.map((cell, i) => (i === 0 ? <th scope="row" data-rk-0ca607="">{cell}</th> : <td data-rk-0ca607="">{cell}</td>))}</tr>
                  ))}
                </tbody>
              </table>
            </div>
          </details>
        )}
      
        <div className="cf__tip" data-chart-tip aria-hidden="true" data-rk-0ca607=""></div>
      </figure>
    </>
  );
}

export default ChartFrame;
ChartFrame.css
/* ChartFrame — ported from astro-component-kit (charts/ChartFrame.astro) */

.cf:where([data-rk-0ca607]) {
  --cf-pad: var(--sp-6, 1.5rem);
  --cf-title: 1.0625rem;
  position: relative;
  margin: 0;
  width: 100%;
  min-width: 0;
  box-sizing: border-box;
  padding: var(--cf-pad);
  border-radius: var(--r-lg, 20px);
  color: var(--c-text-2, #a3a3a3);
  font-family: var(--font-sans, system-ui, sans-serif);
}
.cf--glass:where([data-rk-0ca607]) {
  background: linear-gradient(160deg, rgb(var(--ink-rgb, 255 255 255) / 0.045), rgb(var(--ink-rgb, 255 255 255) / 0.015));
  border: 1px solid var(--c-border, rgb(var(--ink-rgb, 255 255 255) / 0.07));
  backdrop-filter: blur(14px);
  -webkit-backdrop-filter: blur(14px);
}
.cf--solid:where([data-rk-0ca607]) {
  background: var(--chart-surface, #0d0d0f);
  border: 1px solid rgb(var(--ink-rgb, 255 255 255) / 0.08);
}
.cf--ghost:where([data-rk-0ca607]) {
  background: transparent;
  border: 1px solid transparent;
  padding-inline: 0;
}
.cf--sm:where([data-rk-0ca607]) { --cf-pad: var(--sp-4, 1rem); --cf-title: 0.9375rem; }
.cf--lg:where([data-rk-0ca607]) { --cf-pad: var(--sp-8, 2rem); --cf-title: 1.25rem; }

.cf__header:where([data-rk-0ca607]) {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  gap: var(--sp-4, 1rem);
  margin-bottom: var(--sp-4, 1rem);
}
.cf__title:where([data-rk-0ca607]) {
  margin: 0;
  font-size: var(--cf-title);
  font-weight: 650;
  color: var(--c-text-1, #fff);
  letter-spacing: -0.01em;
}
.cf__subtitle:where([data-rk-0ca607]) {
  margin: 0.25rem 0 0;
  font-size: 0.8125rem;
  color: var(--c-text-2, #a3a3a3);
  line-height: 1.45;
}
.cf__headline:where([data-rk-0ca607]) {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 0.2rem;
  flex-shrink: 0;
}
.cf__value:where([data-rk-0ca607]) {
  font-size: 1.5rem;
  font-weight: 700;
  color: var(--c-text-1, #fff);
  font-variant-numeric: tabular-nums;
  line-height: 1.1;
}
.cf__delta:where([data-rk-0ca607]) {
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
  font-size: 0.75rem;
  font-weight: 600;
  color: var(--c-text-1, #fff);
}
.cf__delta--pos:where([data-rk-0ca607]) svg:where([data-rk-0ca607]) { color: var(--chart-pos, #34c38f); }
.cf__delta--neg:where([data-rk-0ca607]) svg:where([data-rk-0ca607]) { color: var(--chart-neg, #f06a6a); }
.cf__delta--neutral:where([data-rk-0ca607]) { color: var(--c-text-3, #8a8a8a); font-weight: 500; }

.cf__legend:where([data-rk-0ca607]) {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem 1rem;
  list-style: none;
  margin: 0 0 var(--sp-3, 0.75rem);
  padding: 0;
}
.cf__legend-item:where([data-rk-0ca607]) {
  display: inline-flex;
  align-items: center;
  gap: 0.45rem;
  font-size: 0.8125rem;
  color: var(--c-text-2, #a3a3a3);
}
.cf__swatch:where([data-rk-0ca607]) {
  width: 10px;
  height: 10px;
  border-radius: 3px;
}

.cf__plot:where([data-rk-0ca607]) {
  position: relative;
  width: 100%;
}
/* Plot SVGs scale fluidly; small inline icons keep their explicit width/height. */
.cf__plot:where([data-rk-0ca607]) svg:not([width]) {
  display: block;
  width: 100%;
  height: auto;
  overflow: visible;
}

/* ── Shared mark vocabulary for every chart ───────────── */
.cf:where([data-rk-0ca607]) .cf-grid {
  stroke: var(--chart-grid, rgb(var(--ink-rgb, 255 255 255) / 0.07));
  stroke-width: 1;
  shape-rendering: crispEdges;
}
.cf:where([data-rk-0ca607]) .cf-axis {
  stroke: var(--chart-axis, rgb(var(--ink-rgb, 255 255 255) / 0.18));
  stroke-width: 1;
  shape-rendering: crispEdges;
}
.cf:where([data-rk-0ca607]) .cf-label {
  fill: var(--c-text-3, #8a8a8a);
  font-size: 11px;
  font-family: var(--font-sans, system-ui, sans-serif);
  font-variant-numeric: tabular-nums;
}
.cf:where([data-rk-0ca607]) .cf-label--strong {
  fill: var(--c-text-1, #fff);
  font-weight: 600;
}
.cf:where([data-rk-0ca607]) .cf-mark {
  transition: opacity 0.2s ease, filter 0.2s ease;
  outline: none;
}
.cf:where([data-rk-0ca607]) .cf-mark:focus-visible {
  stroke: var(--c-text-1, #fff);
  stroke-width: 2px;
}
.cf:where([data-rk-0ca607]) svg:hover .cf-dim,
.cf:where([data-rk-0ca607]) svg:focus-within .cf-dim {
  opacity: 0.45;
}
.cf:where([data-rk-0ca607]) svg .cf-dim:hover,
.cf:where([data-rk-0ca607]) svg .cf-dim:focus-visible {
  opacity: 1;
}
.cf:where([data-rk-0ca607]) .cf-hit {
  fill: transparent;
  cursor: crosshair;
  outline: none;
}
.cf:where([data-rk-0ca607]) .cf-cross {
  stroke: rgb(var(--ink-rgb, 255 255 255) / 0.35);
  stroke-width: 1;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.15s ease;
}
.cf:where([data-rk-0ca607]) .cf-hit-group:hover .cf-cross,
.cf:where([data-rk-0ca607]) .cf-hit-group:focus-within .cf-cross {
  opacity: 1;
}
.cf:where([data-rk-0ca607]) .cf-hit:focus-visible {
  fill: rgb(var(--ink-rgb, 255 255 255) / 0.04);
}

/* ── Tooltip ─────────────────────────────────────────── */
.cf__tip:where([data-rk-0ca607]) {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 5;
  pointer-events: none;
  opacity: 0;
  display: flex;
  flex-direction: column;
  gap: 0.2rem;
  min-width: 7rem;
  max-width: 16rem;
  padding: 0.55rem 0.7rem;
  border-radius: var(--r-sm, 8px);
  background: rgb(var(--paper-rgb, 12 12 14) / 0.96);
  border: 1px solid rgb(var(--ink-rgb, 255 255 255) / 0.12);
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45);
  font-size: 0.75rem;
  color: var(--c-text-1, #fff);
  transition: opacity 0.15s ease;
}
.cf__tip:where([data-rk-0ca607]) strong {
  font-size: 0.75rem;
  font-weight: 650;
  margin-bottom: 0.15rem;
}
.cf__tip:where([data-rk-0ca607]) span {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  color: var(--c-text-2, #c4c4c4);
  font-variant-numeric: tabular-nums;
}
.cf__tip:where([data-rk-0ca607]) i {
  width: 8px;
  height: 8px;
  border-radius: 2px;
  flex-shrink: 0;
}

/* ── Data table fallback ─────────────────────────────── */
.cf__data:where([data-rk-0ca607]) {
  margin-top: var(--sp-3, 0.75rem);
  font-size: 0.8125rem;
}
.cf__data:where([data-rk-0ca607]) summary:where([data-rk-0ca607]) {
  display: inline-flex;
  align-items: center;
  min-height: 32px;
  cursor: pointer;
  color: var(--c-text-3, #8a8a8a);
  border-radius: 6px;
  list-style-position: inside;
}
.cf__data:where([data-rk-0ca607]) summary:hover:where([data-rk-0ca607]) { color: var(--c-text-1, #fff); }
.cf__data:where([data-rk-0ca607]) summary:focus-visible:where([data-rk-0ca607]) {
  outline: 2px solid var(--c-primary-light, #d4a017);
  outline-offset: 2px;
}
.cf__table-wrap:where([data-rk-0ca607]) {
  overflow-x: auto;
  margin-top: 0.5rem;
}
.cf__data:where([data-rk-0ca607]) table:where([data-rk-0ca607]) {
  width: 100%;
  border-collapse: collapse;
  font-variant-numeric: tabular-nums;
}
.cf__data:where([data-rk-0ca607]) th:where([data-rk-0ca607]),
.cf__data:where([data-rk-0ca607]) td:where([data-rk-0ca607]) {
  padding: 0.4rem 0.6rem;
  text-align: right;
  border-bottom: 1px solid rgb(var(--ink-rgb, 255 255 255) / 0.06);
  white-space: nowrap;
}
.cf__data:where([data-rk-0ca607]) th:first-child:where([data-rk-0ca607]) { text-align: left; }
.cf__data:where([data-rk-0ca607]) thead:where([data-rk-0ca607]) th:where([data-rk-0ca607]) { color: var(--c-text-3, #8a8a8a); font-weight: 600; }
.cf__data:where([data-rk-0ca607]) tbody:where([data-rk-0ca607]) th:where([data-rk-0ca607]) { color: var(--c-text-1, #fff); font-weight: 500; }
.cf__data:where([data-rk-0ca607]) td:where([data-rk-0ca607]) { color: var(--c-text-2, #a3a3a3); }

@media (prefers-reduced-motion: reduce) {
  .cf:where([data-rk-0ca607]) * { transition: none !important; animation: none !important; }
}

Información Rápida

Categoría
Gráficos y Estadísticas
Nombre de archivo
ChartFrame.tsx
Dependencias
Ninguna — React + CSS puro
Etiquetas
chartsvga11yfoundationtooltip