iT邦幫忙

2026 iThome 鐵人賽

DAY 10
0
自我挑戰組

30 天打造 Accessible UI Kit:從 shadcn/ui 到自己的 Design System系列 第 10 篇

Day10: Button v1 完成:Shape、Loading 與 Component API

  • 分享至 

  • xImage
  •  

Day 10|Button v1 完成:Shape、Loading 與 Component API

前幾天一路從 shadcn/ui 的 Button 開始拆解,加入 Design Token、Variant、Size、Icon 與無障礙規則。

今天終於要把這顆研究了好多天的 Button 收尾了 😂

這次主要補上兩個功能:

  • Shape:控制 Button 外型
  • Loading:處理送出、儲存等非同步操作狀態

並重新整理目前 CUI Button 的 Component API。


Shape 不應該和 Size 綁在一起

原本 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 不需要互相知道對方的存在。


把 Shape 放進 Component Contract

前面建立 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。


接著處理 Loading

實際系統裡,Button 很常遇到這種情況:

使用者按下「儲存」
        ↓
等待 API Response
        ↓
儲存完成

等待期間如果 Button 還能一直按,就可能重複送出 Request。

所以這次把 loading 正式加入 Button API:

<Button loading>
  儲存
</Button>

建立自己的 ButtonProps

原本 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。


Loading Spinner

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 時避免重複操作

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 不負責猜產品文案

這次有一個小決定:

<Button loading>
  儲存
</Button>

CUI 不會自動把文字改成:

儲存中...

而是保留:

◌ 儲存

如果產品真的希望顯示:

◌ 儲存中...

就明確寫:

<Button loading>
  儲存中...
</Button>

因為 Component 可以負責:

  • Spinner
  • Loading State
  • Interaction
  • Accessibility

但不應該自行猜測產品要使用什麼文案。


Loading + Icon 呢?

目前也測試了:

<Button loading>
  <Save aria-hidden="true" />
  儲存
</Button>

第一版會同時出現 Spinner、Icon 與文字。

如果要讓 Spinner 自動「取代」Save Icon,Button 就必須開始判斷 children 裡哪一個是 Icon,或者另外設計:

<Button
  startIcon={<Save />}
  loading
>
  儲存
</Button>

目前還沒有足夠需求證明需要增加這層 API,因此 v1 暫時維持簡單。

等真正遇到使用情境,再決定是否擴充,而不是先替還不存在的問題增加 abstraction。


Button v1 API

做到 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>

Day 10 Done

今天完成:

✓ 新增 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。


上一篇
Day9: Button 加上 Icon:圖示尺寸與 ARIA 誰負責?
下一篇
Day 11: Input:輸入框不只有一條框線
系列文
30 天打造 Accessible UI Kit:從 shadcn/ui 到自己的 Design System 共 20 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言