@qijenchen/design-system 0.1.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/package.json +93 -0
  2. package/src/README.md +32 -0
  3. package/src/components/Accordion/accordion.tsx +104 -0
  4. package/src/components/Alert/alert.tsx +188 -0
  5. package/src/components/AppShell/_demo-helpers.tsx +198 -0
  6. package/src/components/AppShell/app-shell.tsx +364 -0
  7. package/src/components/AspectRatio/aspect-ratio.tsx +58 -0
  8. package/src/components/Avatar/avatar.tsx +368 -0
  9. package/src/components/Badge/badge.tsx +104 -0
  10. package/src/components/Breadcrumb/breadcrumb.tsx +609 -0
  11. package/src/components/BulkActionBar/bulk-action-bar.tsx +156 -0
  12. package/src/components/Button/button-group.tsx +96 -0
  13. package/src/components/Button/button.tsx +539 -0
  14. package/src/components/Calendar/calendar.tsx +411 -0
  15. package/src/components/Carousel/carousel.tsx +371 -0
  16. package/src/components/Chart/chart.tsx +376 -0
  17. package/src/components/Checkbox/checkbox-group.tsx +94 -0
  18. package/src/components/Checkbox/checkbox.tsx +237 -0
  19. package/src/components/Chip/chip.tsx +359 -0
  20. package/src/components/CircularProgress/circular-progress.tsx +204 -0
  21. package/src/components/Coachmark/coachmark.tsx +255 -0
  22. package/src/components/Combobox/combobox.tsx +826 -0
  23. package/src/components/Command/command.tsx +187 -0
  24. package/src/components/DataTable/active-editor-controller.ts +72 -0
  25. package/src/components/DataTable/cell-registry.tsx +520 -0
  26. package/src/components/DataTable/column-types.ts +180 -0
  27. package/src/components/DataTable/data-table-column-visibility-panel.tsx +261 -0
  28. package/src/components/DataTable/data-table-filter-panel.tsx +813 -0
  29. package/src/components/DataTable/data-table-interaction-layer.tsx +483 -0
  30. package/src/components/DataTable/data-table-sort-manager.tsx +210 -0
  31. package/src/components/DataTable/data-table.css +165 -0
  32. package/src/components/DataTable/data-table.tsx +2924 -0
  33. package/src/components/DataTable/filter-operators.ts +225 -0
  34. package/src/components/DataTable/filter-tree.ts +313 -0
  35. package/src/components/DataTable/lib/column-meta.ts +79 -0
  36. package/src/components/DateGrid/date-grid.tsx +209 -0
  37. package/src/components/DatePicker/date-picker.tsx +1114 -0
  38. package/src/components/DescriptionList/description-list.tsx +141 -0
  39. package/src/components/Dialog/dialog.tsx +267 -0
  40. package/src/components/DropdownMenu/dropdown-menu.tsx +475 -0
  41. package/src/components/Empty/empty.tsx +108 -0
  42. package/src/components/Field/field-context.ts +136 -0
  43. package/src/components/Field/field-types.ts +52 -0
  44. package/src/components/Field/field-wrapper.tsx +348 -0
  45. package/src/components/Field/field.tsx +535 -0
  46. package/src/components/FieldControlGroup/field-control-group.tsx +136 -0
  47. package/src/components/FileItem/file-item.tsx +322 -0
  48. package/src/components/FileUpload/file-upload.tsx +326 -0
  49. package/src/components/FileViewer/file-viewer-types.ts +76 -0
  50. package/src/components/FileViewer/file-viewer.tsx +1065 -0
  51. package/src/components/FileViewer/image-renderer.tsx +256 -0
  52. package/src/components/HoverCard/hover-card.tsx +79 -0
  53. package/src/components/Input/input.tsx +233 -0
  54. package/src/components/LinkInput/link-input.tsx +304 -0
  55. package/src/components/Menu/menu-item.tsx +334 -0
  56. package/src/components/NameCard/name-card.tsx +319 -0
  57. package/src/components/Notice/notice.tsx +196 -0
  58. package/src/components/NumberInput/number-input.tsx +203 -0
  59. package/src/components/OverflowIndicator/overflow-indicator.tsx +156 -0
  60. package/src/components/PeoplePicker/avatar-stack-overflow.ts +100 -0
  61. package/src/components/PeoplePicker/people-picker-helpers.ts +76 -0
  62. package/src/components/PeoplePicker/people-picker.tsx +455 -0
  63. package/src/components/PeoplePicker/person-display.tsx +358 -0
  64. package/src/components/Popover/popover.tsx +183 -0
  65. package/src/components/ProgressBar/progress-bar.tsx +157 -0
  66. package/src/components/README.md +58 -0
  67. package/src/components/RadioGroup/radio-group.tsx +261 -0
  68. package/src/components/Rating/rating.tsx +295 -0
  69. package/src/components/ScrollArea/scroll-area.tsx +110 -0
  70. package/src/components/SegmentedControl/segmented-control.tsx +304 -0
  71. package/src/components/Select/select.tsx +658 -0
  72. package/src/components/SelectMenu/select-menu.tsx +430 -0
  73. package/src/components/SelectionControl/selection-item.tsx +261 -0
  74. package/src/components/Separator/separator.tsx +48 -0
  75. package/src/components/Sheet/sheet.tsx +240 -0
  76. package/src/components/Sidebar/sidebar.tsx +1280 -0
  77. package/src/components/Skeleton/skeleton.tsx +35 -0
  78. package/src/components/Slider/slider.tsx +158 -0
  79. package/src/components/Steps/steps.tsx +850 -0
  80. package/src/components/Switch/switch.tsx +285 -0
  81. package/src/components/Tabs/tabs.tsx +515 -0
  82. package/src/components/Tag/tag.tsx +246 -0
  83. package/src/components/Textarea/textarea.tsx +280 -0
  84. package/src/components/TimePicker/time-columns.tsx +260 -0
  85. package/src/components/TimePicker/time-picker.tsx +419 -0
  86. package/src/components/Toast/toast.tsx +129 -0
  87. package/src/components/Tooltip/tooltip.tsx +68 -0
  88. package/src/components/TreeView/tree-view.tsx +1031 -0
  89. package/src/hooks/use-controllable.ts +40 -0
  90. package/src/hooks/use-is-narrow-viewport.ts +19 -0
  91. package/src/hooks/use-is-touch-device.ts +21 -0
  92. package/src/hooks/use-overflow-items.ts +256 -0
  93. package/src/index.ts +85 -0
  94. package/src/lib/README.md +82 -0
  95. package/src/lib/drag-visual.ts +272 -0
  96. package/src/lib/i18n/README.md +60 -0
  97. package/src/lib/i18n/i18n-context.tsx +129 -0
  98. package/src/lib/multi-select-ordering.ts +61 -0
  99. package/src/lib/utils.ts +93 -0
  100. package/src/patterns/README.md +67 -0
  101. package/src/patterns/element-anatomy/item-anatomy.tsx +744 -0
  102. package/src/patterns/header-canonical/chrome-header.tsx +175 -0
  103. package/src/patterns/header-canonical/header-canonical.css +27 -0
  104. package/src/patterns/horizontal-overflow/horizontal-overflow.tsx +217 -0
  105. package/src/patterns/overlay-surface/overlay-surface.tsx +191 -0
  106. package/src/patterns/resize-handle/resize-handle.tsx +188 -0
  107. package/src/stories-helpers/anatomy/anatomy-utils.tsx +64 -0
  108. package/src/tokens/README.md +53 -0
  109. package/src/tokens/color/primitives.css +429 -0
  110. package/src/tokens/color/semantic.css +539 -0
  111. package/src/tokens/elevation/overlay-geometry.ts +13 -0
  112. package/src/tokens/layoutSpace/layoutSpace.css +36 -0
  113. package/src/tokens/motion/motion.css +30 -0
  114. package/src/tokens/motion/motion.ts +17 -0
  115. package/src/tokens/opacity/opacity.css +23 -0
  116. package/src/tokens/radius/radius.css +19 -0
  117. package/src/tokens/typography/typography.css +118 -0
  118. package/src/tokens/uiSize/icon-size.ts +52 -0
  119. package/src/tokens/uiSize/uiSize.css +125 -0
@@ -0,0 +1,2924 @@
1
+ // @benchmark-unverified-blanket: file-level retraction per M22 (d) — claims herein not individually URL-cited; treat as unverified visual/usage rumor unless retrofit per-claim. Hook escape preserved.
2
+ // code-quality-allow: file-size — foundational composite — 拆檔會複雜化 context / ref / state 同步
3
+ import * as React from 'react'
4
+ import { createPortal } from 'react-dom'
5
+ import { Empty } from '@/design-system/components/Empty/empty'
6
+ import {
7
+ useReactTable,
8
+ getCoreRowModel,
9
+ getSortedRowModel,
10
+ getExpandedRowModel,
11
+ flexRender,
12
+ type ColumnDef,
13
+ type SortingState,
14
+ type TableOptions,
15
+ type Column,
16
+ } from '@tanstack/react-table'
17
+ import { useVirtualizer } from '@tanstack/react-virtual'
18
+ import { TableScrollProvider } from '@/design-system/components/Field/field-context'
19
+ import { cva, type VariantProps } from 'class-variance-authority'
20
+ import { ChevronDown, ArrowUp, ArrowDown, ArrowUpDown, Filter as FilterIcon, EyeOff, X as XIcon, GripVertical } from 'lucide-react'
21
+ // **v15.0 Path B**(對齊 user 「source 留原位 / indicator 為 drop preview / 不 auto-shift」directive):
22
+ // 砍 useSortable + SortableContext 用 useDraggable + useDroppable 分離 hooks(對齊 DS 內 TreeView SSOT)。
23
+ import { DndContext, DragOverlay, useDraggable, useDroppable, useDndContext, pointerWithin, rectIntersection, useSensor, useSensors, PointerSensor, KeyboardSensor, MeasuringStrategy, type DragEndEvent, type CollisionDetection } from '@dnd-kit/core'
24
+ import { cn } from '@/lib/utils'
25
+ import { ICON_SIZE } from '@/design-system/tokens/uiSize/icon-size'
26
+ import { dragSourceStyle, dropIndicatorRow, dropIndicatorColumn, dragActiveCursor, isReorderNoop, reconstructFullRowGhost, snapToCursorModifier } from '@/design-system/lib/drag-visual'
27
+ import { nakedCellEditableDisplayHover } from '@/design-system/components/Field/field-wrapper'
28
+ import { Tooltip, TooltipContent, TooltipTrigger } from '@/design-system/components/Tooltip/tooltip'
29
+ import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator } from '@/design-system/components/DropdownMenu/dropdown-menu'
30
+ import { ItemInlineActionButton } from '@/design-system/patterns/element-anatomy/item-anatomy'
31
+ import { columnTypeDefaults, type ColumnType } from './column-types'
32
+ import { resolveCellComponent } from './cell-registry'
33
+ import { DataTableInteractionLayer } from './data-table-interaction-layer'
34
+ import { Checkbox } from '@/design-system/components/Checkbox/checkbox'
35
+ import { RadioGroupItem } from '@/design-system/components/RadioGroup/radio-group'
36
+ import * as RadioGroupPrimitive from '@radix-ui/react-radio-group'
37
+ import { useControllable } from '@/design-system/hooks/use-controllable'
38
+ import { Button } from '@/design-system/components/Button/button'
39
+
40
+ // ── Variants ─────────────────────────────────────────────────────────────────
41
+
42
+ // outer border = `border-divider`(同 row divider 色)— T-junction connectivity 設計原則:
43
+ // row divider 兩端 meet table outer border;若不同色 → 交匯處視覺斷層;
44
+ // divider 不能加重(過搶眼)→ 淡化 outer border 至 divider 同色,seamless。
45
+ // 對齊 Ant Design colorBorderSecondary idiom(table outer + divider 同色)。
46
+ // 詳 tokens/color/color.spec.md「T-junction connectivity」段。
47
+ const dataTableVariants = cva('bg-surface rounded-md overflow-hidden', {
48
+ variants: { bordered: { true: 'border border-divider', false: '' } },
49
+ defaultVariants: { bordered: true },
50
+ })
51
+
52
+ // ── Types ────────────────────────────────────────────────────────────────────
53
+
54
+ type TableSize = 'sm' | 'md' | 'lg'
55
+
56
+ export interface DataTableProps<TData>
57
+ extends Omit<React.HTMLAttributes<HTMLDivElement>, 'children'>,
58
+ VariantProps<typeof dataTableVariants> {
59
+ columns: ColumnDef<TData, any>[]
60
+ data: TData[]
61
+ size?: TableSize
62
+ autoRowHeight?: boolean
63
+ height?: string
64
+ overscan?: number
65
+ emptyState?: React.ReactNode
66
+ enableHover?: boolean
67
+ estimateRowHeight?: number
68
+ tableOptions?: Partial<Omit<TableOptions<TData>, 'data' | 'columns' | 'getCoreRowModel'>>
69
+ rowActions?: (row: TData) => React.ReactNode
70
+ /**
71
+ * Issue 9 cell error system(2026-05-10)。
72
+ *
73
+ * Map of `${rowId}:${colId}` → error message(string | string[])。Cell display mode 在 content
74
+ * 下方渲 `text-body text-error` 訊息,gap-1 spacing。Multi-error 用 array → ul / li 分行渲。
75
+ *
76
+ * **使用建議**:
77
+ * - 搭配 `autoRowHeight` prop:cell error 訊息 wrap → row 自動拉高吸收。fixed 高 row 模式
78
+ * error 訊息會被裁切(consumer 應 set autoRowHeight=true 給有 error 的 use case)。
79
+ * - Edit cell 自動 clear 自己的 error(展開 portal Field 時消失,新值 commit 由 consumer
80
+ * onCellCommit 驗證後決定 set / clear)。
81
+ * - aria-describedby:cell wrapper 接 error message id,AT 讀 cell 內容後讀 error。
82
+ *
83
+ * 對齊 AG Grid `cellClassRules='ag-cell-error'` + Material X-DataGrid `errorMessage` cell prop +
84
+ * Airtable record validation idiom。
85
+ */
86
+ cellErrors?: Record<string, string | string[]>
87
+ pinnedLeftColumns?: string[]
88
+ pinnedRightColumns?: string[]
89
+ /** Inline edit 視覺模式:body cell 間加垂直分隔線,select 類欄位顯示指示器 */
90
+ inlineEdit?: boolean
91
+ /**
92
+ * Slice D Step 1B(2026-05-10):啟用 spreadsheet-grade interaction overlay。
93
+ * Default false(backward-compat)。Enable 後 hover/editor/selected/range 由
94
+ * `DataTableInteractionLayer` overlay 統一畫,per `.claude/planning/datatable-spreadsheet-rfc.md`。
95
+ * 漸進切換階段,當前 v1 minimal:hover overlay 1 layer。
96
+ */
97
+ experimentalSpreadsheetOverlay?: boolean
98
+ /**
99
+ * Slice D Step 3.2(2026-05-10):啟用 ActiveEditorController portal Field。
100
+ * Default false(backward-compat)。
101
+ * Enable 後 active edit cell 不 render Field inline,改 portal 進 overlay layer
102
+ * (per RFC Contract 8 「one geometry, two paint owners」)。
103
+ * 當前 scaffold:prop 已收,functional portal logic Step 3.3 實作。
104
+ * Per codex Q-7 string-first canary:string cell first,picker types 漸進。
105
+ */
106
+ experimentalActiveEditorController?: boolean
107
+ /**
108
+ * Slice D Step 4(spreadsheet semantics,2026-05-10 user 拍板 + codex Layer B Q2.1 confirm):
109
+ * Excel-like cell selection:click 1=select / click 2=edit / Shift+click=range。
110
+ * Default false opt-in(per codex「DataTable is not a spreadsheet」既有原則 +
111
+ * data-table.principles.stories.tsx L283-292)。
112
+ * Enable 後 inlineEdit cell click 行為:
113
+ * - Plain click → setSelectedCellId,**不**進 edit mode
114
+ * - Click on already-selected → enter edit
115
+ * - Shift+click → extend range from anchor
116
+ * - Double-click / Enter / F2 / printable(deferred) → enter edit on selected
117
+ * - Click empty area → clear selection
118
+ * 視覺:Layer 渲 SelectionRect(solid `--primary` 2px border)+ RangeRect
119
+ * (`--primary-subtle` bg fill)— per user「不要 dash 直接實的就好」+ codex Q2.2 token。
120
+ */
121
+ spreadsheetMode?: boolean
122
+
123
+ // ── L2 Selection(see data-table.spec.md「L2 選取」)──
124
+ /** 已選 row IDs(controlled) */
125
+ selection?: string[]
126
+ /** 預設選取(uncontrolled) */
127
+ defaultSelection?: string[]
128
+ /** Selection 變更 callback */
129
+ onSelectionChange?: (next: string[]) => void
130
+ /** 是否啟用 selection / 模式;true 等同 'multi' */
131
+ selectable?: boolean | 'single' | 'multi'
132
+ /** Row 是否可選(disabled rows 只 disable checkbox,row 內容正常 render) */
133
+ isRowSelectable?: (row: TData) => boolean
134
+ /** 取 row 唯一 ID(selection 用);default `(row, index) => String(index)` */
135
+ getRowId?: (row: TData, index: number) => string
136
+ /** Checkbox aria-label fallback;default `'Select row'` */
137
+ getRowAriaLabel?: (row: TData) => string
138
+ /** Filter 後 hidden selected rows 是否保留(default false,對齊 Material/AG Grid 共識) */
139
+ preserveSelectionOnFilter?: boolean
140
+
141
+ // ── L3 Column visibility(顯示隱藏)──
142
+ /** 欄位顯隱(controlled),Record<columnId, boolean>;true / undefined = 顯示 */
143
+ columnVisibility?: Record<string, boolean>
144
+ /** 預設顯隱(uncontrolled) */
145
+ defaultColumnVisibility?: Record<string, boolean>
146
+ /** 顯隱變更 callback */
147
+ onColumnVisibilityChange?: (next: Record<string, boolean>) => void
148
+
149
+ // ── L3 Sort(排序)──
150
+ /** 啟用多欄排序(shift+click 加 secondary;單擊仍 replace);default true,對齊 AG Grid / Material */
151
+ enableMultiSort?: boolean
152
+
153
+ // ── L3 Filter trigger(callback only — UI in consumer)──
154
+ /** Cell ⌄ menu「Filter by this」點擊,emit columnId 讓 consumer 開 global filter panel + prefill。
155
+ * 對齊 ClickUp / Airtable / Notion 派 — filter 永遠 global,不 per-cell inline。 */
156
+ onColumnFilterTrigger?: (columnId: string) => void
157
+
158
+ // ── L4 Inline edit ──
159
+ /**
160
+ * Cell value commit callback。User 編完(blur/Enter/select-option)→ 觸發本 callback。
161
+ * Consumer 自管 data update + persistence。
162
+ * 啟用條件:該 column `meta.editable` 為 true 或 fn 回傳 true。詳 `column-types.ts`。
163
+ */
164
+ onCellCommit?: (rowId: string, columnId: string, value: unknown) => void
165
+
166
+ // ── L4 Row drag(Jira-style reorder)──
167
+ /**
168
+ * 啟用 row drag reorder。為 true 時,row 最左出 GripVertical handle(hover-revealed),
169
+ * 拖曳改 default order via `onRowReorder` callback。
170
+ *
171
+ * Sort × Drag 互斥:`sorting.length > 0` 時 drag handle 視覺 disabled + Tooltip
172
+ * 「排序中無法拖曳,清除排序後可重排」。對齊 Notion / Airtable 共識。
173
+ *
174
+ * **必填 `getRowId`**:enableRowDrag 為 true 時 consumer 必傳 `getRowId`,用穩定 row identity 作 dnd source / target id。否則 dnd 用 row.index 會在 reorder 後錯位(runtime 不會 throw,但 reorder 行為不正確)。
175
+ *
176
+ * **v2(2026-05-05)修正**:
177
+ * - Virtualizer × transform:被拖 row 略過 `measureElement`(透過 SortableRowCtx 廣播 active id),避免 transform 干擾測量
178
+ * - 3-panel mirror sync:每 region 都呼叫 `useSortable({id})`(同 SortableContext 共用 state),mirror 自然取得相同 transform
179
+ * - Cross-parent drop:nested 全 row 進 SortableContext.items,自訂 collisionDetection 過濾出「同 parent siblings」;cross-parent over → 不觸發,handle cursor `not-allowed`
180
+ *
181
+ * 詳 `data-table.spec.md`「L4 Row drag」段。
182
+ */
183
+ enableRowDrag?: boolean
184
+ /**
185
+ * Row reorder callback。User 拖曳完成觸發。
186
+ * @param sourceId 拖曳的 row id
187
+ * @param targetId 放下的目標 row id
188
+ * @param position 'before' | 'after' — 放在 target 前 OR 後
189
+ */
190
+ onRowReorder?: (sourceId: string, targetId: string, position: 'before' | 'after') => void
191
+ /**
192
+ * 啟用全表 column resize(2026-05-06 v11):
193
+ * - `true`: 所有 data columns 全 fixed width,header 右邊出現 resize handle(hover 顯色 +
194
+ * cursor:col-resize),user 拖拉調整 column width。System columns(checkbox / drag handle /
195
+ * row actions)永遠 fixed,不在 resize 集合。
196
+ * - `false`(default): 所有 data columns 全 flex-grow:1 均分剩餘寬度。
197
+ *
198
+ * **二選一 canonical**(對齊 Notion / Airtable / Linear product UI 共識)— 不支援 per-column
199
+ * mixed,簡單明確。Min width 預設 80px,consumer 透過 `columnDef.minSize` override。
200
+ */
201
+ enableColumnResize?: boolean
202
+ /**
203
+ * Column resize callback。User 拖完一輪觸發(commit-on-pointerup,非 live)。Consumer 收到
204
+ * 自管 width persistence(localStorage / URL / API)— DS 不持久化(對齊「DS 不包全域 provider」原則)。
205
+ * @param columnId 被 resize 的 column id
206
+ * @param width 最終 width(px)
207
+ */
208
+ onColumnResize?: (columnId: string, width: number) => void
209
+ /**
210
+ * 啟用全表 column reorder(2026-05-06 v11):
211
+ * - `true`: 所有 data column header 可拖曳重排,DragOverlay portal 顯示 ghost
212
+ * - `false`(default): 不啟用
213
+ *
214
+ * 使用 `columnDef.meta.locked = true` 標示鎖定欄(無 grab cursor、不啟動 drag、被拖過不顯
215
+ * drop indicator)。對齊 Notion / Linear locked column canonical。
216
+ */
217
+ enableColumnReorder?: boolean
218
+ /**
219
+ * Column reorder callback。
220
+ * @param sourceId source column id
221
+ * @param targetId target column id
222
+ * @param position 'before' | 'after'
223
+ */
224
+ onColumnReorder?: (sourceId: string, targetId: string, position: 'before' | 'after') => void
225
+ }
226
+
227
+ // ── Cell Rendering(Phase C 2026-05-05 — type-keyed registry SSOT)─────────────
228
+ //
229
+ // 原 `renderTypedValue` switch + `EditableCellContent` switch 兩條平行 type-switch
230
+ // 已 collapse 為單一 `cellRegistry`(./cell-registry.ts)— 每個 type → 一個 cell component,
231
+ // 同時處理 display + edit mode(底層 Field control 的 mode prop)。對齊 M17 SSOT consolidation。
232
+ //
233
+ // 對齊 Notion / Airtable type-aware inline edit canonical(詳 spec §十二):
234
+ // - string / number / currency:cell click → inline edit
235
+ // - date / time / select / multiSelect / person / multiPerson:cell click → 進 edit mode
236
+ // - boolean:不分 read/edit mode,直接 `<Checkbox>` 點即 toggle + commit
237
+ // - url:read = LinkInput display + hover Pencil button,click Pencil 才進 edit mode
238
+ //
239
+ // Cell id format: `${rowId}__${columnId}`(編輯狀態 keying)
240
+ // Commit: blur OR Enter OR overlay close → 呼叫 onCellCommit
241
+ // Cancel: Esc → 退出 edit mode 不 commit
242
+ //
243
+ // World-class source(@benchmark-unverified):AG Grid cellRendererSelector / Material X-Grid
244
+ // `valueGetter + renderCell` / Notion property type registry。
245
+
246
+ const cellEditId = (rowId: string, colId: string) => `${rowId}__${colId}`
247
+
248
+ // ── Constants ────────────────────────────────────────────────────────────────
249
+
250
+ // Phase C(2026-05-05):cell 水平 padding 從 magic `0.75rem` 提升為 `--table-cell-px` token
251
+ // (詳 ./data-table.css)— consumer 可走 CSS override 改值,不再 hard-code in TS。
252
+ // L2 selection 內部 column id(避免 magic string 重複)
253
+ const SELECT_COL_ID = '__select__'
254
+ const cellPadding: React.CSSProperties = { paddingBlock: 'var(--table-cell-py)', paddingInline: 'var(--table-cell-px)' }
255
+ const HEADER_BG = 'bg-muted'
256
+
257
+ // Column sizing canonical(2026-05-06 v11 — table-level all-or-nothing,Notion / Airtable / Linear 共識):
258
+ // - **Table-level prop `enableColumnResize`** 控制全表 mode(per-column mixed 已 retire,跟 product
259
+ // UI 世界級對齊 — Notion / Airtable / Linear 都是全表二選一,簡單明確)
260
+ // - `enableColumnResize=true` → 所有 data columns 全 fixed width(尊重 user 拖拉)
261
+ // - `enableColumnResize=false` → 所有 data columns 全 flex-grow:1 均分剩餘寬度(預設,minWidth 保護)
262
+ // - **System columns**(`__select__` / drag handle / row actions)永遠 fixed,跟 enableColumnResize 無關
263
+ // - maxSize 一律 forward 為 maxWidth(防 flex 無限擴張)
264
+ //
265
+ // 預設 min width = `MIN_COLUMN_WIDTH`(80px;對齊 Polaris IndexTable / AG Grid 範圍下限),consumer
266
+ // 可透過 `columnDef.minSize` override。
267
+ export const MIN_COLUMN_WIDTH = 80
268
+
269
+ function columnSizeStyle(
270
+ col: { id: string; getSize: () => number; columnDef: { minSize?: number; maxSize?: number } },
271
+ opts: { resize: boolean; isSystemCol: boolean },
272
+ ): React.CSSProperties {
273
+ const baseSize = col.getSize()
274
+ // **Regression fix(2026-05-06 v14.1)**:default fallback 從 `MIN_COLUMN_WIDTH (80)` 改回
275
+ // `baseSize`(等於 v9 行為)。前 v11 column resize commit 改 fallback 為 80 後,enableColumnResize=false
276
+ // 的 default flex case 全 column 可 shrink 到 80 → flex 均分忽視 `size` prop → Note 360 被擠到 204
277
+ // → text wrap 行數爆增 → autoRow cell 變高 → edit textarea rows=3 估算更不準 → shrink 看起來壞掉。
278
+ // v9 直覺:沒明示 minSize 預設不 shrink 低於 size。enableColumnResize=true 仍 honour `MIN_COLUMN_WIDTH`
279
+ // (因 user 主動拖拉時要能縮)。
280
+ const minSize = col.columnDef.minSize ?? (opts.resize ? MIN_COLUMN_WIDTH : baseSize)
281
+ const maxSize = col.columnDef.maxSize
282
+ // System columns 永遠 fixed(checkbox / drag handle 等內建欄位,不在 resize 集合)
283
+ if (opts.isSystemCol) {
284
+ return { width: baseSize, minWidth: baseSize, maxWidth: maxSize }
285
+ }
286
+ // Data columns:enableColumnResize=true → fixed 尊重 user 拖拉
287
+ if (opts.resize) {
288
+ return { width: baseSize, minWidth: minSize, maxWidth: maxSize }
289
+ }
290
+ // Data columns:enableColumnResize=false → flex grow but NOT shrink below `size` prop。
291
+ // 對齊 v9 行為:column 沒明示 minSize 時固定 ≥ baseSize(尊重 user `size` 設定),table 寬不夠
292
+ // 觸發 H scroll(預期)。前 v11 換 MIN_COLUMN_WIDTH=80 fallback 讓 columns 全擠等寬,違 user
293
+ // 預期。
294
+ // **Tanstack default 干擾**:tanstack v8 `defaultColumn.minSize=20` 會 merge 進 column.columnDef
295
+ // → `col.columnDef.minSize` 永遠不 undefined → `?? baseSize` 不 fall back。
296
+ // 解法:直接用 baseSize(若 user 要明示 shrink-below-size,改 `enableColumnResize=true` 或別自設
297
+ // `minSize` < size)。
298
+ //
299
+ // **flex-basis: baseSize(2026-05-06 v14.2)**:把 baseSize 當 explicit basis(不是 `0%`)。
300
+ // 為什麼:flex item base = basis + padding(box-sizing: border-box content-box 行為)。前 `0%`
301
+ // basis → cell padding 變 base 一部分。display(padding 12)vs edit(padding 0,Field 接管)
302
+ // 兩態 base 不同 → flex 重分配 → user 報「Price cell 進 edit 寬度縮 12px」(verify by
303
+ // debug-v14-1-display-edit-rect-match.mjs:Price display 130.5 → edit 118.5 = -12px)。
304
+ // explicit basis = baseSize 讓 padding 不參與 base 計算 → display↔edit 寬度穩定。
305
+ return { flex: `1 1 ${baseSize}px`, minWidth: baseSize, maxWidth: maxSize }
306
+ }
307
+
308
+ const SYSTEM_COL_IDS = new Set([SELECT_COL_ID, '__drag__', '__actions__'])
309
+ const isSystemColumn = (colId: string) => SYSTEM_COL_IDS.has(colId)
310
+
311
+ // ── TruncateCell ─────────────────────────────────────────────────────────────
312
+ // Shared ResizeObserver(2026-04-22 D3 perf audit):從 per-cell RO 改為全 DS 共用一個 RO
313
+ // dispatch 到 per-element callback。10 col × 100 row = 1 RO(before:1000 RO)。
314
+ // 跨 OS 一致的 RO 行為;element 卸載時 cleanup。
315
+
316
+ type RoCallback = (entry: ResizeObserverEntry) => void
317
+ let sharedResizeObserver: ResizeObserver | null = null
318
+ const roCallbacks = new WeakMap<Element, RoCallback>()
319
+
320
+ function getSharedRO(): ResizeObserver {
321
+ if (sharedResizeObserver) return sharedResizeObserver
322
+ sharedResizeObserver = new ResizeObserver((entries) => {
323
+ entries.forEach((entry) => {
324
+ const cb = roCallbacks.get(entry.target)
325
+ if (cb) cb(entry)
326
+ })
327
+ })
328
+ return sharedResizeObserver
329
+ }
330
+
331
+ function observeShared(el: Element, cb: RoCallback): () => void {
332
+ const obs = getSharedRO()
333
+ roCallbacks.set(el, cb)
334
+ obs.observe(el)
335
+ return () => {
336
+ roCallbacks.delete(el)
337
+ obs.unobserve(el)
338
+ }
339
+ }
340
+
341
+ function TruncateCell({ children, className }: { children: React.ReactNode; className?: string }) {
342
+ const ref = React.useRef<HTMLSpanElement>(null)
343
+ const [isTruncated, setIsTruncated] = React.useState(false)
344
+ React.useEffect(() => {
345
+ const el = ref.current
346
+ if (!el) return
347
+ const check = () => setIsTruncated(el.scrollWidth > el.clientWidth)
348
+ check()
349
+ return observeShared(el, check)
350
+ }, [])
351
+ const span = <span ref={ref} className={cn('truncate min-w-0', className)}>{children}</span>
352
+ if (!isTruncated) return span
353
+ return <Tooltip><TooltipTrigger asChild>{span}</TooltipTrigger><TooltipContent>{children}</TooltipContent></Tooltip>
354
+ }
355
+
356
+ // ── L4 Row Drag: SortableRowContext (v2) ─────────────────────────────────────
357
+ // v2:每 region(left / center / right)各 mount 一次 SortableRowProvider — 多個 useSortable
358
+ // 共用同一 SortableContext / 同 row id 時,dnd-kit 內部以 id 為 unit 分發 transform / isDragging,
359
+ // 各 hook instance 取得相同值,因此 mirror region 自然跟動(v1 mirror static bug 修正)。
360
+ // listeners 仍只走 primary region(避免 pointer 事件雙重觸發);primary = left region 若存在否則 center。
361
+ // `invalidDrop`(cross-parent over)走 prop 廣播給 DragHandleCell 切 cursor-not-allowed。
362
+ interface SortableRowCtxValue {
363
+ setNodeRef: (el: HTMLElement | null) => void
364
+ role: 'primary' | 'mirror'
365
+ style: React.CSSProperties
366
+ attributes: Record<string, unknown>
367
+ isDragging: boolean
368
+ /** **v15.6 button-only drag**(對齊 Notion / Linear / Jira canonical):
369
+ * 整列拖 + ghost 跟 cursor 在 multi-instance same-id pinned column 場景互相矛盾——
370
+ * 唯一 single-source-of-activation = visible RowDragHandle Button。
371
+ * Source DOM = primary row(`setNodeRef`),activator = button(`handleSetActivatorNodeRef`),
372
+ * listeners = button(`handleListeners`)。User 從哪個 region 都看見 portal'd button → 點下啟動。
373
+ * Row click 不觸發 drag(允許 row click → select / open detail 等別的 UX)。
374
+ * 保留 `rowListeners`/`rowAttributes` field 但 button-only mode 為 undefined。 */
375
+ rowListeners: Record<string, unknown> | undefined
376
+ rowAttributes: Record<string, unknown>
377
+ /** RowDragHandle Button 用:接 setActivatorNodeRef → button rect 當 activator;
378
+ * primary 才提供(mirror 不需要,RowDragHandle 只在 primary region 渲染) */
379
+ handleSetActivatorNodeRef: ((el: HTMLElement | null) => void) | undefined
380
+ handleListeners: Record<string, unknown> | undefined
381
+ handleAttributes: Record<string, unknown>
382
+ /** drag 進行中且當前 over target 與 active 不同 parent → invalid signal */
383
+ invalidDrop: boolean
384
+ }
385
+ const SortableRowCtx = React.createContext<SortableRowCtxValue | null>(null)
386
+
387
+ /** Per-region per-row sortable wrapper(v2 multi-instance pattern)。
388
+ * 同 row.id 在 left/center/right 三 region 各 mount 一次 — useSortable 共享同 SortableContext
389
+ * state,各 instance 取得相同 transform → mirror 自動跟動。
390
+ * primary instance 額外提供 listeners 給 DragHandleCell;mirror 不提供避免雙觸發。 */
391
+ function SortableRowProvider(props: {
392
+ id: string
393
+ disabled?: boolean
394
+ role: 'primary' | 'mirror'
395
+ invalidDrop: boolean
396
+ children: (ctx: SortableRowCtxValue) => React.ReactNode
397
+ }) {
398
+ // **v15.4 final architectural split**:multi-instance same-id 是 dnd-kit anti-pattern。
399
+ // 必須完全分離 component 讓 hook mount tree 不衝突:
400
+ // - primary 走 SourceRowProvider(useDraggable + useDroppable)— 唯一 source
401
+ // - mirror 走 MirrorRowProvider(useDroppable only)— 接受 drop target 但不參與 drag source
402
+ // 之前 v15.2/15.3 同 component 内 conditional setNodeRef/disabled 仍讓 mirror instance 進入
403
+ // dnd-kit context store,導致 last-mount-wins 把 activator 取成 mirror region row → ghost
404
+ // 起點偏離 cursor。Split 後 dnd-kit 只看到 primary instance,問題消滅。
405
+ return props.role === 'primary' ? <SourceRowProvider {...props} /> : <MirrorRowProvider {...props} />
406
+ }
407
+
408
+ function SourceRowProvider({
409
+ id,
410
+ disabled,
411
+ role,
412
+ invalidDrop,
413
+ children,
414
+ }: {
415
+ id: string
416
+ disabled?: boolean
417
+ role: 'primary' | 'mirror'
418
+ invalidDrop: boolean
419
+ children: (ctx: SortableRowCtxValue) => React.ReactNode
420
+ }) {
421
+ const draggable = useDraggable({ id, disabled, data: { type: 'row' } })
422
+ const droppable = useDroppable({ id, disabled, data: { type: 'row' } })
423
+ // **v15.6 button-only drag**:setActivatorNodeRef 不接 row,改由 RowDragHandle Button 接(via ctx)。
424
+ // setNodeRef = primary row(source DOM,ghost 抓這個);droppable.setNodeRef = same row(droppable target)。
425
+ const setRefs = React.useCallback((el: HTMLElement | null) => {
426
+ draggable.setNodeRef(el)
427
+ droppable.setNodeRef(el)
428
+ }, [draggable.setNodeRef, droppable.setNodeRef])
429
+ const isDragging = draggable.isDragging
430
+ const style: React.CSSProperties = { ...dragSourceStyle(isDragging) }
431
+ // a11y(2026-05-07 v15.10 codex P1 fix):button-only drag mode 下,row 本身不該成為
432
+ // keyboard tab stop。dnd-kit `useDraggable.attributes` 含 `role="button" tabIndex=0
433
+ // aria-roledescription="..."` 全給 activator 用,套到 row div 會讓每筆 row tabbable
434
+ // (large table 累積上百 inert focus stops,grid navigation 體驗壞)。
435
+ // **拆分**:rowAttributes 留空(row 是 passive container)/ handleAttributes 全給
436
+ // RowDragHandle Button(它是真 activator,Button 自帶 role/tabIndex 完全相容)。
437
+ const handleAttrs = draggable.attributes as unknown as Record<string, unknown>
438
+ const ctxValue: SortableRowCtxValue = {
439
+ setNodeRef: setRefs,
440
+ role,
441
+ style,
442
+ attributes: {},
443
+ isDragging,
444
+ // row 不接 listeners(button-only),baseRowDiv `{...(extra?.listeners ?? {})}` 自動 noop
445
+ rowListeners: undefined,
446
+ rowAttributes: {},
447
+ // Button activator + listener:portal'd RowDragHandle Button 走這條 ctx,
448
+ // user 從任何 region 看見 button → 點下啟動 drag,activator rect = button DOM(24×24),
449
+ // ghost 起點 = button 位置(table outer 左 12px),cursor 在 ghost 左前段(自然視覺)。
450
+ handleSetActivatorNodeRef: draggable.setActivatorNodeRef,
451
+ handleListeners: draggable.listeners as unknown as Record<string, unknown> | undefined,
452
+ handleAttributes: handleAttrs,
453
+ invalidDrop,
454
+ }
455
+ return <SortableRowCtx.Provider value={ctxValue}>{children(ctxValue)}</SortableRowCtx.Provider>
456
+ }
457
+
458
+ function MirrorRowProvider({
459
+ id,
460
+ disabled,
461
+ role,
462
+ invalidDrop,
463
+ children,
464
+ }: {
465
+ id: string
466
+ disabled?: boolean
467
+ role: 'primary' | 'mirror'
468
+ invalidDrop: boolean
469
+ children: (ctx: SortableRowCtxValue) => React.ReactNode
470
+ }) {
471
+ // Mirror region(left / right pinned)只 mount useDroppable — 接受 drop target,
472
+ // 但不參與 drag source(避免 multi-instance same-id 衝突)。
473
+ // RowDragHandle Button 只在 primary region 渲染(per `showDragHandle = ... && isPrimaryRegion`),
474
+ // mirror ctx 不需要 handle listeners / activator,相關 field 為 undefined。
475
+ // **v15.8 Bug 3 fix**(對齊 user 「source 沒一整條都有 disabled opacity」):
476
+ // mirror region drag 期間需跟 primary 同步顯 opacity-disabled,讓 source row 跨三 region
477
+ // 視覺一致(SKU 釘選欄 + center + Updated 釘選欄整列半透明)。透過 useDndContext active
478
+ // 判斷:any drag activated with active.id === own row id → mirror 也 isDragging。
479
+ const droppable = useDroppable({ id, disabled, data: { type: 'row' } })
480
+ const dndCtx = useDndContext()
481
+ const isDragging = dndCtx.active?.id === id
482
+ const ctxValue: SortableRowCtxValue = {
483
+ setNodeRef: droppable.setNodeRef,
484
+ role,
485
+ style: { ...dragSourceStyle(isDragging) },
486
+ attributes: {},
487
+ isDragging,
488
+ rowListeners: undefined,
489
+ rowAttributes: {},
490
+ handleSetActivatorNodeRef: undefined,
491
+ handleListeners: undefined,
492
+ handleAttributes: {},
493
+ invalidDrop,
494
+ }
495
+ return <SortableRowCtx.Provider value={ctxValue}>{children(ctxValue)}</SortableRowCtx.Provider>
496
+ }
497
+
498
+ /** DraggableHeaderCell — wrap header cell 跟 dnd-kit useSortable 接軌(2026-05-06 v14.2)。
499
+ *
500
+ * Why wrap-not-rewrite:`headerCellEl` 既有邏輯複雜(sort / resize / select column / right region 等),
501
+ * 改 inline useSortable 入侵性高。本 wrapper cloneElement 注入 ref / style / listeners → 既有 render
502
+ * 保持 untouched,單一職責 = 加 drag affordance。
503
+ *
504
+ * Behavior:
505
+ * - useSortable 永遠 call(Rules of Hooks)— `disabled=true` 時不啟動 listeners
506
+ * - `data: { type: 'column', columnId }` 餵 dnd-kit handleDragStart / handleDragEnd 區分 row/column drag
507
+ * - 注入 ref(setNodeRef)+ transform style + transition + opacity(drag 時 source invisible,DragOverlay 顯 ghost)
508
+ * - draggable 時注入 attributes + listeners + cursor:grab / `data-column-id` (DragOverlay snapshot 用)
509
+ * - locked column / system column → disabled,無 grab cursor / 不啟動 drag
510
+ *
511
+ * 對齊 TanStack Column DnD canonical(<https://tanstack.com/table/latest/docs/framework/react/examples/column-dnd>)
512
+ * + Notion / Airtable header drag UX。 */
513
+ function DraggableHeaderCell({
514
+ id,
515
+ disabled,
516
+ isLocked,
517
+ dropIndicatorSide,
518
+ children,
519
+ }: {
520
+ id: string
521
+ disabled: boolean
522
+ isLocked: boolean
523
+ /** Notion blue line drop indicator(2026-05-06 v14.4):'before' = 左邊緣藍線 / 'after' = 右邊緣藍線 / null = 無 */
524
+ dropIndicatorSide: 'before' | 'after' | null
525
+ children: React.ReactElement
526
+ }) {
527
+ // **v15.0 Path B refactor**(對齊 TreeView SSOT):分離 useDraggable + useDroppable,不 auto-shift
528
+ const draggable = useDraggable({ id, disabled, data: { type: 'column', columnId: id } })
529
+ const droppable = useDroppable({ id, disabled, data: { type: 'column', columnId: id } })
530
+ const setRefs = React.useCallback((el: HTMLElement | null) => {
531
+ draggable.setNodeRef(el)
532
+ droppable.setNodeRef(el)
533
+ }, [draggable.setNodeRef, droppable.setNodeRef])
534
+ const isDragging = draggable.isDragging
535
+ const dragStyle: React.CSSProperties = {
536
+ ...dragSourceStyle(isDragging),
537
+ }
538
+ // cloneElement 注入 — 不額外加 wrapper div(避免破壞 flex / column width 計算)
539
+ const childProps = (children as React.ReactElement<{ style?: React.CSSProperties; className?: string; role?: string }>).props
540
+ // useDraggable.attributes 含 `role="button"` + `tabIndex` 等 — 全部 spread 會蓋掉 header 原 `role="columnheader"`
541
+ // (a11y 必保 columnheader 語意)。strip role + 保留 aria-* / tabIndex / aria-roledescription:
542
+ const { role: _draggableRole, ...draggableAttrs } = draggable.attributes as unknown as Record<string, unknown>
543
+ // Drop indicator(SSOT 對齊 TreeView):2px primary line via pseudo-element
544
+ const indicatorClass = dropIndicatorSide === 'before'
545
+ ? dropIndicatorColumn.pseudoBefore
546
+ : dropIndicatorSide === 'after'
547
+ ? dropIndicatorColumn.pseudoAfter
548
+ : ''
549
+ return React.cloneElement(children as React.ReactElement<Record<string, unknown>>, {
550
+ ref: setRefs,
551
+ style: { ...(childProps.style ?? {}), ...dragStyle },
552
+ 'data-column-id': id,
553
+ 'data-column-locked': isLocked || undefined,
554
+ ...(disabled ? {} : { ...draggableAttrs, ...(draggable.listeners as unknown as Record<string, unknown>) }),
555
+ // 2026-05-06 v14.9 cursor canonical(對齊 Notion / Jira):
556
+ // **idle hover NOT 顯 cursor-grab** — header click 觸發 sort,grab cursor 會誤導 user 以為「點 = 拖」;
557
+ // **drag activation 後**(isDragging=true,過 8px activationConstraint)才顯 cursor-grabbing。
558
+ // user 點 = sort / 長壓 = drag,兩語意分開不互踩。
559
+ className: cn(childProps.className, isDragging && dragActiveCursor, indicatorClass),
560
+ })
561
+ }
562
+
563
+ /** Row drag handle — Portal-rendered, position:fixed 真正水平置中於 table outer border line(Jira canonical)。
564
+ *
565
+ * v15.2 重構:**Button 純視覺 affordance**,不再 spread drag listeners — 改由 row div 整列接 listeners
566
+ * (TreeView SSOT)。Button 只負責顯示「此 row 可拖」的視覺暗示。
567
+ *
568
+ * Why Portal + position:fixed(2026-05-05 v4):
569
+ * DataTable 結構含三層 overflow-hidden(outer wrapper / leftBody / row),用 absolute + translate-x:-50%
570
+ * 凸出 row 左 border 會被三層任一裁切。position:fixed escape 所有 ancestor overflow constraint。
571
+ *
572
+ * - 不佔 column 空間;hover-revealed 透過 row.dataset.hovered MutationObserver 觸發
573
+ * - Button variant="tertiary" iconOnly size="xs"(24px chip)
574
+ * - 任何 row drag 進行時(activeDragId != null)整體隱藏 — 對齊 user directive:
575
+ * drag 期間「INDICATOR + GHOST」就夠了,所有 row 不顯 hover bg / drag button */
576
+ // code-quality-allow: long-function — Portal escape + cross-region hover delegation + MutationObserver + scroll-tracking 4 mechanism 結合在 RowDragHandle 內;每 mechanism 獨立 hook 會破壞 row context coupling
577
+ function RowDragHandle({ disabled, anyDragActive }: { disabled: boolean; anyDragActive: boolean }) {
578
+ const ctx = React.useContext(SortableRowCtx)
579
+ const [rowEl, setRowEl] = React.useState<HTMLDivElement | null>(null)
580
+ const [portalTarget, setPortalTarget] = React.useState<HTMLElement | null>(null)
581
+ const [pos, setPos] = React.useState<{ top: number; left: number; rowHovered: boolean } | null>(null)
582
+ // Portal 逃逸 row DOM → cursor 移到 button 上時 row mouseleave → button hide → cycle flicker(2026-05-05)。
583
+ // Fix:button 自帶 hover state,visibility = rowHovered || buttonHovered || isDragging。
584
+ const [buttonHovered, setButtonHovered] = React.useState(false)
585
+
586
+ // Anchor span ref callback finds the parent row element(自身位置 = row 內部,parentElement = row div)。
587
+ // 用 useState 觸發 effect re-run(child ref callback 會 fire 在 commit phase,early enough for layout effect)
588
+ const anchorRef = React.useCallback((node: HTMLSpanElement | null) => {
589
+ setRowEl((node?.parentElement as HTMLDivElement) ?? null)
590
+ }, [])
591
+
592
+ React.useLayoutEffect(() => {
593
+ if (!rowEl || !ctx || ctx.role !== 'primary') return
594
+
595
+ // Portal target = table outer 的 parent(保持 CSS variable / theme scope 繼承,
596
+ // 不 portal 到 document.body — body 沒 theme tokens 會使 Button tertiary 變透明)
597
+ const tableEl = rowEl.closest<HTMLElement>('[data-data-table-outer]')
598
+ setPortalTarget(tableEl?.parentElement ?? null)
599
+
600
+ const update = () => {
601
+ if (!tableEl) return
602
+ const rRect = rowEl.getBoundingClientRect()
603
+ const tRect = tableEl.getBoundingClientRect()
604
+ // v15.1:drag 期間 source button hide(visible 邏輯已 guard isDragging),
605
+ // 此處只報「真實 hover」狀態,不疊 isDragging mask。
606
+ const rowHovered = rowEl.hasAttribute('data-hovered')
607
+ setPos({
608
+ top: rRect.top + rRect.height / 2,
609
+ left: tRect.left, // table outer 左 border line position(viewport coords)
610
+ rowHovered,
611
+ })
612
+ }
613
+
614
+ update()
615
+
616
+ // Observe row data-hovered changes(cross-region hover delegation 設置 dataset.hovered)
617
+ const observer = new MutationObserver(update)
618
+ observer.observe(rowEl, { attributes: true, attributeFilter: ['data-hovered'] })
619
+
620
+ // Update on scroll(capture phase 抓所有 scroll container)+ resize
621
+ // 2026-05-16 Round 5 codex audit fix:capture rAF ID + cancel on cleanup(原 uncancelled
622
+ // rAF 在 unmount 後可能 fire `update` → setPos on stale ref。Same race-pattern class as
623
+ // useOverflowCount fix `combobox.tsx:130`)。
624
+ let scrollRafId = 0
625
+ const onScroll = () => {
626
+ if (scrollRafId) cancelAnimationFrame(scrollRafId)
627
+ scrollRafId = requestAnimationFrame(() => {
628
+ scrollRafId = 0
629
+ update()
630
+ })
631
+ }
632
+ window.addEventListener('scroll', onScroll, true)
633
+ window.addEventListener('resize', onScroll)
634
+
635
+ return () => {
636
+ observer.disconnect()
637
+ if (scrollRafId) cancelAnimationFrame(scrollRafId)
638
+ window.removeEventListener('scroll', onScroll, true)
639
+ window.removeEventListener('resize', onScroll)
640
+ }
641
+ }, [rowEl, ctx])
642
+
643
+ // 永遠 render anchor span(讓 anchorRef 可拿到 row element)。
644
+ // A3 fix(2026-05-05):顯式 `top:0 left:0 pointer-events:none` — 雖 width/height=0 不該佔
645
+ // flex space,但部分瀏覽器對 abs span 在 flex container 行為微妙 → 顯式座標固定原點,
646
+ // 確保第一個 cell 文字位置不被推開。
647
+ // ctx 為 null 或 mirror role 時 anchor 仍渲染但不渲 handle Portal
648
+ const anchor = (
649
+ <span
650
+ ref={anchorRef}
651
+ aria-hidden
652
+ style={{ position: 'absolute', top: 0, left: 0, width: 0, height: 0, pointerEvents: 'none' }}
653
+ />
654
+ )
655
+
656
+ if (!ctx || ctx.role !== 'primary' || !pos) return anchor
657
+
658
+ const canDrag = !disabled
659
+ const showInvalid = !!ctx.invalidDrop && !!ctx.isDragging
660
+ // Visibility canonical v15.3(對齊 Linear / Jira 世界級 + user directive
661
+ // 「source 的 drag button 反倒是可以留在原本的位置維持被壓住的狀態」):
662
+ // - idle:rowHovered || buttonHovered → 顯示
663
+ // - drag 進行中:**source row 強制顯示 + active 視覺**(讓 user 知道哪個被壓住)
664
+ // 其他 row 的 button 隱藏(由 anyDragActive guard)
665
+ const visible = ctx.isDragging || (!anyDragActive && (pos.rowHovered || buttonHovered))
666
+
667
+ // 2026-05-12 fix(user 抓 image 1):
668
+ // (a) tooltip 偶爾不出 — root cause:`disabled={!canDrag}` HTML attribute 阻 pointer events
669
+ // → Radix Tooltip pointerenter 不 fire → tooltip 不 trigger。Fix:改 `aria-disabled`
670
+ // only(Button cva 已 handle disabled visual via aria-disabled),pointer events 仍 fire,
671
+ // Tooltip stable trigger。
672
+ // (b) drag button bg 透明蓋不住 row content — 加 `bg-surface-raised` overlay。
673
+ // (c) source row drag button 在 drag 中應 dimmed visual — `isDragging` 加 `opacity-disabled`。
674
+ const handle = (
675
+ <Button
676
+ ref={canDrag ? ctx.handleSetActivatorNodeRef : undefined}
677
+ variant="tertiary"
678
+ iconOnly
679
+ size="xs"
680
+ startIcon={GripVertical}
681
+ aria-label={canDrag ? '拖曳重排此列' : '排序中無法拖曳'}
682
+ aria-disabled={!canDrag || undefined}
683
+ tabIndex={canDrag ? 0 : -1}
684
+ // 2026-05-12 fix(a):移除 disabled HTML attr(改 aria-disabled);pointer events 必 fire 才能
685
+ // 接 Tooltip pointerenter。Button cva 已 handle aria-disabled visual styling。
686
+ onMouseEnter={() => setButtonHovered(true)}
687
+ onMouseLeave={() => setButtonHovered(false)}
688
+ style={{
689
+ position: 'fixed',
690
+ top: pos.top,
691
+ left: pos.left,
692
+ transform: 'translate(-50%, -50%)',
693
+ zIndex: 50,
694
+ // 2026-05-12 fix v2(user 抓「drag column sort 啟用時 button 不是 disable 視覺」):
695
+ // 前 Round 4.5 加 `aria-disabled:opacity-[var(--opacity-disabled)]` 在 Button cva
696
+ // 沒生效 — 因為 inline style `opacity` 永遠 win over Tailwind class。Fix:把 disabled
697
+ // state opacity 也 compute 進 inline style。priority order:invisible 0 → drag 0.5 →
698
+ // canDrag=false(sort active)disabled visual var(--opacity-disabled) 0.45 → idle 1。
699
+ opacity: visible
700
+ ? (ctx.isDragging ? 0.5 : (canDrag ? 1 : 'var(--opacity-disabled)' as unknown as number))
701
+ : 0,
702
+ pointerEvents: visible ? 'auto' : 'none',
703
+ transition: 'opacity 150ms ease',
704
+ }}
705
+ className={cn(
706
+ // 2026-05-12 debug fix(user 抓「hover 還是透明」)— Round 4.5 我未授權加
707
+ // `border / shadow / hover:bg-neutral-hover` = over-design + hover override 讓
708
+ // drag button hover bg 變 neutral-hover 跟 row hover bg 同色 → 視覺融入 row = 透明。
709
+ // 撤回:**只保 bg-surface-raised(idle + hover + 所有 state 都同 bg)**,
710
+ // border / shadow / hover override 全 retire(user verbatim「我有叫你加 elevation 嗎」)。
711
+ // 對所有 state(idle / hover / aria-disabled / data-state)套同 bg-surface-raised — 跟
712
+ // row 任何 state 視覺都有 token-level 對比(在 token 差異存在的 mode;light mode --surface-raised
713
+ // 等於 --surface 是 design token semantic,非本 fix scope)。
714
+ 'bg-surface-raised hover:bg-surface-raised aria-disabled:bg-surface-raised',
715
+ canDrag && !showInvalid && 'cursor-grab',
716
+ canDrag && showInvalid && 'cursor-not-allowed !text-error !border-error',
717
+ // drag 進行中 source button cursor(opacity 0.5 via style;aria-disabled visual 由 Button cva 接管)
718
+ ctx.isDragging && 'cursor-grabbing',
719
+ )}
720
+ {...(canDrag ? ctx.handleListeners ?? {} : {})}
721
+ {...(canDrag ? ctx.handleAttributes ?? {} : {})}
722
+ />
723
+ )
724
+
725
+ const wrapped = disabled ? (
726
+ <Tooltip>
727
+ <TooltipTrigger asChild>{handle}</TooltipTrigger>
728
+ <TooltipContent>排序中無法拖曳,清除排序後可重排</TooltipContent>
729
+ </Tooltip>
730
+ ) : handle
731
+
732
+ return (
733
+ <>
734
+ {anchor}
735
+ {portalTarget && createPortal(wrapped, portalTarget)}
736
+ </>
737
+ )
738
+ }
739
+
740
+ // ══════════════════════════════════════════════════════════════════════════════
741
+ // AG Grid 模式:header 在 scroll 外面,body 是唯一的垂直 scroll container。
742
+ //
743
+ // table
744
+ // ├── header(固定頂部,不在 scroll 內)
745
+ // │ ├── left-header
746
+ // │ ├── center-header(overflow:hidden,JS sync scrollLeft)
747
+ // │ └── right-header
748
+ // └── body-viewport(overflow-y:auto,display:flex)
749
+ // ├── left-body(overflow:hidden)
750
+ // ├── center-body(overflow-x:auto, overflow-y:hidden)
751
+ // └── right-body(overflow:hidden)
752
+ //
753
+ // 不用 CSS sticky。Header 永遠在頂部。
754
+ // ══════════════════════════════════════════════════════════════════════════════
755
+
756
+ // code-quality-allow: long-function — foundational composite main body — 拆 sub-fn 會複雜化 local state / ref / context binding
757
+ function DataTableInner<TData>(
758
+ {
759
+ columns, data, size = 'md', autoRowHeight = false, height = '400px',
760
+ overscan = 5, emptyState, enableHover = true, bordered,
761
+ estimateRowHeight, tableOptions, rowActions, cellErrors,
762
+ pinnedLeftColumns, pinnedRightColumns, inlineEdit = false,
763
+ selection: selectionProp, defaultSelection, onSelectionChange,
764
+ selectable = false, isRowSelectable, getRowId, getRowAriaLabel,
765
+ preserveSelectionOnFilter = false,
766
+ columnVisibility: columnVisibilityProp, defaultColumnVisibility, onColumnVisibilityChange,
767
+ enableMultiSort = true,
768
+ onColumnFilterTrigger,
769
+ onCellCommit,
770
+ enableRowDrag = false,
771
+ onRowReorder,
772
+ enableColumnResize = false,
773
+ onColumnResize,
774
+ enableColumnReorder = false,
775
+ onColumnReorder,
776
+ experimentalSpreadsheetOverlay = false,
777
+ experimentalActiveEditorController = false,
778
+ spreadsheetMode = false,
779
+ className, ...props
780
+ }: DataTableProps<TData>,
781
+ ref: React.ForwardedRef<HTMLDivElement>
782
+ ) {
783
+ // ── L4 Inline edit state ──
784
+ // editingCellId: `${rowId}__${columnId}` 標識當前進 edit mode 的 cell;null = 無
785
+ const [editingCellId, setEditingCellId] = React.useState<string | null>(null)
786
+ // Phase 7 D.3 portal Field virtualizer unmount preserve draft(2026-05-10 per codex Q-B4 verdict):
787
+ // Lifted draft state in DataTable — Cell DOM unmount(virtualizer scroll out)時 draft 不丟,
788
+ // mount-back 時 portal Field value=draft 而非 row.value,user 編輯中字保留。
789
+ // editingCellId 變時 useEffect reset draft 到新 cell row.value(全新 edit session)。
790
+ const [editingDraft, setEditingDraft] = React.useState<unknown>(undefined)
791
+ const exitEdit = React.useCallback(() => {
792
+ setEditingCellId(null)
793
+ setEditingDraft(undefined)
794
+ }, [])
795
+
796
+ // ── Slice D Step 4 spreadsheet semantics state(2026-05-10) ──
797
+ // selectedCellId:`${rowId}:${colId}` Excel-like 選取(click 1)
798
+ // rangeAnchor / rangeFocus:Shift+click range 起點 / 終點(rectangle from anchor↔focus)
799
+ const [selectedCellId, setSelectedCellId] = React.useState<string | null>(null)
800
+ const [rangeAnchor, setRangeAnchor] = React.useState<string | null>(null)
801
+ const [rangeFocus, setRangeFocus] = React.useState<string | null>(null)
802
+ // tableRef declared below (line 967) — click-outside effect 在 tableRef ready 後 wire,
803
+ // 為避免 ordering 問題用 forwarded ref query via DOM `[data-data-table-outer]`。
804
+ // 2026-05-12 click-outside canonical(user 抓「選完 range 後點任何地方該清掉 / 選 cell 後點別處該取消」):
805
+ // 對齊 Excel / Google Sheets / Notion / Airtable cell-selection canonical — pointerdown 落在
806
+ // table outer 外 → clear selection + range。內 cell click 由 onClick 自處理(不衝突)。
807
+ React.useEffect(() => {
808
+ if (!spreadsheetMode) return
809
+ if (selectedCellId == null && rangeAnchor == null) return
810
+ const handler = (e: PointerEvent) => {
811
+ const target = e.target as HTMLElement | null
812
+ if (!target) return
813
+ // 點 table outer 外 → clear all selection
814
+ if (!target.closest('[data-data-table-outer]')) {
815
+ setSelectedCellId(null)
816
+ setRangeAnchor(null)
817
+ setRangeFocus(null)
818
+ }
819
+ }
820
+ document.addEventListener('pointerdown', handler, true)
821
+ return () => document.removeEventListener('pointerdown', handler, true)
822
+ }, [spreadsheetMode, selectedCellId, rangeAnchor])
823
+ const commitCell = React.useCallback(
824
+ (rowId: string, colId: string, next: unknown) => {
825
+ onCellCommit?.(rowId, colId, next)
826
+ setEditingCellId(null)
827
+ setEditingDraft(undefined) // Phase 7:commit 後清 draft
828
+ },
829
+ [onCellCommit],
830
+ )
831
+ // 判 column meta.editable 對特定 row 是否成立(支援 fn)
832
+ // column meta 是 free-form consumer bag(同 renderTypedValue any policy),不適合窄型化
833
+ const isCellEditable = React.useCallback(
834
+ // any-allow: free-form consumer meta — same rationale as L143 renderTypedValue
835
+ (meta: Record<string, any> | undefined, row: unknown): boolean => {
836
+ const e = meta?.editable
837
+ if (typeof e === 'function') return e(row) === true
838
+ return e === true
839
+ },
840
+ [],
841
+ )
842
+ // 2026-05-13 Stream C Cluster B Q3 ship(per codex Q3 verdict + user 拍板「全部馬不停蹄做完」):
843
+ // Mirror isCellEditable pattern。`column.meta.disabled` 接 bool 或 (row) => boolean fn。
844
+ // cell disabled → (a) TD 加 `bg-disabled cursor-not-allowed` + 抑制 hover, (b) inner Field
845
+ // 透過 isDisabled prop 走 mode='disabled'(各 Field type 內部具體 disabled token,非 wrapper blanket opacity),
846
+ // (c) edit entry condition: `cellEditable && !cellDisabled`。
847
+ const isCellDisabled = React.useCallback(
848
+ // any-allow: free-form consumer meta — same rationale as L143 renderTypedValue
849
+ (meta: Record<string, any> | undefined, row: unknown): boolean => {
850
+ const d = meta?.disabled
851
+ if (typeof d === 'function') return d(row) === true
852
+ return d === true
853
+ },
854
+ [],
855
+ )
856
+ // 2026-05-13:canEditCell helper consolidation(per codex V4 follow-up + user「沒理由不做」拍板)
857
+ // 抽 4-site repeated `editable && !disabled` pattern。3 path sites(keyboard / Tab / InteractionLayer)
858
+ // call canEditCell;另 2 sites(renderCellContent + onEditableCellClick)用 already-computed `editable`/
859
+ // `disabled` 變數(因為 disabled 還要單獨給 isDisabled prop + bg-disabled class),不 collapse to helper。
860
+ const canEditCell = React.useCallback(
861
+ (meta: Record<string, unknown> | undefined, row: unknown): boolean =>
862
+ isCellEditable(meta, row) && !isCellDisabled(meta, row),
863
+ [isCellEditable, isCellDisabled],
864
+ )
865
+ const [sorting, setSorting] = React.useState<SortingState>(tableOptions?.state?.sorting as SortingState ?? [])
866
+
867
+ // ── L3 Column visibility state(controllable)──
868
+ const [columnVisibility, setColumnVisibility] = useControllable<Record<string, boolean>>({
869
+ value: columnVisibilityProp,
870
+ defaultValue: defaultColumnVisibility ?? {},
871
+ onChange: onColumnVisibilityChange,
872
+ })
873
+
874
+ // ── L2 Selection state ──
875
+ const enabled = selectable !== false
876
+ const mode = selectable === 'single' ? 'single' : 'multi'
877
+ const [selection, setSelection] = useControllable<string[]>({
878
+ value: selectionProp,
879
+ defaultValue: defaultSelection ?? [],
880
+ onChange: onSelectionChange,
881
+ })
882
+ // Shift-click anchor:存最後一次「單擊」的 row id,shift-click 時做區間選
883
+ const anchorRowIdRef = React.useRef<string | null>(null)
884
+
885
+ // 注入 checkbox column(L2 selection;L4 row drag handle 不佔 column,absolute 浮在 row 左 border)
886
+ // 順序:[__select__?, ...consumer columns]
887
+ // **Column resizable canonical**(2026-05-05 user E rule):per-column `enableResizing` flag
888
+ // 決定 width 行為(getCanResize=true → fixed / false → flex 1 1 0%)。**無 auto-default**
889
+ // "last column !resizable" — consumer 顯式設(對齊 user 拒絕「autoFillLastColumn」決策)。
890
+ //
891
+ // **2026-05-06 v14.3 DS canonical width API**:consumer 寫 `meta.width` / `meta.minWidth` /
892
+ // `meta.maxWidth`(DS-internal naming,避開跟 `size: 'sm'|'md'|'lg'` density 命名衝突)。
893
+ // 此 pre-process 把 meta 值 copy 到 root size/minSize/maxSize,確保 TanStack column
894
+ // resize state 正常運作。**Back-compat**:consumer 寫 root `size` 仍 work(meta.width 沒設則
895
+ // 不覆蓋 root)。新 code 一律用 meta.width。
896
+ const dsProcessedColumns = React.useMemo<ColumnDef<TData>[]>(() => {
897
+ return columns.map((c) => {
898
+ const meta = c.meta as { width?: number; minWidth?: number; maxWidth?: number } | undefined
899
+ if (!meta) return c
900
+ const cAny = c as { size?: number; minSize?: number; maxSize?: number }
901
+ const updates: { size?: number; minSize?: number; maxSize?: number } = {}
902
+ if (meta.width !== undefined && cAny.size === undefined) updates.size = meta.width
903
+ if (meta.minWidth !== undefined && cAny.minSize === undefined) updates.minSize = meta.minWidth
904
+ if (meta.maxWidth !== undefined && cAny.maxSize === undefined) updates.maxSize = meta.maxWidth
905
+ return Object.keys(updates).length > 0 ? ({ ...c, ...updates } as ColumnDef<TData>) : c
906
+ })
907
+ }, [columns])
908
+
909
+ const columnsWithSelection = React.useMemo(() => {
910
+ if (!enabled) return dsProcessedColumns
911
+ const selectCol: ColumnDef<TData, any> = {
912
+ id: SELECT_COL_ID,
913
+ size: 40,
914
+ enableSorting: false,
915
+ enableResizing: false,
916
+ enableHiding: false, // selection col 不能藏(L3 column visibility)
917
+ header: 'Select', // header cell 由下方自訂 render 取代
918
+ cell: () => null, // body cell 由下方自訂 render 取代
919
+ }
920
+ return [selectCol, ...dsProcessedColumns]
921
+ }, [dsProcessedColumns, enabled])
922
+
923
+ // pinned-left 自動加 __select__(__select__ 永遠最左)
924
+ const effectivePinnedLeft = React.useMemo(() => {
925
+ const list = pinnedLeftColumns ?? []
926
+ const out = [...list]
927
+ if (enabled && !out.includes(SELECT_COL_ID)) out.unshift(SELECT_COL_ID)
928
+ return out
929
+ }, [pinnedLeftColumns, enabled])
930
+
931
+ // columnOrder 自動加 __select__ 在最前:consumer 傳的 columnOrder 通常只列 data
932
+ // columns,TanStack 會把不在 order 的 column 推到末位 → 同步幫他補上
933
+ const userColumnOrder = tableOptions?.state?.columnOrder
934
+ const effectiveColumnOrder = React.useMemo(() => {
935
+ if (!userColumnOrder) return userColumnOrder
936
+ if (!enabled) return userColumnOrder
937
+ const out = [...userColumnOrder]
938
+ if (enabled && !out.includes(SELECT_COL_ID)) out.unshift(SELECT_COL_ID)
939
+ return out
940
+ }, [userColumnOrder, enabled])
941
+
942
+ // 注意:`...tableOptions` 必 spread 在 `state` 前,否則 user 傳的 tableOptions 會
943
+ // 整個 override 掉我們組的 state(含 __select__ 自動 pinning + columnOrder 注入)。
944
+ // 之前 bug:checkbox column 跑到右邊 = 此處 spread 順序錯。
945
+ const table = useReactTable({
946
+ ...tableOptions,
947
+ data, columns: columnsWithSelection,
948
+ state: {
949
+ sorting, columnVisibility,
950
+ ...tableOptions?.state,
951
+ // columnPinning + columnOrder 在 user state 後 override,確保 __select__ 永遠左
952
+ columnPinning: { left: effectivePinnedLeft, right: pinnedRightColumns ?? [] },
953
+ ...(effectiveColumnOrder ? { columnOrder: effectiveColumnOrder } : {}),
954
+ },
955
+ enableMultiSort,
956
+ // **#1 fix(2026-05-04)**:chain user `tableOptions.onSortingChange`(spread 在前被 override = 之前 bug)
957
+ // 同 onColumnVisibilityChange:both internal setState + forward 給 user external state
958
+ onSortingChange: (updater) => {
959
+ setSorting(updater)
960
+ tableOptions?.onSortingChange?.(updater)
961
+ },
962
+ onColumnVisibilityChange: (updater) => {
963
+ const next = typeof updater === 'function' ? updater(columnVisibility) : updater
964
+ setColumnVisibility(next)
965
+ tableOptions?.onColumnVisibilityChange?.(updater)
966
+ },
967
+ getCoreRowModel: getCoreRowModel(),
968
+ getSortedRowModel: getSortedRowModel(),
969
+ // L4 nested rows:啟用 expanded row model(consumer 透過 tableOptions.getSubRows + state.expanded forward)
970
+ getExpandedRowModel: getExpandedRowModel(),
971
+ getRowId: getRowId,
972
+ // 2026-05-06 v14 column resize:`onChange` mode → drag 中 column 即時跟動 cursor(world-class
973
+ // canonical:TanStack docs / AG Grid / Excel / Google Sheets 全部 live resize)。前 v13.2
974
+ // 用 `onEnd` 拖完才 jump,user 報「感覺超頓像 bug」。tanstack 內部管 columnSizing state
975
+ // (uncontrolled);`columnSizingState` 變動透過 useEffect 觀測 + 呼 callback。
976
+ //
977
+ // 前 v11 用 `onColumnSizingChange` 接管 updater 但忘了 setColumnSizing,導致 state 永遠不變動 →
978
+ // column.getSize() 永遠回初始值 → drag visual 完全沒效果(user 報 "drag 沒反應")。本 v13.2 改回
979
+ // tanstack uncontrolled state(預設行為)+ useEffect 觀測 columnSizing 變動 fire callback。
980
+ enableColumnResizing: enableColumnResize,
981
+ columnResizeMode: 'onChange',
982
+ })
983
+
984
+ // v13.2:onColumnResize callback 透過 useEffect 觀測 columnSizing state 變動 fire(uncontrolled state pattern)
985
+ const columnSizingState = table.getState().columnSizing
986
+ const prevColumnSizingRef = React.useRef(columnSizingState)
987
+ React.useEffect(() => {
988
+ if (!onColumnResize) return
989
+ const prev = prevColumnSizingRef.current
990
+ Object.keys(columnSizingState).forEach(id => {
991
+ if (columnSizingState[id] !== prev[id]) {
992
+ onColumnResize(id, columnSizingState[id])
993
+ }
994
+ })
995
+ prevColumnSizingRef.current = columnSizingState
996
+ }, [columnSizingState, onColumnResize])
997
+
998
+ const { rows } = table.getRowModel()
999
+ const isEmpty = rows.length === 0
1000
+ const hasHeightConstraint = height !== 'auto'
1001
+ // Fill-parent mode:height='100%' / '100vh' / 'fill' 等百分比 / 視口語義 → outer flex column + body flex-1 撐滿。
1002
+ // 固定 px/rem 仍維持 maxHeight cap 行為(資料少 = 內容高度,資料多 = 上限後 scroll)— 對齊既有 stories 預期。
1003
+ const isFillHeight = hasHeightConstraint && /^(100%|100vh|fill)$/.test(height)
1004
+ // **Virtualization threshold(2026-05-07 v15.9 Bug G fix)**:小資料集 skip 虛擬化。
1005
+ // Root cause:虛擬化器(TanStack Virtual)`getVirtualItems()` 在 scrollElement
1006
+ // 還沒 mount(first render,centerBodyRef = null)時會返回空陣列 →「0 row → N row」
1007
+ // 跨 frame transition,user 看到「table 從矮長高 + 資料慢慢露出」。≤ 30 rows
1008
+ // direct render 完全 bypass 此 race,且小資料下虛擬化沒效益(浪費 reflow)。
1009
+ // 對齊 AG Grid `suppressVirtualization` / TanStack Table virtualization-when-needed idiom。
1010
+ const VIRTUAL_THRESHOLD = 30
1011
+ const useVirtual = hasHeightConstraint && !isEmpty && rows.length > VIRTUAL_THRESHOLD
1012
+ const hasRowActions = !!rowActions
1013
+
1014
+ // Refs
1015
+ const tableRef = React.useRef<HTMLDivElement | null>(null)
1016
+ const bodyRef = React.useRef<HTMLDivElement>(null)
1017
+ const centerHeaderRef = React.useRef<HTMLDivElement>(null)
1018
+ const centerBodyRef = React.useRef<HTMLDivElement>(null)
1019
+ const leftHeaderRef = React.useRef<HTMLDivElement>(null)
1020
+ const rightHeaderRef = React.useRef<HTMLDivElement>(null)
1021
+ const [leftWidth, setLeftWidth] = React.useState(0)
1022
+ const [rightWidth, setRightWidth] = React.useState(0)
1023
+
1024
+ // estimate 預設 size-aware 對齊 token(--table-row-{sm,md,lg} = 32/40/48 md density)
1025
+ // Q7 fix(2026-05-04):前用 hardcode 36 跟真高 40 差 4px,N rows 累積誤差呈現「table 慢慢長高」假象。
1026
+ // ResizeObserver+measureElement 的修正過程被 user 看見 = mount-time growth bug 的真因。
1027
+ const ESTIMATE_BY_SIZE: Record<string, number> = { sm: 32, md: 40, lg: 48 }
1028
+ const resolvedEstimate = estimateRowHeight ?? ESTIMATE_BY_SIZE[size] ?? 40
1029
+ // 2026-05-06 v10 DragOverlay canonical:retire windowed sticky range extractor (v4-v9 workaround)。
1030
+ // 改用 `<DragOverlay>` portal 把 source row 視覺解耦 — source 即使 unmount(virtual scroll out)
1031
+ // overlay 仍 render 由 cloned outerHTML 提供視覺。dnd-kit transform / collision 走 active item id
1032
+ // (id 永遠在 SortableContext.items 集合,跟 hook instance mount 狀態無關)。
1033
+ // 對齊 dnd-kit GitHub #1674 + drag-overlay docs canonical「virtualized list MUST use DragOverlay」。
1034
+ // overscan 仍輕微拉高(避免 source row 旁邊 rows 也 unmount 致使 hover signal 計算抖動)。
1035
+ const effectiveOverscan = enableRowDrag ? Math.max(overscan, 5) : overscan
1036
+ const activeDragIdRef = React.useRef<string | null>(null)
1037
+
1038
+ const virtualizer = useVirtualizer({
1039
+ count: useVirtual ? rows.length : 0,
1040
+ // V scroll 現在在 centerBodyRef(不是外層 bodyRef)
1041
+ getScrollElement: () => centerBodyRef.current,
1042
+ estimateSize: () => resolvedEstimate,
1043
+ overscan: effectiveOverscan, enabled: useVirtual,
1044
+ // 2026-05-14 P3 perf tune(per codex+Layer A 共識,user 拍板「全部做完」+
1045
+ // CPU-throttle-reproducible verify infra):150ms → 250ms 減少 scroll
1046
+ // start/end flip 次數 → TableScrollContext 重 cascade visible rich cell
1047
+ // tree 機會降低。對齊 TanStack Virtual `isScrollingResetDelay` API。
1048
+ isScrollingResetDelay: 250,
1049
+ })
1050
+
1051
+ // ── isFillHeight body maxHeight JS 計算(2026-04-30)──
1052
+ // CSS `%` height 在 flex column min-h-0 + auto basis 場景下,Chromium 不可靠 shrink
1053
+ // (實測:outer maxHeight 100% bind parent,但 body 不 shrink 反映 outer 約束 → outer
1054
+ // overflow-hidden 切掉 content,V scroll 不 trigger)。
1055
+ // 改用 ResizeObserver 算 body avail = outer rect - header rect → set centerBody
1056
+ // maxHeight = pixel value(不是 %)。content 大 → V scroll;content 小 → centerBody
1057
+ // = content,outer = intrinsic,沒留白。
1058
+ // **Q7 mount-time growth fix(2026-05-04 v3 真因)**:不是 visibility race,是 estimateRowHeight
1059
+ // 預設 36 ≠ 真實 row height(token md=40 / sm=32 / lg=48),virtualizer initial total = 6×36 = 216,
1060
+ // 後續 measureElement 修正到 6×40 = 240,差 24px 視覺看起來像「table 慢慢長高」。fix = estimate
1061
+ // 預設 size-aware 對齊 token(見下方 estimateRowHeight default 計算)。
1062
+ const [bodyMaxHeight, setBodyMaxHeight] = React.useState<number | null>(null)
1063
+ React.useLayoutEffect(() => {
1064
+ if (!isFillHeight) { setBodyMaxHeight(null); return }
1065
+ // **R4 真根因 fix(2026-05-09 v2 — codex Q3.6 root cause + Q3.9 reproduce verified)**:
1066
+ //
1067
+ // Bug:isFillHeight 時 outer 用 `style={{ maxHeight: height }}`(L1819-1824)沒 explicit height
1068
+ // → outer.getBoundingClientRect().height 受 children 反向影響(因 outer = children intrinsic,
1069
+ // children 又被 bodyMaxHeight 限制)→ **circular dependency**。
1070
+ //
1071
+ // 表現:viewport / layout 變化時 parent 變(392→672)但 outer 永遠卡(282)→ body 永遠 240,
1072
+ // 不跟 parent 變大時填滿。Initial mount 過程則看起來像 stepping(parent 從 0 慢慢長,outer 跟著
1073
+ // 一階一階長)。
1074
+ //
1075
+ // 真 fix:**改量 parent slot,不量 outer**。Parent 是 definite height 限制因子,不被 child 反向影響。
1076
+ // - rAF coalesce:RO callback 多次觸發 → 1 frame 內只 compute 1 次(降頻,防 RO 連續 fire redundant)
1077
+ // - diff guard:< 1px 不 setState(防 micro-step)
1078
+ // - **observe parent 而非 self**(打破 circular)
1079
+ //
1080
+ // Codex root cause cite:circular feedback `tableRef.height ↔ bodyMaxHeight ↔ body layout ↔ tableRef.height`
1081
+ // Reproduce verified:viewport 1280→1920→900,parentH 392/672/292 變化,但 a524e03 fix 下 bodyRectH 永遠 240。
1082
+ let rafId: number | null = null
1083
+ let stableTimer: ReturnType<typeof setTimeout> | null = null
1084
+ let lastValue: number | null = null
1085
+ let pendingValue: number | null = null
1086
+ // 2026-05-21 v4 真根因 fix(per user「請你仔細查查,務必仔細查」+「確保這個問題不再出現」):
1087
+ // 即使 v3(observe parent + rAF + diff guard < 4px),Tabs / Storybook iframe / nested
1088
+ // AppShell flex chain 仍可能 100ms+ settle period 內每 frame growth > 4px → setState
1089
+ // 多次 fire → user 視覺「stepping growth」。
1090
+ //
1091
+ // **v4 加 stability window**:layout 連續 100ms 無變動才 setState。意味:
1092
+ // - 初始 mount:bodyMaxHeight=null → body 不受 maxHeight 限制 → 顯全內容(intrinsic 高度)
1093
+ // - RO 多 frame fire(layout settling):每 fire reset timer,setState 不 fire
1094
+ // - Layout 穩定 100ms:setState fire 最終值,body 套 constraint(若 parent > content 無視覺變化)
1095
+ // - 真實 resize(viewport 縮 / aside toggle):δ 必 ≫ 4px + 跨多 frame,timer 自然 settle
1096
+ // 對齊 TanStack Virtual `observeElementRect` + Material X-DataGrid 「resize debounce 100ms」慣例。
1097
+ const compute = () => {
1098
+ if (!tableRef.current) return
1099
+ // ⭐ 量 parent slot(definite height,不受 child 反向影響),fallback 用 outer
1100
+ const parentEl = tableRef.current.parentElement
1101
+ const slotH = parentEl?.getBoundingClientRect().height
1102
+ ?? tableRef.current.getBoundingClientRect().height
1103
+ const headerEl = tableRef.current.firstElementChild as HTMLElement | null
1104
+ const headerH = headerEl?.getBoundingClientRect().height ?? 0
1105
+ const next = Math.max(0, slotH - headerH)
1106
+ // Diff guard < 4px(濾 micro-step,real resize δ 必 ≫ 4px)
1107
+ if (lastValue != null && Math.abs(next - lastValue) < 4) return
1108
+ lastValue = next
1109
+ pendingValue = next
1110
+ // Stability window 100ms:layout 連續 100ms 無變才 setState
1111
+ if (stableTimer != null) clearTimeout(stableTimer)
1112
+ stableTimer = setTimeout(() => {
1113
+ if (pendingValue != null) setBodyMaxHeight(pendingValue)
1114
+ stableTimer = null
1115
+ }, 100)
1116
+ }
1117
+ const scheduleCompute = () => {
1118
+ if (rafId != null) return
1119
+ rafId = requestAnimationFrame(() => {
1120
+ rafId = null
1121
+ compute()
1122
+ })
1123
+ }
1124
+ compute() // initial schedule(會 enter stability window 等 100ms settle)
1125
+ // ⭐ 只 observe parent,不 observe tableRef(打破 circular)
1126
+ const obs = new ResizeObserver(scheduleCompute)
1127
+ if (tableRef.current?.parentElement) obs.observe(tableRef.current.parentElement)
1128
+ return () => {
1129
+ obs.disconnect()
1130
+ if (rafId != null) cancelAnimationFrame(rafId)
1131
+ if (stableTimer != null) clearTimeout(stableTimer)
1132
+ }
1133
+ }, [isFillHeight])
1134
+
1135
+ // JS scroll sync(AR44 user-reported UX fix):
1136
+ // 原本 V scroll 在 body-viewport(外層),center-body H scroll 於其內部底部 = 所有 row 都 render 下方。
1137
+ // Virtualized 1800px 內容 → H scrollbar 在 1800px 下方,user 必須 V-scroll 到底才看見 → UX bug。
1138
+ // **現在 V scroll 移到各 region 自己(left / center / right 分別)**,三者 scrollTop JS 同步;
1139
+ // H scroll 仍在 center-body,但因 center-body 現在有自己的 maxHeight,H scrollbar 落在 visible 視窗底部 → user 一眼看到。
1140
+ const leftBodyRef = React.useRef<HTMLDivElement>(null)
1141
+ const rightBodyRef = React.useRef<HTMLDivElement>(null)
1142
+ const onCenterBodyScroll = React.useCallback(() => {
1143
+ const cb = centerBodyRef.current
1144
+ if (!cb) return
1145
+ if (centerHeaderRef.current) centerHeaderRef.current.scrollLeft = cb.scrollLeft
1146
+ if (leftBodyRef.current) leftBodyRef.current.scrollTop = cb.scrollTop
1147
+ if (rightBodyRef.current) rightBodyRef.current.scrollTop = cb.scrollTop
1148
+ }, [])
1149
+
1150
+ // ── Phase 9 Issue 1 fix(2026-05-10):range cells lifted compute + Set ────
1151
+ // 計算 spreadsheet range cell IDs(Shift+click rectangle from anchor↔focus),
1152
+ // 提供:
1153
+ // 1. `rangeCellIds` array → pass to layer for outer ring 4 line div boundary
1154
+ // 2. `rangeCellIdSet` Set → cell wrapper data-range-cell attr for cell-bg fill
1155
+ // (per codex Q1 verdict:bg fill 移到 cell bg layer 不在 overlay,內容才不被蓋)
1156
+ const rangeCellIds = React.useMemo<string[] | undefined>(() => {
1157
+ if (!spreadsheetMode || !rangeAnchor || !rangeFocus || rangeAnchor === rangeFocus) return undefined
1158
+ const parseCell = (id: string) => {
1159
+ const lastColon = id.lastIndexOf(':')
1160
+ return { rowId: id.slice(0, lastColon), colId: id.slice(lastColon + 1) }
1161
+ }
1162
+ const a = parseCell(rangeAnchor)
1163
+ const f = parseCell(rangeFocus)
1164
+ const allRows = table.getRowModel().rows.map((r) => r.id)
1165
+ const allCols = table.getVisibleLeafColumns().map((c) => c.id).filter((id) => id !== SELECT_COL_ID)
1166
+ const aRowIdx = allRows.indexOf(a.rowId)
1167
+ const fRowIdx = allRows.indexOf(f.rowId)
1168
+ const aColIdx = allCols.indexOf(a.colId)
1169
+ const fColIdx = allCols.indexOf(f.colId)
1170
+ if (aRowIdx < 0 || fRowIdx < 0 || aColIdx < 0 || fColIdx < 0) return undefined
1171
+ const rowStart = Math.min(aRowIdx, fRowIdx)
1172
+ const rowEnd = Math.max(aRowIdx, fRowIdx)
1173
+ const colStart = Math.min(aColIdx, fColIdx)
1174
+ const colEnd = Math.max(aColIdx, fColIdx)
1175
+ const ids: string[] = []
1176
+ for (let r = rowStart; r <= rowEnd; r++) {
1177
+ for (let c = colStart; c <= colEnd; c++) {
1178
+ ids.push(`${allRows[r]}:${allCols[c]}`)
1179
+ }
1180
+ }
1181
+ return ids
1182
+ // any-allow: react-table runtime lookup
1183
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1184
+ }, [spreadsheetMode, rangeAnchor, rangeFocus, table])
1185
+ const rangeCellIdSet = React.useMemo(() => new Set(rangeCellIds || []), [rangeCellIds])
1186
+
1187
+ // 三區域欄位
1188
+ const leftCols = table.getLeftVisibleLeafColumns()
1189
+ const centerCols = table.getCenterVisibleLeafColumns()
1190
+ const rightCols = table.getRightVisibleLeafColumns()
1191
+ const hasLeft = leftCols.length > 0
1192
+ const hasRight = rightCols.length > 0 || hasRowActions
1193
+ // 2026-05-06 v13.1:Center region SSOT width — header inner wrapper + body inner wrapper 共用
1194
+ // 同一個 minWidth 算法,確保 header / body cell 寬度永遠對齊(前 v8 用 `w-max min-w-full`
1195
+ // 在 header(content max-content 小)vs body(content max-content 大)會 diverge 76+ px,
1196
+ // user 報「header / row 對不起來」)。
1197
+ const centerColsWidth = centerCols.reduce((a, c) => a + c.getSize(), 0)
1198
+
1199
+ // Header 寬度 → body region 同步(virtual mode 需要明確寬度)
1200
+ React.useEffect(() => {
1201
+ const measure = () => {
1202
+ if (leftHeaderRef.current) setLeftWidth(leftHeaderRef.current.offsetWidth)
1203
+ if (rightHeaderRef.current) setRightWidth(rightHeaderRef.current.offsetWidth)
1204
+ }
1205
+ measure()
1206
+ const obs = new ResizeObserver(measure)
1207
+ if (leftHeaderRef.current) obs.observe(leftHeaderRef.current)
1208
+ if (rightHeaderRef.current) obs.observe(rightHeaderRef.current)
1209
+ return () => obs.disconnect()
1210
+ }, [hasLeft, hasRight, rows.length])
1211
+
1212
+ const rowHeight = `h-table-row-${size}`
1213
+
1214
+ // ── Cross-region row hover (2026-04-22 D3 perf audit):event delegation 改 per-row closure
1215
+ // 舊:每 row 建 `{ onMouseEnter, onMouseLeave }` + 2 arrow functions → 100 row = 200 closures/render
1216
+ // 新:表格層 single onMouseOver / onMouseOut,透過 event.target.closest 找 data-row-index
1217
+ const enterLeaveHandlers = React.useMemo(() => {
1218
+ if (!enableHover) return { onMouseOver: undefined, onMouseOut: undefined }
1219
+ const findRowIndex = (target: EventTarget | null): string | null => {
1220
+ if (!(target instanceof HTMLElement)) return null
1221
+ const rowEl = target.closest<HTMLElement>('[data-row-index]')
1222
+ return rowEl?.dataset.rowIndex ?? null
1223
+ }
1224
+ return {
1225
+ onMouseOver: (e: React.MouseEvent) => {
1226
+ // v15.3:drag 進行中只允許 source row 自己被標 hover(維持 active 視覺
1227
+ // 對齊 Linear / Jira「source 維持 pressed 狀態」canonical)。其他 row 抑制。
1228
+ if (activeDragIdRef.current != null) {
1229
+ const target = e.target instanceof HTMLElement ? e.target : null
1230
+ const rowEl = target?.closest<HTMLElement>('[data-sortable-row-id]')
1231
+ const isSource = rowEl?.dataset.sortableRowId === activeDragIdRef.current
1232
+ if (!isSource) return
1233
+ }
1234
+ const idx = findRowIndex(e.target)
1235
+ if (idx == null) return
1236
+ tableRef.current?.querySelectorAll(`[data-row-index="${idx}"]`).forEach((el) => ((el as HTMLElement).dataset.hovered = ''))
1237
+ },
1238
+ onMouseOut: (e: React.MouseEvent) => {
1239
+ const idx = findRowIndex(e.target)
1240
+ if (idx == null) return
1241
+ // 仍在同一 row 的子元素間 bubble(e.g. cell → text node)則 relatedTarget 還在 row 內
1242
+ const related = e.relatedTarget instanceof HTMLElement ? e.relatedTarget.closest<HTMLElement>('[data-row-index]') : null
1243
+ if (related?.dataset.rowIndex === idx) return
1244
+ tableRef.current?.querySelectorAll(`[data-row-index="${idx}"]`).forEach((el) => delete (el as HTMLElement).dataset.hovered)
1245
+ },
1246
+ }
1247
+ }, [enableHover])
1248
+ // 維持 API:hoverProps(idx) 仍存在但 no-op,實際邏輯搬到 table 層 delegation
1249
+ const hoverProps = (_idx: number): Record<string, never> => ({})
1250
+
1251
+ // ── Cell render(Phase C 2026-05-05 — type-keyed registry SSOT)──
1252
+ // 命中 columnType → 走 cellRegistry(display / edit mode 同元件 with `mode` prop);
1253
+ // 無 columnType → consumer 自訂 cell.columnDef.cell。
1254
+ const renderCellContent = (cell: ReturnType<typeof rows[number]['getVisibleCells']>[number]) => {
1255
+ const meta = cell.column.columnDef.meta
1256
+ const colType = meta?.type as ColumnType | undefined
1257
+ const wrap = autoRowHeight && meta?.wrap === true
1258
+ // 已知 compound 欄位(Tag / PersonDisplay / LinkInput 等自帶 layout)直接 bypass TruncateCell,
1259
+ // 因為 `truncate` 的 inline baseline context 會破壞自訂 layout 的垂直對齊。
1260
+ // 2026-05-09 D-path:date / time 加入(showDisplayEndIcon → Field naked-display 需 full width 才能
1261
+ // 右對齊 ItemSuffix。TruncateCell 的 `<span truncate min-w-0>` block-display 會 collapse Field
1262
+ // to content size,讓 Calendar / Clock icon 緊貼 value text 而非右邊緣)。
1263
+ const isKnownCompound = colType === 'select' || colType === 'multiSelect' || colType === 'person' || colType === 'multiPerson' || colType === 'url' || colType === 'date' || colType === 'time'
1264
+ const rowId = cell.row.id
1265
+ const colId = cell.column.id
1266
+ const editable = isCellEditable(meta, cell.row.original)
1267
+ const disabled = isCellDisabled(meta, cell.row.original)
1268
+ const isEditingThisCell = editingCellId === cellEditId(rowId, colId)
1269
+
1270
+ let content: React.ReactNode
1271
+ if (colType) {
1272
+ const Cell = resolveCellComponent(colType)
1273
+ // 2026-05-10 Slice D Step 5(D.3 portal Field):當 portal flag 啟 + cell 編輯中 →
1274
+ // cell 保持 display mode(SSOT preserved per codex Q6.2),portal layer 渲 edit Field 在上。
1275
+ // 預設 inline-edit:isEditingThisCell ? edit : display。
1276
+ // 2026-05-13 Q3 cell-disabled:disabled cell 永遠 display lifecycle(state overlay,不進 edit)。
1277
+ const cellMode: 'edit' | 'display' =
1278
+ (experimentalActiveEditorController && isEditingThisCell)
1279
+ ? 'display'
1280
+ : (isEditingThisCell && !disabled) ? 'edit' : 'display'
1281
+ content = (
1282
+ <Cell
1283
+ value={cell.getValue()}
1284
+ meta={meta ?? {}}
1285
+ mode={cellMode}
1286
+ size={size}
1287
+ autoRowHeight={autoRowHeight}
1288
+ isEditable={editable}
1289
+ isDisabled={disabled}
1290
+ onCommit={(next) => commitCell(rowId, colId, next)}
1291
+ onCommitLive={(next) => onCellCommit?.(rowId, colId, next)}
1292
+ onCancel={exitEdit}
1293
+ onRequestEdit={() => !disabled && setEditingCellId(cellEditId(rowId, colId))}
1294
+ />
1295
+ )
1296
+ } else {
1297
+ content = flexRender(cell.column.columnDef.cell, cell.getContext())
1298
+ }
1299
+ // Consumer 自訂 cell(無 colType)若回傳 React element,視為 compound — consumer 自己處理
1300
+ // 對齊與截斷。回傳 primitive(string / number)才走 TruncateCell。
1301
+ // 理由:TruncateCell 的 `span.truncate` 強制 white-space:nowrap + inline baseline,
1302
+ // 對 inline-flex / icon+text 自訂結構會拉歪(見 circular-progress sync table 案例)。
1303
+ // **edit mode bypass**(2026-05-05 v9 Bug 2 修):editing cell 內部是 Field 控件
1304
+ // (Input/Textarea/Select etc.)自管 layout + 替代元素(textarea)不該被包進 inline span
1305
+ // baseline context — 否則 line-box descender 加 5-7px 致 cell 進 edit 後 row 撐高 layout shift。
1306
+ const isConsumerCompound = !colType && React.isValidElement(content)
1307
+ return isEditingThisCell ? content
1308
+ : wrap ? <span className="break-words min-w-0">{content}</span>
1309
+ : (isKnownCompound || isConsumerCompound) ? content
1310
+ : <TruncateCell>{content}</TruncateCell>
1311
+ }
1312
+
1313
+ // 2026-05-18 改 import ICON_SIZE SSOT(per user『做完』approval,消除 M17 違反 7+ 重複 ternary)
1314
+ const iconSize = ICON_SIZE[size as 'sm' | 'md' | 'lg']
1315
+
1316
+ // 2026-05-09 D-path retired:`getEditIndicator(colType)` parallel system 移除。
1317
+ // Indicator authority 從 DataTable cellEl 移交 **Field naked-display branch via `showDisplayEndIcon` opt-in**
1318
+ // — Select / TimePicker / DatePicker / Combobox / PeoplePicker 5 picker 的 display mode 內建
1319
+ // `<ItemSuffix>` 渲對應 trigger icon(同 edit DOM 結構)。LinkInput URL anchor 例外(無 suffix)。
1320
+ // SSOT chain:cell-registry.tsx(opt-in props)→ Field component(intrinsic icon + ItemSuffix DOM)→
1321
+ // item-anatomy ItemPrefix/ItemSuffix layout SSOT。詳 `.claude/planning/cell-indicator-ssot-rfc.md`。
1322
+ // 不再有 DataTable-level cell indicator code path — 跨元件 SSOT 對齊 Field family。
1323
+
1324
+ // L4 row drag:sort active 時 drag handle disabled(對齊 Notion / Airtable 共識)
1325
+ const dragDisabled = sorting.length > 0
1326
+
1327
+ // ── L4 Row drag v2:nested rows + parent map ─────────────────────────────────
1328
+ // v2 cross-parent fix:全 row 進 SortableContext.items(含 sub-rows),custom collisionDetection
1329
+ // 過濾掉 cross-parent over candidates,保留「同 parent siblings」。沒命中 → invalid drop signal。
1330
+ // parentMap: rowId → parentId(top-level row 的 parent = '' 哨兵 string)
1331
+ const { allRowIds, parentMap } = React.useMemo(() => {
1332
+ const ids: string[] = []
1333
+ const pmap = new Map<string, string>()
1334
+ const walk = (r: typeof rows[number], parentId: string) => {
1335
+ ids.push(r.id)
1336
+ pmap.set(r.id, parentId)
1337
+ const subs = (r as unknown as { subRows?: typeof rows }).subRows
1338
+ if (subs && Array.isArray(subs)) subs.forEach(s => walk(s, r.id))
1339
+ }
1340
+ rows.forEach(r => { if ((r.depth ?? 0) === 0) walk(r, '') })
1341
+ return { allRowIds: ids, parentMap: pmap }
1342
+ }, [rows])
1343
+
1344
+ // active drag state(state for invalid signal re-render;ref for fast lookup in collisionDetection)
1345
+ const [activeDragId, setActiveDragId] = React.useState<string | null>(null)
1346
+ // sync ref + force virtualizer recompute so rangeExtractor 看得到新 active id(M25 chain invariant)
1347
+ React.useEffect(() => {
1348
+ activeDragIdRef.current = activeDragId
1349
+ if (enableRowDrag && useVirtual) virtualizer.measure()
1350
+ }, [activeDragId, enableRowDrag, useVirtual, virtualizer])
1351
+ const [invalidDropActive, setInvalidDropActive] = React.useState(false)
1352
+ // code-quality-allow: long-function — audit 誤偵測 invalidRef 為 function;真實 long-function = 下方 cellEl(L1334+,已標 markers per L1336)。type-shadow,不需 refactor
1353
+ const invalidRef = React.useRef(false)
1354
+ invalidRef.current = invalidDropActive
1355
+
1356
+ // code-quality-allow: long-function — cell render 含 selection / pinned / type-aware formatter 三邏輯,拆會增 prop drilling
1357
+ const cellEl = (cell: ReturnType<typeof rows[number]['getVisibleCells']>[number], _isLastInRow = false) => {
1358
+ // L2 selection:__select__ 欄自訂 render
1359
+ // multi 模式 → Checkbox(可多選)
1360
+ // single 模式 → Radio(單選 visual,對齊 Material DataGrid / Polaris IndexTable canonical)
1361
+ if (enabled && cell.column.id === SELECT_COL_ID) {
1362
+ const rowId = cell.row.id
1363
+ const rowOriginal = cell.row.original
1364
+ const isDisabled = isRowSelectable ? !isRowSelectable(rowOriginal) : false
1365
+ const ariaLabel = getRowAriaLabel?.(rowOriginal) ?? 'Select row'
1366
+ const checkboxSize = size === 'lg' ? 'lg' : 'md'
1367
+ // Cell 整格可點:click cell padding 也觸發 toggle/select(對齊 Linear / Apple Mail / Material DataGrid)
1368
+ // 內部 checkbox/radio 用 stopPropagation 避免 double-fire
1369
+ const onCellClick = isDisabled ? undefined : (e: React.MouseEvent) => {
1370
+ e.stopPropagation()
1371
+ if (mode === 'single') setSelection([rowId])
1372
+ else toggleRow(rowId, rowOriginal, { shiftKey: e.shiftKey })
1373
+ }
1374
+ return (
1375
+ <div
1376
+ key={cell.id}
1377
+ role="cell"
1378
+ // data-column-id 給 CSS scope:`[data-column-id="__select__"]` 在 data-table.css 加
1379
+ // border-right divider,視覺把 system selection col 跟 data col 切開(Notion / Airtable
1380
+ // / Linear idiom)。**只有 inlineEdit + selectable 模式且 select 不在 leftBody 邊界時** style
1381
+ // 才生效(避免雙線)— CSS 用 `:not(:last-child)` selector 處理。
1382
+ data-column-id={SELECT_COL_ID}
1383
+ className={cn('flex items-center justify-center shrink-0', !isDisabled && 'cursor-pointer')}
1384
+ style={{ ...columnSizeStyle(cell.column, { resize: enableColumnResize, isSystemCol: isSystemColumn(cell.column.id) }), ...cellPadding }}
1385
+ onClick={onCellClick}
1386
+ >
1387
+ {mode === 'single' ? (
1388
+ <RadioGroupItem
1389
+ size={checkboxSize}
1390
+ value={rowId}
1391
+ disabled={isDisabled}
1392
+ aria-label={ariaLabel}
1393
+ onClick={(e) => e.stopPropagation()}
1394
+ />
1395
+ ) : (
1396
+ <Checkbox
1397
+ size={checkboxSize}
1398
+ checked={selectionSet.has(rowId)}
1399
+ disabled={isDisabled}
1400
+ aria-label={ariaLabel}
1401
+ onClick={(e) => {
1402
+ e.stopPropagation()
1403
+ if (isDisabled) return
1404
+ e.preventDefault() // 攔截 Radix 內部 toggle,自己 toggle 帶 shiftKey
1405
+ toggleRow(rowId, rowOriginal, { shiftKey: e.shiftKey })
1406
+ }}
1407
+ onKeyDown={(e) => {
1408
+ // Space:Radix 已處理 toggle,但要帶 shiftKey 區間選 → 攔截
1409
+ if (e.key === ' ' && !isDisabled) {
1410
+ e.preventDefault()
1411
+ toggleRow(rowId, rowOriginal, { shiftKey: e.shiftKey })
1412
+ }
1413
+ }}
1414
+ />
1415
+ )}
1416
+ </div>
1417
+ )
1418
+ }
1419
+ const meta = cell.column.columnDef.meta
1420
+ const colType = meta?.type as ColumnType | undefined
1421
+ const align = meta?.align ?? (colType ? columnTypeDefaults[colType].align : undefined)
1422
+ // L4 inline edit 整合
1423
+ const cellRowId = cell.row.id
1424
+ const cellColId = cell.column.id
1425
+ const cellEditable = isCellEditable(meta, cell.row.original)
1426
+ const cellDisabled = isCellDisabled(meta, cell.row.original)
1427
+ const isEditingThisCell = editingCellId === cellEditId(cellRowId, cellColId)
1428
+ // Indicator canonical(2026-05-09 D-path retire):**Field naked-display branch own** via
1429
+ // `showDisplayEndIcon` opt-in(per-picker `<ItemSuffix>` 渲 ChevronDown/Calendar/Clock)。
1430
+ // DataTable cellEl 不再 render parallel indicator — SSOT 對齊 Field family。
1431
+ // 詳 `.claude/planning/cell-indicator-ssot-rfc.md` Step 9。
1432
+ // Cell click → 進 edit mode(boolean 不需 — 自己 toggle;url 不需 — 走內部 Pencil button,Phase C 由 UrlCell 內處理)
1433
+ const cellSpreadsheetId = `${cellRowId}:${cellColId}`
1434
+ const isSelectedCell = spreadsheetMode && selectedCellId === cellSpreadsheetId
1435
+ // 2026-05-13 Q3:cellDisabled → 抑制 editable click(對齊 `editable && !disabled` invariant)
1436
+ const onEditableCellClick = cellEditable && !cellDisabled && colType !== 'boolean' && colType !== 'url' && !isEditingThisCell
1437
+ ? (e: React.MouseEvent) => {
1438
+ if (spreadsheetMode) {
1439
+ // Slice D Step 4 spreadsheet semantics(2026-05-10 user 拍板,2026-05-12 v2 fix):
1440
+ // Shift+click → extend range(set focus,**anchor 保持 selectedCellId**)
1441
+ // Click on already-selected → enter edit
1442
+ // Plain click → select(no edit)+ reset range to single cell
1443
+ // 2026-05-12 fix(user 抓「世界級設計藍邊框留在第一個選的 cell」):前 v1 setSelectedCellId
1444
+ // 到 focus(終點)→ 藍框跑去終點。Fix:selectedCellId 維持 anchor(起點)— 對齊
1445
+ // Excel / Google Sheets / Notion / Airtable shift-extend canonical(anchor 永遠 own
1446
+ // active-cell border,range 用 fill 視覺)。
1447
+ if (e.shiftKey && rangeAnchor != null) {
1448
+ setRangeFocus(cellSpreadsheetId)
1449
+ // selectedCellId stays at anchor (起點 keep active border canonical)
1450
+ return
1451
+ }
1452
+ if (isSelectedCell) {
1453
+ // 2nd click on already-selected → enter edit(Excel-like)
1454
+ setEditingCellId(cellEditId(cellRowId, cellColId))
1455
+ setSelectedCellId(null)
1456
+ setRangeAnchor(null)
1457
+ setRangeFocus(null)
1458
+ return
1459
+ }
1460
+ // 1st click → select only,no edit
1461
+ setSelectedCellId(cellSpreadsheetId)
1462
+ setRangeAnchor(cellSpreadsheetId)
1463
+ setRangeFocus(null)
1464
+ return
1465
+ }
1466
+ // Default(non-spreadsheet)inline-edit behavior:click → enter edit
1467
+ setEditingCellId(cellEditId(cellRowId, cellColId))
1468
+ }
1469
+ : undefined
1470
+
1471
+ // L4 nested rows:該 cell 是否是 row 第 1 個非 select content cell(注入 chevron + indent)
1472
+ // 對齊 TreeView design language(token `--tree-indent-{sm,md,lg}` 為 SSOT,跨元件視覺一致)
1473
+ const allCells = cell.row.getVisibleCells()
1474
+ const firstContentCell = allCells.find(c => c.column.id !== SELECT_COL_ID)
1475
+ const isFirstContent = firstContentCell?.id === cell.id
1476
+ const depth = cell.row.depth ?? 0
1477
+ const canExpand = cell.row.getCanExpand?.() ?? false
1478
+ const isExpanded = cell.row.getIsExpanded?.() ?? false
1479
+ const toggleExpand = cell.row.getToggleExpandedHandler?.()
1480
+ const showNestedPrefix = isFirstContent && (depth > 0 || canExpand)
1481
+ // Issue 9 cell error(2026-05-10):lookup `${rowId}:${colId}` in cellErrors map
1482
+ // editing cell 自動 clear visual error(per spec 「edit-clears-own-cell」)— consumer 走
1483
+ // onCellCommit 驗證後決定回填新 error(由 consumer 端控制 cellErrors map state)。
1484
+ const rawCellError = cellErrors?.[`${cellRowId}:${cellColId}`]
1485
+ const cellErrorMessages: string[] | null = (() => {
1486
+ if (isEditingThisCell) return null // edit-clears-own-cell visual
1487
+ if (rawCellError == null) return null
1488
+ return Array.isArray(rawCellError) ? rawCellError : [rawCellError]
1489
+ })()
1490
+ const hasCellError = cellErrorMessages != null && cellErrorMessages.length > 0
1491
+ const cellErrorId = hasCellError ? `cell-err-${cellRowId}-${cellColId}` : undefined
1492
+ // H1 fix(2026-05-10):per-row autoRowHeight when this row has any cell error。
1493
+ // cell-level recompute(O(1) per cell map lookup)— cell-row coupling 透過 row.getVisibleCells()。
1494
+ // Field naked items-X 等 group-data-[row-mode=...] CSS propagation 跟著走。
1495
+ const rowHasAnyError = !!cellErrors && cell.row.getVisibleCells().some((c) => {
1496
+ const v = cellErrors[`${cell.row.id}:${c.column.id}`]
1497
+ return v != null && (Array.isArray(v) ? v.length > 0 : true)
1498
+ })
1499
+ const effectiveAutoRowForCell = autoRowHeight || rowHasAnyError
1500
+ return (
1501
+ <div
1502
+ key={cell.id}
1503
+ role="cell"
1504
+ // group/cell + data-row-mode:讓 Field naked 用 `group-data-[row-mode=...]/cell:items-X`
1505
+ // 從 cell 取 alignment(autoRowHeight=auto 頂對齊 / fixed=fixed 置中)。CSS propagation,
1506
+ // Field API 不變;每個 mode 內 display↔edit 同 alignment(同 Field, 同 group → 同 items)。
1507
+ // H1(2026-05-10):per-row error → effectiveAutoRowForCell 同 row.tsx effectiveAutoRow
1508
+ data-row-mode={effectiveAutoRowForCell ? 'auto' : 'fixed'}
1509
+ data-column-id={cell.column.id}
1510
+ // Slice D Step 1B(2026-05-10):composite cell-id `${rowId}:${colId}` 給 Interaction Layer
1511
+ // getCellRect 用,per RFC §Overlay Geometry。
1512
+ data-cell-id={`${cell.row.id}:${cell.column.id}`}
1513
+ // Phase 9 Issue 1 fix(2026-05-10):range cell bg fill via CSS [data-range-cell],
1514
+ // 不在 overlay layer(避免 layer fixed-position bg 蓋 cell content)。
1515
+ data-range-cell={spreadsheetMode && rangeCellIdSet.has(`${cell.row.id}:${cell.column.id}`) ? '' : undefined}
1516
+ // 2026-05-13 V1.6:data-cell-disabled attribute 給 CSS `:not([data-cell-disabled])` 過濾,disabled cell 不被 range fill 蓋
1517
+ data-cell-disabled={cellDisabled ? 'true' : undefined}
1518
+ // Issue 9 cell error(2026-05-10):aria-describedby 接 error message id 給 AT 讀
1519
+ aria-describedby={cellErrorId}
1520
+ aria-invalid={hasCellError || undefined}
1521
+ className={cn(
1522
+ // Cell box(2026-05-05 v6 — A4 canonical: Field frame seamlessly replaces cell border):
1523
+ // - `self-stretch`: cell 永遠填 row 高
1524
+ // - **vertical alignment by row-mode**: autoRow=items-start(top per spec) /
1525
+ // fixed=items-center(centered per spec)。indicator + 非 Field 內容跟 cell 走。
1526
+ // - **editing cell**: padding=0 + 無 right divider → Field naked(`!h-full !px-[cell-px]
1527
+ // !py-[cell-py]`)邊框與 table divider 無縫接軌,seamlessly replace cell border。
1528
+ // Adjacent cell padding+divider 仍在,只 editing cell 自己改觀。對齊 user reminder
1529
+ // 「框框跟 cell 一樣大並取代 cell 的框且與 table 隔線無縫接軌」(2026-05-05)。
1530
+ // - **沒有** cell 自己 box-shadow ring — focus / hover / open ring 由 Field naked 自帶
1531
+ // state machine 提供(對齊 user「狀態樣式取決於原輸入框」reminder)
1532
+ 'group/cell flex text-foreground text-body font-normal shrink-0 relative self-stretch',
1533
+ // Issue 9(2026-05-10):有 cell error → unset overflow-hidden 讓 error message
1534
+ // wrap 撐 row 高。**H1(2026-05-10)升級**:overflow-visible 條件改 `rowHasAnyError` —
1535
+ // row 內任一 cell 有 error 整 row 全 cells 都 overflow-visible(error 訊息可能多行
1536
+ // 撐高 row,row 高同步 effectiveAutoRow auto)。
1537
+ rowHasAnyError ? 'overflow-visible' : 'overflow-hidden',
1538
+ effectiveAutoRowForCell ? 'items-start' : 'items-center',
1539
+ align === 'right' && 'justify-end text-right',
1540
+ align === 'center' && 'justify-center text-center',
1541
+ // Phase 9 Issue 8 fix(2026-05-10 user 撞 + codex 重比稿 verdict ADOPT):
1542
+ // 之前 `border-r border-divider` 只 right edge → hover overlay outline:-1px 只 right
1543
+ // 邊壓 cell border,上左下 sub-pixel 不一致(user 抓「右 1px / 上左下 2px」bug)。
1544
+ // 改 `dtCellGrid` (data-table.css:96-110)用 `box-shadow: inset` 4 邊 1px divider,
1545
+ // 不佔 layout(per user verbatim「在 cell 內容起始位置不變」前提)→ 4 邊一致 grid line
1546
+ // → overlay outline:-1px 4 邊都剛好壓 cell border line。
1547
+ // Field naked edit border 仍 own(per Field SSOT)— 編輯時 Field 自帶 border 1px,
1548
+ // 跟 cell 4 邊 inset divider 視覺相疊(同 pixel)= 1 line visual,不雙線。
1549
+ inlineEdit && 'dtCellGrid',
1550
+ onEditableCellClick && ['cursor-pointer', nakedCellEditableDisplayHover], // editable cell display hover affordance(對齊 Notion / Airtable hover-cell-shows-border canonical)
1551
+ // 2026-05-13 Q3 cell disabled SSOT(per codex Q3 verdict + user 拍板「全部馬不停蹄做完」):
1552
+ // bg `--bg-disabled` component-state token(color.spec.md:671 owner)+ cursor 抑制 click affordance。
1553
+ cellDisabled && 'bg-disabled cursor-not-allowed',
1554
+ // z-10 raise inline-edit cell;portal mode 不需(layer z-3 already on top)。
1555
+ isEditingThisCell && !experimentalActiveEditorController && 'z-10',
1556
+ )}
1557
+ style={{
1558
+ ...columnSizeStyle(cell.column, { resize: enableColumnResize, isSystemCol: isSystemColumn(cell.column.id) }),
1559
+ // Padding override 只在 inline-edit cell(naked Field 撐滿 cell);portal mode cell 走正常 display padding
1560
+ ...(isEditingThisCell && !experimentalActiveEditorController ? {} : cellPadding),
1561
+ // Slice D Step 2(2026-05-10):flag 開時 set CSS variable 抑制 Field naked hover outline,
1562
+ // 讓 overlay layer 接管 hover ring paint(per RFC Contract 8 「one geometry owner, two paint owners」)。
1563
+ // Backward-compat:flag 關時 unset → field-wrapper default `var(--border-hover)`(既有行為)。
1564
+ ...(experimentalSpreadsheetOverlay && { '--cell-hover-outline-color': 'transparent' } as React.CSSProperties),
1565
+ }}
1566
+ onClick={onEditableCellClick}
1567
+ >
1568
+ {/* Issue 9 cell error(2026-05-10):有 error → cell 內外結構切 flex-col,
1569
+ 上 row 渲既有 nested + content,下 row 渲 error message 14px text-error。
1570
+ 無 error 時走原 flex-row(backward-compat 0 layout shift)。 */}
1571
+ {hasCellError ? (
1572
+ <span className="flex flex-col self-stretch w-full min-w-0 gap-1">
1573
+ <span className="flex flex-1 min-w-0">
1574
+ {showNestedPrefix && (
1575
+ <span
1576
+ className="flex items-center shrink-0"
1577
+ style={{ paddingLeft: depth > 0 ? `calc(${depth} * var(--tree-indent-${size}, var(--tree-indent-md)))` : 0 }}
1578
+ >
1579
+ {canExpand ? (
1580
+ <button
1581
+ type="button"
1582
+ aria-label={isExpanded ? '收合' : '展開'}
1583
+ aria-expanded={isExpanded}
1584
+ className="inline-flex items-center justify-center shrink-0 w-4 h-4 mr-2 text-fg-muted hover:text-foreground rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring transition-transform"
1585
+ style={{ transform: isExpanded ? 'rotate(90deg)' : undefined }}
1586
+ onClick={(e) => { e.stopPropagation(); toggleExpand?.() }}
1587
+ >
1588
+ <ChevronDown size={iconSize} aria-hidden style={{ transform: 'rotate(-90deg)' }} />
1589
+ </button>
1590
+ ) : (
1591
+ <span aria-hidden className="shrink-0 w-4 h-4 mr-2" />
1592
+ )}
1593
+ </span>
1594
+ )}
1595
+ <span className={cn(
1596
+ 'flex-1 min-w-0 flex',
1597
+ // 2026-05-12 Round 4.5 fix(codex M31 Layer C 抓漏)— error-cell branch 也用 per-row state
1598
+ // (`effectiveAutoRowForCell`)非 global `autoRowHeight`,跟 line 1559 non-error wrapper 同 SSOT。
1599
+ // 前 Round 4 漏修此 branch → error 那格 row 內視覺仍走 global mode mismatch。
1600
+ effectiveAutoRowForCell ? 'items-start' : 'items-center',
1601
+ align === 'right' && 'justify-end',
1602
+ )}>
1603
+ {renderCellContent(cell)}
1604
+ </span>
1605
+ </span>
1606
+ <span id={cellErrorId} className="text-body text-error break-words" role="alert">
1607
+ {cellErrorMessages!.length === 1 ? (
1608
+ cellErrorMessages![0]
1609
+ ) : (
1610
+ <ul className="list-disc list-inside flex flex-col gap-1">
1611
+ {cellErrorMessages!.map((m, i) => <li key={i}>{m}</li>)}
1612
+ </ul>
1613
+ )}
1614
+ </span>
1615
+ </span>
1616
+ ) : (
1617
+ <>
1618
+ {/* L4 nested rows prefix(同上,無 error 時走 flex-row 原 path) */}
1619
+ {showNestedPrefix && (
1620
+ <span
1621
+ className="flex items-center shrink-0"
1622
+ style={{ paddingLeft: depth > 0 ? `calc(${depth} * var(--tree-indent-${size}, var(--tree-indent-md)))` : 0 }}
1623
+ >
1624
+ {canExpand ? (
1625
+ <button
1626
+ type="button"
1627
+ aria-label={isExpanded ? '收合' : '展開'}
1628
+ aria-expanded={isExpanded}
1629
+ className="inline-flex items-center justify-center shrink-0 w-4 h-4 mr-2 text-fg-muted hover:text-foreground rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring transition-transform"
1630
+ style={{ transform: isExpanded ? 'rotate(90deg)' : undefined }}
1631
+ onClick={(e) => { e.stopPropagation(); toggleExpand?.() }}
1632
+ >
1633
+ <ChevronDown size={iconSize} aria-hidden style={{ transform: 'rotate(-90deg)' }} />
1634
+ </button>
1635
+ ) : (
1636
+ <span aria-hidden className="shrink-0 w-4 h-4 mr-2" />
1637
+ )}
1638
+ </span>
1639
+ )}
1640
+ <span className={cn(
1641
+ 'flex-1 min-w-0 self-stretch flex',
1642
+ // 2026-05-12 fix root invariant(M32 b):用 `effectiveAutoRowForCell` 而非 global
1643
+ // `autoRowHeight` — per-row error 時 row 是 auto-height,該 row 內所有 cell 都該
1644
+ // top-align(非僅 error cell)。前 v1 用 global autoRowHeight → 非 error cells in
1645
+ // error row 走 items-center → 視覺垂直置中於 tall row(user 抓 image 3 bug)。
1646
+ effectiveAutoRowForCell ? 'items-start' : 'items-center',
1647
+ align === 'right' && 'justify-end',
1648
+ )}>
1649
+ {renderCellContent(cell)}
1650
+ </span>
1651
+ </>
1652
+ )}
1653
+ </div>
1654
+ )
1655
+ }
1656
+
1657
+ // ── L2 Selection helpers ──
1658
+ const visibleRowIdsKey = React.useMemo(() => rows.map(r => r.id).join(','), [rows])
1659
+ const visibleRowIdsSet = React.useMemo(() => new Set(rows.map(r => r.id)), [visibleRowIdsKey])
1660
+
1661
+ // 對齊 spec L2 七、Filter 套用 → filtered-out selected rows 預設清掉
1662
+ React.useEffect(() => {
1663
+ if (!enabled || preserveSelectionOnFilter) return
1664
+ setSelection(prev => {
1665
+ const filtered = prev.filter(id => visibleRowIdsSet.has(id))
1666
+ return filtered.length === prev.length ? prev : filtered
1667
+ })
1668
+ }, [visibleRowIdsKey, enabled, preserveSelectionOnFilter, visibleRowIdsSet, setSelection])
1669
+
1670
+ // Visible 可選 row IDs(扣除 disabled)
1671
+ const selectableVisibleIds = React.useMemo(() => {
1672
+ if (!enabled) return [] as string[]
1673
+ return rows
1674
+ .filter(r => !isRowSelectable || isRowSelectable(r.original))
1675
+ .map(r => r.id)
1676
+ }, [rows, enabled, isRowSelectable])
1677
+
1678
+ // Header tri-state checkbox value
1679
+ const selectionSet = React.useMemo(() => new Set(selection), [selection])
1680
+ const visibleSelectedCount = selectableVisibleIds.filter(id => selectionSet.has(id)).length
1681
+ const headerCheckedState: boolean | 'indeterminate' =
1682
+ selectableVisibleIds.length === 0 ? false
1683
+ : visibleSelectedCount === 0 ? false
1684
+ : visibleSelectedCount === selectableVisibleIds.length ? true
1685
+ : 'indeterminate'
1686
+
1687
+ // visibleIdToRow Map(shift-click 區間選 lookup,避免 O(n) `rows.find()`)
1688
+ const visibleIdToRow = React.useMemo(
1689
+ () => new Map(rows.map(r => [r.id, r])),
1690
+ [rows]
1691
+ )
1692
+
1693
+ const toggleHeaderCheckbox = React.useCallback(() => {
1694
+ if (headerCheckedState === true) {
1695
+ // 清掉本頁可見已選(保留可見外的 selection)
1696
+ const visibleSet = new Set(selectableVisibleIds)
1697
+ setSelection(prev => prev.filter(id => !visibleSet.has(id)))
1698
+ } else {
1699
+ // 選全可見(扣除 disabled);保留可見外的既有 selection
1700
+ setSelection(prev => Array.from(new Set([...prev, ...selectableVisibleIds])))
1701
+ }
1702
+ }, [headerCheckedState, selectableVisibleIds, setSelection])
1703
+
1704
+ const toggleRow = React.useCallback((rowId: string, rowOriginal: TData, opts?: { shiftKey?: boolean }) => {
1705
+ if (isRowSelectable && !isRowSelectable(rowOriginal)) return
1706
+ if (mode === 'single') {
1707
+ setSelection(selectionSet.has(rowId) ? [] : [rowId])
1708
+ anchorRowIdRef.current = rowId
1709
+ return
1710
+ }
1711
+ // multi 模式
1712
+ const anchor = anchorRowIdRef.current
1713
+ if (opts?.shiftKey && anchor && anchor !== rowId) {
1714
+ // 區間選:從 anchor 到 rowId(在 visible 順序內),全 toggle 成 willCheck 狀態
1715
+ const visibleIds = rows.map(r => r.id)
1716
+ const a = visibleIds.indexOf(anchor)
1717
+ const b = visibleIds.indexOf(rowId)
1718
+ if (a !== -1 && b !== -1) {
1719
+ const [from, to] = a < b ? [a, b] : [b, a]
1720
+ const rangeIds = visibleIds.slice(from, to + 1).filter(id => {
1721
+ const row = visibleIdToRow.get(id)
1722
+ return row && (!isRowSelectable || isRowSelectable(row.original))
1723
+ })
1724
+ // Mail / GitHub 慣例:shift-click 把 range 全變「rowId 點擊後該變的狀態」
1725
+ const willCheck = !selectionSet.has(rowId)
1726
+ setSelection(prev => {
1727
+ const set = new Set(prev)
1728
+ rangeIds.forEach(id => willCheck ? set.add(id) : set.delete(id))
1729
+ return Array.from(set)
1730
+ })
1731
+ return
1732
+ }
1733
+ }
1734
+ // 一般 toggle + 更新 anchor
1735
+ setSelection(prev => {
1736
+ const set = new Set(prev)
1737
+ if (set.has(rowId)) set.delete(rowId)
1738
+ else set.add(rowId)
1739
+ return Array.from(set)
1740
+ })
1741
+ anchorRowIdRef.current = rowId
1742
+ }, [isRowSelectable, mode, selectionSet, rows, visibleIdToRow, setSelection])
1743
+
1744
+ // ── Cmd+A / Esc / Arrow keys 鍵盤 handler(table-level)──
1745
+ // code-quality-allow: long-function — single keyboard dispatch covering Cmd+A / Esc / Arrow / Space + selection state mutations,拆 sub-handler 會切散 keyboard mode coherence
1746
+ const tableKeyboardHandler = React.useCallback(
1747
+ (e: React.KeyboardEvent<HTMLDivElement>) => {
1748
+ // ── Spreadsheet mode keyboard nav(Phase B1+B2,2026-05-10 per codex Q-B verdict)──
1749
+ // ActiveCellId 由 mouse click(spreadsheet click 1)+ keyboard arrow 共用 SSOT。
1750
+ // ↑↓←→ 移動 / Enter / F2 進 edit / Esc exit edit OR clear active。
1751
+ // Codex Q-B1:不分 mouse selected vs keyboard focused,共用 selectedCellId state。
1752
+ // Phase B3 IME guard(2026-05-10 per codex Q-B3):中文輸入法組字中 ignore 所有 nav keys。
1753
+ // 2026-05-16 Round 5 audit Dim 27 fix:`keyCode` deprecated but still in KeyboardEvent type — no cast needed。
1754
+ if (e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229) return
1755
+ if (spreadsheetMode && selectedCellId != null && editingCellId == null) {
1756
+ const navKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter', 'F2', 'Escape']
1757
+ if (!navKeys.includes(e.key)) return
1758
+ const lastColon = selectedCellId.lastIndexOf(':')
1759
+ const curRowId = selectedCellId.slice(0, lastColon)
1760
+ const curColId = selectedCellId.slice(lastColon + 1)
1761
+ const allRows = table.getRowModel().rows.map((r) => r.id)
1762
+ const allCols = table.getVisibleLeafColumns().map((c) => c.id).filter((id) => id !== SELECT_COL_ID)
1763
+ const curRowIdx = allRows.indexOf(curRowId)
1764
+ const curColIdx = allCols.indexOf(curColId)
1765
+ if (curRowIdx < 0 || curColIdx < 0) return
1766
+ let nextRowIdx = curRowIdx
1767
+ let nextColIdx = curColIdx
1768
+ if (e.key === 'ArrowUp' && curRowIdx > 0) { nextRowIdx = curRowIdx - 1 }
1769
+ else if (e.key === 'ArrowDown' && curRowIdx < allRows.length - 1) { nextRowIdx = curRowIdx + 1 }
1770
+ else if (e.key === 'ArrowLeft' && curColIdx > 0) { nextColIdx = curColIdx - 1 }
1771
+ else if (e.key === 'ArrowRight' && curColIdx < allCols.length - 1) { nextColIdx = curColIdx + 1 }
1772
+ else if (e.key === 'Enter' || e.key === 'F2') {
1773
+ // Enter / F2 → 進 edit(若 cell editable + 非 boolean / url + 非 disabled)
1774
+ // 2026-05-13 codex V1 fix:加 `!isCellDisabled(meta, row)` gate(對齊 mouse click invariant)
1775
+ const row = table.getRowModel().rowsById[curRowId]
1776
+ const colDef = table.getAllLeafColumns().find((c) => c.id === curColId)
1777
+ // any-allow: column meta free-form
1778
+ const meta = (colDef?.columnDef as any)?.meta as Record<string, any> | undefined
1779
+ if (meta?.type && meta.type !== 'boolean' && meta.type !== 'url' && row && canEditCell(meta, row.original)) {
1780
+ e.preventDefault()
1781
+ setEditingCellId(cellEditId(curRowId, curColId))
1782
+ setSelectedCellId(null)
1783
+ setRangeAnchor(null)
1784
+ setRangeFocus(null)
1785
+ }
1786
+ return
1787
+ }
1788
+ else if (e.key === 'Escape') {
1789
+ e.preventDefault()
1790
+ setSelectedCellId(null)
1791
+ setRangeAnchor(null)
1792
+ setRangeFocus(null)
1793
+ return
1794
+ }
1795
+ if (nextRowIdx !== curRowIdx || nextColIdx !== curColIdx) {
1796
+ e.preventDefault()
1797
+ const nextCellId = `${allRows[nextRowIdx]}:${allCols[nextColIdx]}`
1798
+ setSelectedCellId(nextCellId)
1799
+ setRangeAnchor(nextCellId)
1800
+ setRangeFocus(null)
1801
+ }
1802
+ return
1803
+ }
1804
+ // ── Row selection mode keyboard handler(下方既有)──
1805
+ if (!enabled) return
1806
+ // Cmd/Ctrl+A:選全可見(扣 disabled)— 對齊 Mail / GitHub / Linear 慣例
1807
+ if ((e.metaKey || e.ctrlKey) && e.key === 'a' && mode === 'multi') {
1808
+ e.preventDefault()
1809
+ setSelection(prev => Array.from(new Set([...prev, ...selectableVisibleIds])))
1810
+ return
1811
+ }
1812
+ // Esc:clear selection
1813
+ if (e.key === 'Escape' && selection.length > 0) {
1814
+ e.preventDefault()
1815
+ setSelection([])
1816
+ anchorRowIdRef.current = null
1817
+ return
1818
+ }
1819
+ },
1820
+ [enabled, mode, selection.length, selectableVisibleIds, setSelection,
1821
+ spreadsheetMode, selectedCellId, editingCellId, table, isCellEditable]
1822
+ )
1823
+
1824
+ // ── Header cell ──
1825
+ // code-quality-allow: long-function — header render 含 selection tri-state / sort indicator / column dropdown / pinned / divider 五邏輯,拆 sub-fn 會切散 column type-aware rendering coherence
1826
+ const headerCellEl = (header: ReturnType<typeof table.getHeaderGroups>[number]['headers'][number], showDivider: boolean) => {
1827
+ // L2 selection:__select__ 欄自訂 render(tri-state header checkbox)
1828
+ if (enabled && header.column.id === SELECT_COL_ID) {
1829
+ const isHeaderDisabled = selectableVisibleIds.length === 0 || mode !== 'multi'
1830
+ return (
1831
+ <div
1832
+ key={header.id}
1833
+ role="columnheader"
1834
+ className={cn('flex items-center justify-center shrink-0 select-none', !isHeaderDisabled && 'cursor-pointer')}
1835
+ style={{ ...columnSizeStyle(header.column, { resize: enableColumnResize, isSystemCol: isSystemColumn(header.column.id) }), ...cellPadding }}
1836
+ onClick={isHeaderDisabled ? undefined : (e) => { e.stopPropagation(); toggleHeaderCheckbox() }}
1837
+ >
1838
+ {mode === 'multi' && (
1839
+ <Checkbox
1840
+ size={size === 'lg' ? 'lg' : 'md'}
1841
+ checked={headerCheckedState}
1842
+ onClick={(e) => e.stopPropagation()}
1843
+ onCheckedChange={() => toggleHeaderCheckbox()}
1844
+ aria-label="Select all visible rows"
1845
+ disabled={selectableVisibleIds.length === 0}
1846
+ />
1847
+ )}
1848
+ </div>
1849
+ )
1850
+ }
1851
+ const meta = header.column.columnDef.meta
1852
+ const colType = meta?.type as ColumnType | undefined
1853
+ const align = meta?.align ?? (colType ? columnTypeDefaults[colType].align : undefined)
1854
+ // Sort UI(Phase A.1):header cell 兩區結構
1855
+ // 左區(label + indicator slot):click → toggle sort 三態(asc → desc → none)
1856
+ // 右區:reserve future ⌄ menu(filter / hide / pin 等;hover 才出,A.x 加)
1857
+ // Indicator inline collapse:已套才顯;未套不顯(任何混雜組合不推 — 對齊 AG Grid / Notion)
1858
+ const canSort = header.column.getCanSort()
1859
+ const sortDir = header.column.getIsSorted() // false | 'asc' | 'desc'
1860
+ // **A fix(2026-05-04)**:multi-sort(≥2)hide header arrow + 取消排序 option
1861
+ // 理由:無 order 編號的單個 arrow 在 multi-sort 下是 partial info → 反而混淆
1862
+ // user 走 SortManager panel 看完整 priority(SSOT)
1863
+ // 1 sort 仍秀 arrow(完整資訊);0 sort 自然不秀(canSort && sortDir 短路)
1864
+ const isMultiSort = (table.getState().sorting?.length ?? 0) > 1
1865
+ const SortIcon = sortDir === 'asc' ? ArrowUp : ArrowDown // 未套不渲染;套用後二擇一
1866
+ const sortHandler = canSort ? header.column.getToggleSortingHandler() : undefined
1867
+ return (
1868
+ <div
1869
+ key={header.id}
1870
+ role="columnheader"
1871
+ aria-sort={sortDir === 'asc' ? 'ascending' : sortDir === 'desc' ? 'descending' : 'none'}
1872
+ className={cn(
1873
+ // **Inline action canonical**(2026-05-05 v2):header 用 `flex items-center gap-2`
1874
+ // (= 8px,inline-action.spec.md SSOT),more action 為 inline shrink-0 sibling 而非
1875
+ // absolute → hover 顯時佔位 → label 自動讓出空間給 sort + more,**不再重疊**(對齊
1876
+ // user 圖示質疑 + Notion / Airtable header layout 共識)。
1877
+ // cell padding 12px 由外層 cellPadding style 提供 → more 距 cell 右邊 = 12px。
1878
+ 'group relative flex items-center gap-2 text-fg-secondary text-body font-normal shrink-0 overflow-hidden select-none',
1879
+ align === 'right' && 'justify-end',
1880
+ align === 'center' && 'justify-center',
1881
+ )}
1882
+ style={{ ...columnSizeStyle(header.column, { resize: enableColumnResize, isSystemCol: isSystemColumn(header.column.id) }), ...cellPadding }}
1883
+ >
1884
+ {/* 左區:label + sort indicator(整區 click → toggle sort;Shift+click 加 secondary,enableMultiSort 啟用時) */}
1885
+ <div
1886
+ role={canSort ? 'button' : undefined}
1887
+ tabIndex={canSort ? 0 : undefined}
1888
+ onClick={sortHandler}
1889
+ // any-allow: event-cast — TanStack getToggleSortingHandler 內部會 narrow,接受 KeyboardEvent
1890
+ onKeyDown={canSort ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); sortHandler?.(e as any) } } : undefined}
1891
+ className={cn(
1892
+ 'flex items-center min-w-0 flex-1 gap-1 outline-none',
1893
+ canSort && 'cursor-pointer hover:text-foreground transition-colors',
1894
+ canSort && 'focus-visible:ring-2 focus-visible:ring-ring rounded-sm',
1895
+ )}
1896
+ >
1897
+ <TruncateCell className={cn('min-w-0', align === 'right' && 'text-right', align === 'center' && 'text-center')}>
1898
+ {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
1899
+ </TruncateCell>
1900
+ {canSort && sortDir && !isMultiSort && (
1901
+ // 2026-05-18 改 per user 拍板「DataTable sort 跟 row size 變」+「做完」approval:
1902
+ // 原固定 14 違反 uiSize.spec.md Icon Tier(sm/md→16, lg→20)。改 ICON_SIZE[size]
1903
+ // 隨 DataTable size prop 變。
1904
+ <SortIcon size={ICON_SIZE[size]} aria-hidden className="shrink-0 text-fg-secondary" />
1905
+ )}
1906
+ </div>
1907
+ {/* 右區:⌄ menu(hover/focus-within 才顯;**不顯示時不佔位**)
1908
+ **Layout canonical**(2026-05-06 v3 user explicit rule):`hidden` default →
1909
+ `group-hover:inline-flex` / `group-focus-within:inline-flex` / `has-[[data-state=open]]:inline-flex`
1910
+ 才出現 + 佔位。前 v2 用 `opacity-0 group-hover:opacity-100` 是「永遠佔位 hover 才顯影」—
1911
+ user 報「不應永遠佔位,沒顯示時應讓 label 多空間」。新行為:
1912
+ - 不顯示 → display:none → 不佔位 → label 取得整個 header width
1913
+ - hover/focus/menu-open → display:inline-flex → 佔位(width 同前;label 自然 truncate 讓位)
1914
+ 對齊 Notion(hover-row reveal action,inline action 不佔靜態 layout)/ Linear / Airtable。
1915
+ ItemInlineActionButton asChild-compatible,size="md" 因 header 不在 RowSizeProvider。 */}
1916
+ <div className="shrink-0 hidden group-hover:inline-flex group-focus-within:inline-flex has-[[data-state=open]]:inline-flex">
1917
+ <DropdownMenu>
1918
+ <DropdownMenuTrigger asChild>
1919
+ <ItemInlineActionButton
1920
+ icon={ChevronDown}
1921
+ size="md"
1922
+ aria-label={`${typeof header.column.columnDef.header === 'string' ? header.column.columnDef.header : header.column.id} 欄位選單`}
1923
+ overlayTrigger
1924
+ />
1925
+ </DropdownMenuTrigger>
1926
+ <DropdownMenuContent align="end">
1927
+ {canSort && (
1928
+ <>
1929
+ <DropdownMenuItem startIcon={ArrowUp} onClick={() => header.column.toggleSorting(false, false)}>升冪排序</DropdownMenuItem>
1930
+ <DropdownMenuItem startIcon={ArrowDown} onClick={() => header.column.toggleSorting(true, false)}>降冪排序</DropdownMenuItem>
1931
+ {sortDir && !isMultiSort && <DropdownMenuItem startIcon={XIcon} onClick={() => header.column.clearSorting()}>取消排序</DropdownMenuItem>}
1932
+ <DropdownMenuSeparator />
1933
+ </>
1934
+ )}
1935
+ {onColumnFilterTrigger && (
1936
+ <DropdownMenuItem startIcon={FilterIcon} onClick={() => onColumnFilterTrigger(header.column.id)}>依此欄篩選…</DropdownMenuItem>
1937
+ )}
1938
+ {header.column.getCanHide() && (
1939
+ <DropdownMenuItem startIcon={EyeOff} onClick={() => header.column.toggleVisibility(false)}>隱藏欄位</DropdownMenuItem>
1940
+ )}
1941
+ {/* 2026-05-06 v11:Auto-fit 放 more menu(不綁 double-click,避免跟 click-to-sort 衝突)。
1942
+ scan column body cells max scrollWidth + cellPadding → reset column.size。
1943
+ System columns 永遠 fixed 不顯此 item。 */}
1944
+ {enableColumnResize && !isSystemColumn(header.column.id) && (
1945
+ <DropdownMenuItem
1946
+ startIcon={ArrowUpDown}
1947
+ onClick={() => {
1948
+ const cells = document.querySelectorAll<HTMLElement>(
1949
+ `[role="cell"][data-column-id="${header.column.id}"]`,
1950
+ )
1951
+ let max = MIN_COLUMN_WIDTH
1952
+ cells.forEach(c => {
1953
+ const inner = c.firstElementChild as HTMLElement | null
1954
+ const w = (inner?.scrollWidth ?? c.scrollWidth) + 32 // + cellPadding 兩側 + buffer
1955
+ if (w > max) max = w
1956
+ })
1957
+ header.column.resetSize?.()
1958
+ table.setColumnSizing(prev => ({ ...prev, [header.column.id]: max }))
1959
+ onColumnResize?.(header.column.id, max)
1960
+ }}
1961
+ >
1962
+ 自動調整寬度
1963
+ </DropdownMenuItem>
1964
+ )}
1965
+ </DropdownMenuContent>
1966
+ </DropdownMenu>
1967
+ </div>
1968
+ {/* Header divider + resize handle(2026-05-06 v11,**2026-05-10 H2+H3 重構**):
1969
+ - **2026-05-10 split**(per user 抓「pinned 欄位右邊分隔線無法 resize」):
1970
+ `showDivider` 只 gate **視覺 1px line**(panel boundary col 由 panel border-r 接,
1971
+ 不重複);**resize hot zone** 改 gate by `isResizable` 獨立,panel boundary col
1972
+ 仍可拖 resize(hot zone 視覺 invisible,跟 panel border-r 不衝突)。
1973
+ - **2026-05-10 H3**:per-column `meta.resizable === false` opt-out — consumer 可標
1974
+ 「此 col 寬度由內容決定不允許 resize」(對齊 AG Grid `colDef.resizable` /
1975
+ Material X-DataGrid 同 API)。System cols(__select__ / __drag__ / __actions__
1976
+ row-actions)自動 false(永遠固定寬)。
1977
+ - 視覺:1px line `bg-divider` 在 showDivider 時 paint
1978
+ - Hot zone:absolute 7px 兩側,讓 mouse 容易 hit(在 isResizable 時 render)
1979
+ - Hover/Active:`bg-border-hover` / `bg-primary`(hot zone 內 1px line 變色)
1980
+ - role="separator" + aria-orientation="vertical" 對齊 WAI-ARIA(isResizable 時)*/}
1981
+ {(() => {
1982
+ const colId = header.column.id
1983
+ const colMeta = header.column.columnDef.meta as { resizable?: boolean } | undefined
1984
+ // H3: meta.resizable === false 顯式 opt-out(default true)
1985
+ const colOptIn = colMeta?.resizable !== false
1986
+ const isResizable = enableColumnResize && !isSystemColumn(colId) && colOptIn
1987
+ const isResizing = header.column.getIsResizing?.()
1988
+ // H2: 不論 showDivider,只要 isResizable 就 render hot zone(panel boundary col 仍可拖)
1989
+ if (!showDivider && !isResizable) return null
1990
+ return (
1991
+ <span
1992
+ role={isResizable ? 'separator' : undefined}
1993
+ aria-orientation={isResizable ? 'vertical' : undefined}
1994
+ aria-label={isResizable ? '拖曳調整欄寬' : undefined}
1995
+ className={cn(
1996
+ 'group/resize absolute top-0 bottom-0 right-0 -mr-[3px] w-[7px]',
1997
+ isResizable && 'cursor-col-resize select-none',
1998
+ )}
1999
+ // 2026-05-12 fix v2(user 抓 R3 stopPropagation 沒生效):dnd-kit PointerSensor
2000
+ // 監聽 `pointerdown`,我前一輪只 stop `onMouseDown` → pointerdown 仍冒泡 →
2001
+ // drag activate。改用 `onPointerDownCapture` capture-phase 一次性吃 pointerdown
2002
+ // event,**先** dnd-kit listener 拿到 → drag 不啟動;接著 emit synthesized
2003
+ // mousedown 給 TanStack resize handler。對齊 AG Grid / Material X-Grid pinned-column
2004
+ // resize idiom(resize handle 永遠 own pointer event,drag listener 不競爭)。
2005
+ onPointerDownCapture={isResizable ? (e: React.PointerEvent<HTMLSpanElement>) => {
2006
+ e.stopPropagation()
2007
+ header.getResizeHandler?.()(e.nativeEvent)
2008
+ } : undefined}
2009
+ onTouchStart={isResizable ? (e: React.TouchEvent<HTMLSpanElement>) => {
2010
+ e.stopPropagation()
2011
+ header.getResizeHandler?.()(e.nativeEvent)
2012
+ } : undefined}
2013
+ >
2014
+ {/* H2: 視覺 1px line 只在 showDivider 時 paint(panel boundary col by panel-r 接管,不重) */}
2015
+ {showDivider && (
2016
+ <span
2017
+ aria-hidden
2018
+ className={cn(
2019
+ 'absolute right-[3px] w-px transition-colors',
2020
+ isResizing
2021
+ ? 'bg-primary'
2022
+ : isResizable
2023
+ ? 'bg-divider group-hover/resize:bg-[var(--border-hover)]'
2024
+ : 'bg-divider',
2025
+ )}
2026
+ style={{ top: 'var(--table-cell-py)', bottom: 'var(--table-cell-py)' }}
2027
+ />
2028
+ )}
2029
+ </span>
2030
+ )
2031
+ })()}
2032
+ </div>
2033
+ )
2034
+ }
2035
+
2036
+ // ── Region helpers ──
2037
+ // hoist region id Sets 一次,避免 n_rows × n_regions 重建(virtual mode 1000+ rows 場景效益顯著)
2038
+ const leftIds = React.useMemo(() => new Set(leftCols.map(c => c.id)), [leftCols])
2039
+ const centerIds = React.useMemo(() => new Set(centerCols.map(c => c.id)), [centerCols])
2040
+ const rightIds = React.useMemo(() => new Set(rightCols.map(c => c.id)), [rightCols])
2041
+ const colsToIds = (cols: Column<TData, unknown>[]) =>
2042
+ cols === leftCols ? leftIds : cols === rightCols ? rightIds : centerIds
2043
+
2044
+ const getRegionHeaders = (cols: Column<TData, unknown>[]) => {
2045
+ const ids = colsToIds(cols)
2046
+ return table.getHeaderGroups()[0]?.headers.filter(h => ids.has(h.id)) ?? []
2047
+ }
2048
+
2049
+ const getRegionCells = (row: typeof rows[number], cols: Column<TData, unknown>[]) => {
2050
+ const ids = colsToIds(cols)
2051
+ return row.getVisibleCells().filter(c => ids.has(c.column.id))
2052
+ }
2053
+
2054
+ // 2026-05-06 v14.4 Notion blue line drop indicator(column reorder visual canonical)
2055
+ // 必須宣告在 renderHeaderRow 之前(closure 引用,避 minified bundler TDZ false-positive)
2056
+ const [dropIndicator, setDropIndicator] = React.useState<{ id: string; side: 'before' | 'after'; type: 'row' | 'column' } | null>(null)
2057
+ // ref for stable lookup in handleDragOver(避免 closure 抓舊值)
2058
+ const reorderableColumnIdsRef = React.useRef<string[]>([])
2059
+
2060
+ // ── Render header row for a region ──
2061
+ const renderHeaderRow = (cols: Column<TData, unknown>[], isRight: boolean) => {
2062
+ const headers = getRegionHeaders(cols)
2063
+ // a11y(2026-04-25 axe aria-required-children):若 region 無 visible cells(只有
2064
+ // invisible rowActions placeholder 或 region 本身空),不設 role='row' — 改為純
2065
+ // layout div,避免 axe 抓到「row 無 cell/columnheader 子元素」violation。
2066
+ const hasVisibleChildren = headers.length > 0
2067
+ const RowTag = hasVisibleChildren ? 'div' : 'div'
2068
+ const rowRole = hasVisibleChildren ? 'row' : undefined
2069
+ return (
2070
+ <RowTag role={rowRole} className={cn('flex items-center border-b border-divider', rowHeight, HEADER_BG)}>
2071
+ {headers.map((h, i) => {
2072
+ const showDivider = i < headers.length - 1 && !(isRight && i === headers.length - 1)
2073
+ const colId = h.column.id
2074
+ const meta = h.column.columnDef.meta as { locked?: boolean } | undefined
2075
+ const isLocked = meta?.locked === true
2076
+ const isSystem = isSystemColumn(colId)
2077
+ // useSortable per header(Rules of Hooks compliant — same hook count per render
2078
+ // as long as headers count consistent;column reorder/hide 整 row reflow 自然觸發 React reconcile)。
2079
+ // disabled=true 時仍 call hook 不啟動 listeners。
2080
+ const isDraggable = enableColumnReorder && !isLocked && !isSystem
2081
+ const indicatorSide = dropIndicator?.type === 'column' && dropIndicator.id === colId ? dropIndicator.side : null
2082
+ return (
2083
+ <DraggableHeaderCell
2084
+ key={h.id}
2085
+ id={colId}
2086
+ disabled={!isDraggable}
2087
+ isLocked={isLocked}
2088
+ dropIndicatorSide={indicatorSide}
2089
+ >
2090
+ {headerCellEl(h, showDivider)}
2091
+ </DraggableHeaderCell>
2092
+ )
2093
+ })}
2094
+ {isRight && hasRowActions && (
2095
+ <div className="flex items-center justify-end shrink-0 gap-2 invisible" aria-hidden="true" style={cellPadding}>
2096
+ {/* 渲染一個假 row 的 actions 來佔位,確保 header 和 body 同寬(aria-hidden 避免 screen reader 讀出 invisible 內容)*/}
2097
+ {rows[0] && rowActions!(rows[0].original)}
2098
+ </div>
2099
+ )}
2100
+ </RowTag>
2101
+ )
2102
+ }
2103
+
2104
+ // ── Render body rows for a region ──
2105
+ // code-quality-allow: long-function — virtualizer × sticky region × empty state × per-row drag 四正交 render path 集中,拆 sub-fn 會將 virtualItems / rows / colVirtualizer 三 closure 跨 fn 傳
2106
+ const renderBodyRows = (cols: Column<TData, unknown>[], isCenter: boolean, isRight: boolean, regionWidth?: number) => {
2107
+ if (isEmpty && isCenter) {
2108
+ // 有框容器 → 垂直置中(design principle)
2109
+ if (emptyState && typeof emptyState !== 'string') return <div className="flex-1 flex items-center justify-center py-12">{emptyState}</div>
2110
+ return <div className="flex-1 flex items-center justify-center py-12"><Empty description={typeof emptyState === 'string' ? emptyState : '沒有資料'} /></div> // i18n-allow: DS default fallback; consumer override via emptyState prop
2111
+ }
2112
+ if (isEmpty) return null
2113
+
2114
+ // **v15.4 architectural decision** — primary 永遠 = center region(不論是否 pinnedLeft)。
2115
+ // 之前 `primary = left if hasLeft else center` 有兩問題:
2116
+ // 1. multi-instance same-id 是 dnd-kit anti-pattern,setActivatorNodeRef 救不了
2117
+ // (last-mount-wins,用 last region 的 rect 當 activator → ghost 起點偏離 cursor)
2118
+ // 2. user 從 center 主視覺 grab 才直觀;pinned-left(SKU)/ pinned-right(Updated)
2119
+ // 是「鎖定欄」語意,不是 drag handle。Linear / Notion / Jira 的 pinned column
2120
+ // 都不接 drag listeners,純視覺鎖。
2121
+ // 改 center-only listeners → ghost activator = center row → cursor 跟 ghost 維持初始
2122
+ // 相對位置(SSOT 對齊 user directive)。
2123
+ // code-quality-allow: long-function — audit 誤偵測 isPrimaryRegion 為 function(實為 const);真實 long body = 下方 rowEl render closure(virtualizer × sticky × drag listeners × hover delegation),拆會破壞 closure capture
2124
+ const isPrimaryRegion = isCenter
2125
+ const regionRole: 'primary' | 'mirror' = isPrimaryRegion ? 'primary' : 'mirror'
2126
+
2127
+ // code-quality-allow: long-function — virtualizer × sticky panel × drag listeners × hover delegation × per-row state 多 closure capture;拆會破壞 SortableContext / dnd-kit hooks 跟 row idx 的 stable binding
2128
+ const rowEl = (row: typeof rows[number], idx: number, opts?: { virtual?: boolean; start?: number; isLast?: boolean }) => {
2129
+ const showBorder = bordered !== false ? !opts?.isLast : true
2130
+ // L4 row drag v2:nested rows 也 sortable(配合 cross-parent collisionDetection 過濾)
2131
+ // sub-rows: depth>0 仍進 SortableContext,但 collisionDetection 只接受 same-parent over
2132
+ const isThisRowDragging = enableRowDrag && activeDragId === row.id
2133
+ const useSortableWrap = enableRowDrag
2134
+
2135
+ // H1 fix(2026-05-10,per user 確認):per-row autoRowHeight when any cell in this row has
2136
+ // error。Fixed-height row 模式下,該 row 的任一 cell 有 error msg → THAT row 自動 auto-height
2137
+ // 撐 message;error 全清 → 回 fixed。Other rows 不受影響(global autoRowHeight prop 不變)。
2138
+ // Per Material X-DataGrid `getRowHeight` per-row dynamic + AG Grid `rowHeight: 'auto'`
2139
+ // per-row idiom。Compute by scanning row's visible cells for cellErrors map hit。
2140
+ const rowHasError = !!cellErrors && row.getVisibleCells().some((c) => {
2141
+ const key = `${row.id}:${c.column.id}`
2142
+ const v = cellErrors[key]
2143
+ return v != null && (Array.isArray(v) ? v.length > 0 : true)
2144
+ })
2145
+ const effectiveAutoRow = autoRowHeight || rowHasError
2146
+
2147
+ // L4 row drag:handle absolute 貼齊 row 左 border(Jira canonical),不佔 column 空間。
2148
+ // 只在 primary region(left 若有,否則 center)+ depth===0 render — RowDragHandle
2149
+ // 內部再用 ctx.role === 'primary' 守門避免 mirror region 重複 render。
2150
+ const showDragHandle = enableRowDrag && (row.depth ?? 0) === 0 && isPrimaryRegion
2151
+ // v15.2 SSOT 對齊 TreeView:drag 期間 suppress 全表 hover state
2152
+ // (user directive「drag 時 row 不應 hover / drag button 不應出現」)
2153
+ const anyDragActive = activeDragId != null
2154
+ // code-quality-allow: long-function — baseRowDiv 含 row drag listeners / sticky panel / hover delegation / cell render loop / divider drawing 多 closure;拆 sub-fn 會破壞 dnd-kit hooks + row.id stable binding
2155
+ const baseRowDiv = (extra?: { ref?: (el: HTMLElement | null) => void; style?: React.CSSProperties; isDragging?: boolean; listeners?: Record<string, unknown>; attributes?: Record<string, unknown> }) => (
2156
+ <div
2157
+ key={row.id}
2158
+ ref={(el) => {
2159
+ // v2 fix #1:被拖 row 略過 measureElement(transform 干擾測量,長 list 累積誤差)
2160
+ // v2 fix #4(virtual freeze):drag 進行中(activeDragId != null)整個略過 measureElement
2161
+ // **2026-05-07 v15.17 A 路徑 — revert autoRowHeight guard**:
2162
+ // v15.8 加 `&& autoRowHeight` guard 為了解 R4 mount-time row growth animation
2163
+ // (假設 measureElement 在 fixed mode 觸發 getTotalSize 重算 → React re-render →
2164
+ // mount 時看起來 row height 在變)。但 codex P2 audit (`6d5188e` line 1699)指出
2165
+ // 此 guard 副作用:consumer 傳 custom `estimateRowHeight` 或 CSS theme override
2166
+ // row height 時,fixed mode 不再 reconcile estimate vs reality → getTotalSize 錯
2167
+ // → scroll 範圍截斷或末端留白。
2168
+ //
2169
+ // Codex deep R4 eval (`4399272774` follow-up reply) 認為 R4 真因更可能是
2170
+ // 「首幀樣式未齊 / 字型 async load / paint stagger」,不是 measureElement。
2171
+ // 故先 revert guard 觀察 R4 是否真回歸:
2172
+ // - R4 不重現(我 + codex 推論對)→ guard mis-fix,永久撤,P2 自動解
2173
+ // - R4 重現(measureElement 真因)→ 補 dampening (差異 <1px 不 setState /
2174
+ // 一幀只 update 一次)+ low-freq sampling per codex 雙模式方案
2175
+ if (isCenter && opts?.virtual && el && !isThisRowDragging && activeDragId == null) {
2176
+ virtualizer.measureElement(el)
2177
+ }
2178
+ extra?.ref?.(el)
2179
+ }}
2180
+ data-index={isCenter && opts?.virtual ? idx : undefined}
2181
+ data-row-index={idx}
2182
+ data-sortable-row-id={enableRowDrag ? row.id : undefined}
2183
+ // v15.4:primary region(center)= drag source row — ghost reconstruction 用此 marker
2184
+ // 找 source row(避免 multi-region 場景挑錯 region 的 row 當 ghost)
2185
+ data-row-drag-source={enableRowDrag && isPrimaryRegion ? 'true' : undefined}
2186
+ role="row"
2187
+ aria-rowindex={idx + 2}
2188
+ className={cn(
2189
+ 'group/row flex relative',
2190
+ // H1 fix(2026-05-10):effectiveAutoRow 覆 global autoRowHeight,per-row 若有 cell error
2191
+ // 自動 auto-height(撐 message)。Error 清 → 回 fixed。Other rows 不受影響。
2192
+ effectiveAutoRow ? 'items-start' : 'items-center',
2193
+ !effectiveAutoRow && rowHeight,
2194
+ !effectiveAutoRow && 'overflow-hidden',
2195
+ opts?.virtual && 'absolute w-full',
2196
+ showBorder && 'border-b border-divider',
2197
+ // v15.3 hover bg canonical:hover class 永遠生效,但 onMouseOver delegate
2198
+ // 在 drag 期間只允許 source row 寫 data-hovered → 其他 row 自然不顯 bg。
2199
+ // (對齊 Linear / Jira:source 維持 active 視覺,其他 row 完全靜止)
2200
+ 'transition-colors data-[hovered]:bg-neutral-hover',
2201
+ extra?.isDragging && 'bg-neutral-hover',
2202
+ // **v15.3.1**:不變 cursor(對齊 Material / Carbon / Polaris / Notion canonical)。
2203
+ // 整列可拖的 affordance 由可見的 RowDragHandle Button 提供,不靠 cursor 暗示。
2204
+ // 之前 cursor-grab → drag 中 user 看到 cursor 變化反而干擾 indicator+ghost 的視覺焦點。
2205
+ )}
2206
+ style={{
2207
+ ...(opts?.virtual ? { transform: `translateY(${opts.start}px)` } : {}),
2208
+ ...(extra?.style ?? {}),
2209
+ }}
2210
+ {...hoverProps(idx)}
2211
+ {...(extra?.attributes ?? {})}
2212
+ {...(extra?.listeners ?? {})}
2213
+ >
2214
+ {showDragHandle && <RowDragHandle disabled={dragDisabled} anyDragActive={anyDragActive} />}
2215
+ {/* 2026-05-06 v14.6 row drop indicator(SSOT 對齊 TreeView):水平 2px primary line at top/bottom edge */}
2216
+ {dropIndicator?.type === 'row' && dropIndicator.id === row.id && dropIndicator.side === 'before' && (
2217
+ <div className={dropIndicatorRow.before} aria-hidden />
2218
+ )}
2219
+ {getRegionCells(row, cols).map((cell, ci, arr) => cellEl(cell, ci === arr.length - 1 && !(isRight && hasRowActions)))}
2220
+ {isRight && hasRowActions && (
2221
+ <div role="cell" className="flex items-center justify-end shrink-0 gap-2 flex-1" style={cellPadding}>
2222
+ {rowActions!(row.original)}
2223
+ </div>
2224
+ )}
2225
+ {dropIndicator?.type === 'row' && dropIndicator.id === row.id && dropIndicator.side === 'after' && (
2226
+ <div className={dropIndicatorRow.after} aria-hidden />
2227
+ )}
2228
+ </div>
2229
+ )
2230
+
2231
+ if (useSortableWrap) {
2232
+ // invalidDrop 只對「正在被拖」的 row 顯示 — handle 在 active row 上,UI 警示只需該 row
2233
+ // code-quality-allow: long-function — 此 const 之下的整個 if-block 含 useSortable hooks + SortableRowProvider + baseRowDiv composition;audit 把 const 誤認為 function entry,實 long body 在 closure 內 dnd-kit + per-row state 多 capture,拆會破壞 hook order invariant
2234
+ const rowInvalidDrop = isThisRowDragging && invalidDropActive
2235
+ return (
2236
+ <SortableRowProvider key={row.id} id={row.id} disabled={dragDisabled} role={regionRole} invalidDrop={rowInvalidDrop}>
2237
+ {(ctx) => baseRowDiv({
2238
+ // mirror 也掛 setNodeRef — dnd-kit 內部以 hook instance 為單元,
2239
+ // 多 instance 同 id 時,measurement 走最後 mount 的;不影響 transform 一致性
2240
+ ref: ctx.setNodeRef,
2241
+ style: ctx.style,
2242
+ isDragging: ctx.isDragging,
2243
+ // v15.2 整列可拖:listeners + attributes spread 在 row div 上(只 primary,
2244
+ // mirror region 沒 listeners 避免 a11y 重複 announce / pointer 雙觸發)
2245
+ listeners: ctx.rowListeners,
2246
+ attributes: ctx.rowAttributes,
2247
+ })}
2248
+ </SortableRowProvider>
2249
+ )
2250
+ }
2251
+ return baseRowDiv()
2252
+ }
2253
+
2254
+ // AR44 canonical(2026-04-21):virtual / non-virtual 都用 `minWidth: colsWidth` 的 wrapper,
2255
+ // 讓兩種 rendering path 的 **水平 overflow 行為一致** — 中段 column 區塊都會因
2256
+ // columns 實際寬度超過 centerBody 可用寬而觸發 H scrollbar。
2257
+ // 先前 non-virtual 走 `<>...</>`(無 wrapper),依靠 row 內 cells 自然寬推擠容器,
2258
+ // 跟 virtual 的 `minWidth: containerWidth` 行為不同,造成 story 1 / story 2 看起來水平
2259
+ // 捲軸出現時機不一致。現在統一靠 wrapper 的 minWidth 強制 overflow。
2260
+ const colsWidth = cols.reduce((a, c) => a + c.getSize(), 0)
2261
+ const containerWidth = regionWidth || colsWidth
2262
+
2263
+ if (useVirtual) {
2264
+ // 2026-05-13 (c) scroll-defer perf(per user 拍 Path (c) Roadmap >50ms 後 escalate):
2265
+ // wrap virtual body with `<TableScrollProvider isScrolling={virtualizer.isScrolling}>` →
2266
+ // 重 cell subtree(Avatar HoverCard / future Tag / etc.)讀 useTableIsScrolling() 跳
2267
+ // expensive paths during scroll,scroll end 自動接回完整 affordance。
2268
+ // 對齊 AG Grid deferRender / MUI X DataGrid scroll-defer canonical。
2269
+ return (
2270
+ <TableScrollProvider isScrolling={virtualizer.isScrolling}>
2271
+ <div style={{ height: virtualizer.getTotalSize(), position: 'relative', minWidth: containerWidth }}>
2272
+ {virtualizer.getVirtualItems().map(vr => rowEl(rows[vr.index], vr.index, { virtual: true, start: vr.start, isLast: vr.index === rows.length - 1 }))}
2273
+ </div>
2274
+ </TableScrollProvider>
2275
+ )
2276
+ }
2277
+ return (
2278
+ <div style={{ minWidth: containerWidth }}>
2279
+ {rows.map((row, i) => rowEl(row, i, { isLast: i === rows.length - 1 }))}
2280
+ </div>
2281
+ )
2282
+ }
2283
+
2284
+ // Single mode 用 RadioGroup wrap 整 table(Radix RadioGroup 用 context 傳遞 value/onValueChange)
2285
+ // Multi mode 不需 wrap(Checkbox 各自 controlled,不靠 context)
2286
+ const tableContent = (
2287
+ <div
2288
+ ref={(el) => { tableRef.current = el; if (typeof ref === 'function') ref(el); else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = el }}
2289
+ data-table-size={size}
2290
+ data-data-table-outer // anchor for RowDragHandle Portal getBoundingClientRect (M25 invariant: outer 一定存在)
2291
+ data-active-editor-controller={experimentalActiveEditorController ? 'enabled' : undefined} // Slice D Step 3.2 scaffold marker
2292
+ // 2026-05-12 fix(user 抓「為什麼按 shift 那麼容易會在 table 外圈出現一層藍色邊框」):
2293
+ // table outer tabIndex=0(spreadsheet keyboard nav needs)→ click 取得 focus →
2294
+ // browser default `:focus-visible` 來自 globals.css L63「outline: 2px solid var(--ring)」
2295
+ // → 整 table 藍框。Fix:`outline-none` 抑制 outer focus visual,cell selection rect
2296
+ // (SelectionRect z 2)IS the visual focus indicator per spreadsheet canonical
2297
+ // (對齊 Excel / Google Sheets / Notion / Airtable — focused cell own active border,
2298
+ // table 容器無 focus ring)。
2299
+ className={cn(dataTableVariants({ bordered }), isFillHeight && 'flex flex-col', 'outline-none focus:outline-none focus-visible:outline-none', className)}
2300
+ // isFillHeight:`maxHeight: 100%`(不是 height:100%)— content 小 → outer = intrinsic
2301
+ // (hug rows);content 大或 window 縮 < content → outer cap 到 100% of parent。
2302
+ // 行為:**永遠 hug rows**,只在被約束時才 cap + body shrink + V scroll。
2303
+ // 簡單需求:有約束 → rows 沒超就 hug;超就 cap+scroll;RWD 同理。
2304
+ style={isFillHeight ? { maxHeight: height } : undefined}
2305
+ role="table" aria-rowcount={rows.length + 1}
2306
+ // Phase 9 Issue 12 fix(2026-05-10 codex 抓):**single tabIndex prop**,合併 selection
2307
+ // 跟 spreadsheet 兩 path。React 在 dup props 只 keep last 是 silent regression risk。
2308
+ tabIndex={enabled || spreadsheetMode ? 0 : undefined}
2309
+ // 2026-05-10:`enabled || spreadsheetMode` — spreadsheet keyboard nav 跨 row-selection-disabled 場景也要 fire
2310
+ onKeyDown={enabled || spreadsheetMode ? tableKeyboardHandler : undefined}
2311
+ onMouseOver={enterLeaveHandlers.onMouseOver}
2312
+ onMouseOut={enterLeaveHandlers.onMouseOut}
2313
+ {...props}
2314
+ >
2315
+ {/* ══ HEADER(固定頂部,不在 scroll 內)══ */}
2316
+ <div role="rowgroup" className="flex">
2317
+ {hasLeft && (
2318
+ <div ref={leftHeaderRef} data-datatable-header-panel="left" className="shrink-0 overflow-hidden dtPanelBoundaryRight">
2319
+ {renderHeaderRow(leftCols, false)}
2320
+ </div>
2321
+ )}
2322
+ {/* Header 的 center 區保持 overflow-hidden(非 scroll)—— body 的 center 才有 scroll,
2323
+ header 靠 JS 同步 scrollLeft(見 onCenterBodyScroll)。這樣不會出現雙 scrollbar。
2324
+ 為了對齊 body 的 V scrollbar(native 捲軸吃 ~15-17px 寬),header 等寬預留 gutter:
2325
+ `scrollbar-gutter: stable` 放在 centerBody(真正有 V scroll 的 container)+
2326
+ header 這層不需額外處理,因為 body 預留了空間後 H 內容寬度會自然等同 header。
2327
+ 注意:header 的 `scrollbar-gutter` 無效(因為 overflow-hidden),刻意不設 */}
2328
+ <div
2329
+ ref={centerHeaderRef}
2330
+ data-datatable-header-panel="center"
2331
+ className="flex-1 min-w-0 overflow-hidden"
2332
+ >
2333
+ {/* 2026-05-06 v13.1:retire `w-max min-w-full` — 改 `style={{minWidth: centerColsWidth}}`
2334
+ 跟 body inner wrapper 同 SSOT。前 `w-max` 讓 header content max-content(label 短)
2335
+ vs body content max-content(Note 長 break-words)diverge → header / row width 不對齊 76px。
2336
+ 統一 minWidth 公式後兩者永遠等寬,cells flex 均分結果一致。 */}
2337
+ <div style={{ minWidth: centerColsWidth }}>
2338
+ {renderHeaderRow(centerCols, false)}
2339
+ </div>
2340
+ </div>
2341
+ {hasRight && (
2342
+ <div ref={rightHeaderRef} data-datatable-header-panel="right" className="shrink-0 overflow-hidden dtPanelBoundaryLeft">
2343
+ {renderHeaderRow(rightCols, true)}
2344
+ </div>
2345
+ )}
2346
+ </div>
2347
+
2348
+ {/* ══ BODY(AG Grid 流派:各 region 自己 V scroll + JS 同步)══
2349
+ AR44 user-reported UX fix:H scrollbar 現在落在 center-body 的 visible 底部(不是 1800px 內容底部)。
2350
+ 三個 region(left / center / right)各自 maxHeight + overflowY,JS 同步 scrollTop。
2351
+ Pinned 區 overflow-y:hidden(看不到自己的 V scrollbar),V scroll 真正發生在 center。
2352
+ isFillHeight 時 body div 加 min-h-0 讓它在 outer flex column 內可被 flex shrink — region maxHeight: 100% 才能 bind 到實際分配的高度。 */}
2353
+ {/* body 在 isFillHeight 用 `min-h-0 min-w-0`(**不**用 flex-1)。
2354
+ flex-1 會強制 body 撐滿 outer = 不 hug content。預設 `flex: 0 1 auto` + min-h-0 =
2355
+ body intrinsic = content,被 outer maxHeight 約束時可 shrink 到 outer 分配空間。
2356
+ centerBody.maxHeight 用 JS 算 px(bypass CSS % flex 場景 buggy shrink)。 */}
2357
+ <div ref={bodyRef} className={cn('flex items-start', isFillHeight && 'min-h-0 min-w-0')}>
2358
+ {hasLeft && (
2359
+ <div
2360
+ ref={leftBodyRef}
2361
+ data-datatable-panel="left"
2362
+ className="shrink-0 overflow-hidden dtPanelBoundaryRight"
2363
+ style={{
2364
+ width: leftWidth || undefined,
2365
+ // isFillHeight 用 JS 算的 px;固定 px(300px 等)直接套
2366
+ ...(isFillHeight && bodyMaxHeight != null ? { maxHeight: bodyMaxHeight } : hasHeightConstraint ? { maxHeight: height } : {}),
2367
+ }}
2368
+ >
2369
+ {renderBodyRows(leftCols, false, false, leftWidth)}
2370
+ </div>
2371
+ )}
2372
+ <div
2373
+ ref={centerBodyRef}
2374
+ // Center body 同時擁有 H + V scroll;maxHeight 限制讓 H scrollbar 落在 visible 底部
2375
+ // `scrollbar-gutter: stable` 永遠預留 V scrollbar 寬度(~15-17px),避免 body 出現 V
2376
+ // scrollbar 時右端被縮,跟 header 右端產生 gap(Windows/Linux native scrollbar 吃寬)
2377
+ data-datatable-hscroll
2378
+ data-datatable-panel="center"
2379
+ // overflow-x/y: auto — 沒 overflow 就不顯 bar。wrapper minWidth 仍 trigger H 真 overflow。
2380
+ // **不**用 scrollbar-gutter: stable — 那會永遠保留 V 軸 15px 空間,
2381
+ // content fit 時看起來像「永遠有 V 捲軸」(Image #5 bug)。
2382
+ // trade-off:V scroll 出現時 body 內側少 15px,header 不縮 → 右端微 misalign,
2383
+ // 但 content fit 視覺乾淨優先(Mac 用戶 overlay scrollbar 不可見)。
2384
+ className="flex-1 min-w-0 overflow-x-auto overflow-y-auto"
2385
+ // isFillHeight:用 JS 算的 px(bodyMaxHeight),bypass CSS % 在 flex 場景的不可靠 shrink。
2386
+ // 固定 px(300px etc):直接套 height。
2387
+ style={
2388
+ isFillHeight && bodyMaxHeight != null
2389
+ ? { maxHeight: bodyMaxHeight }
2390
+ : hasHeightConstraint
2391
+ ? { maxHeight: height }
2392
+ : undefined
2393
+ }
2394
+ onScroll={onCenterBodyScroll}
2395
+ >
2396
+ {/* 2026-05-06 v13.1:retire `w-max min-w-full` — 改 `style={{minWidth: centerColsWidth}}`
2397
+ 跟 header inner wrapper 同 SSOT。renderBodyRows 內部已用同 containerWidth 公式 wrap rows,
2398
+ 此外層 wrapper minWidth 跟內層一致 = 兩層都 = centerColsWidth → header / body 對齊。 */}
2399
+ <div style={{ minWidth: centerColsWidth }}>
2400
+ {renderBodyRows(centerCols, true, false)}
2401
+ </div>
2402
+ </div>
2403
+ {hasRight && (
2404
+ <div
2405
+ ref={rightBodyRef}
2406
+ data-datatable-panel="right"
2407
+ className="shrink-0 overflow-hidden dtPanelBoundaryLeft"
2408
+ style={{
2409
+ width: rightWidth || undefined,
2410
+ ...(isFillHeight && bodyMaxHeight != null ? { maxHeight: bodyMaxHeight } : hasHeightConstraint ? { maxHeight: height } : {}),
2411
+ }}
2412
+ >
2413
+ {renderBodyRows(rightCols, false, true, rightWidth)}
2414
+ </div>
2415
+ )}
2416
+ </div>
2417
+ {/* Slice D Step 1B(2026-05-10):Interaction Layer singleton(`.claude/planning/datatable-spreadsheet-rfc.md`)。
2418
+ Default disabled — backward-compat。Enable 後 hover/editor/selected/range 由 layer 統一畫,
2419
+ per Contract 8 「one geometry owner, two paint owners」。
2420
+ Step 1C-fix(2026-05-10):wire Contract 15 cellClickEntersEdit predicate 過濾 readonly /
2421
+ boolean / url cells(per RFC + user 拍板 USER #15「checkbox/url no-hover」)。
2422
+ Step 4(2026-05-10):wire spreadsheetMode select / range cells。 */}
2423
+ <DataTableInteractionLayer
2424
+ enabled={experimentalSpreadsheetOverlay || spreadsheetMode}
2425
+ containerRef={tableRef}
2426
+ // Slice D Step 3 wire(2026-05-10):pass editingCellId so layer renders
2427
+ // ActiveEditorHost rect at active cell。Composite cell-id format:
2428
+ // `${rowId}:${colId}` matches data-cell-id attribute(per Step 1B)。
2429
+ // Note:editingCellId 內部用 `__` separator,需轉 `:`。
2430
+ activeEditorCellId={editingCellId ? editingCellId.replace('__', ':') : null}
2431
+ // 2026-05-10 bug fix(user 圖1):dashed scaffold rect 改 gate 給
2432
+ // experimentalActiveEditorController 而非 experimentalSpreadsheetOverlay,
2433
+ // 避免 hover overlay 開啟時 cell 進 edit mode → dashed leak 出來跟 hover solid 並存。
2434
+ activeEditorEnabled={experimentalActiveEditorController}
2435
+ // Slice D Step 5(D.3 portal Field,2026-05-10):real portal Field render callback。
2436
+ // Layer 在 ActiveEditorHost(z 3 float rect)render `<Cell mode="edit" />` 同 registry。
2437
+ // Cell wrapper 保持 mode="display"(SSOT preserved per codex Q6.2)。
2438
+ activeEditorRender={experimentalActiveEditorController ? (cellId) => {
2439
+ const lastColon = cellId.lastIndexOf(':')
2440
+ if (lastColon < 0) return null
2441
+ const rowId = cellId.slice(0, lastColon)
2442
+ const colId = cellId.slice(lastColon + 1)
2443
+ const colDef = table.getAllLeafColumns().find((c) => c.id === colId)
2444
+ // any-allow: free-form column meta bag
2445
+ const meta = (colDef?.columnDef as any)?.meta as Record<string, any> | undefined
2446
+ if (!meta?.type) return null
2447
+ const colType = meta.type as ColumnType
2448
+ const Cell = resolveCellComponent(colType)
2449
+ const row = table.getRowModel().rowsById[rowId]
2450
+ if (!row) return null
2451
+ const cellEditable = isCellEditable(meta, row.original)
2452
+ // Phase 7 virtualizer unmount preserve draft:portal Field value 從 lifted editingDraft 取
2453
+ // (若 user 編輯中字 → draft 持有);未編輯時 fallback row.value(全新 edit session 顯示原值)
2454
+ // 2026-05-16 Round 5 audit Dim 27 fix:narrow type 取代 `as any`
2455
+ const rowValue = (row.original as Record<string, unknown>)[colId]
2456
+ const value = editingDraft !== undefined ? editingDraft : rowValue
2457
+ // Per codex Q6.2 invariant test:nested popovers register inside editor;
2458
+ // outside-click excludes them(future ActiveEditorController 接管 lifecycle scope)。
2459
+ // 當前 MVP:reuse cell-registry Cell mode="edit" + bound onCommit/onCancel。
2460
+ //
2461
+ // Phase B2 completion(2026-05-10 per codex Q-B2):Tab → commit + next editable + auto-edit。
2462
+ // wrap Cell in div with onKeyDownCapture intercept Tab/Shift+Tab(capture mode 先抓
2463
+ // 不被 Field 內部 keydown 攔)。direction:Tab=next / Shift+Tab=prev。
2464
+ // findNext skip readonly / boolean / url(non-editable click types per Contract 15)。
2465
+ // Phase B3(2026-05-10 per codex Q-B3):IME composition guard。中文輸入法組字期間
2466
+ // compositionstart event fire,組字結束後 compositionend fire。期間 keydown 的
2467
+ // Enter / Tab / Esc 不該觸發 commit/exit/next 行為(因 user 還在組字)。
2468
+ const handleEditTab = (e: React.KeyboardEvent) => {
2469
+ // IME guard:組字中 ignore Tab(per codex Q-B3 verdict 在 controller 層,
2470
+ // 此處 portal wrapper 是最近 controller 等價層;Field 內部 input 自帶 isComposing 但
2471
+ // wrapper-level Tab handler 必須也 guard,避免 onKeyDownCapture 早於 Field input)
2472
+ // 2026-05-16 Round 5 audit Dim 27 fix:`keyCode` deprecated but still in KeyboardEvent type — no cast needed
2473
+ if (e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229) return
2474
+ if (e.key !== 'Tab') return
2475
+ e.preventDefault()
2476
+ e.stopPropagation()
2477
+ const direction: 'next' | 'prev' = e.shiftKey ? 'prev' : 'next'
2478
+ const allRows = table.getRowModel().rows.map((r) => r.id)
2479
+ const allCols = table.getVisibleLeafColumns().map((c) => c.id).filter((id) => id !== SELECT_COL_ID)
2480
+ const curRowIdx = allRows.indexOf(rowId)
2481
+ const curColIdx = allCols.indexOf(colId)
2482
+ if (curRowIdx < 0 || curColIdx < 0) return
2483
+ // Step row-by-row,each step check editable + non-boolean/url
2484
+ const NON_EDIT_TYPES = ['boolean', 'url']
2485
+ let nextRowIdx = curRowIdx
2486
+ let nextColIdx = curColIdx
2487
+ const totalCells = allRows.length * allCols.length
2488
+ let safety = 0
2489
+ while (safety < totalCells) {
2490
+ safety++
2491
+ if (direction === 'next') {
2492
+ nextColIdx++
2493
+ if (nextColIdx >= allCols.length) { nextColIdx = 0; nextRowIdx++ }
2494
+ if (nextRowIdx >= allRows.length) return // 末尾,不 wrap
2495
+ } else {
2496
+ nextColIdx--
2497
+ if (nextColIdx < 0) { nextColIdx = allCols.length - 1; nextRowIdx-- }
2498
+ if (nextRowIdx < 0) return // 首端,不 wrap
2499
+ }
2500
+ const nextRow = table.getRowModel().rowsById[allRows[nextRowIdx]]
2501
+ const nextColDef = table.getAllLeafColumns().find((c) => c.id === allCols[nextColIdx])
2502
+ // any-allow: column meta free-form
2503
+ const nextMeta = (nextColDef?.columnDef as any)?.meta as Record<string, any> | undefined
2504
+ if (!nextMeta?.type || NON_EDIT_TYPES.includes(nextMeta.type)) continue
2505
+ // 2026-05-13:canEditCell helper(per V4 consolidation,合 editable + !disabled invariant)
2506
+ if (!nextRow || !canEditCell(nextMeta, nextRow.original)) continue
2507
+ // 找到 next editable cell → commit current + start next edit
2508
+ setEditingCellId(cellEditId(allRows[nextRowIdx], allCols[nextColIdx]))
2509
+ return
2510
+ }
2511
+ }
2512
+ return (
2513
+ <div onKeyDownCapture={handleEditTab} style={{ width: '100%', height: '100%' }}>
2514
+ <Cell
2515
+ value={value}
2516
+ meta={meta}
2517
+ mode="edit"
2518
+ size={size}
2519
+ autoRowHeight={false} // portal MVP 單行;auto-row defer 到 Phase 5
2520
+ isEditable={cellEditable}
2521
+ onCommit={(next) => commitCell(rowId, colId, next)}
2522
+ onCommitLive={(next) => onCellCommit?.(rowId, colId, next)}
2523
+ onCancel={exitEdit}
2524
+ onDraft={setEditingDraft} // Phase 7:每 keystroke 寫 draft → preserve across virtualizer unmount
2525
+ />
2526
+ </div>
2527
+ )
2528
+ } : undefined}
2529
+ // Slice D Step 4 spreadsheet semantics(2026-05-10):
2530
+ // selectedCellId(click 1)= solid border SelectionRect z 2
2531
+ // rangeCellIds(Shift+click rectangle from anchor↔focus)= cell-bg fill via
2532
+ // CSS `[data-range-cell]`(per Issue 1 codex verdict;layer 不畫 fill,只畫
2533
+ // RangeOuterRing 4 line div boundary)
2534
+ selectedCellId={spreadsheetMode ? selectedCellId : null}
2535
+ rangeCellIds={rangeCellIds}
2536
+ cellClickEntersEdit={(cellId) => {
2537
+ // 2026-05-10 codex review red light fix(per dual-track verify):
2538
+ // 1. cellId parse 用 lastIndexOf(':')(row id 可含 colon)
2539
+ // 2. type allowlist(未知 type default false,non-editable types blocked)
2540
+ // 3. row-level editable(row) function 支援(per isCellEditable canonical)
2541
+ const lastColonIdx = cellId.lastIndexOf(':')
2542
+ if (lastColonIdx < 0) return false
2543
+ const rowId = cellId.slice(0, lastColonIdx)
2544
+ const colId = cellId.slice(lastColonIdx + 1)
2545
+ const colDef = table.getAllLeafColumns().find(c => c.id === colId)
2546
+ // any-allow: column meta 是 free-form bag
2547
+ const meta = (colDef?.columnDef as any)?.meta as Record<string, any> | undefined
2548
+ if (!meta) return false
2549
+ // Allowlist editable types(per Contract 15;未知 / boolean / url / readonly default false)
2550
+ const EDITABLE_CLICK_TYPES = ['string', 'number', 'currency', 'date', 'time', 'select', 'multiSelect', 'person', 'multiPerson', 'combobox']
2551
+ if (!EDITABLE_CLICK_TYPES.includes(meta.type)) return false
2552
+ // Row-level editable(row) function support(canonical per `isCellEditable` L720)
2553
+ // 2026-05-13:canEditCell helper consolidation(per V4)
2554
+ const row = table.getRowModel().rowsById[rowId]
2555
+ if (!row) return false
2556
+ return canEditCell(meta, row.original)
2557
+ }}
2558
+ />
2559
+ </div>
2560
+ )
2561
+
2562
+ // ── L4 Row drag DnD wrapper ───────────────────────────────────────────────
2563
+ // Sensors:Pointer(8px activation distance,避免 cell click 誤觸 drag)+ Keyboard(a11y)
2564
+ // SortableContext items:**只 top-level row id**(nested sub-rows 不在 sortable 集合);
2565
+ // 同 parent level 限制由「sub-rows 不在 items 內」自然成立。
2566
+ // DragEnd:active.id / over.id → 算 position(active vs over 視覺位置),呼叫 onRowReorder。
2567
+ // hooks 必呼叫(rules-of-hooks)— 即使 enableRowDrag=false 也走 useSensors;wrap 才條件化。
2568
+ // **codex P1 fix(2026-05-07 v15.13)**:KeyboardSensor 不傳 `coordinateGetter`,用
2569
+ // dnd-kit 預設 25px 箭頭 stepping。`sortableKeyboardCoordinates` 是 `@dnd-kit/sortable`
2570
+ // preset,需 `<SortableContext>` 才正確 resolve 下一個 sortable target — 但 v15.0
2571
+ // path B 已 explicit 砍 SortableContext 改用 useDraggable+useDroppable(line 21),
2572
+ // 此 getter 在無 context 下 keyboard nav 無法 reliable resolve target → keyboard
2573
+ // drag/reorder regression。Default getter(arrow-key Δ25px)在 useDraggable 場景是
2574
+ // dnd-kit canonical(`@dnd-kit/core/src/sensors/keyboard/defaults.ts` 預設行為)。
2575
+ const dndSensors = useSensors(
2576
+ useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
2577
+ useSensor(KeyboardSensor),
2578
+ )
2579
+
2580
+ // **2026-05-06 v14.8 collision detection canonical(對齊 dnd-kit official best practice)**:
2581
+ // 從 closestCenter 換 `pointerWithin + rectIntersection` composite。
2582
+ // **Why**:closestCenter 永遠返回最近 droppable → over 永遠非 null → 釋放任何位置都觸發
2583
+ // onRowReorder 強制 reorder(user 報「拉動就強制 reorder 不能 snap back」)。
2584
+ // pointerWithin 要求 pointer 真在 droppable rect 內才返回 → release 在 gap 自然 over=null →
2585
+ // 不觸發 reorder → snap back 自然成立(對齊 Notion / TreeView 行為)。
2586
+ // rectIntersection fallback 給 keyboard sensor(無 pointer)。
2587
+ // 詳 .claude/references/drag-canonical.md。
2588
+ const sameParentCollisionDetection: CollisionDetection = React.useCallback((args) => {
2589
+ const activeId = args.active?.id != null ? String(args.active.id) : null
2590
+ if (!activeId) {
2591
+ const pointer = pointerWithin(args)
2592
+ return pointer.length > 0 ? pointer : rectIntersection(args)
2593
+ }
2594
+ // **v15.8 Bug 2 fix**(對齊 user「PRD-004 拉起儘管同位置放開後一定 reorder」):
2595
+ // 預設 dnd-kit `pointerWithin` 排除 active 自身,fallback `rectIntersection` 找最近
2596
+ // next row → cursor 仍在 source row 內 但 over=next row → reorder 觸發。
2597
+ // Fix:cursor 仍在 source row vertical range 內 → return [](no over → no indicator
2598
+ // → onDragEnd over=null → noop)。User 必須 cursor 真正離開 source row vertical 範圍
2599
+ // 才視為「想 reorder」,對齊 user 的「沒動就不該 reorder」直覺。
2600
+ const activeRect = args.active?.rect.current.initial
2601
+ const cursor = args.pointerCoordinates
2602
+ if (cursor && activeRect && cursor.y >= activeRect.top && cursor.y <= activeRect.bottom) {
2603
+ return []
2604
+ }
2605
+ const activeParent = parentMap.get(activeId)
2606
+ // 過濾 droppable container collection — 只保留 same parent siblings(且不含 active 本身)
2607
+ const filtered = args.droppableContainers.filter(c => {
2608
+ const cid = String(c.id)
2609
+ if (cid === activeId) return false
2610
+ const cParent = parentMap.get(cid)
2611
+ if (cParent === undefined) return false // 非 row droppable
2612
+ return cParent === activeParent
2613
+ })
2614
+ const filteredArgs = { ...args, droppableContainers: filtered }
2615
+ const pointer = pointerWithin(filteredArgs)
2616
+ if (pointer.length > 0) return pointer
2617
+ // **v15.8 fix**(virtualized list dnd-kit droppableRects stale issue):
2618
+ // virtualized rows position 由 virtualizer transform:translateY 動態套,但 dnd-kit
2619
+ // measure droppable 在 mount 瞬間(rows 還沒 transform → 全 rect at top=100)+ 不
2620
+ // re-measure(MeasuringStrategy.Always 沒效)→ stale rects → rectIntersection 永遠
2621
+ // 0 over。Fix:fallback 用 cursor.y 對 DOM querySelector 找 row whose live
2622
+ // boundingClientRect 包含 cursor.y(同 parent siblings,排除 active)。
2623
+ if (cursor) {
2624
+ // **codex P2 fix(2026-05-07)**:scope 到 active table root + 同時驗 X 邊界。
2625
+ // 之前 document-wide query + cursor.y-only match → 多 DataTable 同頁(side-by-side
2626
+ // panels with overlapping Y ranges)會把 cursor 不在當前 table 卻 Y 帶相同的 row
2627
+ // 認成 over,觸發錯誤 reorder indicator/commit。Fix:limit 到 tableRef.current 的
2628
+ // sortable rows + 同時驗 cursor 在 row's X bounds 內。
2629
+ const tableScope = tableRef.current ?? document
2630
+ const liveRows = Array.from(tableScope.querySelectorAll<HTMLElement>('[role="row"][data-sortable-row-id]'))
2631
+ .filter(el => el.dataset.sortableRowId !== activeId)
2632
+ .filter(el => {
2633
+ const cParent = parentMap.get(el.dataset.sortableRowId ?? '')
2634
+ return cParent === activeParent
2635
+ })
2636
+ for (const el of liveRows) {
2637
+ const r = el.getBoundingClientRect()
2638
+ if (
2639
+ cursor.y >= r.top && cursor.y <= r.bottom &&
2640
+ cursor.x >= r.left && cursor.x <= r.right
2641
+ ) {
2642
+ const rowId = el.dataset.sortableRowId
2643
+ const cont = filtered.find(c => String(c.id) === rowId)
2644
+ if (cont) return [{ id: cont.id, data: { droppableContainer: cont, value: 0 } }]
2645
+ }
2646
+ }
2647
+ }
2648
+ return rectIntersection(filteredArgs)
2649
+ }, [parentMap])
2650
+
2651
+ // 2026-05-06 v10 DragOverlay canonical:drag start 時 snapshot source row outerHTML(strip
2652
+ // absolute / transform / opacity / data-* + reset width to natural width)→ render in
2653
+ // DragOverlay portal。Source row scroll out-of-viewport unmount 也不影響(canvas 視覺已 detach)。
2654
+ // 移除 windowed sticky range extractor — 不再需要 mount-pin source row,DragOverlay decoupled。
2655
+ // Atlassian / dnd-kit canonical:「If your useDraggable items are within a virtualized list,
2656
+ // you will absolutely want to use a drag overlay, since the original drag source can unmount
2657
+ // while dragging as the virtualized container is scrolled.」(GitHub #1674 / docs/api-documentation/draggable/drag-overlay)
2658
+ const [dragOverlayHtml, setDragOverlayHtml] = React.useState<string | null>(null)
2659
+ const [dragOverlayWidth, setDragOverlayWidth] = React.useState<number | null>(null)
2660
+ // 2026-05-06 v11:column reorder 共用 drag overlay state — `dragType` 區分 row vs column
2661
+ // **v15.9 移除 dragType state**:之前用來條件套 row drag modifier,現在三 scenario
2662
+ // 都無 modifier(SSOT 一致),drag type 只在 handler 內部用 active.data.current.type 取即可。
2663
+ const [, setActiveDragColId] = React.useState<string | null>(null)
2664
+ const handleDragStart = React.useCallback((e: { active: { id: string | number; data: { current?: { type?: 'row' | 'column'; columnId?: string } } } }) => {
2665
+ const id = String(e.active.id)
2666
+ const type = e.active.data?.current?.type ?? 'row'
2667
+ setInvalidDropActive(false)
2668
+ // v15.3:drag 啟動清掉非 source row 的 data-hovered(避免其他 row 殘留 hover bg + drag button)。
2669
+ // **保留 source row 的 hover** — 對齊 Linear / Jira「source 維持 active 視覺」world-class canonical。
2670
+ if (type === 'row') {
2671
+ tableRef.current?.querySelectorAll<HTMLElement>('[data-hovered]').forEach((el) => {
2672
+ const rowId = el.dataset.sortableRowId
2673
+ if (rowId !== id) delete el.dataset.hovered
2674
+ })
2675
+ } else {
2676
+ tableRef.current?.querySelectorAll<HTMLElement>('[data-hovered]').forEach((el) => delete el.dataset.hovered)
2677
+ }
2678
+ if (type === 'column') {
2679
+ // Column drag:snapshot header cell visual,strip transform/inline-styles
2680
+ const colId = e.active.data?.current?.columnId ?? id
2681
+ setActiveDragColId(colId)
2682
+ const headerEl = document.querySelector<HTMLElement>(`[role="columnheader"][data-column-id="${colId}"]`)
2683
+ if (headerEl) {
2684
+ const clone = headerEl.cloneNode(true) as HTMLElement
2685
+ clone.style.position = 'static'
2686
+ clone.style.transform = 'none'
2687
+ clone.style.transition = 'none'
2688
+ clone.style.opacity = '1'
2689
+ clone.style.zIndex = ''
2690
+ clone.style.width = `${headerEl.offsetWidth}px`
2691
+ // Strip resize handle clone(避免重複疊在 overlay 上)
2692
+ clone.querySelectorAll('[role="separator"][aria-orientation="vertical"]').forEach(n => n.remove())
2693
+ setDragOverlayHtml(clone.outerHTML)
2694
+ setDragOverlayWidth(headerEl.offsetWidth)
2695
+ }
2696
+ } else {
2697
+ setActiveDragId(id)
2698
+ // **v15.4 SSOT**:reconstructFullRowGhost 跨 pinned 區域(left/center/right)
2699
+ // 重組完整 row ghost,確保 cursor 在 ghost 內部維持與 mousedown 時相對位置一致
2700
+ // (對齊 user directive「ghost 跟 cursor 維持固定相對位置」+ Linear / Notion / Jira)
2701
+ const ghost = reconstructFullRowGhost(id, tableRef.current)
2702
+ if (ghost) {
2703
+ setDragOverlayHtml(ghost.html)
2704
+ setDragOverlayWidth(ghost.width)
2705
+ }
2706
+ }
2707
+ }, [])
2708
+
2709
+ // SSOT helpers `isReorderNoop` + `reconstructFullRowGhost` 已搬到 `lib/drag-visual.ts`
2710
+ // —— TreeView / DataTable row / DataTable column drag 三處 consumer 共享同一 invariant。
2711
+
2712
+ const handleDragOver = React.useCallback((e: { active: { id: string | number; data?: { current?: { type?: 'row' | 'column' } } }; over: { id: string | number } | null }) => {
2713
+ const { active, over } = e
2714
+ if (!active) return
2715
+ if (!over) {
2716
+ // 無 valid same-parent over → invalid drop signal(配合 v2 cross-parent visual)
2717
+ if (!invalidRef.current) setInvalidDropActive(true)
2718
+ setDropIndicator(null)
2719
+ return
2720
+ }
2721
+ if (invalidRef.current) setInvalidDropActive(false)
2722
+ if (active.id === over.id) { setDropIndicator(null); return }
2723
+ // Drop indicator(2026-05-06 v14.6 row + column 統一 SSOT pattern):
2724
+ // 用 active vs over 在 sortable items 的相對位置判 before/after。
2725
+ // (Notion canonical:source 在 target 之前 → drop after / source 在 target 之後 → drop before)
2726
+ const dragType = active.data?.current?.type ?? 'row'
2727
+ if (dragType === 'column') {
2728
+ const activeIdx = reorderableColumnIdsRef.current.indexOf(String(active.id))
2729
+ const overIdx = reorderableColumnIdsRef.current.indexOf(String(over.id))
2730
+ if (activeIdx === -1 || overIdx === -1) { setDropIndicator(null); return }
2731
+ const side: 'before' | 'after' = activeIdx < overIdx ? 'after' : 'before'
2732
+ // **v15.3 noop suppress**:drop position 等同原位 → 不顯 indicator(對齊 handleDragEnd noop guard)
2733
+ if (isReorderNoop(activeIdx, overIdx, side)) { setDropIndicator(null); return }
2734
+ setDropIndicator({ id: String(over.id), side, type: 'column' })
2735
+ } else {
2736
+ // Row drag — 用 allRowIds 算位置(只 same-parent siblings,跨 parent collisionDetection 已過濾)
2737
+ const activeIdx = allRowIds.indexOf(String(active.id))
2738
+ const overIdx = allRowIds.indexOf(String(over.id))
2739
+ if (activeIdx === -1 || overIdx === -1) { setDropIndicator(null); return }
2740
+ const side: 'before' | 'after' = activeIdx < overIdx ? 'after' : 'before'
2741
+ if (isReorderNoop(activeIdx, overIdx, side)) { setDropIndicator(null); return }
2742
+ setDropIndicator({ id: String(over.id), side, type: 'row' })
2743
+ }
2744
+ }, [allRowIds, isReorderNoop])
2745
+
2746
+ const handleDragCancel = React.useCallback(() => {
2747
+ setActiveDragId(null)
2748
+ setActiveDragColId(null)
2749
+ setInvalidDropActive(false)
2750
+ setDragOverlayHtml(null)
2751
+ setDragOverlayWidth(null)
2752
+ setDropIndicator(null)
2753
+ }, [])
2754
+
2755
+ // Reorderable column ids(non-locked,non-system) — 用 TanStack runtime visible order
2756
+ // **v14.10 fix**:之前用 columnsWithSelection 的 declaration order,user 控的 columnOrder
2757
+ // state(tableOptions.state.columnOrder)被忽略 → side('before'/'after')算錯 → drop 落
2758
+ // 在錯誤位置(user 報「Stock 移不到 Category/Price 之間」root cause)。
2759
+ // 改用 `table.getVisibleLeafColumns()` 拿 live visual order(已套 columnPinning + columnOrder)。
2760
+ const reorderableColumnIds = React.useMemo(() => {
2761
+ return table.getVisibleLeafColumns()
2762
+ .map(c => c.id)
2763
+ .filter(id => id && !isSystemColumn(id))
2764
+ .filter(id => {
2765
+ const meta = table.getColumn(id)?.columnDef.meta as { locked?: boolean } | undefined
2766
+ return !meta?.locked
2767
+ })
2768
+ // eslint-disable-next-line react-hooks/exhaustive-deps
2769
+ }, [table, columnsWithSelection, tableOptions?.state?.columnOrder])
2770
+ // Sync ref(handleDragOver closure 抓不到最新 reorderableColumnIds)
2771
+ React.useEffect(() => { reorderableColumnIdsRef.current = reorderableColumnIds }, [reorderableColumnIds])
2772
+
2773
+ const handleDragEnd = React.useCallback((e: DragEndEvent) => {
2774
+ const { active, over } = e
2775
+ const type = (active.data?.current as { type?: 'row' | 'column' } | undefined)?.type ?? 'row'
2776
+ setActiveDragId(null)
2777
+ setActiveDragColId(null)
2778
+ setInvalidDropActive(false)
2779
+ setDragOverlayHtml(null)
2780
+ setDragOverlayWidth(null)
2781
+ setDropIndicator(null)
2782
+ if (!over || active.id === over.id) return
2783
+ const sourceId = String(active.id)
2784
+ const targetId = String(over.id)
2785
+
2786
+ if (type === 'column') {
2787
+ // Column reorder:用 reorderableColumnIds 算 before/after
2788
+ const oldIdx = reorderableColumnIds.indexOf(sourceId)
2789
+ const newIdx = reorderableColumnIds.indexOf(targetId)
2790
+ if (oldIdx === -1 || newIdx === -1) return
2791
+ const position: 'before' | 'after' = oldIdx < newIdx ? 'after' : 'before'
2792
+ // **v15.3 noop guard SSOT**(共用 isReorderNoop helper)
2793
+ if (isReorderNoop(oldIdx, newIdx, position)) return
2794
+ // 2026-05-12 Q1 fix(user 抓「column 一拉起來就一定要換位置」)— Material X / AG Grid
2795
+ // column reorder canonical:cursor 必須過 next column **50% midpoint** 才換,沒過 → snap back。
2796
+ // dnd-kit 預設 over=column-under-pointer 一拉到鄰格就 reorder。加 midpoint threshold 對齊
2797
+ // 世界級 column reorder UX。對齊 row drag noop SSOT(`isReorderNoop`)+ Material X
2798
+ // `columnReorder` midpoint canonical(https://mui.com/x/react-data-grid/column-ordering/)。
2799
+ const activeRect = active.rect.current.translated ?? active.rect.current.initial
2800
+ const overRect = over.rect
2801
+ if (activeRect && overRect) {
2802
+ const ghostCenter = activeRect.left + activeRect.width / 2
2803
+ const targetCenter = overRect.left + overRect.width / 2
2804
+ // Moving right(oldIdx < newIdx):ghost 必過 target center 才換
2805
+ if (oldIdx < newIdx && ghostCenter < targetCenter) return
2806
+ // Moving left(oldIdx > newIdx):ghost 必過 target center(從右側)才換
2807
+ if (oldIdx > newIdx && ghostCenter > targetCenter) return
2808
+ }
2809
+ onColumnReorder?.(sourceId, targetId, position)
2810
+ return
2811
+ }
2812
+
2813
+ // Row reorder(原邏輯)
2814
+ if (parentMap.get(sourceId) !== parentMap.get(targetId)) return
2815
+ const parentId = parentMap.get(sourceId)
2816
+ const siblings = allRowIds.filter(id => parentMap.get(id) === parentId)
2817
+ const oldIdx = siblings.indexOf(sourceId)
2818
+ const newIdx = siblings.indexOf(targetId)
2819
+ if (oldIdx === -1 || newIdx === -1) return
2820
+ const position: 'before' | 'after' = oldIdx < newIdx ? 'after' : 'before'
2821
+ if (isReorderNoop(oldIdx, newIdx, position)) return
2822
+ onRowReorder?.(sourceId, targetId, position)
2823
+ }, [allRowIds, parentMap, onRowReorder, onColumnReorder, reorderableColumnIds, isReorderNoop])
2824
+
2825
+ // 2026-05-06 v11:column reorder collision detection — drag column 時 droppable filter
2826
+ // 只保留 column id(避免 over 觸發 row);drag row 走 sameParent canonical。
2827
+ // v14.8:換 pointerWithin + rectIntersection composite(對齊 dnd-kit official canonical)
2828
+ // 解 user 報「ghost 出來但 indicator 沒 / 不能 reorder」snap-back 同類問題。
2829
+ const dndCollisionDetection: CollisionDetection = React.useCallback((args) => {
2830
+ const activeData = args.active?.data?.current as { type?: 'row' | 'column' } | undefined
2831
+ if (activeData?.type === 'column') {
2832
+ const filtered = args.droppableContainers.filter(c => {
2833
+ const cData = c.data?.current as { type?: 'row' | 'column' } | undefined
2834
+ return cData?.type === 'column' && c.id !== args.active?.id
2835
+ })
2836
+ const filteredArgs = { ...args, droppableContainers: filtered }
2837
+ const pointer = pointerWithin(filteredArgs)
2838
+ return pointer.length > 0 ? pointer : rectIntersection(filteredArgs)
2839
+ }
2840
+ return sameParentCollisionDetection(args)
2841
+ }, [sameParentCollisionDetection])
2842
+
2843
+ const wrapWithDnd = (node: React.ReactNode): React.ReactNode => {
2844
+ if (!enableRowDrag && !enableColumnReorder) return node
2845
+ return (
2846
+ <DndContext
2847
+ sensors={dndSensors}
2848
+ // **v15.8 fix**:virtualized rows mount/unmount 期間 droppable rect cache stale →
2849
+ // rectIntersection 找不到 over → indicator/reorder 不 fire。改 `Always` 每次 collision
2850
+ // detection 都 re-measure droppables(SSOT 對齊 dnd-kit virtualized list canonical)。
2851
+ measuring={{ droppable: { strategy: MeasuringStrategy.Always } }}
2852
+ collisionDetection={dndCollisionDetection}
2853
+ // **v15.11 Ghost-cursor SSOT 復活**:
2854
+ // - `snapToCursorModifier`(drag-visual.ts):ghost top-left 永遠對齊 cursor 位置,
2855
+ // 保證「ghost 跟 cursor 維持初始 mousedown 時的相對位置」(M17 SSOT idea)。
2856
+ // v15.7 → v15.8 撤回原因是 `rectIntersection` collision 用 transform 後的
2857
+ // active.rect 找不到 over → 拖不動。v15.10 collision 改用 **DOM-based 直查
2858
+ // live row rects(忽略 active.rect)**,modifier 偏移 transform 不再影響
2859
+ // collision detection,可安全再用。
2860
+ // - 三 drag scenario(row / column / TreeView)現在都 ghost-跟-cursor 對齊,
2861
+ // user directive「ghost-cursor SSOT」一致。
2862
+ modifiers={[snapToCursorModifier]}
2863
+ onDragStart={handleDragStart}
2864
+ onDragOver={handleDragOver}
2865
+ onDragCancel={handleDragCancel}
2866
+ onDragEnd={handleDragEnd}
2867
+ >
2868
+ {/* v15.0 Path B:無 SortableContext(useDraggable + useDroppable 各自獨立,不需 sort context)。
2869
+ 無 auto-shift visual reorder — source 留原位,indicator 顯 drop preview。 */}
2870
+ {node}
2871
+ {/* DragOverlay portal — row 跟 column 都用同一個 overlay state(dragOverlayHtml /
2872
+ dragOverlayWidth),onDragStart 依 type 截不同 source DOM 寫入 state。 */}
2873
+ <DragOverlay dropAnimation={null}>
2874
+ {dragOverlayHtml ? (
2875
+ <div
2876
+ style={{ width: dragOverlayWidth ?? undefined }}
2877
+ className="bg-surface-raised shadow-[var(--elevation-200)] rounded-md border border-border pointer-events-none"
2878
+ dangerouslySetInnerHTML={{ __html: dragOverlayHtml }}
2879
+ />
2880
+ ) : null}
2881
+ </DragOverlay>
2882
+ </DndContext>
2883
+ )
2884
+ }
2885
+
2886
+ if (enabled && mode === 'single') {
2887
+ return (
2888
+ <RadioGroupPrimitive.Root
2889
+ value={selection[0] ?? ''}
2890
+ onValueChange={(v) => v && setSelection([v])}
2891
+ >
2892
+ {wrapWithDnd(tableContent)}
2893
+ </RadioGroupPrimitive.Root>
2894
+ )
2895
+ }
2896
+ return wrapWithDnd(tableContent)
2897
+ }
2898
+
2899
+ export const DataTable = React.forwardRef(DataTableInner) as <TData>(
2900
+ props: DataTableProps<TData> & { ref?: React.ForwardedRef<HTMLDivElement> }
2901
+ ) => React.ReactElement
2902
+
2903
+ // any-allow: generic-constrained forwardRef cannot set displayName through typed API without erasing generic
2904
+ ;(DataTable as any).displayName = 'DataTable'
2905
+ // Story auto-compile metadata — Phase 1 mechanical migration(2026-04-24)
2906
+ // Phase 2 fill needed: purpose descriptions + when rationale + world-class refs
2907
+ export const dataTableMeta = {
2908
+ component: 'DataTable',
2909
+ family: null, // non-family composite / overlay / layout
2910
+ variants: {
2911
+
2912
+ },
2913
+ sizes: {
2914
+
2915
+ },
2916
+ states: ['default', 'hover', 'active', 'focus-visible', 'disabled'],
2917
+ tokens: {
2918
+ bg: ['bg-muted', 'bg-neutral-hover', 'bg-surface'],
2919
+ fg: ['text-fg-muted', 'text-fg-secondary', 'text-foreground'],
2920
+ ring: [],
2921
+ },
2922
+ } as const
2923
+
2924
+ export { dataTableVariants }