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

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—— 漸層疊在儲存格既有底色之上,不會蓋掉凍結首欄與黏性表頭的不透明底 (否則被指到的黏性格會透出後面捲過去的欄位,看起來像凍結失效)。

這個前提有兩個相依,2026-09 在手機上實測踩到,現在由宿主的渲染守衛在 390px 寬盯著:

  • cn() 要把 bg-gradient-to-* 認成背景圖片。 tailwind-merge v3 只認 v4 的 bg-linear-*, 預設把 bg-gradient-to-r 當成底色,合併時會吃掉凍結格的 bg-background。utils 已經登記回來—— 自己換掉 cn 的宿主要保留這條。
  • 勾選欄固定 2.5rem。 凍結首欄在有勾選欄時 sticky 在 left: 2.5rem;表格一窄,自動版面會把勾選欄壓到 32px, 兩個凍結格之間多出 8px 縫,內容從縫裡透出來。勾選欄因此是 w-10 min-w-10。

欄寬可調與雙擊自適應​

拖曳欄位右邊界可調寬,雙擊該邊界則自動貼合內容。

為什麼兩個都要有:拖曳解決「我想看清楚這一欄」,雙擊解決「我不想手動喬」。 只有拖曳的話,使用者調過頭之後沒有回頭路,只能重新整理頁面。

✅ 這樣做

調整後的欄寬只活在當次瀏覽。使用者為了看一眼而拉寬某欄, 不代表他希望這個版面永久改變。

🚫 不要這樣

不要把欄寬存進使用者設定,除非有明確的「儲存這個版面」動作。 默默記住的版面,下次打開會讓人以為系統壞了。

搭配欄位設定的 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" }} … />

多選與批次操作(v0.12.0)​

在 Storybook 開啟

selectable 在首欄前插勾選欄;bulkActions 給批次操作列(有選取才出現, sticky 在容器內置底,role="toolbar"+方向鍵移動焦點)。三條規則:

  • 表頭勾選只切當頁。批次操作是寫入,誤殺半徑優先於「跟著篩選走」—— 合計與匯出是唯讀所以算全量,選取是寫入所以保守。
  • 選取跨頁保留(鍵=getRowKey,所以 getRowKey 不得依賴 index)。
  • 選取是暫時狀態:不進網址(見下)、換資料集自行清除。
<DataTable selectable bulkActions={({ selected, clear }) => (
<Button size="sm" onClick={() => { doExport(selected); clear(); }}>匯出所選</Button>
)} … />

Facet 篩選鈕與欄位顯示(v0.12.0)​

在 Storybook 開啟

facets={["status"]} 把 select 型欄位升級成工具列上的 faceted 鈕 (Popover+逐值計數)。它與表頭篩選共用同一份狀態——兩個入口、一個真相, 從哪邊改都反映在同一排篩選 chip。逐值計數對「排除本欄的其餘篩選後」的 列集合計算,勾了一個值不會把其他選項的數字歸零。

columnVisibility 顯示「欄位」切換鈕;欄位設定加 hideable(凍結欄強制不可隱藏) 與 defaultHidden。欄位顯示是暫時狀態,同樣不進網址。

狀態同步網址(v0.12.0)​

在 Storybook 開啟

後台系統的資訊架構的深連結規範在資料表上的落地—— 篩完的清單可以收藏、可以貼給同事、上一頁行為正確:

const { state, onStateChange } = useTableUrlState();
<DataTable state={state} onStateChange={onStateChange} … />
  • 網址參數人看得懂:q=甲、sort=amount.desc、page=2(1-based)、 f.status=已確認、fr.amount=1000..5000;預設值一律省略,網址保持乾淨。
  • 篩選/搜尋變更自動回第 1 頁(page 參數消失)。
  • selection 與 hiddenColumns 不進網址——判準是「別人打開這個連結, 需要看到一樣的東西嗎?」。
  • 預設走 history.replaceState;TanStack Router/Next 宿主注入自己的 adapter(get/set/subscribe 三個函式)。同頁多張表用 prefix 隔開。

state/onStateChange 是逐鍵受控:給了哪個鍵、哪個鍵由宿主管理, 其餘維持元件內部狀態——不接網址同步時行為與舊版完全相同。

這張表撐得住多少資料​

門檻式分頁讓「一頁最多 50 列」成為渲染上限,但篩選與排序仍然掃全量。

資料量表現
≤ 500 筆全部即時,包含關鍵字搜尋
500 – 5,000 筆仍可用。輸入搜尋時每個字元都重算,慢的是搜尋不是渲染
> 5,000 筆該換做法:改由伺服器端分頁與篩選,元件只負責呈現當頁

超寬表格(10 欄以上)的實際限制不是效能,是可讀性:

✅ 這樣做

凍結首欄(freeze)+十字對準。長表水平捲動時, 這兩者一起才回答得了「我現在看的是哪一列的哪一欄」。

🚫 不要這樣

不要靠水平捲動塞 20 欄。使用者捲到第 15 欄時已經忘記這一列是誰了。 次要欄位應該收進展開列或明細頁。

無障礙​

  • 排序表頭帶 aria-sort
  • 篩選鈕帶 aria-label(「篩選 單位」)
  • 整列可點(onRowClick):鍵盤與讀屏的入口是首個可見欄的真 <button>(Enter/Space 觸發、名字就是首欄內容); 列本身只接滑鼠點擊、不是 button——勾選欄與欄內控制項因此不會變成巢狀互動,<tr> 也保住表格列的語意。 首欄的 cell 不要再渲染連結或按鈕:它已經是入口
  • 篩選面板走 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。

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