跳至主要内容
💬 回報這一頁

Story 撰寫慣例

每個元件至少三種 story

類型回答什麼一定要有嗎
典型用法這個元件正常長什麼樣必要
狀態並列它的每一種狀態長什麼樣、彼此差在哪必要(見下)
互動 playground換各種參數會怎樣建議
壓力測試塞極端值會不會壞壓力測試 Story

狀態要並列,不要分開

這是最重要的一條,也是最常被做反的一條。

一個元件有四種狀態時,直覺是寫四支 story。但分開之後每一種看起來都很正常—— 你沒有辦法發現「已改動」與「鎖定」在深色模式下的邊框只差一階亮度, 或是「空選項」的高度比其他三種矮了 4px,讓表單裡的一整列跳動。

export const 四態並列: Story = {
render: () => (
<div className="flex flex-col gap-3">
<Chips label="預設" … />
<Chips label="已改動未送出" … changed />
<Chips label="鎖定" … disabled lockHint="此筆已結案,需先解除鎖定" />
<Chips label="沒有選項" … options={[]} emptyHint="尚未建立任何選項" />
</div>
),
};
✅ 這樣做

把同一個元件的所有狀態放在一支 story 裡垂直排列,每一列標上狀態名。

🚫 不要這樣

不要一個狀態一支 story。它們單獨看都對,並排才看得出不對。

驗收時的用法:把這一支切到深色模式再看一次,然後灰階列印一次。 三種模式都能分辨四種狀態,才算過。

互動 playground:用中文 arg

Storybook 的 Controls 面板可以讓非工程師直接調參數。前提是參數名看得懂

export const 互動: StoryObj<{ 資料筆數: number; 每頁筆數: number; 斑馬紋: boolean }> = {
args: { 資料筆數: 42, 每頁筆數: 15, 斑馬紋: true },
argTypes: {
資料筆數: { control: { type: "range", min: 0, max: 200, step: 1 } },
每頁筆數: { control: "inline-radio", options: [5, 15, 30, 50] },
斑馬紋: { control: "boolean" },
},
render: (a) => (
<DataTable rows={makeRows(a.資料筆數)} pageSize={a.每頁筆數} zebra={a.斑馬紋}/>
),
};

三個部分:中文 args 型別 → argTypes 指定控制項 → render 映射回真實 props

為什麼值得多這一層映射

直接用 props 當 args中文 arg + render 映射
面板可讀性pageSize zebra crosshair每頁筆數、斑馬紋、十字對準
誰能用讀得懂 props 的人設計、驗收的人
能不能組合出真實情境只能一個個調可以做「資料筆數 = 0」這種跨 prop 的情境
代價多一層 render 映射(約 5 行)

實際效益是驗收:「把資料筆數拉到 0 看看空狀態」這句話,對方可以自己做, 不必回頭找工程師改程式碼。

✅ 這樣做

對外展示用的元件(DataTable、EditableField)都配一支中文 playground。

🚫 不要這樣

不要每支 story 都做。playground 是給「參數很多、組合很多」的元件用的; 一個只有兩種變體的元件,並列展示比 playground 有用。

陷阱:playground 裡需要 hook 時

render 是一個函式,不是元件——直接在裡面呼叫 useState 會違反 Hook 規則。 (有時候它「看起來能動」,然後在切換 args 時炸掉,症狀非常難解讀。)

正解是在 render 內宣告一個內部元件:

render: (a) => {
const Demo = () => {
const [v, setV] = useState("0");
return <TabPills tabs={makeTabs(a.分頁數)} value={v} onChange={setV} />;
};
return <Demo key={a.分頁數} />; // ← key 是關鍵
},

key 為什麼必要:改 args 時 React 會沿用同一個元件實例,內部 state 不會重置。 於是「分頁數」從 5 調到 2 之後,value 還停在已經不存在的第 4 個分頁上, 畫面看起來像 bug——但那是 story 的問題,不是元件的問題。 把會影響結構的 arg 放進 key,強制 remount。

更簡單的替代做法:用 render: function Render() { … } 具名函式元件。 兩種都可以,但同一個 repo 只選一種

命名慣例

場景命名
一般 story中文語意完整功能三種空狀態
互動 playground互動⟨元件⟩_互動互動按鈕_互動
狀態並列⟨N⟩態並列三態並列四態並列
元件名需要對照中文在前、英文在後資料表 DataTable
✅ 這樣做

一個 repo 一種慣例,寫在這一頁上。

🚫 不要這樣

不要混用 按鈕_互動互動_按鈕。側邊欄是按字母排序的, 兩種寫法會讓同類 story 散落在不同位置。

story 就是規格的證據

文件頁說「鎖定態仍然可以聚焦」,那就必須有一支 story 能讓人當場用 Tab 鍵驗證

✅ 這樣做

文件頁寫下的每一條行為規範,都要有一支 story 對得上。

🚫 不要這樣

不要寫「元件會處理這個情況」卻沒有任何 story 展示它。 沒有 story 的規範,三個月後沒有人知道它到底還成不成立。

這是 story 與文件的分工: 文件講「為什麼」,story 證明「真的是這樣」。

截圖驗證一定要比對期望值

用無頭瀏覽器對 story 截圖來檢查外觀是好方法,但只用肉眼看截圖會得出錯誤結論

本書實測過:對六組色相主題各截一張按鈕 story,其中幾張拍出來是別組主題的顏色, 看起來像「那組主題壞掉了」。實際上 token 完全正確——是截圖搶在 Storybook 把網址上的 globals 套進 <html> 之前就完成了。

加長等待時間解決不了。 無頭瀏覽器的 --virtual-time-budget 走的是虛擬時間: 它會快轉計時器,但不等真正的非同步工作。把 4000 拉到 12000 之後仍然有三張是錯的, 而且每次錯的不是同一張。

可靠的做法是驗到相符為止:截圖 → 掃描畫面找期望色 → 不符就重截。 實測有一張試到第 4 次才對。

✅ 這樣做

截圖之後取樣像素、與 token 的期望值逐一比對。 程式判定「相符」才算通過,不是人看起來覺得對。

🚫 不要這樣

不要只把截圖拿來目視,也不要靠「等久一點」。搶跑拍到的畫面看起來是 合理的——它不像壞掉的圖,只像另一個顏色。 這種假失敗會讓人去修沒有壞的東西。

🚫 不要這樣

不要用固定的取樣座標。版面一改(多一段說明、換一個視窗尺寸), 座標就落在別的東西上,於是十二組全部報錯——一個純粹由驗證方法 造成的假警報。改成掃描整張圖找期望色,位置無關。

同一個道理適用於任何「印出數字給人看」的驗收腳本:量測對象要是實際會被畫出來的 那個值。本書的色彩驗收腳本犯過兩次——一次拿沒轉過色相的 muted 去量聚焦環對比, 一次拿理想的純白去量 brand 的前景對比,兩次都印出低於門檻的假數字。 報告算錯對象比不印還糟,它會讓人去修沒壞的東西。

play function:有行為規範就要有行為驗證

story 並列的是長相,play function 驗的是行為——焦點去哪裡、鍵盤按了會怎樣、 aria 屬性有沒有連動。判準很簡單:文件頁寫了行為規範的元件,就要有一支 play function 把那條規範變成可執行的斷言。對話框寫了「Esc 關閉、焦點回到觸發鈕」,play 就按一次 Esc 驗一次;欄位錯誤寫了「aria-describedby 指向錯誤訊息」,play 就查那條連線。

寫法慣例(現有八支都照這個模式):

import { within, expect, userEvent, waitFor } from "@storybook/test";

export const 對話框: Story = {
render: () => (),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "作廢這筆" }));
// …斷言
},
};
✅ 這樣做

portal 元件(Dialog、Select、Toast、篩選面板)的斷言要查 canvasElement.ownerDocument.body——浮層掛在 body 直下, canvas 子樹裡找不到它。

✅ 這樣做

查詢一律走 getByRole 帶可及名稱。這讓 play 順便當了半個 無障礙測試:role 或名稱斷了,查詢就失敗。

🚫 不要這樣

不要在 play 結尾留下開著的浮層或改掉的狀態——後面的視覺掃描拍的是 play 跑完的畫面,殘留狀態會讓兩支守衛互相干擾。

兩道 Storybook 守衛(CI 自動跑)

守衛指令管什麼不管什麼
無障礙行為npm run verify:storybook全部 story 的 axe 掃描(role、名稱、巢狀互動…)+ play function 全數執行成功顏色對比(verify:color 是唯一顏色權威);region 等頁面級規則(story 是片段)
視覺回歸npm run verify:visual六主題 × 兩模式 × 哨兵 story:截圖掃全圖,驗「期望色存在+其他主題的 --brand 不存在」版面位移(沒有基準圖比對——跨平台字型假紅與基準維護成本,有三次證據再議)

兩支都吃建好的 Storybook 產物(本機 npm run build-storybook 後直接跑; CI 在建置步驟之後自動接)。上面截圖三坑的方法論——掃全圖不取座標、驗到相符為止、 容差 ±2——就是 verify:visual 的實作,從「寫在文件裡的教訓」變成了每次都跑的閘門。

combobox 有一條特別容易踩的規則,第一次全量掃描就抓到五處: 可及名稱不能取自值文字(值會變,名稱不會)。SelectTrigger 一定要用 <Label htmlFor>id,或給 aria-label——選了「黃金」的下拉,讀屏要念的是 「等級」,不是「黃金」。