前幾天,我們開始離開單一元件的世界。
Day 17 做了:
Badge
Alert
Empty State
Day 18 做了:
Table
Day 19 又加入:
Pagination
到目前為止,每一個元件單獨看都已經可以使用。
但真實系統不會長這樣:
這裡一顆 Badge
下面一張 Table
再下面一個 Pagination
真正的管理系統通常會是:
申請紀錄
[ 搜尋........................ ]
共 237 筆資料
┌──────────────────────────────────────┐
│ 姓名 申請項目 狀態 更新時間 │
├──────────────────────────────────────┤
│ 王小明 學分抵免 已通過 10/01 │
│ 陳小華 休學申請 待審核 10/01 │
│ 林小美 獎學金申請 已退回 09/30 │
└──────────────────────────────────────┘
< 1 [2] 3 4 … 10 >
今天要做的事情,就是第一次把前面完成的元件真正組起來。
從 Component 開始進入 Pattern。
今天不會新增很多底層元件。
主要使用前面已經完成的:
Input
Field
Table
Badge
Pagination
組成:
Data List
│
├── Heading
├── Search
├── Result Count
├── Table
│ └── Badge
└── Pagination
同時開始處理幾個「單獨做元件時不太會遇到」的問題:
aria-label 要不要更具體?到目前為止,我們做的大部分東西都是 Component。
例如:
<Input />
<Badge />
<Table />
<Pagination />
它們各自解決一個相對單純的 UI 問題。
但是:
Search
+
Result Count
+
Table
+
Pagination
組合起來之後,就開始形成一個:
Pattern
也就是一種在系統裡會反覆出現的 UI 組合方式。
例如:
學生資料列表
課程列表
申請紀錄
公告管理
帳號管理
報名資料
其實都可能使用:
Data List Pattern
只是裡面的資料不同。
<DataList />看到:
Data List Pattern
很容易立刻想:
<DataList
data={applications}
columns={columns}
searchable
pagination
/>
然後開始設計一個超大型 Component。
但今天先不要。
因為我們現在還不知道:
Search 一定存在嗎?
Pagination 一定存在嗎?
Result Count 一定存在嗎?
Table 一定是唯一呈現方式嗎?
Filter 以後怎麼加入?
Action 又怎麼加入?
如果現在就做:
<DataList />
很可能最後得到:
<DataList
searchable
filterable
paginated
selectable
sortable
loading
empty
error
compact
striped
...
/>
然後成功養出一隻 Props 怪獸。
所以今天先做:
Composition Pattern
也就是使用現有元件把它組起來。
等 Pattern 穩定之後,再決定哪些東西真的值得抽象。
先準備一組比較接近真實系統的資料:
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",
},
{
id: 4,
name: "張志豪",
type: "交換學生申請",
status: "primary",
statusLabel: "審核中",
updatedAt: "2026/09/29",
},
] as const
今天先用 Client-side filtering 模擬 Search。
真正串 API、Server-side Pagination 並不是今天的重點。
首先加入:
const [query, setQuery] = useState("")
然後過濾資料:
const filteredApplications = applications.filter((application) => {
const keyword = query.trim().toLowerCase()
if (!keyword) {
return true
}
return (
application.name.toLowerCase().includes(keyword) ||
application.type.toLowerCase().includes(keyword) ||
application.statusLabel.toLowerCase().includes(keyword)
)
})
接著使用之前完成的 Input:
<Input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="搜尋姓名、申請項目或狀態"
/>
看起來已經可以用了。
但是 Accessibility Check 馬上會發現一件事:
這個 Input 的 Label 在哪裡?
我們很常看到:
<Input placeholder="搜尋" />
然後覺得:
使用者看得到「搜尋」,應該知道這是什麼。
問題是 Placeholder 有幾個限制。
當使用者開始輸入:
王小明
原本:
搜尋姓名、申請項目或狀態
就消失了。
而且 Placeholder 的角色本來就比較接近:
輸入格式或提示。
不是欄位名稱。
所以:
placeholder
≠
label
這跟 Day 12 做 Field 時的原則完全一樣。
最直接的方式:
<Field>
<FieldLabel htmlFor="application-search">
搜尋申請紀錄
</FieldLabel>
<Input
id="application-search"
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="輸入姓名、申請項目或狀態"
/>
</Field>
這樣非常清楚。
畫面:
搜尋申請紀錄
[ 輸入姓名、申請項目或狀態........ ]
如果設計上真的不希望顯示 Label,也可以:
<FieldLabel
htmlFor="application-search"
className="sr-only"
>
搜尋申請紀錄
</FieldLabel>
視覺上只看到:
[ 搜尋........................ ]
但 Input 仍然具有:
Accessible Name
→ 搜尋申請紀錄
aria-label 嗎?也可以:
<Input
type="search"
aria-label="搜尋申請紀錄"
/>
但既然 CUI 已經建立:
Field
FieldLabel
Input
在一般 Form Pattern 裡,我還是比較傾向優先使用真正的:
<label>
aria-label 可以留給真的不方便提供 Visible / Visually Hidden Label 的特殊情境。
也就是:
Native HTML first
ARIA when necessary
這個原則從前面做到現在都沒有改變。
搜尋框很常有:
🔍 Search...
如果加入 Lucide:
<SearchIcon />
這個 Icon 通常只是裝飾。
因為 Input 已經有:
搜尋申請紀錄
這個 Accessible Name。
所以:
<SearchIcon aria-hidden="true" />
即可。
不要讓 Screen Reader 得到:
搜尋圖示,搜尋申請紀錄,編輯文字
Icon 沒有增加新的資訊,就不需要被重複朗讀。
搜尋之後,我們還希望顯示:
共 4 筆資料
搜尋「王」之後:
找到 1 筆資料
可以:
<p className="text-sm text-muted-foreground">
共 {filteredApplications.length} 筆資料
</p>
這看起來很普通,但它其實是 Data List 裡非常重要的 Feedback。
因為使用者輸入:
王
之後,需要知道:
我的搜尋到底有沒有作用?
Result Count 就是其中一個非常直接的回饋。
role="alert"?假設:
共 237 筆
輸入搜尋條件後變成:
找到 3 筆資料
要不要:
<p role="alert">
?
通常不用。
搜尋結果數量的更新:
不是 Error
不是緊急警告
不是必須立即打斷使用者的重要訊息
所以 role="alert" 太強了。
如果真的希望動態搜尋結果可以被輔助科技得知,可以考慮:
<p
aria-live="polite"
className="text-sm text-muted-foreground"
>
找到 {filteredApplications.length} 筆資料
</p>
polite 的意思比較接近:
有空的時候告訴我這個內容更新了。
而不是:
現在立刻打斷所有事情念給我聽。
aria-live 也不是看到動態內容就加這裡也要小心。
如果 Search 是:
每輸入一個字
→ filter
→ result count 更新
那:
王
王小
王小明
可能每打一個字都更新 Live Region。
如果搜尋結果很多、輸入速度快,就可能變得很吵。
所以實際產品中還要考慮:
Debounce
Submit Search
Search Button
Live Region 更新頻率
今天 Demo 資料很少,可以先展示:
aria-live="polite"
這個 Pattern。
但不是說:
所有搜尋結果數量都必須加 Live Region。
Accessibility 不是「ARIA 越多越好」。
接著使用 Day 18 的 Table:
<Table>
<TableCaption className="sr-only">
申請紀錄搜尋結果
</TableCaption>
<TableHeader>
<TableRow>
<TableHead scope="col">
姓名
</TableHead>
<TableHead scope="col">
申請項目
</TableHead>
<TableHead scope="col">
狀態
</TableHead>
<TableHead scope="col">
更新時間
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredApplications.map((application) => (
<TableRow key={application.id}>
<TableCell>
{application.name}
</TableCell>
<TableCell>
{application.type}
</TableCell>
<TableCell>
<Badge variant={application.status}>
{application.statusLabel}
</Badge>
</TableCell>
<TableCell>
<time dateTime={application.updatedAt.replaceAll("/", "-")}>
{application.updatedAt}
</time>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
現在:
Input
+
Table
+
Badge
已經第一次真正形成一個使用流程。
接著放入 Day 19:
<Pagination aria-label="申請紀錄分頁">
<PaginationContent>
<PaginationItem>
<PaginationPrevious href="?page=1" />
</PaginationItem>
<PaginationItem>
<PaginationLink
href="?page=1"
isActive
>
1
</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationLink href="?page=2">
2
</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationLink href="?page=3">
3
</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationEllipsis />
</PaginationItem>
<PaginationItem>
<PaginationLink href="?page=10">
10
</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationNext href="?page=2" />
</PaginationItem>
</PaginationContent>
</Pagination>
這裡把:
aria-label="分頁"
改成更具體的:
aria-label="申請紀錄分頁"
因為它現在已經不是孤立的 Pagination Demo。
它是:
這一份申請紀錄的 Pagination。
這其實是今天很重要的一個發現。
昨天單獨做 Pagination 時:
aria-label="分頁"
完全合理。
但今天頁面可能未來還有:
申請紀錄
登入紀錄
操作紀錄
每一區都有自己的 Pagination。
如果全部:
分頁
分頁
分頁
就開始不清楚。
所以組成 Pattern 之後:
Component Accessibility
會開始變成:
Component
+
Context
=
Real Accessibility
這也是為什麼只在 Storybook 看單顆元件是不夠的。
現在可以把它整理成:
<section
aria-labelledby="application-list-title"
className="space-y-4"
>
<div className="space-y-1">
<h2
id="application-list-title"
className="text-xl font-semibold"
>
申請紀錄
</h2>
<p className="text-sm text-muted-foreground">
查看目前的申請與審核狀態。
</p>
</div>
<Field>
<FieldLabel htmlFor="application-search">
搜尋申請紀錄
</FieldLabel>
<Input
id="application-search"
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="輸入姓名、申請項目或狀態"
/>
</Field>
<p
aria-live="polite"
className="text-sm text-muted-foreground"
>
共 {filteredApplications.length} 筆資料
</p>
<Table>
...
</Table>
<Pagination aria-label="申請紀錄分頁">
...
</Pagination>
</section>
這已經不是 Component Showcase。
而是一個真的可以出現在:
Admin Dashboard
校務系統
報名管理
帳號管理
裡面的 Pattern。
<section>?現在這一整塊內容:
申請紀錄
搜尋
結果數量
Table
Pagination
本身就是頁面裡的一個主題區域。
因此可以使用:
<section>
並透過:
aria-labelledby="application-list-title"
和 Heading 建立關係:
<h2 id="application-list-title">
申請紀錄
</h2>
最後:
<section aria-labelledby="application-list-title">
這比:
<div className="data-list">
更能描述這塊內容在 Document Structure 裡的角色。
不過同樣地:
不是看到一組 Component 就一定要加
<section>。
它要真的構成一個有 Heading、具有主題意義的內容區域才適合。
做到這裡,可以重新畫一次責任:
Data List Pattern
│
├── Search
│ └── Input / Field
│
├── Feedback
│ └── Result Count
│
├── Data Display
│ └── Table
│ └── Badge
│
└── Navigation
└── Pagination
每一層負責的事情不同。
負責:
接受搜尋文字
負責:
Label
Description
Error relationship
負責:
Status presentation
負責:
Row / Column data relationship
負責:
Page navigation
負責:
把這些東西放在合理的使用流程裡
今天的 Data List 可以直接存在:
App.tsx
作為 Pattern Demo。
目前不用急著建立:
src/components/ui/data-list.tsx
因為 Data List 已經開始帶有:
Application-specific behavior
例如:
搜尋哪些欄位?
顯示哪些 Column?
Status 有哪些?
Pagination URL 怎麼產生?
這些東西不是所有系統都一樣。
因此目前更合理的是:
UI Primitives
→ CUI components
Pattern
→ Documentation / Example / Composition
等未來真的出現大量重複,再決定抽象程度。
另一個很容易出現的 API 是:
<Table
searchable
searchPlaceholder="搜尋..."
/>
看起來很方便。
但這會讓 Table 開始知道:
Search
Filter
Query
Result Count
而 Table 原本的責任只是:
表達 Row / Column Data。
如果未來某個 Table 不需要 Search:
?
如果 Search 放在 Table 外面:
?
如果同時還有 Filter:
?
API 很快就開始膨脹。
所以 CUI 目前保持:
Table
→ Table
Search
→ Search
Data List Pattern
→ 組合兩者
這條界線會比較乾淨。
同樣:
<Table
pagination
currentPage={2}
totalPages={10}
/>
看起來很方便。
但不是所有 Table 都需要 Pagination。
而且 Pagination 可能是:
URL Navigation
Client-side State
Server-side Pagination
Infinite Data
Table 不需要知道這些事情。
所以:
Table
+
Pagination
是:
Composition
而不是:
Table feature
現在整個 Pattern 變成:
Heading
Search
Result Count
Table
Pagination
Desktop 可以:
申請紀錄
搜尋申請紀錄
[ 搜尋............................ ] 共 237 筆
┌──────────────────────────────────────────┐
│ Table │
└──────────────────────────────────────────┘
Pagination
手機:
申請紀錄
搜尋申請紀錄
[ 搜尋.............. ]
共 237 筆
┌───────────────────┐
│ Table → → → │
└───────────────────┘
< 1 [2] 3 … 10 >
Table 本身繼續使用 Day 18 的:
overflow-x-auto
Pagination 則可以減少顯示頁碼或隱藏視覺文字。
不需要因為 Responsive 就重新改變整個 Semantic Structure。
可以。
例如:
[ 搜尋................ ] 共 237 筆
Desktop 很常這樣設計。
我們可以:
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<Field className="max-w-sm">
...
</Field>
<p className="text-sm text-muted-foreground">
共 {filteredApplications.length} 筆資料
</p>
</div>
手機自動變成:
Search
↓
Result Count
Desktop:
Search Result Count
這種 Responsive 只改 Layout。
HTML 的閱讀順序仍然是:
Search
↓
Result Count
↓
Table
↓
Pagination
因此 Keyboard / Screen Reader order 仍然合理。
假設 Desktop 想讓:
Result Count Search
但 DOM 是:
Search
Result Count
不要為了視覺效果大量使用:
order
把內容順序完全翻轉。
因為可能出現:
Visual Order
A → B → C
Keyboard / Screen Reader
C → A → B
使用者看到 Focus 跳來跳去就會非常困惑。
Responsive Layout 可以改排列方式。
但最好讓 DOM Order 本身就具有合理的閱讀順序。
現在有一個新問題。
假設輸入:
財前五郎
資料裡沒有。
結果:
找到 0 筆資料
下面還留著:
姓名 申請項目 狀態 更新時間
--------------------------------
這樣可以,但使用者體驗不算很好。
我們 Day 17 已經做過:
Empty State
是不是可以直接:
<EmptyState>
<EmptyStateTitle>
找不到符合條件的資料
</EmptyStateTitle>
<EmptyStateDescription>
請嘗試其他搜尋關鍵字。
</EmptyStateDescription>
</EmptyState>
可以。
但是這裡出現了一個很重要的區別:
Empty
≠
No Results
假設系統本來就沒有任何資料:
尚無申請紀錄
目前沒有任何申請資料。
這叫:
Empty
但原本有 237 筆資料,只是搜尋:
ABCDE
結果沒有符合:
找不到符合「ABCDE」的資料
請嘗試其他搜尋條件。
這叫:
No Results
它們視覺上都可能使用 Empty State Pattern。
但內容與 Action 不應該一樣。
例如:
Empty
→ 新增第一筆資料
No Results
→ 清除搜尋
這就是 Pattern 開始比 Component 更重要的地方。
可以:
{filteredApplications.length > 0 ? (
<>
<Table>
...
</Table>
<Pagination>
...
</Pagination>
</>
) : (
<EmptyState>
<EmptyStateTitle>
找不到符合條件的資料
</EmptyStateTitle>
<EmptyStateDescription>
請嘗試其他搜尋關鍵字。
</EmptyStateDescription>
<EmptyStateAction>
<Button
variant="outline"
onClick={() => setQuery("")}
>
清除搜尋
</Button>
</EmptyStateAction>
</EmptyState>
)}
這時 Day 17 的 Empty State 也正式被真正的 Pattern 使用。
如果:
找到 0 筆資料
下面還顯示:
< 1 >
其實沒有什麼 Navigation 意義。
所以:
No Results
→ Empty State
→ 不顯示 Pagination
這也是:
UI state 應該影響整個 Pattern,而不是只有 Table Body。
到這裡,我們其實已經開始碰到下一個主題:
Data List
│
├── Has Data
│ ├── Table
│ └── Pagination
│
└── No Results
└── Empty State
但真實世界還有:
Loading
Error
Empty
所以完整狀態其實會變成:
Data List
│
├── Loading
├── Data
├── Empty
├── No Results
└── Error
這部分今天先不要全部塞進來。
因為下一篇會專門處理。
如果只看:
src/components/ui
今天可能幾乎沒有新增東西。
但這反而是很重要的一天。
因為 Design System 的價值不是:
有 50 顆 Component。
而是:
這 50 顆 Component 能不能合理地組成產品。
今天第一次把:
Field
Input
Badge
Table
Pagination
Empty State
真正組成:
Data List Pattern
也第一次開始看到:
Component 在 Demo 裡正確
不代表:
放進真實 Context 後就一定正確
單獨的 Input 可以做到:
Focus ✓
Disabled ✓
Invalid ✓
但放進 Data List 後才會發現:
Search 的 Label 是什麼?
單獨 Pagination 可以做到:
aria-current ✓
Previous / Next ✓
但放進頁面後才會發現:
這是哪一份資料的 Pagination?
單獨 Empty State 可以做到:
Title ✓
Description ✓
Action ✓
但放進 Search 後才會發現:
這是 Empty 還是 No Results?
所以:
Accessible Component
+
Accessible Composition
=
Accessible Interface
只把每顆元件 individually 做到 Accessibility,還不代表整個頁面就一定 Accessible。
最後完整檢查一次。
Section
└── Heading
↓
Search
↓
Result Count
↓
Table / No Results
↓
Pagination
閱讀順序合理。
有真正的:
<label>
或其他有效 Accessible Name。
不要只依賴:
placeholder
Search Icon 如果只是裝飾:
aria-hidden="true"
搜尋結果更新如果需要通知:
aria-live="polite"
不要使用過強的:
role="alert"
也不要讓 Live Region 更新得過度頻繁。
保留:
table
thead
tbody
tr
th
td
Column Header:
scope="col"
狀態仍然使用文字:
已通過
待審核
已退回
而不是只靠 Badge 顏色。
使用:
nav
並提供 Context-specific label:
aria-label="申請紀錄分頁"
Current Page:
aria-current="page"
搜尋不到資料時:
說明為什麼沒有資料
+
提供合理的下一步
例如:
清除搜尋
不要顯示沒有意義的 Pagination。
最後再用鍵盤走一次。
預期:
Tab
↓
Search Input
↓
清除搜尋(如果有)
↓
Table 裡的 Interactive Controls(如果有)
↓
Pagination Previous
↓
Page Links
↓
Pagination Next
注意:
Table Row
Table Cell
Result Count
Badge
如果本身沒有 Interaction,就不需要加入 Tab Order。
不要為了「Accessibility」把整個頁面所有東西都變成:
tabIndex={0}
那反而會讓鍵盤使用變得非常痛苦。
完成後:
npm run build
npm run lint
npm run check:contrast
確認:
git status
git diff
如果今天主要修改:
App.tsx
作為 Data List Pattern Demo,可以 Commit:
git add src/App.tsx
git commit -m "feat: add data list pattern"
如果另外拆出了 Demo data 或 Pattern example,再依實際檔案一起加入。
今天終於出現了一個很像真正產品的畫面:
申請紀錄
搜尋申請紀錄
[ 搜尋............................. ]
共 237 筆資料
┌──────────────────────────────────────────┐
│ 姓名 申請項目 狀態 更新時間 │
├──────────────────────────────────────────┤
│ 王小明 學分抵免 [已通過] 10/01 │
│ 陳小華 休學申請 [待審核] 10/01 │
│ 林小美 獎學金申請 [已退回] 09/30 │
└──────────────────────────────────────────┘
< 1 [2] 3 4 … 10 >
有趣的是,今天幾乎沒有發明新的 Primitive。
只是把以前做過的:
Input
Field
Badge
Table
Pagination
Empty State
重新組合。
但這正是 UI Kit 開始有價值的時刻。
因為我們終於從:
「我做了一顆 Accessible Button。」
慢慢走到:
「我可以用這些元件組出一個 Accessible 的系統介面。」
而且組合之後,我們也開始發現新的 Accessibility 問題。
這些問題不是單獨看 Component 就能發現的。
現在 Data List 已經可以:
搜尋
顯示資料
顯示狀態
換頁
沒有搜尋結果
但是目前我們一直假設一件事:
資料一定成功拿得到。
現實當然沒這麼善良。
API 可能還在跑:
Loading...
資料可能真的一筆都沒有:
Empty
也可能直接:
500 Internal Server Error
所以接下來要處理完整的 Data State:
Data List
│
├── Loading
├── Success
├── Empty
├── No Results
└── Error
並加入:
Skeleton
Empty State
Alert / Error State
Retry
aria-live
aria-busy
Day 21:
資料列表不只有「有資料」:Loading、Empty、Error。