Tabla ordenable de registros
Tabla semántica con búsqueda, orden por nombre o importe, estado vacío y desplazamiento horizontal en móvil.
| Estado | ||
|---|---|---|
| Auditoría de interfaz | Completado | USD 900 |
| Portal de clientes | Pendiente | USD 4,600 |
| Sistema de diseño | En curso | USD 3,200 |
| Sitio editorial | Completado | USD 1,800 |
4 de 4 registros
Código completo
Es exactamente el archivo que se descarga. Cópialo a tu proyecto y úsalo.
"use client";
import { useState, useId, type CSSProperties } from "react";
/** Tabla ordenable de registros. React + TypeScript, sin dependencias. Personaliza con variables --gczt-*. */
export type TableRecord = {
id: string;
name: string;
status: "En curso" | "Completado" | "Pendiente";
amount: number;
};
export type DatosTablaProps = {
rows?: TableRecord[];
currency?: string;
className?: string;
style?: CSSProperties;
};
const CSS = `
.gczt-root { box-sizing: border-box; min-width: 0; max-width: 100%; color: var(--gczt-fg, #202421); font: 400 15px/1.5 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
.gczt-root *, .gczt-root *::before, .gczt-root *::after { box-sizing: border-box; }
.gczt-root button, .gczt-root input, .gczt-root textarea, .gczt-root select { font: inherit; }
.gczt-root button, .gczt-root a { -webkit-tap-highlight-color: transparent; }
.gczt-root :focus-visible { outline: 2px solid var(--gczt-accent, #25724b); outline-offset: 4px; }
.gczt-root ::selection { background: var(--gczt-accent, #25724b); color: #fff; }
.gczt-root button:disabled { opacity: .5; cursor: not-allowed; }
.gczt-root svg { flex-shrink: 0; }
@media (prefers-reduced-motion: reduce) { .gczt-root *, .gczt-root *::before, .gczt-root *::after { animation: none !important; transition-duration: .01ms !important; scroll-behavior: auto !important; } }
.gczt-root { width:700px; padding:24px; border-radius:12px; background:var(--gczt-bg,#fff); }
.gczt-toolbar { display:flex; align-items:center; justify-content:space-between; gap:16px; flex-wrap:wrap; margin-bottom:20px; }
.gczt-toolbar label { font-size:14px; font-weight:600; }
.gczt-toolbar input { min-height:44px; min-width:0; max-width:100%; border:1px solid #c7d0db; border-radius:5px; padding:10px 12px; background:#f9fafc; color:#304357; font-size:12px; }
.gczt-toolbar input::placeholder { color:#637286; }
.gczt-scroll { overflow-x:auto; scrollbar-color:#b2bfce #eef2f6; scrollbar-width:thin; }
.gczt-root table { width:100%; border-collapse:collapse; text-align:left; font-size:12px; min-width:440px; }
.gczt-root caption { text-align:left; caption-side:bottom; padding-top:15px; font-size:10px; color:#637286; }
.gczt-root th,.gczt-root td { padding:14px 10px; border-bottom:1px solid #e2e7ed; font-weight:400; white-space:nowrap; }
.gczt-root thead th { color:#586a80; background:#f1f4f8; font-weight:500; }
.gczt-root th button { border:0; padding:0; min-height:30px; background:transparent; color:inherit; cursor:pointer; font-size:12px; display:flex; gap:12px; align-items:center; }
.gczt-root tbody th { color:#304357; font-weight:550; }
.gczt-root td:last-child { font-variant-numeric:tabular-nums; }
.gczt-root [data-status] { display:inline-block; padding:4px 8px; border-radius:4px; background:#f4eedc; color:#795b22; font-size:10px; }
.gczt-root [data-status="Completado"] { background:#e7f0e6; color:#315c39; }
.gczt-root [data-status="En curso"] { background:#e4ecf7; color:#36577e; }
.gczt-root>p { font-size:11px; color:#637286; margin:16px 0 0; }`;
export function DatosTabla({
rows = [
{ id: "001", name: "Sistema de diseño", status: "En curso", amount: 3200 },
{ id: "002", name: "Sitio editorial", status: "Completado", amount: 1800 },
{
id: "003",
name: "Portal de clientes",
status: "Pendiente",
amount: 4600,
},
{
id: "004",
name: "Auditoría de interfaz",
status: "Completado",
amount: 900,
},
],
currency = "USD",
className = "",
style,
}: DatosTablaProps) {
const [query, setQuery] = useState("");
const [sort, setSort] = useState<{
key: "name" | "amount";
descending: boolean;
}>({ key: "name", descending: false });
const id = useId();
const fmt = new Intl.NumberFormat("es-MX", {
style: "currency",
currency,
maximumFractionDigits: 0,
});
const visible = rows
.filter((row) =>
row.name.toLocaleLowerCase().includes(query.toLocaleLowerCase()),
)
.sort(
(a, b) =>
(sort.key === "name"
? a.name.localeCompare(b.name)
: a.amount - b.amount) * (sort.descending ? -1 : 1),
);
function order(key: "name" | "amount") {
setSort({ key, descending: sort.key === key ? !sort.descending : false });
}
return (
<div className={`gczt-root ${className}`} style={style}>
<style>{CSS}</style>
<div className="gczt-toolbar">
<label htmlFor={id}>Buscar registros</label>
<input
id={id}
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Nombre del proyecto"
/>
</div>
<div
className="gczt-scroll"
tabIndex={0}
role="region"
aria-label="Tabla desplazable"
>
<table>
<caption>Proyectos de muestra · importes en {currency}</caption>
<thead>
<tr>
<th
scope="col"
aria-sort={
sort.key === "name"
? sort.descending
? "descending"
: "ascending"
: "none"
}
>
<button type="button" onClick={() => order("name")}>
Proyecto{" "}
<span aria-hidden="true">
{sort.key === "name" ? (sort.descending ? "↓" : "↑") : "↕"}
</span>
</button>
</th>
<th scope="col">Estado</th>
<th
scope="col"
aria-sort={
sort.key === "amount"
? sort.descending
? "descending"
: "ascending"
: "none"
}
>
<button type="button" onClick={() => order("amount")}>
Importe{" "}
<span aria-hidden="true">
{sort.key === "amount"
? sort.descending
? "↓"
: "↑"
: "↕"}
</span>
</button>
</th>
</tr>
</thead>
<tbody>
{visible.map((row) => (
<tr key={row.id}>
<th scope="row">{row.name}</th>
<td>
<span data-status={row.status}>{row.status}</span>
</td>
<td>{fmt.format(row.amount)}</td>
</tr>
))}
{!visible.length && (
<tr>
<td colSpan={3}>
No hay registros que coincidan. Prueba otra búsqueda.
</td>
</tr>
)}
</tbody>
</table>
</div>
<p role="status">
{visible.length} de {rows.length} registros
</p>
</div>
);
}
Úsalo en tu proyecto
Instálalo con la CLI de shadcn.
npx shadcn@latest add https://www.gassierconsulting.com/r/datos-tablaEjemplo de uso de Tabla ordenable de registros
Después de copiar datos-tabla.tsx a tu proyecto, impórtalo y colócalo donde lo necesites.
import { DatosTabla } from "./datos-tabla";
export default function Ejemplo() {
return <DatosTabla currency="Ejemplo" />;
}Propiedades de DatosTabla
DatosTabla acepta 4 propiedades: todas son opcionales y tienen un valor por defecto, así que funciona sin configurar nada.
| Propiedad | Tipo | Descripción |
|---|---|---|
rows? | TableRecord[] | Opcional. |
currency? | string | Opcional. |
className? | string | Opcional. |
style? | CSSProperties | Opcional. |
Personaliza Tabla ordenable de registros con variables CSS
Define estas variables en el contenedor o en tu hoja de estilos para cambiar los colores sin tocar el componente. Si no las defines, se usa el valor por defecto.
| Variable | Valor por defecto |
|---|---|
--gczt-fg | #202421 |
--gczt-accent | #25724b |
--gczt-bg | #fff |
Accesibilidad y rendimiento
El archivo tiene 182 líneas (7 KB) y no necesita @keyframes. Esto es lo que hace Tabla ordenable de registros por quien lo usa:
- Detecta prefers-reduced-motion y baja o quita el movimiento cuando la persona lo pide.
- Muestra un anillo de foco visible al navegar con teclado (:focus-visible).
- Declara roles ARIA para lectores de pantalla: region, status.
- Es un componente de cliente de Next.js: incluye la directiva "use client".