前幾天一路從 shadcn/ui 的 Button 開始拆解,加入 Design Token、Variant、Size、Icon 與無障礙規則。
今天終於要把這顆研究了好多天的 Button 收尾了 😂
這次主要補上兩個功能:
並重新整理目前 CUI Button 的 Component API。
原本 Button 的圓角直接寫在基礎樣式:
"rounded-lg"
但如果未來除了預設圓角,也希望支援直角或 Pill Button,就不適合把形狀寫死在 base style。
因此新增一個 shape variant:
shape: {
square: "rounded-none",
default: "rounded-lg",
pill: "rounded-full",
},
使用時:
<Button shape="square">
Square
</Button>
<Button shape="default">
Default
</Button>
<Button shape="pill">
Pill
</Button>
目前 CUI 提供三種 Shape:
| Shape | 樣式 |
|---|---|
square |
無圓角 |
default |
系統預設圓角 |
pill |
完全圓角 |
這裡刻意沒有另外建立 circle。
因為 Icon Button 本身已經是正方形:
<Button
size="icon-md"
shape="pill"
aria-label="新增"
>
<Plus aria-hidden="true" />
</Button>
icon-md 負責讓 Button 成為正方形,pill 再讓四個角完全變圓,自然就得到 Circle Button。
也就是:
size → 決定尺寸
shape → 決定外型
兩個 API 不需要互相知道對方的存在。
前面建立 Button 時,已經加入:
data-cui-slot="button"
data-cui-variant={variant}
data-cui-size={size}
現在再加入:
data-cui-shape={shape}
最後產生的 DOM 可以像:
<button
data-cui-slot="button"
data-cui-variant="primary"
data-cui-size="md"
data-cui-shape="pill"
>
送出
</button>
這些 data-cui-* 不只是方便 React Component 辨識狀態。
未來如果要讓同一套 CUI 支援 Legacy HTML / CDN,也能繼續沿用相同的 Component Contract。
實際系統裡,Button 很常遇到這種情況:
使用者按下「儲存」
↓
等待 API Response
↓
儲存完成
等待期間如果 Button 還能一直按,就可能重複送出 Request。
所以這次把 loading 正式加入 Button API:
<Button loading>
儲存
</Button>
原本 Button 的型別直接使用:
ButtonPrimitive.Props &
VariantProps<typeof buttonVariants>
但 loading 是 CUI 自己定義的 API,因此開始替 Button 建立自己的 Props:
type ButtonProps = ButtonPrimitive.Props &
VariantProps<typeof buttonVariants> & {
loading?: boolean
}
Button 就能接收:
function Button({
variant = "primary",
size = "md",
shape = "default",
loading = false,
...
}: ButtonProps) {
做到這裡,Button 已經不只是修改 shadcn/ui 提供的樣式,而是開始形成自己的 CUI Component API。
Icon Library 已經使用 Lucide,因此 Loading Spinner 也直接使用:
import { LoaderCircle } from "lucide-react"
Loading 時:
{loading && (
<LoaderCircle
aria-hidden="true"
className="animate-spin"
/>
)}
最後:
<Button loading>
儲存
</Button>
會呈現:
◌ 儲存
Spinner 本身只是 Loading 狀態的視覺呈現,因此使用:
aria-hidden="true"
避免它被當成額外內容朗讀。
另外,Day 9 已經讓 Button 根據 Size 控制內部 SVG 尺寸,因此 Spinner 不需要重新設定:
sm → 14px
md → 16px
lg → 20px
前面建立好的 Component Rule,在這裡直接被重用了。
Loading 通常代表目前的操作還沒完成,因此這段期間不應該再次觸發 Button。
Button 同時可能本來就有:
disabled
所以將兩種狀態合併:
const isDisabled = disabled || loading
再交給 Button:
<ButtonPrimitive
disabled={isDisabled}
>
因此:
disabled = true
→ 不可操作
loading = true
→ 不可操作
兩者都是 false
→ 正常操作
這也避免使用者在 API 還沒回應以前連續送出相同操作。
aria-busy 表達處理中的狀態除了畫面上的 Spinner,也加入:
aria-busy={loading || undefined}
Loading 時產生:
<button
aria-busy="true"
disabled
>
儲存
</button>
同時延續 CUI 的 Component Contract:
data-cui-loading={loading || undefined}
所以 Loading Button 會具有:
data-cui-loading="true"
未來不論是 Styling、Testing 或 Legacy implementation,都能辨識這個狀態。
這次有一個小決定:
<Button loading>
儲存
</Button>
CUI 不會自動把文字改成:
儲存中...
而是保留:
◌ 儲存
如果產品真的希望顯示:
◌ 儲存中...
就明確寫:
<Button loading>
儲存中...
</Button>
因為 Component 可以負責:
但不應該自行猜測產品要使用什麼文案。
目前也測試了:
<Button loading>
<Save aria-hidden="true" />
儲存
</Button>
第一版會同時出現 Spinner、Icon 與文字。
如果要讓 Spinner 自動「取代」Save Icon,Button 就必須開始判斷 children 裡哪一個是 Icon,或者另外設計:
<Button
startIcon={<Save />}
loading
>
儲存
</Button>
目前還沒有足夠需求證明需要增加這層 API,因此 v1 暫時維持簡單。
等真正遇到使用情境,再決定是否擴充,而不是先替還不存在的問題增加 abstraction。
做到 Day 10,目前的 Button 已經大致形成:
Button
│
├─ variant
│ ├─ primary
│ ├─ secondary
│ ├─ outline
│ ├─ ghost
│ ├─ destructive
│ └─ link
│
├─ size
│ ├─ sm
│ ├─ md
│ ├─ lg
│ ├─ icon-sm
│ ├─ icon-md
│ └─ icon-lg
│
├─ shape
│ ├─ square
│ ├─ default
│ └─ pill
│
├─ content
│ ├─ text
│ ├─ icon + text
│ └─ icon-only
│
└─ state
├─ default
├─ hover
├─ focus
├─ active
├─ disabled
└─ loading
使用上也可以自由組合:
<Button
variant="primary"
size="lg"
shape="pill"
loading
>
儲存
</Button>
今天完成:
✓ 新增 square / default / pill
✓ Size 與 Shape 各自負責不同設計決策
✓ 加入 data-cui-shape
✓ 建立正式 ButtonProps
✓ 新增 loading API
✓ Loading Spinner
✓ Loading 時禁止重複操作
✓ 加入 aria-busy
✓ 加入 data-cui-loading
✓ Spinner 延續 Button 的 Icon Size 規則
✓ Component 不自行猜測 Loading 文案
從 Day 4 第一次拆開 shadcn/ui Button,到今天為止,終於把第一顆 CUI Component 的基本規則整理完成。
過程中做的不只是「把按鈕畫漂亮」,而是不斷在決定:
哪些事情應該由 Design System 負責?哪些事情應該留給使用 Component 的人決定?
而這大概也是開始做自己的 UI Kit 後,最常遇到的問題。
Button 可以先畢業了 🎓
Day 11 終於換下一位:Input。