前幾天已經把 shadcn/ui 的 Button 拆開來看過,也建立了自己的 Design Tokens。
到了今天,終於可以開始動第一個真正的元件:Button。
這個 UI Kit 一開始就希望可以同時提供給兩種環境使用:
React + TypeScript
Legacy
HTML + CSS
開始改樣式以前,我想先決定一件更重要的事:
在這個 UI Kit 裡,一顆 Button 到底應該怎麼被描述?
const buttonVariants = cva(
"...",
{
variants: {
variant: {
default: "...",
outline: "...",
secondary: "...",
ghost: "...",
destructive: "...",
link: "...",
},
size: {
default: "...",
xs: "...",
sm: "...",
lg: "...",
icon: "...",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
會這樣組成一個button
<Button variant="outline" size="sm">
取消
</Button>
第一個想改的是:
variant="default"
它沒有錯,但開始建立自己的 Design System 後,我比較希望 API 本身就能表達設計語意。
例如:
<Button variant="primary">
儲存
</Button>
看到 primary,就知道它代表主要操作。
因此第一版先把 Button variant 定成:
primarysecondaryoutlineghostdestructivelink
size 也整理成比較一致的:
smmdlg
icon-smicon-mdicon-lg
使用時就可以寫:
<Button>儲存</Button>
<Button variant="secondary">
上一步
</Button>
<Button variant="outline" size="sm">
取消
</Button>
React 可以這樣描述一顆 Button:
<Button variant="primary" size="md">
儲存
</Button>
但原生html不會跑React
如果未來舊系統另外變成:
<button class="btn btn-primary btn-md">
儲存
</button>
我們其實又開始建立第二套規則了。
所以我希望 React 和 Legacy 至少先有一套共同的語言。
最後決定使用 data-cui-*:
<button
data-cui-slot="button"
data-cui-variant="primary"
data-cui-size="md"
>
儲存
</button>
它們分別表示:
於是 React 與 Legacy 雖然使用方式不同,描述的卻是同一件事
React API HTML Contract
<Button <button
variant="primary" data-cui-slot="button"
size="md" data-cui-variant="primary"
> data-cui-size="md"
function Button({
className,
variant = "primary",
size = "md",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
data-cui-slot="button"
data-cui-variant={variant}
data-cui-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
React寫這樣:
<Button variant="outline" size="lg">
取消
</Button>
實際產生的 DOM 就會包含:
<button
data-slot="button"
data-cui-slot="button"
data-cui-variant="outline"
data-cui-size="lg"
>
取消
</button>
data-slot 先保留給 shadcn/ui 元件內部的組合與 selector 使用data-cui-* 則是自己的 UI Kit 對外定義的 contract
Day5 做的 Design Tokens,現在也開始派上用場了。
primary:
"bg-primary text-primary-foreground hover:bg-primary/80"
其中 primary 最後會一路對應到自己的 semantic token:
Button
↓
bg-primary
↓
--primary
↓
--cui-color-primary
↓
Primitive Color
所以Button並不知道藍色到底是什麼藍 色號是什麼
它只知道:
我是 Primary Button,所以我要使用 Primary Color
這也就是前面把 Primitive Token 和 Semantic Token 分開的原因
Button 還有另一件很重要的事:狀態。
目前保留並測試:
hoveractivefocus-visibledisabled
例如 focus:
focus-visible:border-ring
focus-visible:ring-3
focus-visible:ring-ring/50
disabled:
disabled:pointer-events-none
disabled:opacity-50
active:
active:not-aria-[haspopup]:translate-y-px
這也是我覺得直接從成熟的 primitive 開始很方便的地方。
如果一開始自己寫:
<button className="bg-blue-600 text-white">
畫面很快就會有一顆漂亮的藍色按鈕。
但真正的 Button 還要考慮鍵盤操作、focus、disabled 等行為。
UI Kit 做的並不只是「元件看起來一致」,也希望這些互動規則可以一起被帶進每個系統
做到這裡有一個很明顯的問題。
既然已經定義:
<button
data-cui-slot="button"
data-cui-variant="primary"
data-cui-size="md"
>
是不是現在就可以寫:
[data-cui-slot="button"] {...}
[data-cui-variant="primary"] {...}
我這次刻意沒有做。
因為目前 React 的 Button Rules 在:
const buttonVariants = cva(...)
如果現在又人工維護:
[data-cui-variant="primary"] {...}
架構就會變成:
Button
/ \
/ \
CVA / Tailwind CSS
↓ ↓
React Legacy
也就是兩份 source of truth
Primary Button 改 padding,要改兩次
Focus 樣式調整,要改兩次
新增 variant,又要記得兩邊一起新增
這正是 Day 6 想避免的事情。
某種程度上,是 😂
目前完成的是:
Button Contract
│
┌───────────┴───────────┐
▼ ▼
React API Legacy HTML
│ │
variant="primary" data-cui-variant
size="md" data-cui-size
│ │
└───────────┬───────────┘
│
Shared Language
React 已經有實際 styling。
Legacy 則已經知道「怎麼描述同一顆 Button」,但還沒有真正取得 Button CSS。
接下來真正要解的問題會是:
Button Definition
│
Build
┌──────┴──────┐
▼ ▼
React CDN Assets
│ │
▼ ▼
React App HTML+CSS
也就是:
怎麼讓同一份 Button 規則,同時輸出給 React 和 Legacy 使用?
這個問題比另外複製一份 CSS 麻煩一點,但也正是這個 UI Kit 想解決的核心問題之一。
所以先不急著偷懶複製 CSS,留給後面的自己處理 😆
Day 7 最後,第一顆 CUI 元件已經有了自己的基本規則:
Button
Variants
├─ primary
├─ secondary
├─ outline
├─ ghost
├─ destructive
└─ link
Sizes
├─ sm
├─ md
├─ lg
├─ icon-sm
├─ icon-md
└─ icon-lg
Contract
├─ data-cui-slot
├─ data-cui-variant
└─ data-cui-size
States
├─ hover
├─ active
├─ focus-visible
└─ disabled
從畫面上看,今天可能只是多了幾顆 Button。
但從架構上來看,這是第一次把前面幾天的東西真正串起來:
Design Tokens
↓
Component Rules
↓
Component Contract
↓
React Button
↓
未來的 CDN Output
所以今天做的其實不是「把 shadcn/ui Button 換成自己的顏色」。
而是開始回答:
一個元件要怎麼成為這套 Design System 的元件?
第一顆 Button 完成!
接下來,就可以繼續看看怎麼讓這些規則不只活在 React 裡