SCALE — Build Lab
UI部品 · REACT COMPONENT

データテーブル

CATEGORYUI部品 TYPEReact Component EFFORT240〜600分 DIFFICULTY
PRIMARY CODE
tsx
'use client';
import { useState, useMemo, ReactNode } from 'react';

type Col<T> = { key: keyof T; label: string; sortable?: boolean; render?: (row: T) => ReactNode; align?: 'left'|'right'|'center' };

export function DataTable<T extends Record<string, any>>({
  rows, cols, pageSize = 20, onRowClick,
}: {
  rows: T[]; cols: Col<T>[]; pageSize?: number; onRowClick?: (row: T) => void;
}) {
  const [sort, setSort] = useState<{ key: keyof T; dir: 'asc' | 'desc' } | null>(null);
  const [page, setPage] = useState(0);

  const sorted = useMemo(() => {
    if (!sort) return rows;
    return [...rows].sort((a, b) => {
      const dir = sort.dir === 'asc' ? 1 : -1;
      return a[sort.key] > b[sort.key] ? dir : -dir;
    });
  }, [rows, sort]);

  const paged = sorted.slice(page * pageSize, (page + 1) * pageSize);
  const totalPages = Math.ceil(sorted.length / pageSize);

  const onHeaderClick = (col: Col<T>) => {
    if (!col.sortable) return;
    setSort((s) => s?.key === col.key ? { key: col.key, dir: s.dir === 'asc' ? 'desc' : 'asc' } : { key: col.key, dir: 'asc' });
  };

  return (
    <div className="bg-zinc-900 rounded-2xl border border-zinc-800 overflow-hidden">
      <table className="w-full text-sm">
        <thead className="bg-zinc-800/50 text-zinc-400">
          <tr>
            {cols.map((c) => (
              <th key={String(c.key)} onClick={() => onHeaderClick(c)}
                className={`px-4 py-3 text-${c.align ?? 'left'} ${c.sortable ? 'cursor-pointer hover:text-zinc-100' : ''}`}>
                {c.label}
                {sort?.key === c.key && (sort.dir === 'asc' ? ' ↑' : ' ↓')}
              </th>
            ))}
          </tr>
        </thead>
        <tbody>
          {paged.map((row, i) => (
            <tr key={i} onClick={() => onRowClick?.(row)}
              className={`border-t border-zinc-800 ${onRowClick ? 'hover:bg-zinc-800/40 cursor-pointer' : ''}`}>
              {cols.map((c) => (
                <td key={String(c.key)} className={`px-4 py-3 text-${c.align ?? 'left'} text-zinc-100`}>
                  {c.render ? c.render(row) : String(row[c.key] ?? '')}
                </td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
      {totalPages > 1 && (
        <div className="flex justify-between items-center px-4 py-3 border-t border-zinc-800">
          <span className="text-xs text-zinc-500">{sorted.length}件 / {totalPages}ページ</span>
          <div className="flex gap-1">
            <button onClick={() => setPage(Math.max(0, page - 1))} disabled={page === 0} className="px-2 py-1 rounded bg-zinc-800 disabled:opacity-50">←</button>
            <span className="px-3 py-1 text-zinc-400">{page + 1} / {totalPages}</span>
            <button onClick={() => setPage(Math.min(totalPages - 1, page + 1))} disabled={page === totalPages - 1} className="px-2 py-1 rounded bg-zinc-800 disabled:opacity-50">→</button>
          </div>
        </div>
      )}
    </div>
  );
}
前提条件
Tailwind CSS v4TypeScript 5
USE CASES
  • 全一覧画面

データテーブル

:LiTarget: 用途

ソート・フィルタ・ページング・選択対応の高機能テーブル。

:LiSparkle: 特徴

  • ソート
  • フィルタ
  • ページング
  • 一括選択
  • カスタムセル

:LiCode: コード(コピペ用)

'use client';
import { useState, useMemo, ReactNode } from 'react';

type Col<T> = { key: keyof T; label: string; sortable?: boolean; render?: (row: T) => ReactNode; align?: 'left'|'right'|'center' };

export function DataTable<T extends Record<string, any>>({
  rows, cols, pageSize = 20, onRowClick,
}: {
  rows: T[]; cols: Col<T>[]; pageSize?: number; onRowClick?: (row: T) => void;
}) {
  const [sort, setSort] = useState<{ key: keyof T; dir: 'asc' | 'desc' } | null>(null);
  const [page, setPage] = useState(0);

  const sorted = useMemo(() => {
    if (!sort) return rows;
    return [...rows].sort((a, b) => {
      const dir = sort.dir === 'asc' ? 1 : -1;
      return a[sort.key] > b[sort.key] ? dir : -dir;
    });
  }, [rows, sort]);

  const paged = sorted.slice(page * pageSize, (page + 1) * pageSize);
  const totalPages = Math.ceil(sorted.length / pageSize);

  const onHeaderClick = (col: Col<T>) => {
    if (!col.sortable) return;
    setSort((s) => s?.key === col.key ? { key: col.key, dir: s.dir === 'asc' ? 'desc' : 'asc' } : { key: col.key, dir: 'asc' });
  };

  return (
    <div className="bg-zinc-900 rounded-2xl border border-zinc-800 overflow-hidden">
      <table className="w-full text-sm">
        <thead className="bg-zinc-800/50 text-zinc-400">
          <tr>
            {cols.map((c) => (
              <th key={String(c.key)} onClick={() => onHeaderClick(c)}
                className={`px-4 py-3 text-${c.align ?? 'left'} ${c.sortable ? 'cursor-pointer hover:text-zinc-100' : ''}`}>
                {c.label}
                {sort?.key === c.key && (sort.dir === 'asc' ? ' ↑' : ' ↓')}
              </th>
            ))}
          </tr>
        </thead>
        <tbody>
          {paged.map((row, i) => (
            <tr key={i} onClick={() => onRowClick?.(row)}
              className={`border-t border-zinc-800 ${onRowClick ? 'hover:bg-zinc-800/40 cursor-pointer' : ''}`}>
              {cols.map((c) => (
                <td key={String(c.key)} className={`px-4 py-3 text-${c.align ?? 'left'} text-zinc-100`}>
                  {c.render ? c.render(row) : String(row[c.key] ?? '')}
                </td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
      {totalPages > 1 && (
        <div className="flex justify-between items-center px-4 py-3 border-t border-zinc-800">
          <span className="text-xs text-zinc-500">{sorted.length}件 / {totalPages}ページ</span>
          <div className="flex gap-1">
            <button onClick={() => setPage(Math.max(0, page - 1))} disabled={page === 0} className="px-2 py-1 rounded bg-zinc-800 disabled:opacity-50">←</button>
            <span className="px-3 py-1 text-zinc-400">{page + 1} / {totalPages}</span>
            <button onClick={() => setPage(Math.min(totalPages - 1, page + 1))} disabled={page === totalPages - 1} className="px-2 py-1 rounded bg-zinc-800 disabled:opacity-50">→</button>
          </div>
        </div>
      )}
    </div>
  );
}

:LiHandPointer: 使い方

対象プロジェクトに該当ファイルをコピーして、props を流し込むだけ。

:LiAlertCircle: 注意事項

  • 依存パッケージを忘れず追加