@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.
- package/package.json +93 -0
- package/src/README.md +32 -0
- package/src/components/Accordion/accordion.tsx +104 -0
- package/src/components/Alert/alert.tsx +188 -0
- package/src/components/AppShell/_demo-helpers.tsx +198 -0
- package/src/components/AppShell/app-shell.tsx +364 -0
- package/src/components/AspectRatio/aspect-ratio.tsx +58 -0
- package/src/components/Avatar/avatar.tsx +368 -0
- package/src/components/Badge/badge.tsx +104 -0
- package/src/components/Breadcrumb/breadcrumb.tsx +609 -0
- package/src/components/BulkActionBar/bulk-action-bar.tsx +156 -0
- package/src/components/Button/button-group.tsx +96 -0
- package/src/components/Button/button.tsx +539 -0
- package/src/components/Calendar/calendar.tsx +411 -0
- package/src/components/Carousel/carousel.tsx +371 -0
- package/src/components/Chart/chart.tsx +376 -0
- package/src/components/Checkbox/checkbox-group.tsx +94 -0
- package/src/components/Checkbox/checkbox.tsx +237 -0
- package/src/components/Chip/chip.tsx +359 -0
- package/src/components/CircularProgress/circular-progress.tsx +204 -0
- package/src/components/Coachmark/coachmark.tsx +255 -0
- package/src/components/Combobox/combobox.tsx +826 -0
- package/src/components/Command/command.tsx +187 -0
- package/src/components/DataTable/active-editor-controller.ts +72 -0
- package/src/components/DataTable/cell-registry.tsx +520 -0
- package/src/components/DataTable/column-types.ts +180 -0
- package/src/components/DataTable/data-table-column-visibility-panel.tsx +261 -0
- package/src/components/DataTable/data-table-filter-panel.tsx +813 -0
- package/src/components/DataTable/data-table-interaction-layer.tsx +483 -0
- package/src/components/DataTable/data-table-sort-manager.tsx +210 -0
- package/src/components/DataTable/data-table.css +165 -0
- package/src/components/DataTable/data-table.tsx +2924 -0
- package/src/components/DataTable/filter-operators.ts +225 -0
- package/src/components/DataTable/filter-tree.ts +313 -0
- package/src/components/DataTable/lib/column-meta.ts +79 -0
- package/src/components/DateGrid/date-grid.tsx +209 -0
- package/src/components/DatePicker/date-picker.tsx +1114 -0
- package/src/components/DescriptionList/description-list.tsx +141 -0
- package/src/components/Dialog/dialog.tsx +267 -0
- package/src/components/DropdownMenu/dropdown-menu.tsx +475 -0
- package/src/components/Empty/empty.tsx +108 -0
- package/src/components/Field/field-context.ts +136 -0
- package/src/components/Field/field-types.ts +52 -0
- package/src/components/Field/field-wrapper.tsx +348 -0
- package/src/components/Field/field.tsx +535 -0
- package/src/components/FieldControlGroup/field-control-group.tsx +136 -0
- package/src/components/FileItem/file-item.tsx +322 -0
- package/src/components/FileUpload/file-upload.tsx +326 -0
- package/src/components/FileViewer/file-viewer-types.ts +76 -0
- package/src/components/FileViewer/file-viewer.tsx +1065 -0
- package/src/components/FileViewer/image-renderer.tsx +256 -0
- package/src/components/HoverCard/hover-card.tsx +79 -0
- package/src/components/Input/input.tsx +233 -0
- package/src/components/LinkInput/link-input.tsx +304 -0
- package/src/components/Menu/menu-item.tsx +334 -0
- package/src/components/NameCard/name-card.tsx +319 -0
- package/src/components/Notice/notice.tsx +196 -0
- package/src/components/NumberInput/number-input.tsx +203 -0
- package/src/components/OverflowIndicator/overflow-indicator.tsx +156 -0
- package/src/components/PeoplePicker/avatar-stack-overflow.ts +100 -0
- package/src/components/PeoplePicker/people-picker-helpers.ts +76 -0
- package/src/components/PeoplePicker/people-picker.tsx +455 -0
- package/src/components/PeoplePicker/person-display.tsx +358 -0
- package/src/components/Popover/popover.tsx +183 -0
- package/src/components/ProgressBar/progress-bar.tsx +157 -0
- package/src/components/README.md +58 -0
- package/src/components/RadioGroup/radio-group.tsx +261 -0
- package/src/components/Rating/rating.tsx +295 -0
- package/src/components/ScrollArea/scroll-area.tsx +110 -0
- package/src/components/SegmentedControl/segmented-control.tsx +304 -0
- package/src/components/Select/select.tsx +658 -0
- package/src/components/SelectMenu/select-menu.tsx +430 -0
- package/src/components/SelectionControl/selection-item.tsx +261 -0
- package/src/components/Separator/separator.tsx +48 -0
- package/src/components/Sheet/sheet.tsx +240 -0
- package/src/components/Sidebar/sidebar.tsx +1280 -0
- package/src/components/Skeleton/skeleton.tsx +35 -0
- package/src/components/Slider/slider.tsx +158 -0
- package/src/components/Steps/steps.tsx +850 -0
- package/src/components/Switch/switch.tsx +285 -0
- package/src/components/Tabs/tabs.tsx +515 -0
- package/src/components/Tag/tag.tsx +246 -0
- package/src/components/Textarea/textarea.tsx +280 -0
- package/src/components/TimePicker/time-columns.tsx +260 -0
- package/src/components/TimePicker/time-picker.tsx +419 -0
- package/src/components/Toast/toast.tsx +129 -0
- package/src/components/Tooltip/tooltip.tsx +68 -0
- package/src/components/TreeView/tree-view.tsx +1031 -0
- package/src/hooks/use-controllable.ts +40 -0
- package/src/hooks/use-is-narrow-viewport.ts +19 -0
- package/src/hooks/use-is-touch-device.ts +21 -0
- package/src/hooks/use-overflow-items.ts +256 -0
- package/src/index.ts +85 -0
- package/src/lib/README.md +82 -0
- package/src/lib/drag-visual.ts +272 -0
- package/src/lib/i18n/README.md +60 -0
- package/src/lib/i18n/i18n-context.tsx +129 -0
- package/src/lib/multi-select-ordering.ts +61 -0
- package/src/lib/utils.ts +93 -0
- package/src/patterns/README.md +67 -0
- package/src/patterns/element-anatomy/item-anatomy.tsx +744 -0
- package/src/patterns/header-canonical/chrome-header.tsx +175 -0
- package/src/patterns/header-canonical/header-canonical.css +27 -0
- package/src/patterns/horizontal-overflow/horizontal-overflow.tsx +217 -0
- package/src/patterns/overlay-surface/overlay-surface.tsx +191 -0
- package/src/patterns/resize-handle/resize-handle.tsx +188 -0
- package/src/stories-helpers/anatomy/anatomy-utils.tsx +64 -0
- package/src/tokens/README.md +53 -0
- package/src/tokens/color/primitives.css +429 -0
- package/src/tokens/color/semantic.css +539 -0
- package/src/tokens/elevation/overlay-geometry.ts +13 -0
- package/src/tokens/layoutSpace/layoutSpace.css +36 -0
- package/src/tokens/motion/motion.css +30 -0
- package/src/tokens/motion/motion.ts +17 -0
- package/src/tokens/opacity/opacity.css +23 -0
- package/src/tokens/radius/radius.css +19 -0
- package/src/tokens/typography/typography.css +118 -0
- package/src/tokens/uiSize/icon-size.ts +52 -0
- 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
|
+
}
|