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

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)filterTextsortValue
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預設什麼時候才該改
zebratrue表格只有 2–3 列時可關(斑馬紋在短表上像是雜訊)
densefalse一次要看很多列的掃視型表格。代價是觸控目標變小,手機上不要用
stickyHeadertrue幾乎不該關。列印時元件已自動改用 table-header-group(見 Table
crosshairtrue欄位少於 4 欄時關掉——沒有對錯行的風險,highlight 只是多餘的動靜
resizabletrue欄寬經過精算的固定版面(例如要對齊上方的統計卡)可關
✅ 這樣做

同一個系統裡,同一類表格用同一組開關。

🚫 不要這樣

不要每張表各自調。「這張稍微緊一點比較好看」重複五次之後, 你會有五種行高,而且沒有人記得為什麼。

文案可整包覆寫

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

延伸閱讀:模式 → 資料表標準