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