@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,813 @@
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
+ // same-row-mixed-allow: header chrome corner buttons(close)跟 row inline actions(trash)不在同 row
3
+ // code-quality-allow: file-size — 2026-05-03 M21 retract:filter-value-picker.tsx(187 行 / 1 consumer)inline 回本檔。panel 從 505 → 687 行,進 transition zone 但未過 800 hard cap。等 inline filter UI 接入第 2 consumer 再考慮抽出。
4
+ import * as React from 'react'
5
+ import { Plus, Trash2, X as XIcon, RotateCcw } from 'lucide-react'
6
+ import type { ColumnDef } from '@tanstack/react-table'
7
+ import { cn } from '@/lib/utils'
8
+ import { Button } from '@/design-system/components/Button/button'
9
+ import { Select, type SelectOption } from '@/design-system/components/Select/select'
10
+ import { Combobox } from '@/design-system/components/Combobox/combobox'
11
+ import { Input } from '@/design-system/components/Input/input'
12
+ import { NumberInput } from '@/design-system/components/NumberInput/number-input'
13
+ import { DatePicker, DatePickerRange } from '@/design-system/components/DatePicker/date-picker'
14
+ import { PeoplePicker } from '@/design-system/components/PeoplePicker/people-picker'
15
+ import type { PersonValue } from '@/design-system/components/PeoplePicker/person-display'
16
+ import { SurfaceHeader, SurfaceBody } from '@/design-system/patterns/overlay-surface/overlay-surface'
17
+ import { PopoverTitle, PopoverClose } from '@/design-system/components/Popover/popover'
18
+ import { ButtonDivider } from '@/design-system/components/Button/button-group'
19
+ import { FieldControlGroup } from '@/design-system/components/FieldControlGroup/field-control-group'
20
+ import type { ColumnType } from './column-types'
21
+ import { getColumnId, getColumnLabel, getColumnMeta } from './lib/column-meta'
22
+ import {
23
+ OPERATOR_REGISTRY,
24
+ DEFAULT_OPERATOR,
25
+ DATE_RELATIVE_OPTIONS,
26
+ DATE_RELATIVE_GROUPS,
27
+ getOperatorSpec,
28
+ getValueShape,
29
+ type ValueShape,
30
+ } from './filter-operators'
31
+ import {
32
+ createEmptyFilterTree,
33
+ isFilterTreeActive,
34
+ isFilterTreeEqual,
35
+ evaluateTree,
36
+ dataTableFilterMatch,
37
+ type Conjunction,
38
+ type FilterCondition,
39
+ type FilterGroup,
40
+ type FilterTree,
41
+ type FilterTreeFlat,
42
+ type FilterTreeNested,
43
+ } from './filter-tree'
44
+
45
+ // Re-export public API from filter-tree(consumer 既有 import path 不變)
46
+ export {
47
+ createEmptyFilterTree,
48
+ isFilterTreeActive,
49
+ evaluateTree,
50
+ dataTableFilterMatch,
51
+ }
52
+ export type { Conjunction, FilterCondition, FilterGroup, FilterTree, FilterTreeFlat, FilterTreeNested }
53
+
54
+ /**
55
+ * DataTableFilterPanel — ClickUp-style 進階篩選 panel
56
+ *
57
+ * 對齊 ClickUp / Airtable / Notion / Coda / Linear advanced-filter 派 —
58
+ * parenthesized boolean expression builder。
59
+ *
60
+ * 兩種 mode 由 consumer 拍板:
61
+ * - `flat`:root 下只能裝 condition,無 group
62
+ * - `nested`:root 下裝 1+ group(灰底框),每個 group 內裝 1+ condition,**剛好 1 層**
63
+ *
64
+ * Source-of-truth:
65
+ * - Operator definitions:`./filter-operators.ts` `OPERATOR_REGISTRY`(SSOT,禁 hardcode op 字串)
66
+ * - Filter state:**FilterTree**(本檔自管;搭配 TanStack `globalFilter` 求值)
67
+ *
68
+ * 詳:`./advanced-filter.draft.md` + `./advanced-filter-operators.draft.md`
69
+ */
70
+
71
+ // ── Internal — id seed ──────────────────────────────────────────────────
72
+
73
+ let _idSeed = 0
74
+ const newId = () => `f${++_idSeed}-${Date.now().toString(36)}`
75
+
76
+ // ── Helpers — internal types ────────────────────────────────────────────
77
+
78
+ interface FilterColumn {
79
+ id: string
80
+ label: string
81
+ type: ColumnType
82
+ options?: Array<{ value: string; label: string }>
83
+ /** People pool for person/multiPerson filter picker(對齊 cell-registry meta.people SSOT)*/
84
+ people?: Array<{ name: string; avatarUrl?: string; description?: string }>
85
+ includeTime?: boolean
86
+ }
87
+
88
+ function extractColumns<TData>(columns: ColumnDef<TData, any>[]): FilterColumn[] {
89
+ const out: FilterColumn[] = []
90
+ for (const col of columns) {
91
+ const id = getColumnId(col)
92
+ if (!id || id === '__select__') continue
93
+ const meta = getColumnMeta(col)
94
+ const type: ColumnType | undefined = meta?.type
95
+ if (!type) continue
96
+ if (meta?.filterable === false) continue
97
+ out.push({
98
+ id,
99
+ label: getColumnLabel(col, id),
100
+ type,
101
+ options: meta?.options,
102
+ people: meta?.people,
103
+ includeTime: meta?.includeTime,
104
+ })
105
+ }
106
+ return out
107
+ }
108
+
109
+ function getOperatorOptions(type?: ColumnType): SelectOption[] {
110
+ const registry = type && OPERATOR_REGISTRY[type] ? OPERATOR_REGISTRY[type] : OPERATOR_REGISTRY.string
111
+ return registry.map((op) => ({ value: op.op, label: op.label }))
112
+ }
113
+
114
+ function getDefaultOperator(type?: ColumnType): string {
115
+ return (type && DEFAULT_OPERATOR[type]) || DEFAULT_OPERATOR.string
116
+ }
117
+
118
+ const newCondition = (firstCol: FilterColumn | undefined): FilterCondition => ({
119
+ kind: 'cond',
120
+ id: newId(),
121
+ field: firstCol?.id ?? '',
122
+ op: firstCol ? getDefaultOperator(firstCol.type) : '',
123
+ value: '',
124
+ })
125
+
126
+ // **G fix(2026-05-04)**:initial-mount 用 — field 不預選,user 自選後 op/value 才 enable
127
+ // Disabled state(field='')→ op + value 在 FilterRow 內走 `disabled={!hasField}` 自動連動
128
+ const newEmptyCondition = (): FilterCondition => ({
129
+ kind: 'cond',
130
+ id: newId(),
131
+ field: '',
132
+ op: '',
133
+ value: '',
134
+ })
135
+
136
+ const newGroup = (firstCol: FilterColumn | undefined): FilterGroup => ({
137
+ kind: 'group',
138
+ id: newId(),
139
+ conjunction: 'or', // group 內 default OR(對齊 ref 圖)
140
+ children: [newCondition(firstCol)],
141
+ })
142
+
143
+ const newEmptyGroup = (): FilterGroup => ({
144
+ kind: 'group',
145
+ id: newId(),
146
+ conjunction: 'or',
147
+ children: [newEmptyCondition()],
148
+ })
149
+
150
+ // ── Internal — FilterValuePicker(value-picker switcher per ValueShape)──
151
+ //
152
+ // 2026-05-03 M21 retract:本 helper 原本獨立檔 `filter-value-picker.tsx`,
153
+ // claim「未來 inline filter UI 共用」但只 1 consumer(本 panel)= 違 M21
154
+ // premature abstraction。inline 回 panel,日後若真有第 2 consumer 再抽。
155
+
156
+ interface FilterValuePickerColInfo {
157
+ id: string
158
+ label: string
159
+ options?: Array<{ value: string; label: string }>
160
+ /** Person pool — person/multiPerson filter picker 用(2026-05-07 升級,SSOT 對齊 cell-registry) */
161
+ people?: Array<{ name: string; avatarUrl?: string; description?: string }>
162
+ }
163
+
164
+ interface FilterValuePickerProps {
165
+ shape: ValueShape | null
166
+ value: unknown
167
+ onChange: (v: unknown) => void
168
+ colInfo?: FilterValuePickerColInfo
169
+ disabled?: boolean
170
+ /** 用 column.label 組「{label} 篩選值」(panel 每 row 不顯式 label,a11y 必填) */
171
+ ariaLabel?: string
172
+ /** Forward 給內部 Field control 的 className(2026-05-04 #2 fix)
173
+ * 避免外層包 wrapper div 破壞 FieldControlGroup CSS variants(rounded radii / margin overlap) */
174
+ className?: string
175
+ }
176
+
177
+ function FilterValuePicker({
178
+ shape,
179
+ value,
180
+ onChange,
181
+ colInfo,
182
+ disabled,
183
+ ariaLabel,
184
+ className,
185
+ }: FilterValuePickerProps) {
186
+ if (!shape || disabled) {
187
+ return <Input size="sm" value="" onChange={() => {}} placeholder="輸入值…" disabled aria-label={ariaLabel} className={className} />
188
+ }
189
+
190
+ switch (shape) {
191
+ case 'none':
192
+ return null
193
+
194
+ case 'text':
195
+ return (
196
+ <Input
197
+ size="sm"
198
+ value={String(value ?? '')}
199
+ onChange={(e) => onChange(e.target.value)}
200
+ placeholder="輸入值…"
201
+ aria-label={ariaLabel}
202
+ className={className}
203
+ />
204
+ )
205
+
206
+ case 'number':
207
+ return (
208
+ <NumberInput
209
+ size="sm"
210
+ value={typeof value === 'number' ? value : null}
211
+ onChange={(v) => onChange(v ?? '')}
212
+ placeholder="輸入數字…"
213
+ aria-label={ariaLabel}
214
+ className={className}
215
+ />
216
+ )
217
+
218
+ case 'date_single':
219
+ return (
220
+ <DatePicker
221
+ size="sm"
222
+ value={typeof value === 'string' ? value : null}
223
+ onChange={(v) => onChange(v ?? '')}
224
+ aria-label={ariaLabel}
225
+ className={className}
226
+ />
227
+ )
228
+
229
+ case 'date_range':
230
+ return (
231
+ <DatePickerRange
232
+ size="sm"
233
+ value={Array.isArray(value) && value.length === 2
234
+ ? (value as [string | null, string | null])
235
+ : null}
236
+ onChange={(v) => onChange(v)}
237
+ aria-label={ariaLabel}
238
+ className={className}
239
+ />
240
+ )
241
+
242
+ case 'date_relative': {
243
+ // 群組分類:Past / Current / Future(對齊 Linear / Notion idiom),走 Select.groups → SelectMenu
244
+ const opts: SelectOption[] = DATE_RELATIVE_OPTIONS.map((o) => ({
245
+ value: o.value,
246
+ label: o.label,
247
+ group: o.group,
248
+ }))
249
+ return (
250
+ <Select
251
+ size="sm"
252
+ options={opts}
253
+ groups={DATE_RELATIVE_GROUPS as unknown as Array<{ key: string; label: string }>}
254
+ value={String(value ?? '')}
255
+ onChange={(v) => onChange(v)}
256
+ placeholder="選擇相對日期"
257
+ aria-label={ariaLabel}
258
+ className={className}
259
+ />
260
+ )
261
+ }
262
+
263
+ case 'select_single': {
264
+ const opts: SelectOption[] = (colInfo?.options ?? []).map((o) => ({
265
+ value: o.value,
266
+ label: o.label,
267
+ }))
268
+ return (
269
+ <Select
270
+ size="sm"
271
+ options={opts}
272
+ value={String(value ?? '')}
273
+ onChange={(v) => onChange(v)}
274
+ placeholder="選擇值"
275
+ aria-label={ariaLabel}
276
+ className={className}
277
+ />
278
+ )
279
+ }
280
+
281
+ case 'select_multi': {
282
+ const opts = (colInfo?.options ?? []).map((o) => ({
283
+ value: o.value,
284
+ label: o.label,
285
+ }))
286
+ const arr = Array.isArray(value) ? (value as string[]) : []
287
+ return (
288
+ <Combobox
289
+ size="sm"
290
+ options={opts}
291
+ value={arr}
292
+ onChange={(v) => onChange(v)}
293
+ placeholder="選擇值…"
294
+ aria-label={ariaLabel}
295
+ className={className}
296
+ />
297
+ )
298
+ }
299
+
300
+ case 'datetime_single':
301
+ return (
302
+ <DatePicker
303
+ size="sm"
304
+ showTime
305
+ value={typeof value === 'string' ? value : null}
306
+ onChange={(v) => onChange(v ?? '')}
307
+ aria-label={ariaLabel}
308
+ className={className}
309
+ />
310
+ )
311
+
312
+ case 'datetime_range':
313
+ return (
314
+ <DatePickerRange
315
+ size="sm"
316
+ showTime
317
+ value={Array.isArray(value) && value.length === 2
318
+ ? (value as [string | null, string | null])
319
+ : null}
320
+ onChange={(v) => onChange(v)}
321
+ aria-label={ariaLabel}
322
+ className={className}
323
+ />
324
+ )
325
+
326
+ // person_single / person_multi — 走 PeoplePicker(2026-05-07 升級,對齊 cell-registry SSOT)。
327
+ // colInfo.people 來自 column meta.people。Filter value:
328
+ // - person_single:存 PersonValue | null(picker emit array,我們取 [0])
329
+ // - person_multi:存 PersonValue[]
330
+ case 'person_single': {
331
+ const v = value as PersonValue | null | undefined
332
+ return (
333
+ <PeoplePicker
334
+ size="sm"
335
+ value={v ?? null}
336
+ people={colInfo?.people ?? []}
337
+ onChange={(next) => onChange(next[0] ?? null)}
338
+ aria-label={ariaLabel}
339
+ className={className}
340
+ />
341
+ )
342
+ }
343
+ case 'person_multi': {
344
+ const v = Array.isArray(value) ? (value as PersonValue[]) : []
345
+ return (
346
+ <PeoplePicker
347
+ size="sm"
348
+ value={v}
349
+ people={colInfo?.people ?? []}
350
+ onChange={(next) => onChange(next)}
351
+ aria-label={ariaLabel}
352
+ className={className}
353
+ />
354
+ )
355
+ }
356
+ default:
357
+ return null
358
+ }
359
+ }
360
+
361
+ // ── Component Props ─────────────────────────────────────────────────────
362
+
363
+ export interface DataTableFilterPanelProps<TData> {
364
+ /** flat(無 group)or nested(1-level group)— consumer 拍板 */
365
+ mode: 'flat' | 'nested'
366
+ /** 可被 filter 的 columns */
367
+ columns: ColumnDef<TData, any>[]
368
+ /** 當前 FilterTree(controlled) */
369
+ value: FilterTree
370
+ /** state 變更 callback */
371
+ onChange: (next: FilterTree) => void
372
+ /**
373
+ * 管理員 set-as-default 的 baseline(refresh icon 顯示判定用)。
374
+ * 當 `value` ≠ `defaultValue`(deep equal)→ panel header 顯示 refresh icon,
375
+ * click → reset 回 defaultValue。對齊 sort 邏輯(相同 modified-from-default UX)。
376
+ */
377
+ defaultValue?: FilterTree
378
+ /** Cell ⌄ menu「Filter by this」帶入的 column id(自動 add 一條 condition) */
379
+ prefilledColumnId?: string
380
+ onPrefillConsumed?: () => void
381
+ onClose?: () => void
382
+ className?: string
383
+ }
384
+
385
+ // ── Main Component ──────────────────────────────────────────────────────
386
+
387
+ // 內部 fn — generic + ref 轉發。export 走 cast(對齊 DataTable 同 pattern)
388
+ function DataTableFilterPanelInner<TData>({
389
+ mode,
390
+ columns,
391
+ value,
392
+ onChange,
393
+ defaultValue,
394
+ prefilledColumnId,
395
+ onPrefillConsumed,
396
+ onClose,
397
+ className,
398
+ }: DataTableFilterPanelProps<TData>, ref: React.ForwardedRef<HTMLDivElement>): React.ReactElement {
399
+ const filterableColumns = React.useMemo(() => extractColumns(columns), [columns])
400
+ const fieldOptions: SelectOption[] = React.useMemo(
401
+ () => filterableColumns.map((c) => ({ value: c.id, label: c.label })),
402
+ [filterableColumns],
403
+ )
404
+ // K13 後 firstCol 不再被 add* 消費(改用 newEmpty*),這裡只留 prefill effect 用(已直接讀 prefilledColumnId)。
405
+
406
+ // **G fix(2026-05-04 v2)**:initial-mount 預設 1 empty row(field 未選 → op+value 自動 disabled)
407
+ // useRef gate → 只 mount 一次;user 後續手動刪光 → 不 re-add → 維持「全清 = empty CTA only」UX
408
+ // Two states clearly separated:
409
+ // (a) Initial mount with empty value → auto-add 1 empty row(讓 user 看到 row shape,不必先點 CTA)
410
+ // (b) User explicitly deletes all → empty CTA only(無 row,respect user intent)
411
+ const initialMountDoneRef = React.useRef(false)
412
+ React.useEffect(() => {
413
+ if (initialMountDoneRef.current) return
414
+ initialMountDoneRef.current = true
415
+ if (filterableColumns.length === 0) return
416
+ if (value.children.length > 0) return
417
+ if (value.mode === 'flat') {
418
+ onChange({ ...value, children: [newEmptyCondition()] } as FilterTreeFlat)
419
+ } else {
420
+ onChange({ ...value, children: [newEmptyGroup()] } as FilterTreeNested)
421
+ }
422
+ // eslint-disable-next-line react-hooks/exhaustive-deps
423
+ }, [filterableColumns.length])
424
+
425
+ // Prefill from cell ⌄ menu「Filter by this」
426
+ React.useEffect(() => {
427
+ if (!prefilledColumnId) return
428
+ const colInfo = filterableColumns.find((c) => c.id === prefilledColumnId)
429
+ if (colInfo) {
430
+ const cond: FilterCondition = {
431
+ kind: 'cond',
432
+ id: newId(),
433
+ field: prefilledColumnId,
434
+ op: getDefaultOperator(colInfo.type),
435
+ value: '',
436
+ }
437
+ if (value.mode === 'flat') {
438
+ onChange({ ...value, children: [...value.children, cond] })
439
+ } else {
440
+ // nested mode:add 到第 1 個 group(若無則新建)
441
+ if (value.children.length === 0) {
442
+ onChange({ ...value, children: [{ ...newGroup(colInfo), children: [cond] }] })
443
+ } else {
444
+ const updatedGroups = value.children.map((g, i) =>
445
+ i === 0 ? { ...g, children: [...g.children, cond] } : g
446
+ )
447
+ onChange({ ...value, children: updatedGroups })
448
+ }
449
+ }
450
+ }
451
+ onPrefillConsumed?.()
452
+ // eslint-disable-next-line react-hooks/exhaustive-deps
453
+ }, [prefilledColumnId])
454
+
455
+ // ── flat-mode mutators ──
456
+ const flatTree = value.mode === 'flat' ? value : null
457
+ const updateFlatCondition = (id: string, patch: Partial<FilterCondition>) => {
458
+ if (!flatTree) return
459
+ onChange({
460
+ ...flatTree,
461
+ children: flatTree.children.map((c) => (c.id === id ? { ...c, ...patch } : c)),
462
+ })
463
+ }
464
+ const removeFlatCondition = (id: string) => {
465
+ if (!flatTree) return
466
+ onChange({ ...flatTree, children: flatTree.children.filter((c) => c.id !== id) })
467
+ }
468
+ const addFlatCondition = () => {
469
+ if (!flatTree) return
470
+ // K13 fix(2026-05-04):加篩選 → empty row(field 未選 → op+value disabled)
471
+ // World-class:Notion / Coda / ClickUp 不 auto-select;對齊 initial mount canonical
472
+ onChange({ ...flatTree, children: [...flatTree.children, newEmptyCondition()] })
473
+ }
474
+ const setFlatConjunction = (conj: Conjunction) => {
475
+ if (!flatTree) return
476
+ onChange({ ...flatTree, conjunction: conj })
477
+ }
478
+
479
+ // ── nested-mode mutators ──
480
+ const nestedTree = value.mode === 'nested' ? value : null
481
+ const updateGroup = (groupId: string, patch: Partial<FilterGroup>) => {
482
+ if (!nestedTree) return
483
+ onChange({
484
+ ...nestedTree,
485
+ children: nestedTree.children.map((g) => (g.id === groupId ? { ...g, ...patch } : g)),
486
+ })
487
+ }
488
+ const updateGroupCondition = (groupId: string, condId: string, patch: Partial<FilterCondition>) => {
489
+ if (!nestedTree) return
490
+ onChange({
491
+ ...nestedTree,
492
+ children: nestedTree.children.map((g) =>
493
+ g.id === groupId
494
+ ? { ...g, children: g.children.map((c) => (c.id === condId ? { ...c, ...patch } : c)) }
495
+ : g
496
+ ),
497
+ })
498
+ }
499
+ const removeGroupCondition = (groupId: string, condId: string) => {
500
+ if (!nestedTree) return
501
+ onChange({
502
+ ...nestedTree,
503
+ children: nestedTree.children.map((g) =>
504
+ g.id === groupId ? { ...g, children: g.children.filter((c) => c.id !== condId) } : g
505
+ ),
506
+ })
507
+ }
508
+ // K13 fix(2026-05-04):同 addFlatCondition,巢狀內加條件也 empty row
509
+ const addConditionToGroup = (groupId: string) => {
510
+ if (!nestedTree) return
511
+ onChange({
512
+ ...nestedTree,
513
+ children: nestedTree.children.map((g) =>
514
+ g.id === groupId ? { ...g, children: [...g.children, newEmptyCondition()] } : g
515
+ ),
516
+ })
517
+ }
518
+ const removeGroup = (groupId: string) => {
519
+ if (!nestedTree) return
520
+ onChange({ ...nestedTree, children: nestedTree.children.filter((g) => g.id !== groupId) })
521
+ }
522
+ const addGroup = () => {
523
+ if (!nestedTree) return
524
+ // K13:加群組也用 empty group
525
+ onChange({ ...nestedTree, children: [...nestedTree.children, newEmptyGroup()] })
526
+ }
527
+ const setRootConjunction = (conj: Conjunction) => {
528
+ if (!nestedTree) return
529
+ onChange({ ...nestedTree, conjunction: conj })
530
+ }
531
+
532
+ return (
533
+ // 寬度策略:desktop 680px;mobile 縮到 viewport 內留 32px 邊(避溢出 popover 切右半)。
534
+ // 對齊 Notion / Airtable 的 advanced filter 在 mobile 走 full-width 邊處理。
535
+ // **#8 fix(2026-05-04)**:popover width by mode(由 cell min-w 與 group nested chrome 反推)
536
+ // flat:cell ConjunctionLabel(80) + gap-2(8) + FCG(field-min 160 + op-min 120 + value 200) +
537
+ // gap-2(8) + trash(28) + 2×loose padding(32) = ~636 → 640px
538
+ // nested:再加 group p-2 (16) + outer ConjunctionLabel (80) + outer gap (8) → ~740 → 760px
539
+ // 對齊 Airtable / Notion / Linear filter row 視覺密度 @benchmark-unverified(non-OSS)
540
+ // **K11 fix(2026-05-04)**:viewport-aware scroll chain invariant
541
+ // parent PopoverContent 是 flex flex-col + max-h + overflow-hidden,
542
+ // panel root 必 forward `flex flex-col h-full` 才能讓 SurfaceBody flex-1 min-h-0 overflow-y-auto 生效
543
+ // 無此 forward → 中間 wrapper 斷鏈 → body 不 scroll(NameCard 因為自身設 max-h flex-col 才繞過)
544
+ // 詳 overlay-surface.spec.md「viewport-aware scroll chain invariant」段
545
+ // K11 v2 fix(2026-05-04):flex item 預設 min-h:auto 讓 content 撐 height,h-full 失效。
546
+ // 必加 `min-h-0` 才能讓 panel 在 PopoverContent max-h cap 下正確 shrink + body scroll。
547
+ <div ref={ref} className={cn(
548
+ 'flex flex-col h-full min-h-0',
549
+ mode === 'nested'
550
+ ? 'w-[min(760px,calc(100vw-2rem))]'
551
+ : 'w-[min(640px,calc(100vw-2rem))]',
552
+ className,
553
+ )}>
554
+ {/* Popover 派輕量 chrome — slot 縮 20 匹配 PopoverTitle text-body line-height,header 自然 ~45px */}
555
+ <SurfaceHeader className="[--chrome-slot-h:1.25rem]">
556
+ <PopoverTitle className="flex-1">篩選</PopoverTitle>
557
+ {/* Refresh icon — 只在 value ≠ defaultValue 時顯示(對齊 sort modified-from-default UX)
558
+ 含 ButtonDivider 對齊「欄位顯示」+「排序」chrome corner action canonical(2026-05-04) */}
559
+ {defaultValue && !isFilterTreeEqual(value, defaultValue) && (
560
+ <>
561
+ <Button
562
+ variant="text" size="sm" iconOnly startIcon={RotateCcw}
563
+ aria-label="恢復預設"
564
+ onClick={() => onChange(defaultValue)}
565
+ />
566
+ {onClose && <ButtonDivider />}
567
+ </>
568
+ )}
569
+ {onClose && (
570
+ <PopoverClose asChild>
571
+ <Button data-dismiss iconOnly dismiss size="sm" startIcon={XIcon} aria-label="關閉" onClick={onClose} />
572
+ </PopoverClose>
573
+ )}
574
+ </SurfaceHeader>
575
+
576
+ {/* Body — flat / nested 條件;空條件 → 直接顯 + 加篩選 CTA(對齊 Notion / Airtable / Linear inline 派,
577
+ 無條件時不需要 Empty 元件大區塊,單顆 CTA 引導即可。SurfaceFooter 整層拔除,
578
+ + Add filter / + 加篩選器 inline 緊貼最後一條 row,讓 user 感受到「條件」與「加入」屬同一語境)*/}
579
+ <SurfaceBody className="flex flex-col gap-[var(--layout-space-tight)]">
580
+ {flatTree && flatTree.children.map((cond, idx) => (
581
+ <FilterRow
582
+ key={cond.id}
583
+ index={idx}
584
+ condition={cond}
585
+ conjunction={flatTree.conjunction}
586
+ filterableColumns={filterableColumns}
587
+ fieldOptions={fieldOptions}
588
+ onChangeConjunction={setFlatConjunction}
589
+ onChangeField={(v) => {
590
+ const newCol = filterableColumns.find((c) => c.id === v)
591
+ updateFlatCondition(cond.id, { field: v, op: getDefaultOperator(newCol?.type), value: '' })
592
+ }}
593
+ onChangeOp={(v) => updateFlatCondition(cond.id, { op: v, value: '' })}
594
+ onChangeValue={(v) => updateFlatCondition(cond.id, { value: v })}
595
+ onRemove={() => removeFlatCondition(cond.id)}
596
+ />
597
+ ))}
598
+
599
+ {nestedTree && nestedTree.children.map((group, gIdx) => (
600
+ <GroupBlock
601
+ key={group.id}
602
+ index={gIdx}
603
+ group={group}
604
+ rootConjunction={nestedTree.conjunction}
605
+ filterableColumns={filterableColumns}
606
+ fieldOptions={fieldOptions}
607
+ onChangeRootConjunction={setRootConjunction}
608
+ onChangeGroupConjunction={(c) => updateGroup(group.id, { conjunction: c })}
609
+ onChangeCondition={(condId, patch) => updateGroupCondition(group.id, condId, patch)}
610
+ onRemoveCondition={(condId) => removeGroupCondition(group.id, condId)}
611
+ onAddCondition={() => addConditionToGroup(group.id)}
612
+ onRemoveGroup={() => removeGroup(group.id)}
613
+ />
614
+ ))}
615
+
616
+ {/* Inline CTA(2026-05-04 A1)— root-level「加篩選」用 tertiary(視覺輕量但有邊界,
617
+ 符合 root-CTA 視覺重量);nested 內「加入巢狀篩選」走 text variant(更輕,在 group 內 inline)
618
+ 不放 SurfaceFooter:條件與「加入」屬同一語義群,中間插 footer 切斷敘事 */}
619
+ <div>
620
+ <Button
621
+ variant="tertiary" size="sm" startIcon={Plus}
622
+ onClick={mode === 'flat' ? addFlatCondition : addGroup}
623
+ >
624
+ {mode === 'nested' ? '加入篩選器' : '加篩選'}
625
+ </Button>
626
+ </div>
627
+ </SurfaceBody>
628
+ </div>
629
+ )
630
+ }
631
+
632
+ // Generic + ref forward 套 cast 的 idiom — 對齊 DataTable(同檔家)。
633
+ // React.forwardRef 對 generic component 會丟掉 type param,改 cast 成 generic-preserving signature。
634
+ export const DataTableFilterPanel = React.forwardRef(DataTableFilterPanelInner) as <TData>(
635
+ props: DataTableFilterPanelProps<TData> & { ref?: React.ForwardedRef<HTMLDivElement> }
636
+ ) => React.ReactElement
637
+ ;(DataTableFilterPanel as { displayName?: string }).displayName = 'DataTableFilterPanel'
638
+
639
+ // ── ConjunctionLabel ───────────────────────────────────────────────────
640
+
641
+ const CONJ_OPTIONS: SelectOption[] = [
642
+ { value: 'and', label: 'And' },
643
+ { value: 'or', label: 'Or' },
644
+ ]
645
+
646
+ function ConjunctionLabel({
647
+ index, conjunction, onChange,
648
+ }: { index: number; conjunction: Conjunction; onChange: (c: Conjunction) => void }) {
649
+ // index === 0:首 row 顯示靜態「Where」label
650
+ // index === 1:**唯一可改**的 AND/OR Select(連動整 group conjunction)
651
+ // index ≥ 2:被連動的 row,read-only 顯示當前 conjunction 文字(同 Where 視覺,A6 canonical)
652
+ // 對齊 Airtable / Notion / Linear 共識 @benchmark-unverified(non-OSS)
653
+ // px-3 對齊 Field 內部 padding 12px(Q13)
654
+ if (index === 0) {
655
+ return <div className="w-20 shrink-0 text-body text-fg-muted px-3 self-center">Where</div>
656
+ }
657
+ if (index >= 2) {
658
+ const label = conjunction === 'and' ? 'And' : 'Or'
659
+ return <div className="w-20 shrink-0 text-body text-fg-muted px-3 self-center">{label}</div>
660
+ }
661
+ // index === 1:可切換的 AND/OR Select
662
+ // minRows={2} — And/Or 2 選項,顯式縮 menu 高度避免 reserve 3 row 空白(Q5)
663
+ return (
664
+ <div className="w-20 shrink-0">
665
+ <Select
666
+ size="sm"
667
+ options={CONJ_OPTIONS}
668
+ value={conjunction}
669
+ onChange={(v) => onChange(v as Conjunction)}
670
+ minRows={2}
671
+ aria-label="連接詞 — 同 group 共用"
672
+ />
673
+ </div>
674
+ )
675
+ }
676
+
677
+ // ── FilterRow(flat 用 + group 內共用) ──────────────────────────────
678
+
679
+ function FilterRow({
680
+ index, condition, conjunction, filterableColumns, fieldOptions,
681
+ onChangeConjunction, onChangeField, onChangeOp, onChangeValue, onRemove,
682
+ }: {
683
+ index: number
684
+ condition: FilterCondition
685
+ conjunction: Conjunction
686
+ filterableColumns: FilterColumn[]
687
+ fieldOptions: SelectOption[]
688
+ onChangeConjunction: (c: Conjunction) => void
689
+ onChangeField: (v: string) => void
690
+ onChangeOp: (v: string) => void
691
+ onChangeValue: (v: unknown) => void
692
+ onRemove: () => void
693
+ }) {
694
+ const colInfo = filterableColumns.find((c) => c.id === condition.field)
695
+ const operatorOptions = getOperatorOptions(colInfo?.type)
696
+ const hasField = !!condition.field
697
+ const opSpec = colInfo ? getOperatorSpec(colInfo.type, condition.op) : null
698
+ const valueShape: ValueShape | null = colInfo && opSpec
699
+ ? getValueShape(opSpec, colInfo.type, colInfo.includeTime)
700
+ : null
701
+ // op 'is_set' / 'is_not_set' 等 shape='none' → 無 value cell,op 自動 expand 填剩餘寬
702
+ // 對齊 Notion / Airtable / Linear filter row 行為
703
+ // 注意:valueShape=null(初始無 field 選)時仍 render value cell(disabled placeholder)— 只 'none' 才 fold
704
+ const hasValueCell = valueShape !== 'none'
705
+
706
+ // FieldControlGroup 接合 field + op + value 視覺(2026-05-04 E refactor + 多輪 fix):
707
+ // - border collapse 取代 3 顆獨立 Select 並排,對齊 Airtable / Linear / Notion filter row idiom
708
+ // - ConjunctionLabel + Trash 在 group 外層(meta actions,不屬 control 一體)
709
+ // - **#5 fix**:row 內水平 gap = `gap-2` (8px),layoutSpace 規則 5 緊密相關
710
+ // - **#9 fix**:cell 用 `min-w-[]`(field 160 / op 120),value flex-1 min-w-0,讓 long label 可撐寬
711
+ // - **#2 fix**:FilterValuePicker 直接是 FieldControlGroup direct child(無 wrapper div),CSS variants 命中正確
712
+ return (
713
+ <div className="flex items-center gap-2">
714
+ <ConjunctionLabel index={index} conjunction={conjunction} onChange={onChangeConjunction} />
715
+ {/* **#9 fix(2026-05-04 v4)**:Field controls trigger `w-full` override 外 className,改用 Tailwind `!`
716
+ important 強制 override(`!w-[160px]` / `!w-[120px]`),value 用 `!flex-1 !min-w-0`。
717
+ Select 元件本身沒 destructure `style` prop 所以 inline style flex-basis 行不通,只能用 className。 */}
718
+ <FieldControlGroup block className="flex-1 min-w-0">
719
+ {/* 2026-05-23 Phase A.4 Decision 2:`!w-[160px]` / `!w-[120px]` → tokens
720
+ `--data-table-filter-field-width` / `--data-table-filter-op-width`(SSOT in uiSize.css)
721
+ Behavior preserved 完好如初:flat + nested 同 width(token 是 design constant) */}
722
+ <Select
723
+ className="!w-[var(--data-table-filter-field-width)] flex-shrink-0"
724
+ size="sm"
725
+ options={fieldOptions}
726
+ value={condition.field}
727
+ onChange={onChangeField}
728
+ placeholder="選擇欄位"
729
+ aria-label="篩選欄位"
730
+ />
731
+ <Select
732
+ className={hasValueCell ? '!w-[var(--data-table-filter-op-width)] flex-shrink-0' : '!flex-1 !min-w-0'}
733
+ size="sm"
734
+ options={operatorOptions}
735
+ value={condition.op}
736
+ onChange={onChangeOp}
737
+ disabled={!hasField}
738
+ placeholder="運算子"
739
+ aria-label="篩選運算子"
740
+ />
741
+ {hasValueCell && (
742
+ <FilterValuePicker
743
+ shape={valueShape}
744
+ value={condition.value}
745
+ onChange={onChangeValue}
746
+ colInfo={colInfo}
747
+ disabled={!hasField}
748
+ ariaLabel={colInfo ? `${colInfo.label} 篩選值` : '篩選值'}
749
+ className="!flex-1 !min-w-0"
750
+ />
751
+ )}
752
+ </FieldControlGroup>
753
+ {/* Trash 用 text Button — filter row 是 form-control row,Field 同高對齊(28 md) */}
754
+ <Button variant="text" size="sm" iconOnly startIcon={Trash2} aria-label="刪除" onClick={onRemove} />
755
+ </div>
756
+ )
757
+ }
758
+
759
+ // ── GroupBlock(nested 用) ────────────────────────────────────────────
760
+
761
+ function GroupBlock({
762
+ index, group, rootConjunction, filterableColumns, fieldOptions,
763
+ onChangeRootConjunction, onChangeGroupConjunction,
764
+ onChangeCondition, onRemoveCondition, onAddCondition, onRemoveGroup,
765
+ }: {
766
+ index: number
767
+ group: FilterGroup
768
+ rootConjunction: Conjunction
769
+ filterableColumns: FilterColumn[]
770
+ fieldOptions: SelectOption[]
771
+ onChangeRootConjunction: (c: Conjunction) => void
772
+ onChangeGroupConjunction: (c: Conjunction) => void
773
+ onChangeCondition: (condId: string, patch: Partial<FilterCondition>) => void
774
+ onRemoveCondition: (condId: string) => void
775
+ onAddCondition: () => void
776
+ onRemoveGroup: () => void
777
+ }) {
778
+ return (
779
+ <div className="flex items-start gap-2">
780
+ <div className="pt-2">
781
+ <ConjunctionLabel index={index} conjunction={rootConjunction} onChange={onChangeRootConjunction} />
782
+ </div>
783
+ {/* Group container 灰底 — `bg-muted`(`--muted` neutral-2,user 2026-05-09 拍板 Q3 A)。對齊 color.spec.md L651-654 「table header / tab / code block / skeleton」靜態低重要 surface semantic */}
784
+ <div className="flex-1 min-w-0 rounded-md bg-muted p-2 flex flex-col gap-2">
785
+ {group.children.map((cond, cIdx) => (
786
+ <FilterRow
787
+ key={cond.id}
788
+ index={cIdx}
789
+ condition={cond}
790
+ conjunction={group.conjunction}
791
+ filterableColumns={filterableColumns}
792
+ fieldOptions={fieldOptions}
793
+ onChangeConjunction={onChangeGroupConjunction}
794
+ onChangeField={(v) => {
795
+ const newCol = filterableColumns.find((c) => c.id === v)
796
+ onChangeCondition(cond.id, { field: v, op: getDefaultOperator(newCol?.type), value: '' })
797
+ }}
798
+ onChangeOp={(v) => onChangeCondition(cond.id, { op: v, value: '' })}
799
+ onChangeValue={(v) => onChangeCondition(cond.id, { value: v })}
800
+ onRemove={() => onRemoveCondition(cond.id)}
801
+ />
802
+ ))}
803
+ {/* Q9 — text variant 對齊 inline 派 + 視覺輕量 */}
804
+ <div className="flex items-center justify-between">
805
+ <Button variant="text" size="sm" startIcon={Plus} onClick={onAddCondition}>加入巢狀篩選</Button>
806
+ {group.children.length === 0 && (
807
+ <Button variant="text" size="sm" startIcon={Trash2} danger onClick={onRemoveGroup}>移除空群組</Button>
808
+ )}
809
+ </div>
810
+ </div>
811
+ </div>
812
+ )
813
+ }