Day 17 做完 Badge、Alert 和 Empty State 之後,CUI 已經開始有能力表達「狀態」。
例如:
<Badge variant="success">
已通過
</Badge>
但在真實系統裡,Badge 很少孤零零地出現在頁面上。
更多時候,它會長這樣:
姓名 申請項目 狀態 更新時間
王小明 學分抵免 已通過 2026/10/01
陳小華 休學申請 待審核 2026/10/01
林小美 獎學金申請 已退回 2026/09/30
也就是:
Table
Table 幾乎是後台系統、校務系統、管理系統裡最常見的 UI 之一。
而且也是很容易「看起來沒問題,但 HTML 結構其實完全不是 Table」的地方。
今天就來把 CUI 的第一個資料呈現元件做起來。
今天預計完成:
Table
│
├── Table
├── TableHeader
├── TableBody
├── TableFooter
├── TableRow
├── TableHead
├── TableCell
└── TableCaption
並實際組出:
申請紀錄
姓名 申請項目 狀態 更新時間
王小明 學分抵免 已通過 2026/10/01
陳小華 休學申請 待審核 2026/10/01
林小美 獎學金申請 已退回 2026/09/30
今天主要會處理:
<table>
<thead>、<tbody> 到底有什麼意義<th> 和 <td> 有什麼不同scope="col" 是什麼<caption> 又是做什麼的假設今天要呈現:
姓名 狀態
王小明 已通過
陳小華 待審核
很容易直接寫:
<div className="grid grid-cols-2">
<div>姓名</div>
<div>狀態</div>
<div>王小明</div>
<div>已通過</div>
<div>陳小華</div>
<div>待審核</div>
</div>
畫面上完全可以排成:
姓名 狀態
王小明 已通過
陳小華 待審核
看起來沒有任何問題。
但 HTML 其實只知道:
div
div
div
div
div
div
它不知道:
姓名
↓
是 Column Header
王小明
↓
是姓名欄位的 Cell
已通過
↓
是狀態欄位的 Cell
也就是說:
視覺上的排列關係,不等於 HTML 的語意關係。
如果這些資料本身具有「列與欄」的二維關係,就應該優先使用真正的 Table semantics。
真正的 HTML Table:
<table>
<thead>
<tr>
<th scope="col">姓名</th>
<th scope="col">狀態</th>
</tr>
</thead>
<tbody>
<tr>
<td>王小明</td>
<td>已通過</td>
</tr>
<tr>
<td>陳小華</td>
<td>待審核</td>
</tr>
</tbody>
</table>
這裡的 HTML 本身就已經描述:
table
│
├── thead
│ └── tr
│ ├── th:姓名
│ └── th:狀態
│
└── tbody
├── tr
│ ├── td:王小明
│ └── td:已通過
│
└── tr
├── td:陳小華
└── td:待審核
這些結構不只是為了 CSS。
它們本身就在描述資料之間的關係。
先建立今天的 branch:
git switch main
git pull
git switch -c feat/table
加入 Table:
npx shadcn@latest add table
通常會產生:
src/components/ui/table.tsx
裡面會包含:
Table
TableHeader
TableBody
TableFooter
TableHead
TableRow
TableCell
TableCaption
這次和前幾天的 Select、Checkbox 不太一樣。
Table 基本上沒有複雜 JavaScript interaction。
它主要是在封裝:
<table>
<thead>
<tbody>
<tfoot>
<tr>
<th>
<td>
<caption>
以及它們的樣式。
我們做 UI Kit 時很容易出現一種誘惑:
既然都包成 React Component 了,那是不是可以:
<Table>
最後 render 成:
<div role="table">
理論上可以。
但如果沒有特殊理由,其實完全沒必要。
HTML 已經有:
<table>
就直接使用:
function Table({
className,
...props
}: React.ComponentProps<"table">) {
return (
<table
className={...}
{...props}
/>
)
}
同樣:
<TableHead>
應該 render:
<th>
而:
<TableCell>
應該 render:
<td>
也就是:
Component abstraction 不應該把原本正確的 HTML semantics 抽象掉。
React Component 是方便我們重用樣式與 API。
不是為了把所有東西都變成 <div>。
延續前面的設計,我們可以替每一層加入:
data-cui-slot
例如:
<table
data-slot="table"
data-cui-slot="table"
>
<thead
data-slot="table-header"
data-cui-slot="table-header"
>
<tbody
data-slot="table-body"
data-cui-slot="table-body"
>
<tr
data-slot="table-row"
data-cui-slot="table-row"
>
<th
data-slot="table-head"
data-cui-slot="table-head"
>
<td
data-slot="table-cell"
data-cui-slot="table-cell"
>
最後 DOM 仍然是:
<table>
<thead>
<tr>
<th>...</th>
</tr>
</thead>
</table>
CUI Contract 只是額外提供:
data-cui-slot="table"
未來給:
React
Legacy
cui.css
共用。
th 和 td 到底差在哪?視覺上:
<th>姓名</th>
和:
<td>姓名</td>
都可以透過 CSS 做成粗體。
但語意完全不同。
td 是:
Table Data Cell
也就是資料。
而 th 是:
Table Header Cell
也就是這一列或這一欄資料的 Header。
例如:
<th scope="col">姓名</th>
是在說:
這個 Header 是下面這一欄資料的標題。
所以:
姓名
↓
王小明
陳小華
林小美
之間不是只有「剛好排在一起」。
HTML 本身就可以表達它們的關係。
scope="col" 是什麼?我們可以在 Table Header 寫:
<TableHead scope="col">
姓名
</TableHead>
HTML:
<th scope="col">
姓名
</th>
scope="col" 的意思是:
這個
<th>是這一整個 Column 的 Header。
例如:
<tr>
<th scope="col">姓名</th>
<th scope="col">申請項目</th>
<th scope="col">狀態</th>
<th scope="col">更新時間</th>
</tr>
就是:
姓名 申請項目 狀態 更新時間
↓ ↓ ↓ ↓
Column Column Column Column
如果是 Row Header,則可以使用:
scope="row"
例如:
<tr>
<th scope="row">王小明</th>
<td>學分抵免</td>
<td>已通過</td>
</tr>
表示:
王小明
是這一列資料的 Header。
scope 要不要寫死在 TableHead?這裡有一個 Component API 的問題。
既然大部分:
<TableHead>
都是 Column Header,那是不是直接在元件裡寫:
<th scope="col">
?
我目前比較傾向:
不要寫死。
因為 <th> 不一定永遠是 Column Header。
有時候可能是:
<th scope="row">
如果 Component 裡直接寫死:
scope="col"
未來就會限制使用方式。
因此保留:
<TableHead scope="col">
讓使用端依資料結構決定。
這和前一天 Alert 的:
role="alert"
其實是同一個設計原則:
元件提供能力,但不要替使用者猜所有語意。
接著在 App.tsx 建立測試資料:
const applications = [
{
id: 1,
name: "王小明",
type: "學分抵免",
status: "success",
statusLabel: "已通過",
updatedAt: "2026/10/01",
},
{
id: 2,
name: "陳小華",
type: "休學申請",
status: "warning",
statusLabel: "待審核",
updatedAt: "2026/10/01",
},
{
id: 3,
name: "林小美",
type: "獎學金申請",
status: "error",
statusLabel: "已退回",
updatedAt: "2026/09/30",
},
] as const
然後:
<Table>
<TableHeader>
<TableRow>
<TableHead scope="col">
姓名
</TableHead>
<TableHead scope="col">
申請項目
</TableHead>
<TableHead scope="col">
狀態
</TableHead>
<TableHead scope="col">
更新時間
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{applications.map((application) => (
<TableRow key={application.id}>
<TableCell>
{application.name}
</TableCell>
<TableCell>
{application.type}
</TableCell>
<TableCell>
<Badge variant={application.status}>
{application.statusLabel}
</Badge>
</TableCell>
<TableCell>
{application.updatedAt}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
這時 Day 17 的 Badge 就正式開始被其他元件使用了。
CUI 不再只是:
Button Demo
Badge Demo
Alert Demo
而是開始組成:
Table
↓
Badge
真正的系統 UI。
HTML Table 還有一個很容易被忽略的元素:
<caption>
例如:
<Table>
<TableCaption>
近期申請紀錄
</TableCaption>
...
</Table>
它不是單純:
Table 下面的一行灰色小字。
caption 是這張 Table 的標題或說明。
例如頁面裡同時有:
近期申請紀錄
姓名 ...
以及:
登入紀錄
時間 ...
Table Caption 可以幫助描述:
這一張 Table 到底是什麼資料?
<h2>,還需要 Caption 嗎?假設畫面是:
<section>
<h2>
近期申請紀錄
</h2>
<Table>
...
</Table>
</section>
這時視覺上其實已經很清楚。
是否還要:
<TableCaption>
近期申請紀錄
</TableCaption>
就需要看情境。
不一定要把同一句話重複顯示兩次:
近期申請紀錄
姓名 ...
...
近期申請紀錄
CUI 的 Table 應該提供 TableCaption,但不代表每一張 Table 都要機械式塞一個 Caption。
重點仍然是:
使用者能不能理解這張表格的目的與內容?
而不是:
我是不是把所有可用的 HTML tag 都用了一次?
有些設計裡,Table 前面已經有清楚的 Heading,但仍希望保留 Table 本身的 Caption。
這時可以考慮:
<TableCaption className="sr-only">
近期申請紀錄
</TableCaption>
視覺上不重複顯示,但語意仍然存在。
不過這也不應該變成固定公式。
還是要依實際頁面結構決定。
這是 Table 很現實的一個問題。
Desktop:
姓名 申請項目 狀態 更新時間
完全沒問題。
手機只有 375px 時:
姓名 申請項目 狀態 更新時間...
開始塞爆。
最簡單、也最安全的第一步通常是:
允許 Table 水平捲動。
shadcn/ui 的 Table 通常會在外面包:
<div className="relative w-full overflow-x-auto">
因此:
Viewport
┌─────────────────────┐
│ Table → → → │
└─────────────────────┘
小螢幕可以左右捲動,而不是直接把 Table 結構拆掉。
很常看到 Responsive Table 在手機版變成:
┌─────────────────────┐
│ 姓名:王小明 │
│ 申請項目:學分抵免 │
│ 狀態:已通過 │
│ 更新時間:2026/10/01 │
└─────────────────────┘
這種 Card Pattern 本身不是錯。
但如果只是透過 CSS 把:
<table>
硬拆成奇怪的:
display: block;
甚至把 Header 隱藏掉,再靠 ::before 補欄位名稱,就很容易破壞原本的資料關係。
所以 CUI v1 先採用比較保守的策略:
Desktop
→ 正常 Table
Small viewport
→ Horizontal Scroll
如果未來真的需要 Mobile Card View,再把它當成另一種 Data List Pattern 設計。
不要讓 CSS 偷偷把 Table 變成另一個元件。
做到資料列表時,很容易開始想:
姓名 ↑
申請項目
狀態
更新時間 ↓
那排序是不是也要直接做到 Table 裡?
今天先不要。
目前的 CUI Table 是:
Data Presentation
它負責:
Row
Column
Header
Cell
Caption
Visual Style
但:
Sorting
Filtering
Searching
Pagination
Selection
都屬於更高一層的 Data List behavior。
所以今天不急著做:
<Table sortable>
因為「Table 可以顯示資料」和「Data Grid 可以互動」是兩件不同的事。
這兩個很容易混在一起。
今天做的是:
Table
主要用途是:
閱讀具有 Row / Column 關係的資料。
但如果未來做到:
Arrow Keys 移動 Cell
Editable Cell
Multi-select Row
Column Resize
Column Reorder
Complex Keyboard Interaction
那已經開始接近:
Data Grid
它的 Accessibility 與 Keyboard Pattern 會複雜很多。
CUI 現階段沒有必要因為「未來可能會需要」就把 Table 做成超大型 Data Grid。
先把基本的 Table 做對。
目前我們可以直接:
<TableCell>
2026/10/01
</TableCell>
但如果這個日期本身有機器可讀的確切日期,也可以考慮原生 <time>:
<TableCell>
<time dateTime="2026-10-01">
2026/10/01
</time>
</TableCell>
這也是我很喜歡原生 HTML 的地方。
很多語意其實 HTML 本身已經提供了,不一定需要 ARIA。
真實後台系統很可能還有:
姓名 狀態 操作
王小明 已通過 [查看]
陳小華 待審核 [查看]
這時 Table Cell 裡當然可以放 Button:
<TableCell>
<Button variant="outline" size="sm">
查看
</Button>
</TableCell>
但要注意 Accessible Name。
如果整張表有十列:
查看
查看
查看
查看
查看
Screen Reader 使用者只聽到一堆:
查看、查看、查看……
就不一定知道在查看誰。
可以讓 Accessible Name 更完整,例如:
<Button
variant="outline"
size="sm"
aria-label={`查看 ${application.name} 的申請`}
>
查看
</Button>
視覺仍然是:
[ 查看 ]
但 Accessible Name 會更具體:
查看王小明的申請
這種問題通常就是到了「元件開始組合」之後才會出現。
單獨測 Button 時,很難發現。
例如:
姓名
申請項目
狀態
更新時間
本身就很清楚。
但如果 Header 全部變成:
名稱
類型
狀態
時間
在複雜頁面裡可能就開始變得模糊。
Table accessibility 不只是:
<th scope="col">
而已。
真正的內容文字本身仍然要讓人理解。
這跟前一天 Status Feedback 的結論其實一樣:
Semantics 可以幫助建立結構,但不能拯救本身就寫得很模糊的內容。
這裡剛好可以接昨天做的 Empty State。
假設:
applications.length === 0
是不是要:
<TableBody>
<TableRow>
<TableCell colSpan={4}>
尚無資料
</TableCell>
</TableRow>
</TableBody>
這樣不是錯。
但如果 Empty State 需要:
尚無申請紀錄
目前沒有任何申請資料。
[ 新增申請 ]
那可能就更適合昨天做的:
<EmptyState>
也就是:
有資料
→ Table
沒有資料
→ Empty State
而不是硬讓 Table 承擔所有畫面狀態。
這件事後面的 Data List Pattern 還會再深入處理。
做到這裡,可以先替 Table 畫一條界線:
CUI Table
│
├── Semantic HTML ✓
├── Header / Cell ✓
├── Row / Body / Footer ✓
├── Caption ✓
├── Responsive Scroll ✓
└── Visual Style ✓
Search ✕
Filter ✕
Pagination ✕
Sorting ✕
Loading ✕
Empty State ✕
Error State ✕
不是這些功能永遠不做。
而是:
不要全部塞進 Table 本身。
後面我們會把它們組成更高階的 Data List Pattern。
今天最後檢查 Table:
<table>
<thead>
<tbody>
<tr>
<th>
<td>
Column Header:
<TableHead scope="col">
如果有 Row Header,則依資料結構使用:
scope="row"
如果 Table 需要自己的標題或說明:
<TableCaption>
不要把 Caption 單純當成灰色 Footer Text。
小螢幕先:
overflow-x-auto
不要為了 Responsive 直接破壞 Table semantics。
<time>
今天的 Table 本身如果只有純資料:
姓名
申請項目
狀態
更新時間
其實不需要讓:
Row
Cell
Header
全部可以 Tab。
不要寫:
<TableRow tabIndex={0}>
或:
<TableCell tabIndex={0}>
只因為「鍵盤要可以操作」。
純資料 Table 本身是閱讀內容,不是 Interactive Widget。
如果 Cell 裡真的有:
Link
Button
Checkbox
那些互動元素才自然進入 Tab Order。
例如:
Tab
↓
查看王小明的申請
↓
查看陳小華的申請
↓
查看林小美的申請
這才是合理的 Focus Order。
完成後:
npm run build
npm run lint
npm run check:contrast
再確認:
git status
git diff
如果都正常,就可以 Commit:
git add src/components/ui/table.tsx src/App.tsx
git commit -m "feat: add accessible table component"
今天 CUI 開始從:
單一元件
真正跨進:
資料呈現
而 Table 最重要的並不是:
border-bottom
padding
hover
而是資料本身的關係。
Table
│
├── Caption
│
├── Column Header
│ ↓
│ scope
│
├── Row
│
└── Cell
如果資料本身具有:
Row × Column
的關係,那就應該讓 HTML 也知道這件事。
這也是今天最重要的結論:
畫面排得像表格,不代表它就是一張 Table。
CSS 負責:
它看起來怎麼樣?
HTML semantics 負責:
這些資料彼此是什麼關係?
做 Accessible UI Kit 時,後者往往比前者更重要。
今天有了:
Table
下一步很自然就會遇到:
資料有 237 筆怎麼辦?
不可能一次全部塞進畫面。
所以接下來要做:
Pagination
但 Pagination 也不是:
< 1 2 3 4 5 >
排出來就結束了。
它其實是一種:
Navigation
因此會開始處理:
<nav>
aria-label
aria-current="page"
Previous / Next
Icon Button accessible name
Day 19:
Pagination:上一頁下一頁也是 Navigation。