1 estrella en GitHub = 1 componente nuevo.

Dar una estrella
App y Dashboardappdashboardsaastabledatasortpaginationfilter

Data Table

Tabla con búsqueda, chips de estado, columnas ordenables, selección de filas, acciones masivas, paginación y celdas tipadas.

Vista Previa en Vivo

Reservations 4

Reservations
Status
MR-2841Elena DuarteBooking.com204 · Deluxe3Confirmed€612
MR-2840James WhitfieldDirect310 · Suite2Checked in€890
MR-2839Aiko TanakaExpedia108 · Standard4Pending€476
MR-2838Marco BelliniDirect212 · Deluxe1Cancelled€0

Uso

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

tsx
import { DataTable } from 'react-component-kit-ui/app';

<DataTable title="Customers" rows={rows} columns={[
  { key: 'name', label: 'Name', type: 'person', sortable: true },
  { key: 'status', label: 'Status', type: 'status' },
  { key: 'total', label: 'Total', type: 'money', align: 'right', sortable: true },
]} />

Instalación Manual

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

DataTable.tsx
'use client';

import { useEffect, useRef } from 'react';
import type { CSSProperties } from 'react';
import { initials, hueFrom } from './app-icons';
import './DataTable.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;
}

type CellType = 'text' | 'person' | 'status' | 'money' | 'date' | 'number' | 'tags';

interface Column { key: string; label: string; type?: CellType; align?: 'left' | 'right'; sortable?: boolean }

/**
 * DataTable — Client-side data table for SaaS apps: search, status filter chips, sortable
 * columns, row selection with a bulk-action bar, pagination and typed cells
 * (text, person, status, money, date, number, tags).
 *
 * @param {string} title - Card title (optional).
 * @param {Array<{key: string, label: string, type?: 'text'|'person'|'status'|'money'|'date'|'number'|'tags', align?: 'left'|'right', sortable?: boolean}>} columns - Column definitions.
 * @param {Array<Record<string, any>>} rows - Row data; `person` cells take `{ name, sub? }`, `status` cells take a string.
 * @param {Record<string, 'success'|'warning'|'danger'|'info'|'default'>} statusTones - Status → tone map.
 * @param {string} filterKey - Column used for the filter chips (optional).
 * @param {boolean} selectable - Show row checkboxes and the bulk bar. Default true.
 * @param {string[]} bulkActions - Bulk bar buttons. Default ['Export', 'Send email'].
 * @param {number} pageSize - Rows per page. Default 8.
 * @param {string} searchPlaceholder - Search placeholder. Default 'Search…'.
 * @param {string} currency - ISO currency for money cells. Default 'EUR'.
 * @param {string} locale - Number/date locale. Default 'en-GB'.
 * @param {string} emptyText - Shown when nothing matches. Default 'No results match your filters.'
 * @param {string} id - Table id (unique per page). Default 'data-table'.
 * @param {string} variant - Style (card|plain). Default 'card'.
 * @param {string} density - Row height (compact|comfortable). Default 'comfortable'.
 * @param {string} className - Extra class.
 *
 * Accessibility: real <table> with a caption; sortable headers are buttons inside <th> with
 * aria-sort; checkboxes have row labels; result counts and page changes are announced politely.
 */
export interface DataTableProps {
  title?: string;
  columns?: Column[];
  rows?: Array<Record<string, any>>;
  statusTones?: Record<string, 'success' | 'warning' | 'danger' | 'info' | 'default'>;
  filterKey?: string;
  selectable?: boolean;
  bulkActions?: string[];
  pageSize?: number;
  searchPlaceholder?: string;
  currency?: string;
  locale?: string;
  emptyText?: string;
  id?: string;
  variant?: 'card' | 'plain';
  density?: 'compact' | 'comfortable';
  className?: string;
}

export function DataTable({
  title = 'Reservations',
  columns = [
    { key: 'ref', label: 'Booking', sortable: true },
    { key: 'guest', label: 'Guest', type: 'person', sortable: true },
    { key: 'room', label: 'Room', sortable: true },
    { key: 'checkIn', label: 'Check-in', type: 'date', sortable: true },
    { key: 'nights', label: 'Nights', type: 'number', align: 'right', sortable: true },
    { key: 'status', label: 'Status', type: 'status' },
    { key: 'total', label: 'Total', type: 'money', align: 'right', sortable: true },
  ],
  rows = [
    { ref: 'MR-2841', guest: { name: 'Elena Duarte', sub: 'Booking.com' }, room: '204 · Deluxe', checkIn: '2026-11-04', nights: 3, status: 'Confirmed', total: 612 },
    { ref: 'MR-2840', guest: { name: 'James Whitfield', sub: 'Direct' }, room: '310 · Suite', checkIn: '2026-11-04', nights: 2, status: 'Checked in', total: 890 },
    { ref: 'MR-2839', guest: { name: 'Aiko Tanaka', sub: 'Expedia' }, room: '108 · Standard', checkIn: '2026-11-05', nights: 4, status: 'Pending', total: 476 },
    { ref: 'MR-2838', guest: { name: 'Marco Bellini', sub: 'Direct' }, room: '212 · Deluxe', checkIn: '2026-11-06', nights: 1, status: 'Cancelled', total: 0 },
  ],
  statusTones = { Confirmed: 'info', 'Checked in': 'success', 'Checked out': 'default', Pending: 'warning', Cancelled: 'danger', 'No show': 'danger' },
  filterKey = 'status',
  selectable = true,
  bulkActions = ['Export', 'Send email'],
  pageSize = 8,
  searchPlaceholder = 'Search…',
  currency = 'EUR',
  locale = 'en-GB',
  emptyText = 'No results match your filters.',
  id = 'data-table',
  variant = 'card',
  density = 'comfortable',
  className = '',
}: DataTableProps) {
  const money = (n: number) => new Intl.NumberFormat(locale, { style: 'currency', currency, maximumFractionDigits: 0 }).format(n);
  const date = (v: string) => new Intl.DateTimeFormat(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' }).format(new Date(`${v}T00:00:00Z`));
  const filters = filterKey ? Array.from(new Set(rows.map((r) => String(r[filterKey])))) : [];
  const searchText = (r: Record<string, any>) =>
    columns.map((c) => (c.type === 'person' ? `${r[c.key]?.name ?? ''} ${r[c.key]?.sub ?? ''}` : Array.isArray(r[c.key]) ? r[c.key].join(' ') : r[c.key] ?? '')).join(' ').toLowerCase();
  const sortValue = (c: Column, v: any) => (c.type === 'person' ? v?.name ?? '' : v ?? '');

  // 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;
    document.querySelectorAll<HTMLElement>('[data-table]:not([data-bound])').forEach((root) => {
        root.dataset.bound = '';
        const body = root.querySelector<HTMLTableSectionElement>('[data-body]')!;
        const rows = Array.from(body.querySelectorAll<HTMLTableRowElement>('[data-row]'));
        const size = Number(root.dataset.pageSize) || 8;
        const search = root.querySelector<HTMLInputElement>('[data-search]');
        const chips = Array.from(root.querySelectorAll<HTMLButtonElement>('[data-filter]'));
        const sorters = Array.from(root.querySelectorAll<HTMLButtonElement>('[data-sort]'));
        const all = root.querySelector<HTMLInputElement>('[data-all]');
        const bulk = root.querySelector<HTMLElement>('[data-bulk]');
        const pagesEl = root.querySelector<HTMLElement>('[data-pages]')!;
        let query = '';
        let filter = '';
        let sortKey = '';
        let sortDir = 1;
        let page = 0;
    
        function visibleRows() {
          return rows.filter((r) => (!query || (r.dataset.search || '').includes(query)) && (!filter || r.dataset.filterValue === filter));
        }
    
        function render() {
          let list = visibleRows();
          if (sortKey) {
            const type = sorters.find((s) => s.dataset.sort === sortKey)?.dataset.type;
            const attr = `v${sortKey.toLowerCase().replace(/^./, (c) => c.toUpperCase())}`;
            list = [...list].sort((a, b) => {
              const A = a.dataset[attr] ?? '', B = b.dataset[attr] ?? '';
              const cmp = type === 'money' || type === 'number' ? Number(A) - Number(B) : A.localeCompare(B);
              return cmp * sortDir;
            });
          } else {
            list = [...list].sort((a, b) => Number(a.dataset.index) - Number(b.dataset.index));
          }
          const pages = Math.max(1, Math.ceil(list.length / size));
          page = Math.min(page, pages - 1);
          const shown = list.slice(page * size, page * size + size);
          rows.forEach((r) => { r.hidden = true; });
          body.append(...list);
          shown.forEach((r) => { r.hidden = false; });
          root.querySelector('[data-count]')!.textContent = String(list.length);
          root.querySelector<HTMLElement>('[data-empty]')!.hidden = list.length > 0;
          root.querySelector('[data-range]')!.textContent = list.length ? `${page * size + 1}–${page * size + shown.length} of ${list.length}` : '';
          pagesEl.replaceChildren(...Array.from({ length: pages }, (_, i) => {
            const b = document.createElement('button');
            b.type = 'button';
            b.className = 'dt__page';
            b.textContent = String(i + 1);
            b.setAttribute('aria-label', `Page ${i + 1}`);
            if (i === page) b.setAttribute('aria-current', 'page');
            b.addEventListener('click', () => { page = i; render(); });
            return b;
          }));
          root.querySelector<HTMLButtonElement>('[data-prev]')!.disabled = page === 0;
          root.querySelector<HTMLButtonElement>('[data-next]')!.disabled = page >= pages - 1;
          syncSelection();
        }
    
        function syncSelection() {
          const boxes = rows.map((r) => r.querySelector<HTMLInputElement>('[data-select]')).filter(Boolean) as HTMLInputElement[];
          const selected = boxes.filter((b) => b.checked);
          rows.forEach((r) => r.classList.toggle('is-selected', Boolean(r.querySelector<HTMLInputElement>('[data-select]')?.checked)));
          if (bulk) {
            bulk.hidden = selected.length === 0;
            bulk.querySelector('[data-selected]')!.textContent = String(selected.length);
          }
          if (all) {
            const pageBoxes = rows.filter((r) => !r.hidden).map((r) => r.querySelector<HTMLInputElement>('[data-select]')!).filter(Boolean);
            all.checked = pageBoxes.length > 0 && pageBoxes.every((b) => b.checked);
            all.indeterminate = !all.checked && pageBoxes.some((b) => b.checked);
          }
        }
    
        search?.addEventListener('input', () => { query = search.value.trim().toLowerCase(); page = 0; render(); });
        chips.forEach((c) => c.addEventListener('click', () => {
          filter = c.dataset.filter || '';
          chips.forEach((x) => x.setAttribute('aria-pressed', String(x === c)));
          page = 0;
          render();
        }));
        sorters.forEach((s) => s.addEventListener('click', () => {
          if (sortKey === s.dataset.sort) sortDir = sortDir === 1 ? -1 : sortDir === -1 ? 0 : 1;
          else { sortKey = s.dataset.sort || ''; sortDir = 1; }
          if (sortDir === 0) sortKey = '';
          sorters.forEach((x) => x.closest('th')?.setAttribute('aria-sort', x === s && sortKey ? (sortDir === 1 ? 'ascending' : 'descending') : 'none'));
          render();
        }));
        root.querySelector('[data-prev]')?.addEventListener('click', () => { page = Math.max(0, page - 1); render(); });
        root.querySelector('[data-next]')?.addEventListener('click', () => { page += 1; render(); });
        root.addEventListener('change', (e) => { if ((e.target as HTMLElement).matches('[data-select]')) syncSelection(); });
        all?.addEventListener('change', () => {
          rows.filter((r) => !r.hidden).forEach((r) => { const b = r.querySelector<HTMLInputElement>('[data-select]'); if (b) b.checked = all.checked; });
          syncSelection();
        });
        root.querySelector('[data-clear]')?.addEventListener('click', () => {
          rows.forEach((r) => { const b = r.querySelector<HTMLInputElement>('[data-select]'); if (b) b.checked = false; });
          syncSelection();
        });
        render();
      });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <>
      <section className={cx(['dt', `dt--${variant}`, `dt--${density}`, className])} data-table={id} data-page-size={pageSize} aria-labelledby={`${id}-title`} data-rk-bf3c5c="">
        <div className="dt__toolbar" data-rk-bf3c5c="">
          <h2 className="dt__title" id={`${id}-title`} data-rk-bf3c5c="">{title} <span className="dt__count" aria-live="polite" data-rk-bf3c5c=""><span data-count data-rk-bf3c5c="">{rows.length}</span></span></h2>
          <div className="dt__tools" data-rk-bf3c5c="">
            <label className="dt__search" data-rk-bf3c5c="">
              <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden="true" data-rk-bf3c5c=""><circle cx="11" cy="11" r="7" data-rk-bf3c5c="" /><path d="m20 20-3.5-3.5" data-rk-bf3c5c="" /></svg>
              <span className="sr-only" data-rk-bf3c5c="">{searchPlaceholder}</span>
              <input type="search" placeholder={searchPlaceholder} data-search data-rk-bf3c5c="" />
            </label>
          </div>
        </div>
        {filters.length > 1 && (
          <div className="dt__chips" role="group" aria-label="Filter by status" data-rk-bf3c5c="">
            <button type="button" className="dt__chip" aria-pressed="true" data-filter="" data-rk-bf3c5c="">All <span data-rk-bf3c5c="">{rows.length}</span></button>
            {filters.map((f, __i: number) => (
              <button key={__i} type="button" className="dt__chip" aria-pressed="false" data-filter={f} data-rk-bf3c5c="">{f} <span data-rk-bf3c5c="">{rows.filter((r) => String(r[filterKey]) === f).length}</span></button>
            ))}
          </div>
        )}
      
        {selectable && (
          <div className="dt__bulk" hidden data-bulk data-rk-bf3c5c="">
            <span data-rk-bf3c5c=""><strong data-selected data-rk-bf3c5c="">0</strong> selected</span>
            {bulkActions.map((a, __i: number) => <button key={__i} type="button" className="dt__bulk-btn" data-rk-bf3c5c="">{a}</button>)}
            <button type="button" className="dt__bulk-clear" data-clear data-rk-bf3c5c="">Clear</button>
          </div>
        )}
      
        <div className="dt__scroll" tabIndex={0} role="region" aria-labelledby={`${id}-title`} data-rk-bf3c5c="">
          <table className="dt__table" data-rk-bf3c5c="">
            <caption className="sr-only" data-rk-bf3c5c="">{title}</caption>
            <thead data-rk-bf3c5c="">
              <tr data-rk-bf3c5c="">
                {selectable && <th className="dt__check" scope="col" data-rk-bf3c5c=""><input type="checkbox" aria-label="Select all rows on this page" data-all data-rk-bf3c5c="" /></th>}
                {columns.map((c, __i: number) => (
                  <th key={__i} scope="col" className={cx([{ 'dt__right': c.align === 'right' }])} aria-sort={c.sortable ? 'none' : undefined} data-rk-bf3c5c="">
                    {c.sortable ? (
                      <button type="button" className="dt__sort" data-sort={c.key} data-type={c.type ?? 'text'} data-rk-bf3c5c="">
                        {c.label}
                        <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" data-rk-bf3c5c=""><path d="m7 15 5 5 5-5M7 9l5-5 5 5" data-rk-bf3c5c="" /></svg>
                      </button>
                    ) : c.label}
                  </th>
                ))}
              </tr>
            </thead>
            <tbody data-body data-rk-bf3c5c="">
              {rows.map((r, i) => (
                <tr key={i} data-row data-search={searchText(r)} data-filter-value={filterKey ? String(r[filterKey]) : ''} data-index={i} {...Object.fromEntries(columns.map((c) => [`data-v-${c.key.toLowerCase()}`, String(sortValue(c, r[c.key]))]))} data-rk-bf3c5c="">
                  {selectable && <td className="dt__check" data-rk-bf3c5c=""><input type="checkbox" aria-label={`Select ${columns[0] ? sortValue(columns[0], r[columns[0].key]) : `row ${i + 1}`}`} data-select data-rk-bf3c5c="" /></td>}
                  {columns.map((c) => {
                    const v = r[c.key];
                    return (
                      <td className={cx([{ 'dt__right': c.align === 'right' }])} data-rk-bf3c5c="">
                        {c.type === 'person' ? (
                          <span className="dt__person" data-rk-bf3c5c="">
                            <span className="dt__avatar" style={st(`--h: ${hueFrom(v?.name ?? '')}`)} aria-hidden="true" data-rk-bf3c5c="">{initials(v?.name ?? '?')}</span>
                            <span data-rk-bf3c5c=""><strong data-rk-bf3c5c="">{v?.name}</strong>{v?.sub && <small data-rk-bf3c5c="">{v.sub}</small>}</span>
                          </span>
                        ) : c.type === 'status' ? (
                          <span className={cx(['dt__status', `dt__status--${statusTones[v] ?? 'default'}`])} data-rk-bf3c5c=""><i aria-hidden="true" data-rk-bf3c5c=""></i>{v}</span>
                        ) : c.type === 'money' ? money(Number(v) || 0)
                          : c.type === 'date' ? <time dateTime={v} data-rk-bf3c5c="">{date(v)}</time>
                          : c.type === 'tags' ? <span className="dt__tags" data-rk-bf3c5c="">{(v ?? []).map((tg: string, __i: number) => <span key={__i} data-rk-bf3c5c="">{tg}</span>)}</span>
                          : v}
                      </td>
                    );
                  })}
                </tr>
              ))}
            </tbody>
          </table>
          <p className="dt__empty" hidden data-empty data-rk-bf3c5c="">{emptyText}</p>
        </div>
      
        <div className="dt__foot" data-rk-bf3c5c="">
          <p className="dt__range" aria-live="polite" data-range data-rk-bf3c5c=""></p>
          <div className="dt__pager" data-rk-bf3c5c="">
            <button type="button" className="dt__page" aria-label="Previous page" data-prev data-rk-bf3c5c="">‹</button>
            <span className="dt__pages" data-pages data-rk-bf3c5c=""></span>
            <button type="button" className="dt__page" aria-label="Next page" data-next data-rk-bf3c5c="">›</button>
          </div>
        </div>
      </section>
    </>
  );
}

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

.dt:where([data-rk-bf3c5c]) { container-type: inline-size; display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--sp-3, 0.75rem); min-width: 0; }
.dt--card:where([data-rk-bf3c5c]) { padding: var(--sp-5, 1.25rem); border-radius: var(--r-lg, 16px); border: 1px solid var(--c-border, rgba(255, 255, 255, 0.08)); background: var(--c-bg-1, #0a0a0a); }
.dt__toolbar:where([data-rk-bf3c5c]) { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3, 0.75rem); }
.dt__title:where([data-rk-bf3c5c]) { display: flex; align-items: center; gap: var(--sp-2, 0.5rem); margin: 0; font-size: 1rem; font-weight: 700; color: var(--c-text-1, #fff); }
.dt__count:where([data-rk-bf3c5c]) { padding: 1px 8px; border-radius: 999px; background: rgb(var(--ink-rgb, 255 255 255) / 0.07); font-size: 0.72rem; color: var(--c-text-2, #a3a3a3); }
.dt__search:where([data-rk-bf3c5c]) { display: flex; align-items: center; gap: 6px; height: 38px; padding: 0 0.7rem; border-radius: 10px; border: 1px solid var(--c-border, rgba(255, 255, 255, 0.1)); color: var(--c-text-3, #737373); }
.dt__search:focus-within:where([data-rk-bf3c5c]) { border-color: var(--app-accent, var(--c-primary-light, #d4a017)); }
.dt__search:where([data-rk-bf3c5c]) input:where([data-rk-bf3c5c]) { width: 200px; border: 0; outline: 0; background: none; color: var(--c-text-1, #fff); font: inherit; font-size: 0.85rem; }

.dt__chips:where([data-rk-bf3c5c]) { display: flex; gap: 6px; overflow-x: auto; scrollbar-width: none; }
.dt__chip:where([data-rk-bf3c5c]) { display: inline-flex; align-items: center; gap: 6px; min-height: 32px; padding: 0 0.7rem; border-radius: 999px; border: 1px solid var(--c-border, rgba(255, 255, 255, 0.1)); background: transparent; color: var(--c-text-2, #a3a3a3); font: inherit; font-size: 0.78rem; font-weight: 600; white-space: nowrap; cursor: pointer; }
.dt__chip:where([data-rk-bf3c5c]) span:where([data-rk-bf3c5c]) { color: var(--c-text-3, #737373); }
.dt__chip[aria-pressed='true']:where([data-rk-bf3c5c]) { background: var(--c-text-1, #fff); border-color: var(--c-text-1, #fff); color: var(--c-bg, #050505); }
.dt__chip[aria-pressed='true']:where([data-rk-bf3c5c]) span:where([data-rk-bf3c5c]) { color: inherit; opacity: 0.7; }

.dt__bulk:where([data-rk-bf3c5c]) { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2, 0.5rem); padding: var(--sp-2, 0.5rem) var(--sp-3, 0.75rem); border-radius: 10px; background: color-mix(in srgb, var(--app-accent, var(--c-primary-light, #d4a017)) 12%, transparent); font-size: 0.82rem; color: var(--c-text-1, #fff); }
.dt__bulk[hidden]:where([data-rk-bf3c5c]) { display: none; }
.dt__bulk:where([data-rk-bf3c5c]) > span:where([data-rk-bf3c5c]) { margin-right: auto; }
.dt__bulk-btn:where([data-rk-bf3c5c]), .dt__bulk-clear:where([data-rk-bf3c5c]) { min-height: 32px; padding: 0 0.7rem; border-radius: 8px; border: 1px solid var(--c-border, rgba(255, 255, 255, 0.15)); background: var(--c-bg-1, #0a0a0a); color: var(--c-text-1, #fff); font: inherit; font-size: 0.78rem; font-weight: 600; cursor: pointer; }
.dt__bulk-clear:where([data-rk-bf3c5c]) { background: transparent; border-color: transparent; text-decoration: underline; }

.dt__scroll:where([data-rk-bf3c5c]) { overflow-x: auto; border-radius: 10px; border: 1px solid var(--c-border, rgba(255, 255, 255, 0.08)); }
.dt--plain:where([data-rk-bf3c5c]) .dt__scroll:where([data-rk-bf3c5c]) { border: 0; }
.dt__table:where([data-rk-bf3c5c]) { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
.dt__table:where([data-rk-bf3c5c]) th:where([data-rk-bf3c5c]) { position: sticky; top: 0; padding: 0 var(--sp-3, 0.75rem); height: 40px; background: rgb(var(--ink-rgb, 255 255 255) / 0.03); color: var(--c-text-3, #737373); font-size: 0.72rem; font-weight: 700; letter-spacing: 0.04em; text-align: left; text-transform: uppercase; white-space: nowrap; border-bottom: 1px solid var(--c-border, rgba(255, 255, 255, 0.08)); }
.dt__table:where([data-rk-bf3c5c]) td:where([data-rk-bf3c5c]) { padding: 0 var(--sp-3, 0.75rem); height: 56px; color: var(--c-text-2, #a3a3a3); border-bottom: 1px solid var(--c-border, rgba(255, 255, 255, 0.06)); white-space: nowrap; }
.dt--compact:where([data-rk-bf3c5c]) .dt__table:where([data-rk-bf3c5c]) td:where([data-rk-bf3c5c]) { height: 44px; }
.dt__table:where([data-rk-bf3c5c]) tbody:where([data-rk-bf3c5c]) tr:last-child:where([data-rk-bf3c5c]) td:where([data-rk-bf3c5c]) { border-bottom: 0; }
.dt__table:where([data-rk-bf3c5c]) tbody:where([data-rk-bf3c5c]) tr:hover:where([data-rk-bf3c5c]) td:where([data-rk-bf3c5c]) { background: rgb(var(--ink-rgb, 255 255 255) / 0.025); }
.dt__table:where([data-rk-bf3c5c]) tbody:where([data-rk-bf3c5c]) tr.is-selected:where([data-rk-bf3c5c]) td:where([data-rk-bf3c5c]) { background: color-mix(in srgb, var(--app-accent, var(--c-primary-light, #d4a017)) 8%, transparent); }
.dt__table:where([data-rk-bf3c5c]) tr[hidden]:where([data-rk-bf3c5c]) { display: none; }
.dt__right:where([data-rk-bf3c5c]) { text-align: right !important; font-variant-numeric: tabular-nums; }
.dt__check:where([data-rk-bf3c5c]) { width: 44px; }
.dt__check:where([data-rk-bf3c5c]) input:where([data-rk-bf3c5c]) { width: 16px; height: 16px; accent-color: var(--app-accent, var(--c-primary-light, #d4a017)); cursor: pointer; }
.dt__sort:where([data-rk-bf3c5c]) { display: inline-flex; align-items: center; gap: 4px; min-height: 32px; padding: 0; border: 0; background: none; color: inherit; font: inherit; letter-spacing: inherit; text-transform: inherit; cursor: pointer; }
.dt__sort:where([data-rk-bf3c5c]) svg:where([data-rk-bf3c5c]) { opacity: 0.4; }
th[aria-sort='ascending']:where([data-rk-bf3c5c]) .dt__sort:where([data-rk-bf3c5c]), th[aria-sort='descending']:where([data-rk-bf3c5c]) .dt__sort:where([data-rk-bf3c5c]) { color: var(--c-text-1, #fff); }
th[aria-sort='ascending']:where([data-rk-bf3c5c]) .dt__sort:where([data-rk-bf3c5c]) svg:where([data-rk-bf3c5c]), th[aria-sort='descending']:where([data-rk-bf3c5c]) .dt__sort:where([data-rk-bf3c5c]) svg:where([data-rk-bf3c5c]) { opacity: 1; color: var(--app-accent, var(--c-primary-light, #d4a017)); }
.dt__person:where([data-rk-bf3c5c]) { display: flex; align-items: center; gap: var(--sp-3, 0.75rem); }
.dt__person:where([data-rk-bf3c5c]) strong:where([data-rk-bf3c5c]) { display: block; color: var(--c-text-1, #fff); font-weight: 600; }
.dt__person:where([data-rk-bf3c5c]) small:where([data-rk-bf3c5c]) { display: block; font-size: 0.75rem; color: var(--c-text-3, #737373); }
.dt__avatar:where([data-rk-bf3c5c]) { display: grid; place-items: center; flex-shrink: 0; width: 32px; height: 32px; border-radius: 50%; background: hsl(var(--h) 50% 45%); color: #fff; font-size: 0.7rem; font-weight: 700; }
.dt__status:where([data-rk-bf3c5c]) { --tone: var(--c-text-2, #a3a3a3); display: inline-flex; align-items: center; gap: 6px; padding: 3px 9px; border-radius: 999px; background: color-mix(in srgb, var(--tone) 12%, transparent); color: var(--tone); font-size: 0.75rem; font-weight: 600; }
.dt__status:where([data-rk-bf3c5c]) i:where([data-rk-bf3c5c]) { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.dt__status--success:where([data-rk-bf3c5c]) { --tone: var(--c-success, #34d399); }
.dt__status--warning:where([data-rk-bf3c5c]) { --tone: var(--c-warning, #fbbf24); }
.dt__status--danger:where([data-rk-bf3c5c]) { --tone: var(--c-danger, #f87171); }
.dt__status--info:where([data-rk-bf3c5c]) { --tone: var(--app-accent, var(--c-primary-light, #d4a017)); }
.dt__tags:where([data-rk-bf3c5c]) { display: inline-flex; gap: 4px; }
.dt__tags:where([data-rk-bf3c5c]) span:where([data-rk-bf3c5c]) { padding: 2px 7px; border-radius: 6px; background: rgb(var(--ink-rgb, 255 255 255) / 0.06); font-size: 0.72rem; }
.dt__empty:where([data-rk-bf3c5c]) { margin: 0; padding: var(--sp-10, 2.5rem); text-align: center; color: var(--c-text-3, #737373); }
.dt__empty[hidden]:where([data-rk-bf3c5c]) { display: none; }

.dt__foot:where([data-rk-bf3c5c]) { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3, 0.75rem); }
.dt__range:where([data-rk-bf3c5c]) { margin: 0; font-size: 0.8rem; color: var(--c-text-3, #737373); }
.dt__pager:where([data-rk-bf3c5c]), .dt__pages:where([data-rk-bf3c5c]) { display: flex; gap: 4px; }
.dt__pages:where([data-rk-bf3c5c]) .dt__page, .dt__page:where([data-rk-bf3c5c]) { min-width: 34px; height: 34px; border: 1px solid var(--c-border, rgba(255, 255, 255, 0.1)); border-radius: 8px; background: transparent; color: var(--c-text-2, #a3a3a3); font: inherit; font-size: 0.8rem; cursor: pointer; }
.dt__pages:where([data-rk-bf3c5c]) .dt__page[aria-current='page'] { background: var(--c-text-1, #fff); color: var(--c-bg, #050505); border-color: var(--c-text-1, #fff); }
.dt__page:disabled:where([data-rk-bf3c5c]) { opacity: 0.35; cursor: default; }
.dt:where([data-rk-bf3c5c]) :is(button, input, [tabindex='0']):focus-visible:where([data-rk-bf3c5c]), .dt__pages:where([data-rk-bf3c5c]) .dt__page:focus-visible { outline: 2px solid var(--app-accent, var(--c-primary-light, #d4a017)); outline-offset: 2px; }
.sr-only:where([data-rk-bf3c5c]) { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }

@container (max-width: 560px) {
  .dt__toolbar:where([data-rk-bf3c5c]) { flex-direction: column; align-items: stretch; }
  .dt__search:where([data-rk-bf3c5c]) input:where([data-rk-bf3c5c]) { width: 100%; }
  .dt__foot:where([data-rk-bf3c5c]) { flex-direction: column; }
}

Información Rápida

Categoría
App y Dashboard
Nombre de archivo
DataTable.tsx
Dependencias
Ninguna — React + CSS puro
Etiquetas
appdashboardsaastabledatasortpaginationfilter