這篇記什麼

替一個既有的 Vue 元件庫補了 14 個元件、修了 17 個缺陷之後,整理出來的設計判準。大部分是設計約定,不是可證偽的事實——有 code 或測試佐證的會直接講是哪個元件、哪條測試,其餘是選擇,記下來是為了讓後續元件保持一致。

一、清單基準跟實作範例可以分開

這個庫的 API 對齊 Bootstrap(colors.ts 的色值就是 Bootstrap 5 色票),視覺與實作參考 Flowbite(Tailwind 生態的元件庫)。兩者分工清楚:

  • Bootstrap 決定「有哪些元件、prop 怎麼命名」——它是最多人熟悉的一套詞彙
  • Flowbite 決定「Tailwind 底下這個元件長什麼樣、class 怎麼組」

但 Bootstrap 的清單有時代落差:它沒有 DatePicker、Avatar、Rating、Sidebar、Stepper、Timeline,而這些現在幾乎是必備。所以清單要兩邊交叉,不能只看一邊。

二、元件不替呼叫端做決定

Table 的排序只 emit("sort", key, order),自己不動 rows。理由是:一旦表格自己排,接了後端排序或分頁的人就會發現畫面跟資料對不上——表格只看得到當前這一頁,它排出來的順序是錯的。

同一個原則的其他應用:

  • ToastContainer 收到關閉時只送出 id,實際移除由持有清單的人做
  • Tabs、Select、Radio 都是受控的:目前選什麼由外面決定,元件只負責「送出使用者的意圖」

判準:元件只知道自己看得到的東西。凡是需要更大範圍的資訊才能做對的決定,就不要在元件裡做。

三、能用原生元素就用原生元素

Switch 外觀是滑動開關,骨子裡是 input[type=checkbox] 加 role="switch",視覺上用 sr-only 藏起來。這樣 Tab 聚焦、空白鍵切換、讀螢幕軟體讀成「開/關」全部是瀏覽器給的,不用自己重刻。

Select 用原生 <select>,鍵盤操作、行動裝置的原生選單、輔助軟體支援都免費。

DatePicker 也是同一個判斷:不自己刻日曆面板,用原生的日期輸入框,一個元件涵蓋 date/datetime-local/time/month/week 五種。鍵盤操作、行動裝置的原生選擇器、各地區的日期格式全部是瀏覽器給的。代價是外觀跨瀏覽器不一致、也沒辦法標記特定日期(例如「已額滿」)——需要那些的話才另外做有面板的版本。

反例在同一個 repo 裡:Accordion 的標題列原本是 div + @click,鍵盤 tab 不到、Enter/Space 沒反應;Navbar 的漢堡在 Login.vue 裡是 <Icon @click>,同樣的問題,而且 aria-expanded 沒地方掛。改成 button 之後這些全部不用自己寫。

注意 sr-only 跟 type="hidden" 的差別——後者會讓元素 tab 不到,等於白做。

原生標籤跟 ARIA 的分工(什麼時候原生就夠、什麼時候才真的需要 ARIA),另外整理在 ARIA 和原生語意:什麼時候該用哪個。

四、prop 要「跟著」,不能「抄一份」

這個 repo 裡同一個病灶出現四次,四種不同寫法:

寫法元件症狀
ref(props.validMessage)Input非同步驗證回來的訊息顯示不出來
if (newContent) 漏掉空字串Editor外部清空內容時清不掉
const { list } = props.sectionDataMarquee換資料畫面不動
ref(props.modelValue) 類寫法多處外部更新後不同步

判準:任何從 prop 取值的地方,第一選擇是 computed。用 ref 存 prop 的初始值,就是在賭「這個 prop 不會變」——而它遲早會變。

五、鍵盤行為是元件的一部分,不是加分項

Tabs 的方向鍵移動、跳過停用項、走到底繞回、Home/End 跳頭尾,寫了 6 條測試。這些不是額外功能——少了它,這個元件只有滑鼠使用者能用。

同理:Drawer 打開時要把焦點移進面板(否則鍵盤使用者按 Tab 還停在背景頁面)、Tooltip 要綁 focusin(否則只有滑鼠使用者看得到說明)、Toast 的關閉圖示要能用 Enter/Space 按下去。

六、播報要分輕重

Toast 的 danger/warning 用 role="alert" + aria-live="assertive" 立刻打斷,其餘用 status + polite 等使用者手邊的事告一段落。全部都 assertive 的話,讀螢幕的人會被吵到沒辦法做事。

Spinner 的文字就算不顯示也要留在 DOM 裡——只有一個轉圈的圖形,聽的人不知道發生什麼事。

Progress 在進度未知時不報 aria-valuenow——報一個假的數字比不報更糟。

七、邊界值在元件裡夾好

Progress 把 value 夾回 0 到 max 之間:算出 120% 的寬度會把版面撐爆,負數會播報出不存在的進度。Badge 的數字超過上限顯示成「99+」,不然三位數以上會把版面撐開。

判準:呼叫端傳進來的數字,元件要假設它可能超出合理範圍。

八、事件命名要有一套並且只有一套

這個 repo 修之前混了三種風格:update:modelValue(6 個元件)、update(1 個,缺屬性名)、dropdownSelect(1 個,camelCase 加冗餘前綴)。新增的 14 個元件一律用 update:modelValue,因為那是 Vue 的 v-model 慣例,也是既有的多數。

Alert 還有一個更舊的風格:closeEvent: () => {} 用 callback prop 而不是 emit。

後來三個都改了:dropdownSelect → select(事件名不需要重複元件名,Vue 的慣例是 @select)、update → update:modelValue(連同 externalContent prop 改成 modelValue,v-model 才能直接用)、closeEvent callback prop → close emit。

九、破壞性的命名要趁沒有呼叫端時改

上面那三個改動,我一開始判斷「改了會破壞既有呼叫端,屬於要一起排的破壞性變更」而擱置。後來查證發現:六個前端專案沒有任何一個引用這個庫——當時的套件設定甚至讓它根本引用不了(為什麼引用不了、package.json 要回答哪幾個問題,見元件庫能不能被引用,看的是 package.json)。

也就是說,改名的協調成本在那個當下是零。一旦有第一個專案接上去,同樣的改動就要跟對方排時間、對版本、改呼叫端。

判準:元件庫的 API 一致性清理,成本隨著呼叫端數量線性上升。零個呼叫端的時候是最便宜的,而那通常也是最容易被說「之後再說」的時候。

反過來也成立——如果已經有呼叫端,就不該為了「比較一致」去改名,那時候該做的是新舊並存加上遷移期。

待驗 / 未解

  • 三層結構(components / groups / layouts)的界線目前是憑感覺分的:components 是基礎元件、groups 是組合(Form、Register、Article)、layouts 是頁面骨架(Login)。但 groups 與 layouts 共 973 行完全沒有測試,這個分層是否站得住還沒驗證。
  • 元件該不該內建動畫:目前 Drawer、Toast 都沒有進出場動畫,靠呼叫端自己包 <Transition>。這是省事還是缺漏,沒有結論。