DataTable 資料表
後台系統的主力元件。 一份欄位設定,換來搜尋、篩選、排序、分頁、合計、凍結首欄與匯出。
Storybook:完整功能 互動 playground——可調資料筆數與模式為什麼要收斂成一個元件
因為表格是最容易被各畫面各自手刻的東西。手刻三張表之後,你會得到三種搜尋框位置、 三種排序箭頭、三種「查無資料」文案,而且改一次樣式要找三個地方。
凡是「使用者要在裡面找某一筆」的清單,一律用 DataTable。
不要為了少寫幾行而退回手刻 <table>。
第一張表確實比較快,第三張表開始還債。
| R-2406 | 戊單位 | 戊案 整併 | 12 | $268,800 | 已確認 |
| R-2403 | 丙單位 | 丙案 初版 | 6 | $213,000 | 已確認 |
| R-2402 | 乙單位 | 乙案 追加規格 這一筆刻意加長,用來示範欄位截斷與可調欄寬 | 40 | $156,400 | 已確認 |
| R-2407 | 乙單位 | 乙案 附屬項目 | 300 | $91,200 | 已作廢 |
| R-2401 | 甲單位 | 甲案 第一階段 | 120 | $84,000 | 已完成 |
| 合計 | 5,558 | $893,700 |
外觀不變量:只有列底線
與 Table 相同:表格本體沒有外框、沒有直向格線、
儲存格沒有邊框,唯一的線是列與列之間 --border 的 1px 底線。
篩選面板是 popover 底、--border 1px 框——它 portal 到 body 直下
(避免被水平捲動裁掉),所以宿主的樣式基座必須涵蓋 portal 內容,不能只鋪在
範例容器裡。本站的活範例由渲染守衛 npm run verify:book 驗收,機制見 ADR-0010。
欄位設定就是全部的 API
const columns: Column<Order>[] = [
{
key: "amount",
header: "金額",
numeric: true, // 右對齊 + 等寬數字
cell: (r) => formatMoney(r.amount), // 顯示
sortValue: (r) => r.amount, // 有這個才可排序
filterText: (r) => String(r.amount), // 參與關鍵字搜尋
filter: "range", // 單欄篩選型態(可自動判定)
total: (rows) => formatMoney(sum(rows)),// 合計
freeze: true, // 凍結首欄
truncate: 180, // 超寬截斷 + 提示泡泡
width: 140, // 初始欄寬(使用者仍可拖曳調整)
headerClassName: "text-right", // 只作用在表頭那一格
},
];
三個刻意的決定
1. 門檻式分頁
資料筆數沒超過一頁就不顯示分頁器。永遠顯示「第 1 頁 / 共 1 頁」只是噪音。
2. 合計算「篩選後全部」,不是當頁
使用者篩完就是要那個總數。只加總當頁是經典的對帳災難—— 而且錯得很安靜,通常要等到有人拿去跟別的系統對才會發現。
3. 匯出跟著畫面
匯出的是他現在看到的(篩選+排序後),不是原始全量。 「我明明篩好了,匯出卻是全部」是使用者最常見的困惑之一。
每頁筆數為什麼是 5 / 15 / 30 / 50
四個檔位不是隨手挑的,各自對應一種真實用法:
| 檔位 | 用在什麼時候 |
|---|---|
| 5 | 嵌在卡片或對話框裡的小表 —— 表格不是這個畫面的主角 |
| 15(預設) | 一般筆電螢幕一頁看得完,不必捲。日常作業的預設 |
| 30 | 要在兩批之間來回比對,翻頁比捲動更煩的時候 |
| 50 | 掃視模式:使用者要用眼睛掃過一整片找異常,不是找特定一筆 |
四個檔位全站一致。使用者換一個畫面不必重新學。
不要加「全部」。一萬筆全渲染會讓瀏覽器停住數秒, 而使用者按下去之前完全不知道會發生什麼。真的要全部——那是匯出 CSV 的工作。
單欄篩選的三種型態
| 型態 | 互動 | 自動判定條件 |
|---|---|---|
text | 可加多筆關鍵字,符合任一即列出(同欄 OR、跨欄 AND) | 有 filterText/sortValue |
range | 最小~最大 | 數字欄且可排序 |
select | 從該欄不重複值多選 | 需明確指定 |
文字篩選面板會依輸入推薦最相似的 3 個既有值—— 使用者常常記得「大概長怎樣」但打不出完整字串,尤其中文品名與代碼混排時。
篩選條件可以逐條移除
每個條件都是獨立標籤,能個別拿掉。只提供「一次全清」會逼使用者從頭再篩一次。
載入:兩種長相,元件自己分
loading 一個 prop,長相由「有沒有資料」決定(規範見〈載入中〉):
| 情境 | 長相 |
|---|---|
loading 且還沒有資料(首載) | 骨架列:表頭是真的、列是灰塊,列數=每頁筆數(上限 15——骨架超過一屏沒有意義),版面不跳動 |
loading 且已有資料(重查、換篩選) | 就地變暗+鎖互動+aria-busy——舊資料仍可讀,不蓋骨架(每查一次閃一次) |
讀屏出口由容器宣告一次(role="status" 的「載入中…」,文案走 labels 覆寫),
骨架塊本身 aria-hidden。
空狀態分兩種
| 情境 | 文案 |
|---|---|
| 沒有資料 | 用 empty prop 自訂,通常帶「建立第一筆」的行動 |
| 篩選後沒有結果 | 元件自動切換成「查無符合的資料/請調整關鍵字或欄位篩選」 |
還沒有任何資料
建立第一筆後,這裡會顯示明細與合計。
十字對準
滑鼠指到哪一格,就 highlight 整行整列。20 欄的表格靠這個才不會看錯行。
實作細節:highlight 用背景漸層圖層而不是 background-color——
漸層疊在儲存格既有底色之上,不會蓋掉凍結首欄與黏性表頭的不透明底
(否則被指到的黏性格會透出後面捲過去的欄位,看起來像凍結失效)。
欄寬可調與雙擊自適應
拖曳欄位右邊界可調寬,雙擊該邊界則自動貼合內容。
為什麼兩個都要有:拖曳解決「我想看清楚這一欄」,雙擊解決「我不想手動喬」。 只有拖曳的話,使用者調過頭之後沒有回頭路,只能重新整理頁面。
調整後的欄寬只活在當次瀏覽。使用者為了看一眼而拉寬某欄, 不代表他希望這個版面永久改變。
不要把欄寬存進使用者設定,除非有明確的「儲存這個版面」動作。 默默記住的版面,下次打開會讓人以為系統壞了。
搭配欄位設定的 truncate:截斷是預設行為(配 Tooltip 顯示完整內容),
可調欄寬是臨時的例外出口。反過來做(預設不截斷、讓使用者自己縮)會讓一欄長備註
把其他欄全部擠出畫面。
版面開關:預設就是規範
這幾個 prop 有預設值,而預設值本身就是設計規範。改它們之前先想清楚為什麼。
| Prop | 預設 | 什麼時候才該改 |
|---|---|---|
zebra | true | 表格只有 2–3 列時可關(斑馬紋在短表上像是雜訊) |
dense | false | 一次要看很多列的掃視型表格。代價是觸控目標變小,手機上不要用 |
stickyHeader | true | 幾乎不該關。列印時元件已自動改用 table-header-group(見 Table) |
crosshair | true | 欄位少於 4 欄時關掉——沒有對錯行的風險,highlight 只是多餘的動靜 |
resizable | true | 欄寬經過精算的固定版面(例如要對齊上方的統計卡)可關 |
同一個系統裡,同一類表格用同一組開關。
不要每張表各自調。「這張稍微緊一點比較好看」重複五次之後, 你會有五種行高,而且沒有人記得為什麼。
文案可整包覆寫
<DataTable labels={{ search: "Search…", exportCsv: "Export CSV", prev: "Prev", next: "Next" }} … />
這張表撐得住多少資料
門檻式分頁讓「一頁最多 50 列」成為渲染上限,但篩選與排序仍然掃全量。
| 資料量 | 表現 |
|---|---|
| ≤ 500 筆 | 全部即時,包含關鍵字搜尋 |
| 500 – 5,000 筆 | 仍可用。輸入搜尋時每個字元都重算,慢的是搜尋不是渲染 |
| > 5,000 筆 | 該換做法:改由伺服器端分頁與篩選,元件只負責呈現當頁 |
超寬表格(10 欄以上)的實際限制不是效能,是可讀性:
凍結首欄(freeze)+十字對準。長表水平捲動時,
這兩者一起才回答得了「我現在看的是哪一列的哪一欄」。
不要靠水平捲動塞 20 欄。使用者捲到第 15 欄時已經忘記這一列是誰了。 次要欄位應該收進展開列或明細頁。
無障礙
- 排序表頭帶
aria-sort - 篩選鈕帶
aria-label(「篩選 單位」) - 可點擊列有
role="button"、tabIndex、Enter/Space 觸發 - 篩選面板走 portal + fixed 定位,避免被表格捲動裁切
取用
npx shadcn@latest add https://kielchang.github.io/dooping-design-book/r/data-table.json
相依會自動一起裝:table、input、button、select、tooltip、empty-state、use-sort、csv、download、utils。
延伸閱讀:模式 → 資料表標準