做到 Day 21,CUI 已經累積不少東西了。
從最開始的一顆 Button,到現在已經有:
Button
Input
Field
Textarea
Checkbox
Radio
Switch
Select
Badge
Alert
Empty State
Table
Pagination
Skeleton
甚至開始出現由多個元件組成的 Pattern:
Form Pattern
Data List Pattern
Data State
做到這裡,我開始遇到一個跟 Accessibility 沒有直接關係,卻遲早一定會發生的問題:
元件越多,API 越容易開始不一致。
例如:
<Button size="md" />
<Switch size="default" />
咦?
一個叫:
md
另一個叫:
default
再看看狀態:
<Input aria-invalid="true" />
<Field data-invalid="true" />
<Button disabled />
又或者:
<Badge variant="success" />
<Alert variant="destructive" />
這些單獨看都沒有錯。
但當元件越來越多,就會開始出現:
為什麼這顆叫 md?
為什麼那顆叫 default?
error 和 destructive 是同一件事嗎?
invalid 要用 aria-invalid 還是 data-invalid?
data-slot 和 data-cui-slot 又有什麼差別?
如果現在不整理,再過十天,我大概會開始翻自己以前的程式碼:
「等等,我之前到底怎麼命名的?」🤣
所以今天先不做新元件。
來整理 CUI 到目前為止最重要的一件事:
Component Contract
以前我會把 UI Component 想成:
Component
=
HTML
+
CSS
+
JavaScript
但做到現在,我覺得還少了一層:
Component
=
Structure
+
API
+
State
+
Semantics
+
Styling Contract
例如一顆 Button:
<Button
variant="primary"
size="md"
disabled
>
儲存
</Button>
這裡其實已經包含很多規則:
Component
Button
Variant
primary
Size
md
State
disabled
Semantic
<button disabled>
Style Hook
data-cui-slot="button"
data-cui-variant="primary"
data-cui-size="md"
這些規則加起來,就是:
Button Contract
因為 CUI 一開始就不是只打算給 React 使用。
我們的架構是:
CUI Source
│
Design Tokens
│
Components / Rules
│
Build
┌──────┴──────┐
↓ ↓
React CDN Assets
CSS / JS
│ │
↓ ↓
New Systems Legacy Web
React 可以寫:
<Button
variant="primary"
size="md"
>
儲存
</Button>
但 Legacy 不可能寫 React Component。
它最後可能是:
<button
data-cui-slot="button"
data-cui-variant="primary"
data-cui-size="md"
>
儲存
</button>
所以真正跨平台共享的東西不是:
React Component
而是:
Component Contract
整理到目前為止,大致可以拆成:
CUI Component Contract
│
├── Semantic HTML
├── Component API
│ ├── variant
│ └── size
│
├── Native State
│
├── ARIA State
│
├── CUI Data Attributes
│
└── Design Tokens
今天就逐一整理。
第一個原則其實最簡單:
能使用原生 HTML,就先使用原生 HTML。
例如:
<Button />
最後應該是:
<button>
不是:
<div role="button">
Input:
<input>
Textarea:
<textarea>
Table:
<table>
<thead>
<tbody>
<tr>
<th>
<td>
Pagination:
<nav>
<ul>
<li>
<a>
這些原生元素本身就帶有:
Role
Keyboard Behavior
Browser Behavior
Accessibility Semantics
所以 CUI 的第一層 Contract 不是 ARIA。
而是:
HTML
例如 Table Header:
<TableHead scope="col">
姓名
</TableHead>
最後:
<th scope="col">
姓名
</th>
我們不會寫:
<div role="columnheader">
除非真的有特殊需求。
同樣 Pagination:
<nav aria-label="申請紀錄分頁">
這裡:
nav
負責:
這是一個 Navigation Landmark
而:
aria-label
負責:
這是哪一個 Navigation
所以:
Native HTML
+
ARIA when needed
而不是:
ARIA everywhere
接著是目前最常出現的 API:
variant
例如:
<Button variant="primary" />
<Button variant="secondary" />
<Button variant="outline" />
<Button variant="ghost" />
<Button variant="destructive" />
<Button variant="link" />
Badge:
<Badge variant="success" />
<Badge variant="warning" />
<Badge variant="destructive" />
Alert:
<Alert variant="destructive" />
這裡需要先定義:
Variant 是「視覺與用途的變化」,不是 State。
例如:
primary
secondary
outline
ghost
destructive
可以是 Variant。
但:
disabled
focused
invalid
checked
不是 Variant。
例如不要設計:
<Button variant="disabled">
而應該:
<Button disabled>
也不要:
<Input variant="invalid" />
而是:
<Input aria-invalid="true" />
因為:
Variant
→ 元件長什麼樣 / 用途
State
→ 元件現在處於什麼狀態
兩者概念不同。
整理成:
variant
├── primary
├── secondary
├── outline
├── ghost
└── destructive
state
├── disabled
├── focus
├── invalid
├── checked
└── selected
destructive、error、danger 到底選哪個?這也是 Design System 很容易混亂的地方。
例如:
danger
error
destructive
negative
critical
其實大家都看過。
目前 CUI 已經沿用 shadcn 的:
destructive
我暫時不打算為了「統一感」全部改掉。
因為它描述的是:
這個 Action / Style 帶有破壞性或危險性。
例如:
<Button variant="destructive">
刪除帳號
</Button>
而:
error
比較像 Semantic Status。
例如 Design Token:
--cui-color-error
--cui-color-error-container
所以目前可以理解成:
destructive
→ Component Variant
error
→ Semantic Color / Status
兩者相關,但不完全是同一層。
這次盤點時,我發現 Size 是目前比較容易長歪的地方。
例如 Button 已經使用:
sm
md
lg
icon-sm
icon-md
icon-lg
但有些 shadcn Component 原本可能使用:
sm
default
單獨看沒有問題。
但 CUI 如果要建立自己的 Contract,我比較希望使用一致的尺度:
sm
md
lg
而不是:
small
medium
large
也不是:
sm
default
lg
因此 CUI 的基本 Size Vocabulary 暫定:
sm
md
lg
需要 Icon-only 尺寸時:
icon-sm
icon-md
icon-lg
統一 API 不代表:
每顆元件都硬塞 sm / md / lg。
例如 Table:
<Table size="md">
目前根本沒有必要。
Badge 也不一定需要:
<Badge size="lg">
所以原則是:
需要 Size
→ 使用共同 Vocabulary
不需要 Size
→ 不提供 Size API
這比為了形式一致,把所有 Component 都塞滿 Props 更重要。
有些 State HTML 本來就知道。
例如:
<button disabled>
<input disabled>
<input type="checkbox" checked>
這些應該優先保留 Native State。
CSS 也可以直接:
:disabled
:checked
:focus-visible
而不是另外發明:
data-cui-disabled="true"
如果原生 HTML 已經知道:
它是 Disabled。
我們沒有必要再告訴它第二次。
例如:
:hover
:active
:focus-visible
這些本來就是 CSS State。
所以不需要:
data-cui-state="focus"
讓 JavaScript 去同步:
focus
blur
mouseenter
mouseleave
這反而增加出錯機率。
因此:
Native / CSS 能表達
→ 使用 Native / CSS
有些狀態不是純 CSS,也不是單純視覺。
例如:
invalid
expanded
current
busy
這時 ARIA 本身就是 Contract 的一部分。
我們目前已經用到:
aria-invalid="true"
aria-current="page"
aria-busy="true"
未來還可能碰到:
aria-expanded="true"
這些不只是 Styling Hook。
它們本身有 Accessibility Semantics。
例如已經有:
aria-invalid="true"
CSS 可以:
[aria-invalid="true"] {
...
}
就不一定需要再加:
data-cui-invalid="true"
否則可能發生:
aria-invalid="false"
data-cui-invalid="true"
到底誰是真的?🤣
所以:
ARIA 已經能表達
→ 優先直接使用 ARIA
data-invalid 又是什麼?前面的 Field 我們曾經使用:
<Field data-invalid="true">
同時 Input:
<Input aria-invalid="true" />
這不是重複嗎?
其實用途不同。
Input:
aria-invalid
→ 告訴 Assistive Technology:
這個 Control 的值無效
Field:
data-invalid
→ Parent Styling Hook
例如:
Field
├── Label
├── Input ← aria-invalid
├── Description
└── Error
Parent Field 本身不是 Form Control。
所以:
<Field data-invalid={hasError}>
可以用來控制整組:
Label Color
Error Layout
Spacing
因此這個可以保留。
data-slot目前 shadcn 產生的 Component 很常有:
data-slot="button"
或:
data-slot="table-row"
這是 shadcn Component 內部結構的一部分。
而 CUI 又加入:
data-cui-slot="button"
乍看之下:
這不是一模一樣嗎?
目前確實很像。
但兩者責任不同。
data-slot 和 data-cui-slot我目前把它們分成:
data-slot
→ shadcn / React implementation
data-cui-slot
→ CUI public contract
例如:
<button
data-slot="button"
data-cui-slot="button"
>
未來 React:
<button
data-slot="button"
data-cui-slot="button"
>
Legacy:
<button
data-cui-slot="button"
>
Legacy 根本不需要知道:
shadcn
Base UI
React
它只需要知道:
CUI
data-slot 改掉?因為 shadcn 內部可能存在:
[data-slot="..."]
或 Tailwind Selector:
data-[slot=...]
直接全部替換有可能破壞原本 Component 行為。
所以目前比較安全:
data-slot="..."
data-cui-slot="..."
兩個並存。
CUI 不需要為了「看起來比較乾淨」去破壞 upstream implementation。
data-cui-variant如果 Variant 是 CUI Public Contract,那 Legacy 也需要知道。
React:
<Button variant="primary">
輸出的 DOM 可以帶:
<button
data-cui-slot="button"
data-cui-variant="primary"
>
Legacy:
<button
data-cui-slot="button"
data-cui-variant="primary"
>
這樣未來 CDN CSS 才能:
[data-cui-slot="button"]
[data-cui-variant="primary"] {
...
}
React API 和 Legacy HTML 就有共同語言。
data-cui-sizeSize 也是相同概念。
React:
<Button size="md">
DOM:
<button
data-cui-slot="button"
data-cui-size="md"
>
Legacy:
<button
data-cui-slot="button"
data-cui-size="md"
>
因此目前 CUI 的核心 Public Data Attributes 可以整理成:
data-cui-slot
data-cui-variant
data-cui-size
不是所有 Component 都需要三個。
但如果有:
Variant
Size
就使用這套命名。
data-cui-everything做到這裡很容易開始興奮:
data-cui-slot
data-cui-size
data-cui-variant
data-cui-disabled
data-cui-focused
data-cui-hovered
data-cui-invalid
data-cui-checked
data-cui-current
data-cui-expanded
然後 DOM 長成聖誕樹 🎄
其實不需要。
優先順序應該是:
1. Native HTML
2. ARIA
3. CSS pseudo-class
4. CUI Data Attribute
只有前三者不能清楚表達 CUI Public Contract 時,再建立自己的 Data Attribute。
前面 Day 6 我們建立:
--cui-color-primary
--cui-color-on-primary
--cui-color-surface
--cui-color-on-surface
--cui-color-error
--cui-color-success
--cui-color-warning
這些其實跟:
data-cui-slot
一樣重要。
因為未來不論 React 還是 CDN:
Component
↓
Semantic Token
↓
Primitive Token
例如:
Button Primary
↓
--cui-color-primary
↓
Blue 700
Component 不應該自己知道:
#1d4ed8
它應該知道:
primary
這次整理還有一件很重要的事情:
Contract 是為了建立一致性,不是讓系統不能改。
例如目前:
Button Size
sm / md / lg
未來發現:
xs
真的有需求,還是可以加入。
或者未來:
Badge Variant
需要新增:
info
也不是不能做。
Contract 的意思比較像:
新增以前
先確認既有語言能不能表達
而不是每做一顆元件就發明新的名字。
所以今天沒有:
npx shadcn add ...
而是打開:
src/components/ui/
把目前元件快速盤點一次。
可以整理成:
| Component | Variant | Size | State | CUI Slot |
|---|---|---|---|---|
| Button | ✓ | ✓ | disabled | ✓ |
| Input | — | — | disabled / invalid | ✓ |
| Textarea | — | — | disabled / invalid | ✓ |
| Checkbox | — | — | checked / disabled | ✓ |
| Radio | — | — | checked / disabled | ✓ |
| Switch | — | ✓ | checked / disabled | ✓ |
| Select | — | — | expanded / disabled / invalid | ✓ |
| Badge | ✓ | — | — | ✓ |
| Alert | ✓ | — | — | ✓ |
| Table | — | — | — | ✓ |
| Pagination | — | — | current | ✓ |
| Skeleton | — | — | — | ✓ |
這張表不是 API Specification 的最終版本。
只是第一次:
把我們已經做的東西攤開來看。
不用。
這點我覺得很重要。
例如我們發現:
Button
size="md"
Switch
size="default"
今天可以先記錄:
Switch size naming should be normalized.
再判斷修改會影響哪些地方。
Design System Governance 不等於:
看到不一致就全域 Search & Replace。
尤其 CUI 還建立在:
shadcn
Base UI
Tailwind
之上。
我們要知道哪些是:
Upstream Implementation
哪些才是:
CUI Public API
整理到今天,可以先得到第一版:
CUI Component Contract v0.1
Prefer native HTML semantics.
Use ARIA only when needed.
variant describes visual / functional variation.
State is not variant.
Prefer:
sm
md
lg
Only provide size when the component needs it.
Prefer:
Native HTML
ARIA
CSS pseudo-class
before custom data attributes.
data-cui-slot
data-cui-variant
data-cui-size
Use semantic Design Tokens.
Avoid hard-coded component colors.
整理完 Contract 後,目前架構可以更精確地寫成:
CUI
│
┌────────────┴────────────┐
│ │
Design Tokens Component Contract
│
┌─────────────┴─────────────┐
│ │
React Legacy
│ │
React Components HTML + CDN
│ │
Base UI / shadcn Vanilla JS
共同的是:
Semantics
Tokens
Naming
data-cui-*
Accessibility Rules
不同的是:
Implementation
Behavior Adapter
Rendering Environment
這比:
React 做一份
Legacy 再做一份
好維護很多。
CUI 最後還有一個目標:
AI Skill
這時 Contract 就更重要。
如果未來 AI 問:
我要建立一個主要操作按鈕
Skill 可以明確知道:
<Button
variant="primary"
size="md"
>
而不是 AI 自己猜:
<Button type="main" />
或:
<Button color="blue" />
甚至:
<button className="bg-blue-700 ...">
Contract 越清楚:
Human
AI
React
Legacy
使用同一套 Design System 的機率就越高。
做 Accessibility 最怕的一件事是:
每個開發者都知道一點點,但每個人的做法都不同。
有人:
aria-label
有人:
title
有人:
role
有人直接:
<div onclick>
最後每個頁面都「好像有做 Accessibility」,但行為完全不同。
Design System 的價值之一,就是把這些決策收斂成:
Button 怎麼做
Field 怎麼做
Error 怎麼做
Pagination 怎麼做
Loading 怎麼做
讓使用者在不同系統裡得到比較一致的體驗。
今天 Git Diff 可能沒有前幾天精彩。
沒有:
+ 200 lines
+ New Component
+ Cool UI
甚至可能只改:
一些 naming
一些 data-cui attributes
一些 documentation
但 Design System 開發不能永遠只做:
Add Component
Add Component
Add Component
Add Component
做到一定程度就需要:
Audit
Normalize
Document
Govern
不然最後得到的只是:
一個很大的 components 資料夾。
而不是 Design System。
今天我們第一次正式整理 CUI 的 Component Contract:
Semantic HTML
↓
Component API
↓
Variant / Size
↓
Native & ARIA State
↓
data-cui-* Public Contract
↓
Design Tokens
目前最重要的原則可以濃縮成:
HTML 能表達
→ 不重新發明
ARIA 能表達
→ 不複製 State
CSS 能處理
→ 不加 JavaScript
CUI 真的需要跨平台共享
→ 才建立 data-cui-* Contract
這樣未來不管是:
React
Legacy
CDN
AI Skill
大家都能理解同一套 CUI 語言。
而且做到今天,我才真的開始覺得:
CUI 不只是「我做了一些無障礙元件」。
它開始有自己的規則了。
整理完 Contract,明天終於可以繼續補元件了 😆
但這次不做 Table、Form 這種大型結構。
我們來補一些在真正系統裡很常出現,卻很容易被忽略的小元件:
Tooltip
Separator
Visually Hidden
尤其 Tooltip 很值得單獨看。
因為一個看起來只是:
Hover → 出現小泡泡
的東西,實際上馬上會遇到:
Keyboard 怎麼開?
Focus 時看得到嗎?
Esc 能不能關?
滑鼠移到 Tooltip 上會不會消失?
Touch Device 怎麼辦?
Tooltip 可以放重要資訊嗎?
Icon-only Button 又該怎麼命名?
小小一顆 Tooltip,Accessibility 問題意外地多。
Day 23:
Tooltip 不只是 Hover:補齊 UI Kit 裡的小型輔助元件