@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,180 @@
1
+ import type { RowData } from '@tanstack/react-table'
2
+
3
+ // ── Column Types ─────────────────────────────────────────────────────────────
4
+
5
+ // ── Column Types ─────────────────────────────────────────────────────────────
6
+ //
7
+ // 命名原則:描述**資料型別**本身,不是視覺變體。命名要避開撞 Button `variant` 值。
8
+ // `string` / `url` 是世界級 DS(Atlassian / Notion / Ant Table)的資料型別用語,
9
+ // 跟 Button 的視覺變體 `text` / `link` 在 consumer 心智不會混淆。
10
+ export const columnTypes = [
11
+ 'string', // 前身為 'text',因撞 Button variant="text"(文字樣式按鈕)改名
12
+ 'number',
13
+ 'currency',
14
+ 'date',
15
+ 'time', // Phase C(2026-05-05):time-only column,渲 `<TimePicker>`
16
+ 'select',
17
+ 'multiSelect',
18
+ 'person',
19
+ 'multiPerson',
20
+ 'boolean',
21
+ 'url', // 前身為 'link',因撞 Button variant="link"(連結樣式按鈕)改名
22
+ ] as const
23
+
24
+ export type ColumnType = (typeof columnTypes)[number]
25
+
26
+ // ── Column Type Config ───────────────────────────────────────────────────────
27
+
28
+ export interface ColumnTypeConfig {
29
+ /** Default horizontal alignment */
30
+ align: 'left' | 'right' | 'center'
31
+ // L3: sortingFn
32
+ // L4: filterVariant, filterFn
33
+ // L5: cellRenderer, cellEditor
34
+ }
35
+
36
+ /** Default config per column type */
37
+ export const columnTypeDefaults: Record<ColumnType, ColumnTypeConfig> = {
38
+ string: { align: 'left' },
39
+ number: { align: 'right' },
40
+ currency: { align: 'right' },
41
+ date: { align: 'left' },
42
+ time: { align: 'left' },
43
+ select: { align: 'left' },
44
+ multiSelect: { align: 'left' },
45
+ person: { align: 'left' },
46
+ multiPerson: { align: 'left' },
47
+ boolean: { align: 'left' },
48
+ url: { align: 'left' },
49
+ }
50
+
51
+ // ── Extend TanStack Table ColumnMeta ─────────────────────────────────────────
52
+
53
+ declare module '@tanstack/react-table' {
54
+ interface ColumnMeta<TData extends RowData, TValue> {
55
+ /**
56
+ * Column data type — determines default alignment, rendering, sorting, filtering.
57
+ *
58
+ * **Filterable column 必須設此 prop**(filter UI 只列有 `type` 的 column,
59
+ * 對齊 Notion / Airtable / Linear:每 property 強制有 type)。
60
+ */
61
+ type?: ColumnType
62
+ /** Override default alignment from column type */
63
+ align?: 'left' | 'right' | 'center'
64
+ /** Allow text wrapping (only effective when autoRowHeight is true) */
65
+ wrap?: boolean
66
+ /**
67
+ * Max visible lines before ellipsis(2026-05-14 I9 per codex verdict + user 拍板)。
68
+ * Only effective when `autoRowHeight + wrap=true`。Display 用 `line-clamp-N`,edit textarea
69
+ * 高度 match clamp。對齊 Notion 「Truncate」row toggle / AG Grid `cellClassRules` 上限 row height。
70
+ * Default = undefined(無 clamp,完整 wrap)。
71
+ */
72
+ maxLines?: number
73
+ /**
74
+ * Column 寬度(px)— DS canonical 命名(2026-05-06 v14.3)。
75
+ *
76
+ * **為何不用 TanStack 的 `size`** — DS 內 `size` 既定意為元件 density string
77
+ * `'sm' | 'md' | 'lg'`(Field / Button / Input 等 49+ 處 use case)。Column 寬
78
+ * 是 px 數字,跟 density 概念衝突 → 走 DS-internal 命名 `width`/`minWidth`/`maxWidth`
79
+ * (對齊 CSS 原生 + AG Grid 的命名共識)。
80
+ *
81
+ * 內部 pre-process 會把 `meta.width` copy 到 TanStack 的 root `size`,確保 column
82
+ * resize feature(`enableColumnResize=true` 拖拉 + columnSizing state)正常運作。
83
+ *
84
+ * - **No resize mode**(default): `width` = column 寬度 reserve(cell ≥ width,
85
+ * flex 可 grow,**不可 shrink** 低於 width)。對齊 user 預期「我設多寬就多寬」。
86
+ * - **Resize mode**(`enableColumnResize=true`):`width` = 初始寬度,user 可拖拉
87
+ * 到 `minWidth` 為止。
88
+ */
89
+ width?: number
90
+ /**
91
+ * Column 最小寬度(px)— resize 模式下的拖拉下限。No resize 模式不使用(`width` 即下限)。
92
+ * Default(resize mode 沒明示時)= 80px(`MIN_COLUMN_WIDTH`)。
93
+ */
94
+ minWidth?: number
95
+ /**
96
+ * Column 最大寬度(px)— resize 模式下的拖拉上限。預設無上限。
97
+ */
98
+ maxWidth?: number
99
+ /**
100
+ * Column 是否可被 user 拖拉 resize(opt-out per col)。Default `true`(when `enableColumnResize` 也 true)。
101
+ *
102
+ * Set `false` 表「此 col 寬度由內容決定不允許 resize」(2026-05-10 加,per user
103
+ * 「操作列這種內容決定寬度的欄位應該不允許 resize」)。對齊 AG Grid `colDef.resizable` /
104
+ * Material X-DataGrid 同 API。
105
+ *
106
+ * System cols(__select__ / __drag__ / __actions__)自動 false(內部 hard-code,
107
+ * consumer 不需設)— 永遠固定寬。
108
+ *
109
+ * 典型 use case:custom 圖示欄、status indicator 欄、密度太緊不該 user 動的欄。
110
+ */
111
+ resizable?: boolean
112
+ /**
113
+ * Explicit opt-out from filter UI(預設 accessor column 有 type 即 filterable)。
114
+ *
115
+ * 用於:有 `type` 但不想在 filter UI 出現的 accessor column
116
+ * (例如:internal sorting key、composite display 用的 hidden column)。
117
+ */
118
+ filterable?: boolean
119
+ /** Number/currency formatting options */
120
+ prefix?: string
121
+ suffix?: string
122
+ precision?: number
123
+ locale?: string
124
+ /** Select/multiSelect options — maps value to display label */
125
+ options?: Array<{ value: string; label: string }>
126
+ /** Date: Intl.DateTimeFormat options */
127
+ formatOptions?: Intl.DateTimeFormatOptions
128
+ /**
129
+ * Date: 是否含時間部分(datetime mode)。對齊 Notion idiom — 不另設 datetime column type。
130
+ *
131
+ * - `false`(default):cell 顯示與 filter 比對僅日期(day-level 精度)
132
+ * - `true`:cell 渲 date+time;filter 比對走 ms 精度(避開 Airtable 著名地雷)
133
+ *
134
+ * 在 advanced filter 中,`date` columnType 配 `includeTime=true` 時,
135
+ * `date_*` ValueShape 自動 promote 到 `datetime_*`,渲 `<DatePicker showTime>` /
136
+ * `<DatePickerRange showTime>`(詳 `filter-operators.ts` `getValueShape`)。
137
+ */
138
+ includeTime?: boolean
139
+ /** Link: 自訂顯示文字(不設則自動從 URL 提取 hostname) */
140
+ linkLabel?: string
141
+ /**
142
+ * Inline edit:column 是否可編輯。
143
+ * - `true`:可編
144
+ * - `false`(default):唯讀
145
+ * - `(row) => boolean`:per-row 動態決定(e.g. row.status !== 'archived' 才能編)
146
+ *
147
+ * 互動 per type(對齊 Notion / Airtable):
148
+ * - string / number / currency:click cell → inline `<Input>` autoFocus + selected
149
+ * - date / select / multiSelect / person / multiPerson:click cell → 進 edit mode 的 Field control(用戶按 trigger 開 picker)
150
+ * - boolean:不分 read/edit mode,直接 `<Checkbox>` 點即 toggle + commit
151
+ * - url:read = 連結;**hover cell** 右側出 Pencil 按鈕,click 才進 `<Input>` edit mode(保留 click 連結語意)
152
+ *
153
+ * Esc cancel / blur or Enter commit。Commit 觸發 `onCellCommit`。
154
+ */
155
+ editable?: boolean | ((row: TData) => boolean)
156
+ /**
157
+ * Inline disabled state(2026-05-13 Stream C Cluster B Q3 ship,per codex Q3 verdict):
158
+ * cell 不可操作 state(orthogonal to editable readonly)。
159
+ * - `true`:cell 永遠 disabled
160
+ * - `false` / undef(default):cell normal
161
+ * - `(row) => boolean`:per-row 動態(e.g. row.archived 或 row.locked 才 disabled)
162
+ *
163
+ * 視覺(per `color.spec.md` `--bg-disabled` component-state token):
164
+ * - TD 外殼:`bg-disabled` + `cursor-not-allowed` + 抑制 hover ring
165
+ * - Cell content(Field family):`mode='disabled'` → 各 Field type 內部走具體 disabled token
166
+ * - edit entry 抑制:disabled cell 點擊不進 edit(對齊 `editable && !disabled` invariant)
167
+ *
168
+ * 跟 `editable=false` 區分:editable=false = readonly(可看不可編);disabled = 不可操作 state。
169
+ * 對齊 MUI X `isCellEditable` + cellClassName / AG Grid `cellClassRules` / Notion locked properties。
170
+ */
171
+ disabled?: boolean | ((row: TData) => boolean)
172
+ /**
173
+ * Locked column — column reorder 不可拖,Notion 「primary column」 pattern。
174
+ * 對齊 SKU / ID 等不可移欄位。
175
+ */
176
+ locked?: boolean
177
+ /** Person/multiPerson edit mode: people pool for picker(2026-05-05 v4 type-augmentation)。 */
178
+ people?: Array<{ name: string; avatarUrl?: string; description?: string }>
179
+ }
180
+ }
@@ -0,0 +1,261 @@
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
+ /**
3
+ * DataTableColumnVisibilityPanel — Issue 3 primitive(2026-05-10)。
4
+ *
5
+ * 從 WithBulkActions story L868+(149 lines)+ RoadmapAllInOne(L1796+,53 lines)抽出 SSOT
6
+ * primitive。Roadmap demo 跟 WithBulkActions 都用此 panel 開啟「欄位顯示」popover。
7
+ *
8
+ * **Feature SSOT**(對齊 Linear / Airtable / Notion / Material X-DataGrid 的 columnVisibility panel idiom):
9
+ * - 列出所有 togglable 欄位 + Eye / EyeOff 切換
10
+ * - Locked 欄位(`lockedIds`)— 顯 Lock icon,不可 toggle / 不可 drag
11
+ * - 可選 search filter(`searchable`,跨欄位 label match)
12
+ * - 可選 column drag reorder(`onColumnOrderChange` 傳 → 啟 DnD)
13
+ * - 可選 reset 按鈕(`onReset`,header 區 RotateCcw)
14
+ * - Bidirectional bulk toggle(footer button:全可見 → 全部隱藏 / 任一隱藏 → 顯示全部)
15
+ *
16
+ * **Why 抽 primitive**(per Issue 3 Rule-of-3):RoadmapAllInOne + WithBulkActions + 未來 product
17
+ * UI 至少 3 處消費同 panel。重複 hand-craft → 設計語言 drift(已抓到:Roadmap 版無 search /
18
+ * 無 drag / 無 reset,WithBulkActions 版有,語意不一致)。Primitive 把所有 feature 集中,
19
+ * consumer opt-in via props 確保 SSOT。
20
+ *
21
+ * **對齊**:Linear column-visibility-panel pattern / Material X-DataGrid `<GridColumnsPanel>`
22
+ * / Airtable Field-config drawer。
23
+ */
24
+
25
+ import * as React from 'react'
26
+ import { Eye, EyeOff, Lock, GripVertical, Search, RotateCcw, X as XIcon } from 'lucide-react'
27
+ import { Button } from '@/design-system/components/Button/button'
28
+ import { ButtonDivider } from '@/design-system/components/Button/button-group'
29
+ import { Input } from '@/design-system/components/Input/input'
30
+ import { ScrollArea } from '@/design-system/components/ScrollArea/scroll-area'
31
+ import { PopoverHeader, PopoverFooter, PopoverTitle, PopoverClose } from '@/design-system/components/Popover/popover'
32
+ import { ItemPrefix, ItemLabel, ItemInlineActionButton, ROW_PADDING_BY_SIZE } from '@/design-system/patterns/element-anatomy/item-anatomy'
33
+ import { cn } from '@/lib/utils'
34
+ import { DndContext, closestCenter, type DragEndEvent } from '@dnd-kit/core'
35
+ import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'
36
+ import { CSS } from '@dnd-kit/utilities'
37
+ import { dragSourceStyle } from '@/design-system/lib/drag-visual'
38
+
39
+ interface ColumnVisibilityPanelColumn {
40
+ /** Column id(stable identifier;對齊 DataTable column.id / accessorKey)*/
41
+ id: string
42
+ /** Column header label(human-readable)*/
43
+ label: string
44
+ }
45
+
46
+ export interface DataTableColumnVisibilityPanelProps {
47
+ /** 全部可 toggle 欄位(consumer 已過濾掉 system cols 如 __select__)*/
48
+ columns: ColumnVisibilityPanelColumn[]
49
+ /** 受控 visibility map(`true` / undefined = visible / `false` = hidden)*/
50
+ visibility: Record<string, boolean>
51
+ /** Visibility 變更 callback */
52
+ onVisibilityChange: (next: Record<string, boolean>) => void
53
+ /** 受控 column order(若提供啟 DnD reorder) */
54
+ columnOrder?: string[]
55
+ /** Order 變更 callback;若提供 → 啟用 drag handle / DnD reorder */
56
+ onColumnOrderChange?: (next: string[]) => void
57
+ /** Locked column ids(顯 Lock icon,不可 toggle / 不可 drag)*/
58
+ lockedIds?: string[]
59
+ /** 是否啟搜尋 input(預設 false)*/
60
+ searchable?: boolean
61
+ /** 是否顯 reset button(任一欄隱藏才顯,onClick 清空 visibility map)*/
62
+ resettable?: boolean
63
+ // Issue 3 post-codex audit(2026-05-10):`onClose` prop retired — Radix `PopoverClose asChild`
64
+ // 已自帶 popover dismiss,沒 concrete consumer 需 callback。Future 若需要再加。
65
+ }
66
+
67
+ export function DataTableColumnVisibilityPanel({
68
+ columns,
69
+ visibility,
70
+ onVisibilityChange,
71
+ columnOrder,
72
+ onColumnOrderChange,
73
+ lockedIds = [],
74
+ searchable = false,
75
+ resettable = false,
76
+ }: DataTableColumnVisibilityPanelProps) {
77
+ const [search, setSearch] = React.useState('')
78
+ const lockedSet = React.useMemo(() => new Set(lockedIds), [lockedIds])
79
+ const dndEnabled = !!(columnOrder && onColumnOrderChange)
80
+
81
+ // 計算 visible order:用 columnOrder 排,fallback columns 順序。
82
+ const orderedIds = React.useMemo(() => {
83
+ if (columnOrder && columnOrder.length > 0) {
84
+ const colSet = new Set(columns.map((c) => c.id))
85
+ return columnOrder.filter((id) => colSet.has(id))
86
+ }
87
+ return columns.map((c) => c.id)
88
+ }, [columnOrder, columns])
89
+
90
+ const filteredEntries = React.useMemo(() => {
91
+ return orderedIds
92
+ .map((id) => columns.find((c) => c.id === id))
93
+ .filter((c): c is ColumnVisibilityPanelColumn => c != null)
94
+ .filter((c) => (search ? c.label.toLowerCase().includes(search.toLowerCase()) : true))
95
+ }, [orderedIds, columns, search])
96
+
97
+ const togglableIds = columns.map((c) => c.id).filter((id) => !lockedSet.has(id))
98
+ const allVisible = togglableIds.every((id) => visibility[id] !== false)
99
+ const anyHidden = togglableIds.some((id) => visibility[id] === false)
100
+
101
+ const handleToggle = (id: string) => {
102
+ const visible = visibility[id] !== false
103
+ onVisibilityChange({ ...visibility, [id]: !visible })
104
+ }
105
+
106
+ const handleReset = () => onVisibilityChange({})
107
+
108
+ const handleBulkToggle = () => {
109
+ if (allVisible) {
110
+ const next: Record<string, boolean> = {}
111
+ togglableIds.forEach((id) => { next[id] = false })
112
+ onVisibilityChange(next)
113
+ } else {
114
+ onVisibilityChange({})
115
+ }
116
+ }
117
+
118
+ const handleDragEnd = (e: DragEndEvent) => {
119
+ if (!dndEnabled) return
120
+ const { active, over } = e
121
+ if (!over || active.id === over.id) return
122
+ const oldIdx = columnOrder!.indexOf(active.id as string)
123
+ const newIdx = columnOrder!.indexOf(over.id as string)
124
+ if (oldIdx < 0 || newIdx < 0) return
125
+ // Locked id 不可被 reorder 動到
126
+ if (lockedSet.has(columnOrder![oldIdx]) || lockedSet.has(columnOrder![newIdx])) return
127
+ const next = [...columnOrder!]
128
+ const [m] = next.splice(oldIdx, 1)
129
+ next.splice(newIdx, 0, m)
130
+ onColumnOrderChange!(next)
131
+ }
132
+
133
+ return (
134
+ <>
135
+ <PopoverHeader hideClose>
136
+ <div className="flex items-center gap-1 w-full min-w-0">
137
+ <PopoverTitle className="flex-1">欄位顯示</PopoverTitle>
138
+ {resettable && anyHidden && (
139
+ <>
140
+ <Button
141
+ variant="text" size="sm" iconOnly startIcon={RotateCcw}
142
+ aria-label="恢復預設"
143
+ onClick={handleReset}
144
+ />
145
+ <ButtonDivider />
146
+ </>
147
+ )}
148
+ <PopoverClose asChild>
149
+ <Button data-dismiss iconOnly dismiss size="sm" startIcon={XIcon} aria-label="關閉" />
150
+ </PopoverClose>
151
+ </div>
152
+ </PopoverHeader>
153
+ {searchable && (
154
+ <div className="px-[var(--layout-space-loose)] pt-[var(--layout-space-tight)]">
155
+ <Input
156
+ size="md"
157
+ placeholder="搜尋欄位…"
158
+ value={search}
159
+ onChange={(e) => setSearch(e.target.value)}
160
+ startIcon={Search}
161
+ />
162
+ </div>
163
+ )}
164
+ <ScrollArea className="max-h-72">
165
+ <div className="py-2 flex flex-col" style={{ '--item-prefix-slot': '16px' } as React.CSSProperties}>
166
+ {dndEnabled ? (
167
+ <DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
168
+ <SortableContext
169
+ items={filteredEntries.map((e) => e.id).filter((id) => !lockedSet.has(id))}
170
+ strategy={verticalListSortingStrategy}
171
+ >
172
+ {filteredEntries.map(({ id, label }) => (
173
+ <VisibilityRow
174
+ key={id}
175
+ id={id}
176
+ label={label}
177
+ visible={visibility[id] !== false}
178
+ locked={lockedSet.has(id)}
179
+ draggable
180
+ onToggle={() => handleToggle(id)}
181
+ />
182
+ ))}
183
+ </SortableContext>
184
+ </DndContext>
185
+ ) : (
186
+ filteredEntries.map(({ id, label }) => (
187
+ <VisibilityRow
188
+ key={id}
189
+ id={id}
190
+ label={label}
191
+ visible={visibility[id] !== false}
192
+ locked={lockedSet.has(id)}
193
+ draggable={false}
194
+ onToggle={() => handleToggle(id)}
195
+ />
196
+ ))
197
+ )}
198
+ </div>
199
+ </ScrollArea>
200
+ <PopoverFooter className="justify-start">
201
+ <Button variant="tertiary" size="sm" onClick={handleBulkToggle}>
202
+ {allVisible ? '全部隱藏' : '顯示全部'}
203
+ </Button>
204
+ </PopoverFooter>
205
+ </>
206
+ )
207
+ }
208
+
209
+ // VisibilityRow:single column row。Draggable mode → GripVertical handle / static mode → 無 handle。
210
+ function VisibilityRow({
211
+ id, label, visible, locked, draggable, onToggle,
212
+ }: {
213
+ id: string
214
+ label: string
215
+ visible: boolean
216
+ locked: boolean
217
+ draggable: boolean
218
+ onToggle: () => void
219
+ }) {
220
+ // Conditional hooks 不行 — 永遠呼 useSortable,只是 disabled 時 listeners no-op
221
+ const sortable = useSortable({ id, disabled: locked || !draggable })
222
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = sortable
223
+ const style: React.CSSProperties = draggable
224
+ ? { transform: CSS.Transform.toString(transform), transition, ...dragSourceStyle(isDragging) }
225
+ : {}
226
+ return (
227
+ <div
228
+ ref={draggable ? setNodeRef : undefined}
229
+ style={style}
230
+ className={cn(
231
+ 'flex items-start gap-2 w-full px-[var(--layout-space-loose)]',
232
+ ROW_PADDING_BY_SIZE.md,
233
+ )}
234
+ >
235
+ <ItemPrefix>
236
+ {/* 2026-05-18 改 per user「做完」approval:14 → 16 對齊 uiSize.spec.md Icon Tier */}
237
+ {locked ? (
238
+ <Lock size={16} className="text-fg-muted" aria-hidden />
239
+ ) : draggable ? (
240
+ <ItemInlineActionButton
241
+ icon={GripVertical}
242
+ size="md"
243
+ aria-label="拖曳重排"
244
+ className="cursor-grab active:cursor-grabbing"
245
+ {...attributes}
246
+ {...listeners}
247
+ />
248
+ ) : null}
249
+ </ItemPrefix>
250
+ <ItemLabel className={locked ? 'text-fg-disabled' : undefined}>{label}</ItemLabel>
251
+ <ItemInlineActionButton
252
+ icon={visible ? Eye : EyeOff}
253
+ size="md"
254
+ aria-label={visible ? '隱藏此欄' : '顯示此欄'}
255
+ disabled={locked}
256
+ onClick={onToggle}
257
+ className={locked ? 'cursor-not-allowed text-fg-disabled' : ''}
258
+ />
259
+ </div>
260
+ )
261
+ }