@qijenchen/design-system 0.1.0-beta.84 → 0.1.0-beta.85
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/dist/components/DataTable/data-table-filter-group.d.ts +47 -0
- package/dist/components/DataTable/data-table-filter-group.d.ts.map +1 -0
- package/dist/components/DataTable/data-table-filter-group.js +154 -0
- package/dist/components/DataTable/data-table-filter-group.js.map +1 -0
- package/dist/components/DataTable/data-table-filter-panel.d.ts.map +1 -1
- package/dist/components/DataTable/data-table-filter-panel.js +2 -324
- package/dist/components/DataTable/data-table-filter-panel.js.map +1 -1
- package/dist/components/DataTable/data-table-filter-value-picker.d.ts +29 -0
- package/dist/components/DataTable/data-table-filter-value-picker.d.ts.map +1 -0
- package/dist/components/DataTable/data-table-filter-value-picker.js +187 -0
- package/dist/components/DataTable/data-table-filter-value-picker.js.map +1 -0
- package/dist/components/DataTable/data-table.d.ts.map +1 -1
- package/dist/components/DataTable/data-table.js.map +1 -1
- package/dist/components/Field/index.d.ts +1 -0
- package/dist/components/Field/index.d.ts.map +1 -1
- package/dist/components/Field/index.js +2 -0
- package/dist/components/Field/index.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/ds-canonical/hooks/check_datatable_invariants.sh +6 -3
- package/llms-full.txt +1 -1
- package/llms.txt +1 -1
- package/package.json +1 -1
- package/src/components/Checkbox/checkbox.spec.md +2 -0
- package/src/components/DataTable/data-table-filter-group.tsx +231 -0
- package/src/components/DataTable/data-table-filter-panel.tsx +13 -430
- package/src/components/DataTable/data-table-filter-value-picker.tsx +237 -0
- package/src/components/DataTable/data-table.spec.md +1 -1
- package/src/components/DataTable/data-table.tsx +4 -1
- package/src/components/DataTable/filter-operators.spec.md +3 -3
- package/src/components/Field/index.ts +1 -0
- package/src/components/Select/select.spec.md +2 -0
- package/src/index.ts +4 -0
- package/src/tokens/color/color.spec.md +1 -1
- package/src/tokens/uiSize/uiSize.css +1 -1
|
@@ -1,33 +1,23 @@
|
|
|
1
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
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 回本檔(505 → 687 行)。2026-07-14 deep-audit 誠實更新:現 822 行,**已過 800 hard cap** — 非 stale escape 留著;拆檔候選 = filter-value-picker 區段(等第 2 個 consumer 接入)+ nested-group renderer,屬結構 refactor 需獨立 branch 做 + 完整驗證,列入 file-size escalation 追蹤,禁再增量。
|
|
4
3
|
import * as React from 'react'
|
|
5
|
-
import { Plus,
|
|
4
|
+
import { Plus, X as XIcon, RotateCcw } from 'lucide-react'
|
|
6
5
|
import type { ColumnDef } from '@tanstack/react-table'
|
|
7
6
|
import { cn } from '@/lib/utils'
|
|
8
7
|
import { Button } from '@/design-system/components/Button/button'
|
|
9
|
-
import {
|
|
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'
|
|
8
|
+
import type { SelectOption } from '@/design-system/components/Select/select'
|
|
16
9
|
import { SurfaceHeader, SurfaceBody, COMPACT_HEADER_SLOT } from '@/design-system/patterns/overlay-surface/overlay-surface'
|
|
17
10
|
import { PopoverTitle, PopoverClose } from '@/design-system/components/Popover/popover'
|
|
18
11
|
import { ButtonDivider } from '@/design-system/components/Button/button-group'
|
|
19
|
-
import { FieldControlGroup } from '@/design-system/components/FieldControlGroup/field-control-group'
|
|
20
12
|
import type { ColumnType } from './column-types'
|
|
21
13
|
import { getColumnId, getColumnLabel, getColumnMeta } from './lib/column-meta'
|
|
22
14
|
import {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
type ValueShape,
|
|
30
|
-
} from './filter-operators'
|
|
15
|
+
FilterRow,
|
|
16
|
+
GroupBlock,
|
|
17
|
+
getDefaultOperator,
|
|
18
|
+
datePrecisionOf,
|
|
19
|
+
type FilterColumn,
|
|
20
|
+
} from './data-table-filter-group'
|
|
31
21
|
import {
|
|
32
22
|
createEmptyFilterTree,
|
|
33
23
|
isFilterTreeActive,
|
|
@@ -64,6 +54,9 @@ export type { Conjunction, FilterCondition, FilterGroup, FilterTree, FilterTreeF
|
|
|
64
54
|
* Source-of-truth:
|
|
65
55
|
* - Operator definitions:`./filter-operators.ts` `OPERATOR_REGISTRY`(SSOT,禁 hardcode op 字串)
|
|
66
56
|
* - Filter state:**FilterTree**(本檔自管;搭配 TanStack `globalFilter` 求值)
|
|
57
|
+
* - Row / nested-group renderer:`./data-table-filter-group.tsx`;value picker:
|
|
58
|
+
* `./data-table-filter-value-picker.tsx`(皆 @internal — 2026-07-14 file-size 拆檔,
|
|
59
|
+
* 本檔只留 orchestration:columns 抽取 / FilterTree mutators / panel chrome)
|
|
67
60
|
*
|
|
68
61
|
* 詳:`./filter-operators.spec.md`
|
|
69
62
|
*/
|
|
@@ -73,17 +66,8 @@ export type { Conjunction, FilterCondition, FilterGroup, FilterTree, FilterTreeF
|
|
|
73
66
|
let _idSeed = 0
|
|
74
67
|
const newId = () => `f${++_idSeed}-${Date.now().toString(36)}`
|
|
75
68
|
|
|
76
|
-
// ── Helpers —
|
|
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
|
-
}
|
|
69
|
+
// ── Helpers — column extraction + condition factories ──────────────────
|
|
70
|
+
// (FilterColumn type + operator helpers 住 ./data-table-filter-group.tsx)
|
|
87
71
|
|
|
88
72
|
function extractColumns<TData>(columns: ColumnDef<TData, any>[]): FilterColumn[] {
|
|
89
73
|
const out: FilterColumn[] = []
|
|
@@ -106,20 +90,6 @@ function extractColumns<TData>(columns: ColumnDef<TData, any>[]): FilterColumn[]
|
|
|
106
90
|
return out
|
|
107
91
|
}
|
|
108
92
|
|
|
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
|
-
// Q6(2026-07-04):date 欄位把比對精度寫進 condition(includeTime=true → ms,否則 day)。
|
|
119
|
-
// 'time' 欄暫不寫(值非完整日期,truncate 語意不成立;見 filter-operators.spec v3 time 註)。
|
|
120
|
-
const datePrecisionOf = (col: FilterColumn | undefined): 'day' | 'ms' | undefined =>
|
|
121
|
-
col?.type === 'date' ? (col.includeTime ? 'ms' : 'day') : undefined
|
|
122
|
-
|
|
123
93
|
const newCondition = (firstCol: FilterColumn | undefined): FilterCondition => ({
|
|
124
94
|
kind: 'cond',
|
|
125
95
|
id: newId(),
|
|
@@ -153,217 +123,6 @@ const newEmptyGroup = (): FilterGroup => ({
|
|
|
153
123
|
children: [newEmptyCondition()],
|
|
154
124
|
})
|
|
155
125
|
|
|
156
|
-
// ── Internal — FilterValuePicker(value-picker switcher per ValueShape)──
|
|
157
|
-
//
|
|
158
|
-
// 2026-05-03 M21 retract:本 helper 原本獨立檔 `filter-value-picker.tsx`,
|
|
159
|
-
// claim「未來 inline filter UI 共用」但只 1 consumer(本 panel)= 違 M21
|
|
160
|
-
// premature abstraction。inline 回 panel,日後若真有第 2 consumer 再抽。
|
|
161
|
-
|
|
162
|
-
interface FilterValuePickerColInfo {
|
|
163
|
-
id: string
|
|
164
|
-
label: string
|
|
165
|
-
options?: Array<{ value: string; label: string }>
|
|
166
|
-
/** Person pool — person/multiPerson filter picker 用(2026-05-07 升級,SSOT 對齊 cell-registry) */
|
|
167
|
-
people?: Array<{ name: string; avatarUrl?: string; description?: string }>
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
interface FilterValuePickerProps {
|
|
171
|
-
shape: ValueShape | null
|
|
172
|
-
value: unknown
|
|
173
|
-
onChange: (v: unknown) => void
|
|
174
|
-
colInfo?: FilterValuePickerColInfo
|
|
175
|
-
disabled?: boolean
|
|
176
|
-
/** 用 column.label 組「{label} 篩選值」(panel 每 row 不顯式 label,a11y 必填) */
|
|
177
|
-
ariaLabel?: string
|
|
178
|
-
/** Forward 給內部 Field control 的 className(2026-05-04 #2 fix)
|
|
179
|
-
* 避免外層包 wrapper div 破壞 FieldControlGroup CSS variants(rounded radii / margin overlap) */
|
|
180
|
-
className?: string
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
function FilterValuePicker({
|
|
184
|
-
shape,
|
|
185
|
-
value,
|
|
186
|
-
onChange,
|
|
187
|
-
colInfo,
|
|
188
|
-
disabled,
|
|
189
|
-
ariaLabel,
|
|
190
|
-
className,
|
|
191
|
-
}: FilterValuePickerProps) {
|
|
192
|
-
if (!shape || disabled) {
|
|
193
|
-
return <Input size="sm" value="" onChange={() => {}} placeholder="輸入值…" disabled aria-label={ariaLabel} className={className} />
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
switch (shape) {
|
|
197
|
-
case 'none':
|
|
198
|
-
return null
|
|
199
|
-
|
|
200
|
-
case 'text':
|
|
201
|
-
return (
|
|
202
|
-
<Input
|
|
203
|
-
size="sm"
|
|
204
|
-
value={String(value ?? '')}
|
|
205
|
-
onChange={(e) => onChange(e.target.value)}
|
|
206
|
-
placeholder="輸入值…"
|
|
207
|
-
aria-label={ariaLabel}
|
|
208
|
-
className={className}
|
|
209
|
-
/>
|
|
210
|
-
)
|
|
211
|
-
|
|
212
|
-
case 'number':
|
|
213
|
-
return (
|
|
214
|
-
<NumberInput
|
|
215
|
-
size="sm"
|
|
216
|
-
value={typeof value === 'number' ? value : null}
|
|
217
|
-
onChange={(v) => onChange(v ?? '')}
|
|
218
|
-
placeholder="輸入數字…"
|
|
219
|
-
aria-label={ariaLabel}
|
|
220
|
-
className={className}
|
|
221
|
-
/>
|
|
222
|
-
)
|
|
223
|
-
|
|
224
|
-
case 'date_single':
|
|
225
|
-
return (
|
|
226
|
-
<DatePicker
|
|
227
|
-
size="sm"
|
|
228
|
-
value={typeof value === 'string' ? value : null}
|
|
229
|
-
onChange={(v) => onChange(v ?? '')}
|
|
230
|
-
aria-label={ariaLabel}
|
|
231
|
-
className={className}
|
|
232
|
-
/>
|
|
233
|
-
)
|
|
234
|
-
|
|
235
|
-
case 'date_range':
|
|
236
|
-
return (
|
|
237
|
-
<DatePickerRange
|
|
238
|
-
size="sm"
|
|
239
|
-
value={Array.isArray(value) && value.length === 2
|
|
240
|
-
? (value as [string | null, string | null])
|
|
241
|
-
: null}
|
|
242
|
-
onChange={(v) => onChange(v)}
|
|
243
|
-
aria-label={ariaLabel}
|
|
244
|
-
className={className}
|
|
245
|
-
/>
|
|
246
|
-
)
|
|
247
|
-
|
|
248
|
-
case 'date_relative': {
|
|
249
|
-
// 群組分類:Past / Current / Future(對齊 Linear / Notion idiom),走 Select.groups → SelectMenu
|
|
250
|
-
const opts: SelectOption[] = DATE_RELATIVE_OPTIONS.map((o) => ({
|
|
251
|
-
value: o.value,
|
|
252
|
-
label: o.label,
|
|
253
|
-
group: o.group,
|
|
254
|
-
}))
|
|
255
|
-
return (
|
|
256
|
-
<Select
|
|
257
|
-
size="sm"
|
|
258
|
-
options={opts}
|
|
259
|
-
groups={DATE_RELATIVE_GROUPS as unknown as Array<{ key: string; label: string }>}
|
|
260
|
-
value={String(value ?? '')}
|
|
261
|
-
onChange={(v) => onChange(v)}
|
|
262
|
-
placeholder="選擇相對日期"
|
|
263
|
-
aria-label={ariaLabel}
|
|
264
|
-
className={className}
|
|
265
|
-
/>
|
|
266
|
-
)
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
case 'select_single': {
|
|
270
|
-
const opts: SelectOption[] = (colInfo?.options ?? []).map((o) => ({
|
|
271
|
-
value: o.value,
|
|
272
|
-
label: o.label,
|
|
273
|
-
}))
|
|
274
|
-
return (
|
|
275
|
-
<Select
|
|
276
|
-
size="sm"
|
|
277
|
-
options={opts}
|
|
278
|
-
value={String(value ?? '')}
|
|
279
|
-
onChange={(v) => onChange(v)}
|
|
280
|
-
placeholder="選擇值"
|
|
281
|
-
aria-label={ariaLabel}
|
|
282
|
-
className={className}
|
|
283
|
-
/>
|
|
284
|
-
)
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
case 'select_multi': {
|
|
288
|
-
const opts = (colInfo?.options ?? []).map((o) => ({
|
|
289
|
-
value: o.value,
|
|
290
|
-
label: o.label,
|
|
291
|
-
}))
|
|
292
|
-
const arr = Array.isArray(value) ? (value as string[]) : []
|
|
293
|
-
return (
|
|
294
|
-
<Combobox
|
|
295
|
-
size="sm"
|
|
296
|
-
options={opts}
|
|
297
|
-
value={arr}
|
|
298
|
-
onChange={(v) => onChange(v)}
|
|
299
|
-
placeholder="選擇值…"
|
|
300
|
-
aria-label={ariaLabel}
|
|
301
|
-
className={className}
|
|
302
|
-
/>
|
|
303
|
-
)
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
case 'datetime_single':
|
|
307
|
-
return (
|
|
308
|
-
<DatePicker
|
|
309
|
-
size="sm"
|
|
310
|
-
showTime
|
|
311
|
-
value={typeof value === 'string' ? value : null}
|
|
312
|
-
onChange={(v) => onChange(v ?? '')}
|
|
313
|
-
aria-label={ariaLabel}
|
|
314
|
-
className={className}
|
|
315
|
-
/>
|
|
316
|
-
)
|
|
317
|
-
|
|
318
|
-
case 'datetime_range':
|
|
319
|
-
return (
|
|
320
|
-
<DatePickerRange
|
|
321
|
-
size="sm"
|
|
322
|
-
showTime
|
|
323
|
-
value={Array.isArray(value) && value.length === 2
|
|
324
|
-
? (value as [string | null, string | null])
|
|
325
|
-
: null}
|
|
326
|
-
onChange={(v) => onChange(v)}
|
|
327
|
-
aria-label={ariaLabel}
|
|
328
|
-
className={className}
|
|
329
|
-
/>
|
|
330
|
-
)
|
|
331
|
-
|
|
332
|
-
// person_single / person_multi — 走 PeoplePicker(2026-05-07 升級,對齊 cell-registry SSOT)。
|
|
333
|
-
// colInfo.people 來自 column meta.people。Filter value:
|
|
334
|
-
// - person_single:存 PersonValue | null(picker emit array,我們取 [0])
|
|
335
|
-
// - person_multi:存 PersonValue[]
|
|
336
|
-
case 'person_single': {
|
|
337
|
-
const v = value as PersonValue | null | undefined
|
|
338
|
-
return (
|
|
339
|
-
<PeoplePicker
|
|
340
|
-
size="sm"
|
|
341
|
-
value={v ?? null}
|
|
342
|
-
people={colInfo?.people ?? []}
|
|
343
|
-
onChange={(next) => onChange(next[0] ?? null)}
|
|
344
|
-
aria-label={ariaLabel}
|
|
345
|
-
className={className}
|
|
346
|
-
/>
|
|
347
|
-
)
|
|
348
|
-
}
|
|
349
|
-
case 'person_multi': {
|
|
350
|
-
const v = Array.isArray(value) ? (value as PersonValue[]) : []
|
|
351
|
-
return (
|
|
352
|
-
<PeoplePicker
|
|
353
|
-
size="sm"
|
|
354
|
-
value={v}
|
|
355
|
-
people={colInfo?.people ?? []}
|
|
356
|
-
onChange={(next) => onChange(next)}
|
|
357
|
-
aria-label={ariaLabel}
|
|
358
|
-
className={className}
|
|
359
|
-
/>
|
|
360
|
-
)
|
|
361
|
-
}
|
|
362
|
-
default:
|
|
363
|
-
return null
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
|
|
367
126
|
// ── Component Props ─────────────────────────────────────────────────────
|
|
368
127
|
|
|
369
128
|
export interface DataTableFilterPanelProps<TData> {
|
|
@@ -644,179 +403,3 @@ export const DataTableFilterPanel = React.forwardRef(DataTableFilterPanelInner)
|
|
|
644
403
|
props: DataTableFilterPanelProps<TData> & { ref?: React.ForwardedRef<HTMLDivElement> }
|
|
645
404
|
) => React.ReactElement
|
|
646
405
|
;(DataTableFilterPanel as { displayName?: string }).displayName = 'DataTableFilterPanel'
|
|
647
|
-
|
|
648
|
-
// ── ConjunctionLabel ───────────────────────────────────────────────────
|
|
649
|
-
|
|
650
|
-
const CONJ_OPTIONS: SelectOption[] = [
|
|
651
|
-
{ value: 'and', label: 'And' },
|
|
652
|
-
{ value: 'or', label: 'Or' },
|
|
653
|
-
]
|
|
654
|
-
|
|
655
|
-
function ConjunctionLabel({
|
|
656
|
-
index, conjunction, onChange,
|
|
657
|
-
}: { index: number; conjunction: Conjunction; onChange: (c: Conjunction) => void }) {
|
|
658
|
-
// index === 0:首 row 顯示靜態「Where」label
|
|
659
|
-
// index === 1:**唯一可改**的 AND/OR Select(連動整 group conjunction)
|
|
660
|
-
// index ≥ 2:被連動的 row,read-only 顯示當前 conjunction 文字(同 Where 視覺,A6 canonical)
|
|
661
|
-
// 對齊 Airtable / Notion / Linear 共識 @benchmark-unverified(non-OSS)
|
|
662
|
-
// px-[var(--field-px)] 對齊 Field 內部 padding 12px(Q13)
|
|
663
|
-
if (index === 0) {
|
|
664
|
-
return <div className="w-20 shrink-0 text-body text-fg-muted px-[var(--field-px)] self-center">Where</div>
|
|
665
|
-
}
|
|
666
|
-
if (index >= 2) {
|
|
667
|
-
const label = conjunction === 'and' ? 'And' : 'Or'
|
|
668
|
-
return <div className="w-20 shrink-0 text-body text-fg-muted px-[var(--field-px)] self-center">{label}</div>
|
|
669
|
-
}
|
|
670
|
-
// index === 1:可切換的 AND/OR Select
|
|
671
|
-
// minRows={2} — And/Or 2 選項,顯式縮 menu 高度避免 reserve 3 row 空白(Q5)
|
|
672
|
-
return (
|
|
673
|
-
<div className="w-20 shrink-0">
|
|
674
|
-
<Select
|
|
675
|
-
size="sm"
|
|
676
|
-
options={CONJ_OPTIONS}
|
|
677
|
-
value={conjunction}
|
|
678
|
-
onChange={(v) => onChange(v as Conjunction)}
|
|
679
|
-
minRows={2}
|
|
680
|
-
aria-label="連接詞 — 同 group 共用"
|
|
681
|
-
/>
|
|
682
|
-
</div>
|
|
683
|
-
)
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
// ── FilterRow(flat 用 + group 內共用) ──────────────────────────────
|
|
687
|
-
|
|
688
|
-
function FilterRow({
|
|
689
|
-
index, condition, conjunction, filterableColumns, fieldOptions,
|
|
690
|
-
onChangeConjunction, onChangeField, onChangeOp, onChangeValue, onRemove,
|
|
691
|
-
}: {
|
|
692
|
-
index: number
|
|
693
|
-
condition: FilterCondition
|
|
694
|
-
conjunction: Conjunction
|
|
695
|
-
filterableColumns: FilterColumn[]
|
|
696
|
-
fieldOptions: SelectOption[]
|
|
697
|
-
onChangeConjunction: (c: Conjunction) => void
|
|
698
|
-
onChangeField: (v: string) => void
|
|
699
|
-
onChangeOp: (v: string) => void
|
|
700
|
-
onChangeValue: (v: unknown) => void
|
|
701
|
-
onRemove: () => void
|
|
702
|
-
}) {
|
|
703
|
-
const colInfo = filterableColumns.find((c) => c.id === condition.field)
|
|
704
|
-
const operatorOptions = getOperatorOptions(colInfo?.type)
|
|
705
|
-
const hasField = !!condition.field
|
|
706
|
-
const opSpec = colInfo ? getOperatorSpec(colInfo.type, condition.op) : null
|
|
707
|
-
const valueShape: ValueShape | null = colInfo && opSpec
|
|
708
|
-
? getValueShape(opSpec, colInfo.type, colInfo.includeTime)
|
|
709
|
-
: null
|
|
710
|
-
// op 'is_set' / 'is_not_set' 等 shape='none' → 無 value cell,op 自動 expand 填剩餘寬
|
|
711
|
-
// 對齊 Notion / Airtable / Linear filter row 行為
|
|
712
|
-
// 注意:valueShape=null(初始無 field 選)時仍 render value cell(disabled placeholder)— 只 'none' 才 fold
|
|
713
|
-
const hasValueCell = valueShape !== 'none'
|
|
714
|
-
|
|
715
|
-
// FieldControlGroup 接合 field + op + value 視覺(2026-05-04 E refactor + 多輪 fix):
|
|
716
|
-
// - border collapse 取代 3 顆獨立 Select 並排,對齊 Airtable / Linear / Notion filter row idiom
|
|
717
|
-
// - ConjunctionLabel + Trash 在 group 外層(meta actions,不屬 control 一體)
|
|
718
|
-
// - **#5 fix**:row 內水平 gap = `gap-2` (8px),layoutSpace 規則 5 緊密相關
|
|
719
|
-
// - **#9 fix**:cell 用 `min-w-[]`(field 160 / op 120),value flex-1 min-w-0,讓 long label 可撐寬
|
|
720
|
-
// - **#2 fix**:FilterValuePicker 直接是 FieldControlGroup direct child(無 wrapper div),CSS variants 命中正確
|
|
721
|
-
return (
|
|
722
|
-
<div className="flex items-center gap-2">
|
|
723
|
-
<ConjunctionLabel index={index} conjunction={conjunction} onChange={onChangeConjunction} />
|
|
724
|
-
{/* **#9 fix(2026-05-04 v4)**:Field controls trigger `w-full` override 外 className,改用 Tailwind `!`
|
|
725
|
-
important 強制 override(`!w-[160px]` / `!w-[120px]`),value 用 `!flex-1 !min-w-0`。
|
|
726
|
-
Select 元件本身沒 destructure `style` prop 所以 inline style flex-basis 行不通,只能用 className。 */}
|
|
727
|
-
<FieldControlGroup block className="flex-1 min-w-0">
|
|
728
|
-
{/* 2026-05-23 Phase A.4 Decision 2:`!w-[160px]` / `!w-[120px]` → tokens
|
|
729
|
-
`--data-table-filter-field-width` / `--data-table-filter-op-width`(SSOT in uiSize.css)
|
|
730
|
-
Behavior preserved 完好如初:flat + nested 同 width(token 是 design constant) */}
|
|
731
|
-
<Select
|
|
732
|
-
className="!w-[var(--data-table-filter-field-width)] flex-shrink-0"
|
|
733
|
-
size="sm"
|
|
734
|
-
options={fieldOptions}
|
|
735
|
-
value={condition.field}
|
|
736
|
-
onChange={onChangeField}
|
|
737
|
-
placeholder="選擇欄位"
|
|
738
|
-
aria-label="篩選欄位"
|
|
739
|
-
/>
|
|
740
|
-
<Select
|
|
741
|
-
className={hasValueCell ? '!w-[var(--data-table-filter-op-width)] flex-shrink-0' : '!flex-1 !min-w-0'}
|
|
742
|
-
size="sm"
|
|
743
|
-
options={operatorOptions}
|
|
744
|
-
value={condition.op}
|
|
745
|
-
onChange={onChangeOp}
|
|
746
|
-
disabled={!hasField}
|
|
747
|
-
placeholder="運算子"
|
|
748
|
-
aria-label="篩選運算子"
|
|
749
|
-
/>
|
|
750
|
-
{hasValueCell && (
|
|
751
|
-
<FilterValuePicker
|
|
752
|
-
shape={valueShape}
|
|
753
|
-
value={condition.value}
|
|
754
|
-
onChange={onChangeValue}
|
|
755
|
-
colInfo={colInfo}
|
|
756
|
-
disabled={!hasField}
|
|
757
|
-
ariaLabel={colInfo ? `${colInfo.label} 篩選值` : '篩選值'}
|
|
758
|
-
className="!flex-1 !min-w-0"
|
|
759
|
-
/>
|
|
760
|
-
)}
|
|
761
|
-
</FieldControlGroup>
|
|
762
|
-
{/* Trash 用 text Button — filter row 是 form-control row,Field 同高對齊(28 md) */}
|
|
763
|
-
<Button variant="text" size="sm" iconOnly startIcon={Trash2} aria-label="刪除" onClick={onRemove} />
|
|
764
|
-
</div>
|
|
765
|
-
)
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
// ── GroupBlock(nested 用) ────────────────────────────────────────────
|
|
769
|
-
|
|
770
|
-
function GroupBlock({
|
|
771
|
-
index, group, rootConjunction, filterableColumns, fieldOptions,
|
|
772
|
-
onChangeRootConjunction, onChangeGroupConjunction,
|
|
773
|
-
onChangeCondition, onRemoveCondition, onAddCondition, onRemoveGroup,
|
|
774
|
-
}: {
|
|
775
|
-
index: number
|
|
776
|
-
group: FilterGroup
|
|
777
|
-
rootConjunction: Conjunction
|
|
778
|
-
filterableColumns: FilterColumn[]
|
|
779
|
-
fieldOptions: SelectOption[]
|
|
780
|
-
onChangeRootConjunction: (c: Conjunction) => void
|
|
781
|
-
onChangeGroupConjunction: (c: Conjunction) => void
|
|
782
|
-
onChangeCondition: (condId: string, patch: Partial<FilterCondition>) => void
|
|
783
|
-
onRemoveCondition: (condId: string) => void
|
|
784
|
-
onAddCondition: () => void
|
|
785
|
-
onRemoveGroup: () => void
|
|
786
|
-
}) {
|
|
787
|
-
return (
|
|
788
|
-
<div className="flex items-start gap-2">
|
|
789
|
-
<div className="pt-2">
|
|
790
|
-
<ConjunctionLabel index={index} conjunction={rootConjunction} onChange={onChangeRootConjunction} />
|
|
791
|
-
</div>
|
|
792
|
-
{/* 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 */}
|
|
793
|
-
<div className="flex-1 min-w-0 rounded-md bg-muted p-2 flex flex-col gap-2">
|
|
794
|
-
{group.children.map((cond, cIdx) => (
|
|
795
|
-
<FilterRow
|
|
796
|
-
key={cond.id}
|
|
797
|
-
index={cIdx}
|
|
798
|
-
condition={cond}
|
|
799
|
-
conjunction={group.conjunction}
|
|
800
|
-
filterableColumns={filterableColumns}
|
|
801
|
-
fieldOptions={fieldOptions}
|
|
802
|
-
onChangeConjunction={onChangeGroupConjunction}
|
|
803
|
-
onChangeField={(v) => {
|
|
804
|
-
const newCol = filterableColumns.find((c) => c.id === v)
|
|
805
|
-
onChangeCondition(cond.id, { field: v, op: getDefaultOperator(newCol?.type), value: '', datePrecision: datePrecisionOf(newCol) })
|
|
806
|
-
}}
|
|
807
|
-
onChangeOp={(v) => onChangeCondition(cond.id, { op: v, value: '' })}
|
|
808
|
-
onChangeValue={(v) => onChangeCondition(cond.id, { value: v })}
|
|
809
|
-
onRemove={() => onRemoveCondition(cond.id)}
|
|
810
|
-
/>
|
|
811
|
-
))}
|
|
812
|
-
{/* Q9 — text variant 對齊 inline 派 + 視覺輕量 */}
|
|
813
|
-
<div className="flex items-center justify-between">
|
|
814
|
-
<Button variant="text" size="sm" startIcon={Plus} onClick={onAddCondition}>加入巢狀篩選</Button>
|
|
815
|
-
{group.children.length === 0 && (
|
|
816
|
-
<Button variant="text" size="sm" startIcon={Trash2} danger onClick={onRemoveGroup}>移除空群組</Button>
|
|
817
|
-
)}
|
|
818
|
-
</div>
|
|
819
|
-
</div>
|
|
820
|
-
</div>
|
|
821
|
-
)
|
|
822
|
-
}
|