用Claude Code构建React表格组件:排序、筛选、分页与可访问性
用Claude Code实现React表格组件,覆盖语义化table、排序、筛选、分页、移动端、TanStack Table和Playwright检查。
先定义表格契约,而不是先画样式
后台系统、客户管理、账单历史、商品目录、文章数据看板都会用到表格。表格的第一版通常只是“把数据列出来”,但真实项目很快会出现更多要求:按金额排序、按状态筛选、数据太多时分页、手机上能读、键盘能操作、修改后能用自动化测试确认没有坏掉。
Claude Code很适合处理这些重复而细碎的工作,但前提是你给它清晰的约束。只说“做一个好看的表格”,很容易得到一个视觉上像表格、语义上却只是div网格的实现。那样的代码在屏幕阅读器、键盘操作、排序状态、移动端布局和测试上都容易留下问题。
本文用一个客户表格作为例子,构建可直接复制的React/TypeScript组件。内容包括语义化table基础、列排序、全局筛选、分页、响应式移动端、可访问性、何时选择TanStack Table,以及用Playwright做回归检查。React整体开发可以参考Claude Code React开发,可访问性细节可以继续看Claude Code可访问性实践。
官方资料建议同时打开:HTML表格语义看MDN <table>,排序状态看MDN aria-sort,复杂表格状态看TanStack Table文档,端到端测试看Playwright Writing tests,Claude Code工作流看Claude Code overview。
语义化table基础
当行和列的交叉关系本身有意义时,就应该使用表格。客户名、套餐、月收入、状态、注册日期这类数据,必须通过列标题才能理解,所以它们适合放进table。如果只是几个独立卡片,则不一定要使用表格。
基础结构包含caption、thead、tbody和th scope="col"。caption说明表格目的,列标题使用th,行的主语可以用th scope="row"。这些写法不会让样式自动变好,却能让浏览器和辅助技术理解数据关系。
<table>
<caption>客户月度经常性收入</caption>
<thead>
<tr>
<th scope="col">客户</th>
<th scope="col">套餐</th>
<th scope="col">MRR</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Northwind</th>
<td>Pro</td>
<td>$1,200</td>
</tr>
</tbody>
</table>
给Claude Code的要求也要写到这个级别。例如“使用原生table、添加caption、列标题加scope="col"、第一列作为行标题”。这样比“做一个表格”稳定得多。
flowchart TD
A["需求"] --> B["语义化 table"]
B --> C["排序、筛选、分页状态"]
C --> D["移动端呈现"]
D --> E["可访问性检查"]
E --> F["Playwright 验证"]
给Claude Code的提示词
表格组件横跨数据、状态、CSS和测试,所以提示词要限制修改范围,也要说明禁止项。下面的模板适合直接改成自己的项目路径。
请用 React + TypeScript 实现客户表格组件。
要求:
- 只修改 src/components/DataTable.tsx 和 src/components/data-table.css
- 使用 table、caption、thead、tbody、th scope
- 数据字段为 id, name, plan, mrr, status, signedUpAt
- 支持全文筛选、列排序、每页5条分页
- 只有当前排序列能设置 aria-sort="ascending|descending"
- 列标题内部使用 button 触发排序
- 移动端通过 data-label 显示每个单元格的列名
- 增加 Playwright 测试覆盖筛选、排序、分页和移动端标签
不要:
- 新增项目没有使用的UI库
- 随意添加 role="grid"
- 用伪代码代替可运行代码
重点是把Claude Code当作“保持约束并生成差分的助手”,而不是只让它输出一段组件。表格越接近业务核心,越需要把验收条件写清楚。
可复制的React/TypeScript实现
下面的实现不依赖表格库,适合中小型后台列表。它包含搜索、排序、分页、aria-sort和移动端需要的data-label。
// src/components/DataTable.tsx
"use client";
import { useMemo, useState, type ReactNode } from "react";
import "./data-table.css";
type SortDirection = "asc" | "desc";
type SortState<T> = { key: keyof T; direction: SortDirection } | null;
type Customer = {
id: string;
name: string;
plan: "Free" | "Pro" | "Enterprise";
mrr: number;
status: "active" | "trial" | "paused";
signedUpAt: string;
};
type Column<T> = {
key: keyof T;
label: string;
numeric?: boolean;
render?: (value: T[keyof T], row: T) => ReactNode;
};
const pageSize = 5;
const rows: Customer[] = [
{ id: "cus_001", name: "Northwind", plan: "Pro", mrr: 1200, status: "active", signedUpAt: "2026-01-15" },
{ id: "cus_002", name: "Blue Bottle", plan: "Free", mrr: 0, status: "trial", signedUpAt: "2026-02-02" },
{ id: "cus_003", name: "Kobayashi Studio", plan: "Enterprise", mrr: 8400, status: "active", signedUpAt: "2025-11-20" },
{ id: "cus_004", name: "Atlas Foods", plan: "Pro", mrr: 980, status: "paused", signedUpAt: "2025-12-09" },
{ id: "cus_005", name: "Green Lab", plan: "Pro", mrr: 1600, status: "active", signedUpAt: "2026-03-01" },
{ id: "cus_006", name: "Sakura Dental", plan: "Free", mrr: 0, status: "trial", signedUpAt: "2026-03-18" },
];
const money = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
});
const columns: Column<Customer>[] = [
{ key: "name", label: "Customer" },
{ key: "plan", label: "Plan" },
{ key: "mrr", label: "MRR", numeric: true, render: (_, row) => money.format(row.mrr) },
{ key: "status", label: "Status" },
{ key: "signedUpAt", label: "Signed up", render: (_, row) => new Date(row.signedUpAt).toLocaleDateString("en-US") },
];
function compare<T>(a: T, b: T, key: keyof T) {
const left = a[key];
const right = b[key];
if (typeof left === "number" && typeof right === "number") return left - right;
return String(left).localeCompare(String(right), undefined, { numeric: true, sensitivity: "base" });
}
export function DataTable() {
const [query, setQuery] = useState("");
const [page, setPage] = useState(1);
const [sort, setSort] = useState<SortState<Customer>>({ key: "name", direction: "asc" });
const filtered = useMemo(() => {
const keyword = query.trim().toLowerCase();
if (!keyword) return rows;
return rows.filter((row) =>
columns.some((column) => String(row[column.key]).toLowerCase().includes(keyword)),
);
}, [query]);
const sorted = useMemo(() => {
if (!sort) return filtered;
return [...filtered].sort((a, b) => {
const result = compare(a, b, sort.key);
return sort.direction === "asc" ? result : -result;
});
}, [filtered, sort]);
const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize));
const currentPage = Math.min(page, totalPages);
const pageRows = sorted.slice((currentPage - 1) * pageSize, currentPage * pageSize);
function updateQuery(value: string) {
setQuery(value);
setPage(1);
}
function toggleSort(key: keyof Customer) {
setSort((current) => {
if (!current || current.key !== key) return { key, direction: "asc" };
return { key, direction: current.direction === "asc" ? "desc" : "asc" };
});
}
return (
<section className="table-shell" aria-labelledby="customers-title">
<label>
<span>Filter customers</span>
<input value={query} onChange={(event) => updateQuery(event.target.value)} type="search" />
</label>
<div className="table-scroll" tabIndex={0}>
<table className="data-table">
<caption id="customers-title">Monthly recurring revenue by customer</caption>
<thead>
<tr>
{columns.map((column) => {
const isSorted = sort?.key === column.key;
const ariaSort = isSorted ? (sort.direction === "asc" ? "ascending" : "descending") : undefined;
return (
<th key={String(column.key)} scope="col" aria-sort={ariaSort} className={column.numeric ? "numeric" : undefined}>
<button type="button" onClick={() => toggleSort(column.key)}>{column.label}</button>
</th>
);
})}
</tr>
</thead>
<tbody>
{pageRows.map((row) => (
<tr key={row.id}>
{columns.map((column, index) => {
const content = column.render ? column.render(row[column.key], row) : String(row[column.key]);
return index === 0 ? (
<th key={String(column.key)} scope="row" data-label={column.label}>{content}</th>
) : (
<td key={String(column.key)} data-label={column.label} className={column.numeric ? "numeric" : undefined}>{content}</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
<nav className="pagination" aria-label="Table pagination">
<button type="button" disabled={currentPage === 1} onClick={() => setPage((value) => value - 1)}>Previous</button>
<span aria-live="polite">Page {currentPage} of {totalPages}</span>
<button type="button" disabled={currentPage === totalPages} onClick={() => setPage((value) => value + 1)}>Next</button>
</nav>
</section>
);
}
移动端CSS和可访问性
移动端可以横向滚动,也可以在窄屏下把每一行堆叠成卡片。客户列表、文章列表这类操作型表格通常更适合堆叠显示,但DOM仍然保持表格结构。
.table-scroll {
overflow-x: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th,
.data-table td {
border-top: 1px solid #e5e7eb;
padding: 0.75rem;
text-align: left;
}
.data-table .numeric {
text-align: right;
}
.pagination {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
}
@media (max-width: 640px) {
.data-table thead {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
}
.data-table,
.data-table tbody,
.data-table tr,
.data-table th,
.data-table td {
display: block;
width: 100%;
}
.data-table tr {
border: 1px solid #d8dee8;
border-radius: 0.5rem;
margin-bottom: 0.75rem;
}
.data-table th,
.data-table td {
display: grid;
grid-template-columns: 8rem 1fr;
gap: 0.75rem;
}
.data-table th::before,
.data-table td::before {
content: attr(data-label);
font-weight: 700;
}
}
可访问性检查重点包括:caption是否说明表格目的,列标题是否为th,排序按钮是否可以用键盘操作,筛选框是否有标签,分页状态是否通过aria-live通知。不要随意添加role="grid",除非你真的实现了网格组件需要的键盘交互。
何时选择TanStack Table
如果只是少量列、全局搜索和单列排序,自写组件就足够。若需要列显示控制、列级筛选、行选择、服务端分页、固定列或虚拟滚动,就应该考虑TanStack Table。它是headless库,负责表格状态,不强制你使用某套样式。
| 方案 | 适合场景 | 注意点 |
|---|---|---|
| 自写组件 | 小型列表、简单排序和筛选 | 后续功能变多时维护成本会上升 |
| TanStack Table | 多列状态、服务端数据、行选择 | 需要理解API,UI仍要自己做 |
| 企业级Grid | 类Excel编辑、超大数据 | 依赖、体积、授权都要确认 |
Claude Code引入TanStack Table时,可以要求它使用官方React适配器、保留现有CSS,并把列定义写成类型安全的ColumnDef<Customer>[]。
Playwright检查
表格测试不能只确认页面打开了,还要确认用户行为:排序改变状态,筛选能找到目标行,分页能翻到下一页,移动端仍能看到列名。
// tests/customer-table.spec.ts
import { expect, test } from "@playwright/test";
test("customer table works", async ({ page }) => {
await page.goto("/customers");
await expect(page.getByRole("table", { name: /monthly recurring revenue/i })).toBeVisible();
await page.getByRole("button", { name: /MRR/ }).click();
await expect(page.getByRole("columnheader", { name: /MRR/ })).toHaveAttribute("aria-sort", "ascending");
await page.getByLabel("Filter customers").fill("north");
await expect(page.getByRole("row", { name: /Northwind/ })).toBeVisible();
await page.getByLabel("Filter customers").fill("");
await page.getByRole("button", { name: "Next" }).click();
await expect(page.getByText("Page 2 of 2")).toBeVisible();
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.locator("td[data-label='Plan']").first()).toBeVisible();
});
让Claude Code修复表格时,把这个测试一起交给它,并要求“先复现失败,再修复”。这样比只看截图可靠得多。
用例、坑和收益导线
| 用例 | 表格功能 | 业务价值 |
|---|---|---|
| SaaS客户列表 | 套餐、MRR、状态、续约日 | 发现流失风险和升级机会 |
| 电商商品管理 | 库存、价格、分类、发布状态 | 更快发现缺货和价格错误 |
| 内容运营看板 | PV、读完率、CTA点击、更新时间 | 决定重写顺序和广告收益优化 |
| 账单历史 | 支付状态、金额、到期日 | 降低客服查账成本 |
常见坑有五个:用div伪装表格、只显示排序箭头却不更新aria-sort、筛选后忘记回到第1页、手机端只靠横向滚动、让Claude Code随意加入新UI库。把这些写进提示词,最后再让Claude Code做一次可访问性审查。
表格也是收益工具。内容站可以把PV、CTA点击、广告收入和重写日期放在一起,判断下一篇要改什么。SaaS可以把MRR、使用下降和续约日放在一起,安排客户成功动作。团队想把这类后台表格做成稳定流程,可以看Claude Code培训与导入咨询;个人学习可以从教材与模板开始。
实际试用结果
Masa在一个小型客户列表中试过这个结构,最明显的收益是筛选时自动回到第1页。之前用户在第2页筛选时会看到空表,以为没有结果。加入Playwright后,这类问题很容易被发现。第二个收益是从一开始就给单元格写data-label,移动端不再需要临时补CSS。把语义、状态、移动端、可访问性和测试一起交给Claude Code,比单独让它“美化表格”可靠得多。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
Claude Code权限安全阶梯:逐步放开访问而不失控
从只读到有限编辑、验证命令和部署检查的 Claude Code 权限升级流程。
Claude Code 小PR证据包:让小改动真正可审查
用差异、验证命令、公开URL、CTA路径和回滚说明,把Claude Code的小PR变得可审查。
Claude Code 提交前 Review Gate:同时检查差异、测试、公开 URL 和 CTA
提交前用 Claude Code 审查差异范围、build、公开 URL、Gumroad 链接、咨询 CTA、缺少测试和无关文件。