Day 2 建好了 Vite + React + TypeScript 專案,Day 3 也了解了 shadcn/ui 和一般 Component Library 的不同
講了三天 UI Kit,目前的 Component 數量:
0...
所以今天終於可以加入第一顆 Component 了 XDD
目前的專案只是單純透過 Vite 建立的 React + TypeScript Project,因此先執行:npx shadcn@latest init
結果第一次就失敗了,LI 顯示兩個問題:
× Validating Tailwind CSS. Found v4.
× Validating import alias.
shadcn/ui 還需要一些前置設定,包括 Tailwind CSS,以及 @/* 的 import alias。
這也讓 Day 3 提到的事情變得比較具體:shadcn/ui 並不是安裝一個 package,接著從 package 裡 import component 就結束了。
它會把 Component Code 加入自己的 Project,因此也需要知道 Project 的 Style、路徑等設定。
完成 Tailwind CSS 與 alias 設定後,再次執行:
npx shadcn@latest init
這次就順利通過檢查了。
目前這個 UI Kit 選擇:
Component Library:Base UI
Preset:Nova
這些只是目前 Component 的起點,之後 UI Kit 還會建立自己的 Design Tokens 與 Component Rules,所以暫時不急著修改它的外觀。
完成後CLI呈現:
npx shadcn@latest add button
Project 裡直接出現了:
src/
└─ components/
└─ ui/
└─ button.tsx
也就是 Day 3 提到的 Open Code。
Button 的 Source Code 現在真的在自己的 Project 裡,可以直接打開、閱讀,甚至修改。
接著把它放進 App.tsx:
import { Button } from "@/components/ui/button"
function App() {
return (
<main>
<Button>Default</Button>
<Button variant="outline">Outline</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="destructive">Delete</Button>
<Button disabled>Disabled</Button>
</main>
)
}
export default App
第一顆 Component 正式出現!!
暫時把 Style 縮起來,整個 Button 的結構其實沒有想像中複雜:
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
const buttonVariants = cva(
// styles...
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
大致可以拆成:
Button
│
┌─────────────┼─────────────┐
▼ ▼ ▼
ButtonPrimitive buttonVariants cn()
│ │ │
Base UI CVA Class 合併
│ │
Behavior / Variant
Semantics Size / Style
原本看起來很大一顆的 Button,其實是由幾個不同的角色組合起來的
首先是:
import { Button as ButtonPrimitive } from "@base-ui/react/button"
這顆 Button 並不是直接從:
開始建立,而是使用 Base UI 提供的 Button Primitive。
這也對應到初始化 shadcn/ui 時選擇的:
Component Library:Base UI
所以目前可以先簡化理解成:
Base UI Button
↓
shadcn/ui 加上 Style 與 Component API
↓
目前 Project 裡的 Button
Base UI 這一層比較接近 Button 的 Behavior 與 Semantics 基礎,而 shadcn/ui 再往上加入 Style、Variant 等設定。
這也是為什麼在 App.tsx 使用 Button 時,不需要知道底下到底用了 Base UI 還是其他實作:
<Button variant="outline">
取消
</Button>
使用 Component 只需要面對 Button 提供的 API。
接下來是整份檔案裡最大的一塊:
const buttonVariants = cva(
"...",
{
variants: {
variant: {
default: "...",
outline: "...",
secondary: "...",
ghost: "...",
destructive: "...",
link: "...",
},
size: {
default: "...",
xs: "...",
sm: "...",
lg: "...",
icon: "...",
"icon-xs": "...",
"icon-sm": "...",
"icon-lg": "...",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
這裡使用的是 CVA(Class Variance Authority)。
如果把 Class 全部拿掉,只看結構,就很好理解:
buttonVariants
│
├─ Base Style
│
├─ variant
│ ├─ default
│ ├─ outline
│ ├─ secondary
│ ├─ ghost
│ ├─ destructive
│ └─ link
│
├─ size
│ ├─ default
│ ├─ xs
│ ├─ sm
│ ├─ lg
│ └─ icon...
│
└─ defaultVariants
因此當使用:
可以簡單理解成 CVA 幫忙組合:
Button 共用 Style
+
outline Style
+
sm Style
這裡也開始出現一個 UI Kit 很重要的概念。
variant 和 size 已經不只是 CSS。
它們開始成為 Component API
使用 Button 的人不需要自己記得 Outline Button 要加哪些 Border、Background、Hover Class,只需要使用:
variant="outline"
就可以了。
TypeScript 也參與了 Component API
Button 的 Props 定義也很有意思:
ButtonPrimitive.Props &
VariantProps
它其實來自兩個地方:
ButtonPrimitive.Props
+
VariantProps
↓
Button Props
Base UI 提供 Button 本身需要的 Props,而 CVA 定義的 variant、size 也會成為 Component Props 的一部分。
因此 TypeScript 可以知道:
<Button variant="outline" />
是合法的,但亂寫:
<Button variant="banana" />
就不在定義好的 Variant 裡。
這也是 UI Kit 使用 TypeScript 很實際的一個地方:Component 的使用規則,本身就可以成為型別的一部分。
Button 不需要知道 primary 最後究竟是哪一個 Hex Color,只需要知道:
我現在是 Primary Button,所以使用 Primary。
這其實已經碰到 Design Tokens 的概念了。
不過這裡先忍住,不急著開始改 Button 的顏色。
因為如果 Button、Input、Select、Dialog 都需要共用同一套視覺規則,比起每顆 Component 各自修改,更應該先建立整套 UI Kit 共用的 Tokens。
這就是 Day 5 要處理的事情。
既然這套 UI Kit 的其中一個目標是 Accessibility,那麼加入 Component 後,除了看它的 Code 和外觀,也希望養成一個習慣:
實際操作看看。
先替 Button 加上一個簡單的事件,確認它有沒有真的被觸發:
<Button onClick={() => console.log("Button clicked!")}>
Default
接著把手從滑鼠移開,使用鍵盤測試。
測試 Enter 時,還發現一個有趣的小地方。
使用滑鼠按下 Button 時,可以看到 Button 有一個很輕微的向下位移效果;但使用 Enter 觸發時,雖然事件確實有被觸發,卻沒有看到一樣的動畫。
回頭找 Button Style,可以看到:
active:not-aria-[haspopup]:translate-y-px
這個位移是和 CSS 的 :active State 有關。
也因此,Button 有沒有成功被 Keyboard 觸發,和滑鼠、鍵盤是否呈現完全相同的按壓視覺效果,是兩件不同的事情。
Accessibility Check 真正要確認的是:沒有滑鼠的情況下,使用者是否仍然能夠操作這顆 Button。
這次也第一次很明顯地看到:
Focus State ≠ Active State ≠ Disabled State
一顆看似簡單的 Button,其實有不少不同狀態。
Component 本身有良好的 Accessibility 基礎,不代表任何使用方式都會自動變成 Accessible。
如何使用 Component,一樣是 UI Kit 需要定義的規則。
一顆 Button,其實已經很像一個小型 UI Kit
原本以為今天只是:
npx shadcn@latest add button
結果打開 Source Code 後,一顆 Button 裡已經看到了:
Button
│
├─ Component Base
├─ Props / Type
├─ Variant
├─ Size
├─ Component API
├─ Tailwind CSS
├─ Design Tokens
├─ State
└─ Accessibility
Source Code 已經在 Project 裡了,接下來當然可以直接修改。
但現在反而不急著動 Button。
如果每做一顆 Component,就各自決定自己的顏色、圓角、間距與 Focus Style,很快又會回到一開始想解決的問題:
每個地方都有自己的規則。
所以在正式把 shadcn/ui Button 變成「自己的 Button」之前,下一步先處理所有 Components 都會共用的東西。
Day 5: 在做元件之前,先建立自己的 Design Tokens
iThome鐵人賽