iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0
自我挑戰組

30 天打造 Accessible UI Kit:從 shadcn/ui 到自己的 Design System系列 第 18 篇

Day 18: Table:資料列表不是用一堆 `<div>` 排整齊就好

  • 分享至 

  • xImage
  •  

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> 又是做什麼的
  • Table 如何和昨天的 Badge 組合
  • 小螢幕 Table 怎麼處理
  • 哪些東西不應該塞進 Table 元件本身

1. 看起來像 Table,不代表它真的是 Table

假設今天要呈現:

姓名       狀態
王小明     已通過
陳小華     待審核

很容易直接寫:

<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。


2. HTML 本來就有 Table

真正的 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。

它們本身就在描述資料之間的關係。


3. 加入 shadcn/ui Table

先建立今天的 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>

以及它們的樣式。


4. Table 元件不要破壞原生 HTML

我們做 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>。


5. 加入 CUI Component Contract

延續前面的設計,我們可以替每一層加入:

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

共用。


6. th 和 td 到底差在哪?

視覺上:

<th>姓名</th>

和:

<td>姓名</td>

都可以透過 CSS 做成粗體。

但語意完全不同。

td 是:

Table Data Cell

也就是資料。

而 th 是:

Table Header Cell

也就是這一列或這一欄資料的 Header。

例如:

<th scope="col">姓名</th>

是在說:

這個 Header 是下面這一欄資料的標題。

所以:

姓名
 ↓
王小明
陳小華
林小美

之間不是只有「剛好排在一起」。

HTML 本身就可以表達它們的關係。


7. 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。


8. 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"

其實是同一個設計原則:

元件提供能力,但不要替使用者猜所有語意。


9. 做第一張 CUI Table

接著在 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。


10. Table Caption 是什麼?

HTML Table 還有一個很容易被忽略的元素:

<caption>

例如:

<Table>
  <TableCaption>
    近期申請紀錄
  </TableCaption>

  ...
</Table>

它不是單純:

Table 下面的一行灰色小字。

caption 是這張 Table 的標題或說明。

例如頁面裡同時有:

近期申請紀錄

姓名 ...

以及:

登入紀錄

時間 ...

Table Caption 可以幫助描述:

這一張 Table 到底是什麼資料?


11. 已經有 <h2>,還需要 Caption 嗎?

假設畫面是:

<section>
  <h2>
    近期申請紀錄
  </h2>

  <Table>
    ...
  </Table>
</section>

這時視覺上其實已經很清楚。

是否還要:

<TableCaption>
  近期申請紀錄
</TableCaption>

就需要看情境。

不一定要把同一句話重複顯示兩次:

近期申請紀錄

姓名 ...
...

近期申請紀錄

CUI 的 Table 應該提供 TableCaption,但不代表每一張 Table 都要機械式塞一個 Caption。

重點仍然是:

使用者能不能理解這張表格的目的與內容?

而不是:

我是不是把所有可用的 HTML tag 都用了一次?


12. Caption 也可以視覺隱藏

有些設計裡,Table 前面已經有清楚的 Heading,但仍希望保留 Table 本身的 Caption。

這時可以考慮:

<TableCaption className="sr-only">
  近期申請紀錄
</TableCaption>

視覺上不重複顯示,但語意仍然存在。

不過這也不應該變成固定公式。

還是要依實際頁面結構決定。


13. Table 的 Responsive 怎麼辦?

這是 Table 很現實的一個問題。

Desktop:

姓名   申請項目   狀態   更新時間

完全沒問題。

手機只有 375px 時:

姓名 申請項目 狀態 更新時間...

開始塞爆。

最簡單、也最安全的第一步通常是:

允許 Table 水平捲動。

shadcn/ui 的 Table 通常會在外面包:

<div className="relative w-full overflow-x-auto">

因此:

Viewport
┌─────────────────────┐
│ Table → → →         │
└─────────────────────┘

小螢幕可以左右捲動,而不是直接把 Table 結構拆掉。


14. 不要為了手機硬把 Table 變成假的 Card

很常看到 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 變成另一個元件。


15. 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 可以互動」是兩件不同的事。


16. 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 做對。


17. 日期也是內容的一部分

目前我們可以直接:

<TableCell>
  2026/10/01
</TableCell>

但如果這個日期本身有機器可讀的確切日期,也可以考慮原生 <time>:

<TableCell>
  <time dateTime="2026-10-01">
    2026/10/01
  </time>
</TableCell>

這也是我很喜歡原生 HTML 的地方。

很多語意其實 HTML 本身已經提供了,不一定需要 ARIA。


18. Row 裡有 Button 怎麼辦?

真實後台系統很可能還有:

姓名     狀態      操作
王小明   已通過    [查看]
陳小華   待審核    [查看]

這時 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 時,很難發現。


19. Header 文字也要清楚

例如:

姓名
申請項目
狀態
更新時間

本身就很清楚。

但如果 Header 全部變成:

名稱
類型
狀態
時間

在複雜頁面裡可能就開始變得模糊。

Table accessibility 不只是:

<th scope="col">

而已。

真正的內容文字本身仍然要讓人理解。

這跟前一天 Status Feedback 的結論其實一樣:

Semantics 可以幫助建立結構,但不能拯救本身就寫得很模糊的內容。


20. Empty Table 怎麼辦?

這裡剛好可以接昨天做的 Empty State。

假設:

applications.length === 0

是不是要:

<TableBody>
  <TableRow>
    <TableCell colSpan={4}>
      尚無資料
    </TableCell>
  </TableRow>
</TableBody>

這樣不是錯。

但如果 Empty State 需要:

尚無申請紀錄

目前沒有任何申請資料。

[ 新增申請 ]

那可能就更適合昨天做的:

<EmptyState>

也就是:

有資料
→ Table

沒有資料
→ Empty State

而不是硬讓 Table 承擔所有畫面狀態。

這件事後面的 Data List Pattern 還會再深入處理。


21. CUI Table 的責任邊界

做到這裡,可以先替 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。


22. Accessibility Check

今天最後檢查 Table:

HTML Structure

  • 使用真正的 <table>
  • Header 使用 <thead>
  • Data 使用 <tbody>
  • Row 使用 <tr>
  • Header Cell 使用 <th>
  • Data Cell 使用 <td>

Header Association

Column Header:

<TableHead scope="col">

如果有 Row Header,則依資料結構使用:

scope="row"

Caption

如果 Table 需要自己的標題或說明:

<TableCaption>

不要把 Caption 單純當成灰色 Footer Text。

Responsive

小螢幕先:

overflow-x-auto

不要為了 Responsive 直接破壞 Table semantics。

Content

  • 狀態不只靠顏色
  • Icon 不作為唯一資訊來源
  • 重複 Action Button 要有足夠的 Accessible Name
  • 日期可依需要使用 <time>

23. Keyboard Test

今天的 Table 本身如果只有純資料:

姓名
申請項目
狀態
更新時間

其實不需要讓:

Row
Cell
Header

全部可以 Tab。

不要寫:

<TableRow tabIndex={0}>

或:

<TableCell tabIndex={0}>

只因為「鍵盤要可以操作」。

純資料 Table 本身是閱讀內容,不是 Interactive Widget。

如果 Cell 裡真的有:

Link
Button
Checkbox

那些互動元素才自然進入 Tab Order。

例如:

Tab
 ↓
查看王小明的申請
 ↓
查看陳小華的申請
 ↓
查看林小美的申請

這才是合理的 Focus Order。


24. 最後執行檢查

完成後:

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"

Day 18 完成

今天 CUI 開始從:

單一元件

真正跨進:

資料呈現

而 Table 最重要的並不是:

border-bottom
padding
hover

而是資料本身的關係。

Table
│
├── Caption
│
├── Column Header
│       ↓
│     scope
│
├── Row
│
└── Cell

如果資料本身具有:

Row × Column

的關係,那就應該讓 HTML 也知道這件事。

這也是今天最重要的結論:

畫面排得像表格,不代表它就是一張 Table。

CSS 負責:

它看起來怎麼樣?

HTML semantics 負責:

這些資料彼此是什麼關係?

做 Accessible UI Kit 時,後者往往比前者更重要。


Next:Day 19

今天有了:

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。


上一篇
Day 17: Badge、Alert、Empty State:狀態不能只靠顏色表達
下一篇
Day 19: Pagination:上一頁下一頁也是 Navigation
系列文
30 天打造 Accessible UI Kit:從 shadcn/ui 到自己的 Design System 共 19 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言