@proteos/ui 0.31.0
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/LICENSE +105 -0
- package/dist/chunk-IKAX76MB.js +113 -0
- package/dist/chunk-IKAX76MB.js.map +1 -0
- package/dist/chunk-MWAXK66H.cjs +119 -0
- package/dist/chunk-MWAXK66H.cjs.map +1 -0
- package/dist/index.cjs +6246 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +2126 -0
- package/dist/index.d.ts +2126 -0
- package/dist/index.js +6069 -0
- package/dist/index.js.map +1 -0
- package/dist/pdf-view-3ZMMS6ML.cjs +95 -0
- package/dist/pdf-view-3ZMMS6ML.cjs.map +1 -0
- package/dist/pdf-view-BV5ZD6OC.js +93 -0
- package/dist/pdf-view-BV5ZD6OC.js.map +1 -0
- package/package.json +96 -0
- package/src/components/attachment-chip/attachment-chip.tsx +129 -0
- package/src/components/attachment-chip/index.ts +2 -0
- package/src/components/attribute-type-icon/attribute-type-icon.stories.tsx +70 -0
- package/src/components/attribute-type-icon/attribute-type-icon.tsx +125 -0
- package/src/components/attribute-type-icon/index.ts +8 -0
- package/src/components/avatar/avatar.stories.tsx +42 -0
- package/src/components/avatar/avatar.tsx +48 -0
- package/src/components/avatar/index.ts +2 -0
- package/src/components/avatar-group/avatar-group.test.tsx +38 -0
- package/src/components/avatar-group/avatar-group.tsx +60 -0
- package/src/components/avatar-group/index.ts +2 -0
- package/src/components/badge/badge.stories.tsx +62 -0
- package/src/components/badge/badge.test.tsx +36 -0
- package/src/components/badge/badge.tsx +54 -0
- package/src/components/badge/index.ts +2 -0
- package/src/components/badge-multi-select/badge-multi-select.stories.tsx +177 -0
- package/src/components/badge-multi-select/badge-multi-select.test.tsx +155 -0
- package/src/components/badge-multi-select/badge-multi-select.tsx +272 -0
- package/src/components/badge-multi-select/index.ts +2 -0
- package/src/components/breadcrumbs/breadcrumbs.stories.tsx +43 -0
- package/src/components/breadcrumbs/breadcrumbs.tsx +86 -0
- package/src/components/breadcrumbs/index.ts +2 -0
- package/src/components/button/button.stories.tsx +290 -0
- package/src/components/button/button.test.tsx +59 -0
- package/src/components/button/button.tsx +86 -0
- package/src/components/button/index.ts +2 -0
- package/src/components/card/card.stories.tsx +351 -0
- package/src/components/card/card.test.tsx +141 -0
- package/src/components/card/card.tsx +173 -0
- package/src/components/card/index.ts +13 -0
- package/src/components/checkbox/checkbox-group.tsx +52 -0
- package/src/components/checkbox/checkbox.stories.tsx +48 -0
- package/src/components/checkbox/checkbox.test.tsx +50 -0
- package/src/components/checkbox/checkbox.tsx +71 -0
- package/src/components/checkbox/index.ts +4 -0
- package/src/components/combobox/combobox.stories.tsx +97 -0
- package/src/components/combobox/combobox.test.tsx +123 -0
- package/src/components/combobox/combobox.tsx +239 -0
- package/src/components/combobox/index.ts +2 -0
- package/src/components/command/command-hint.tsx +46 -0
- package/src/components/command/command.stories.tsx +88 -0
- package/src/components/command/command.test.tsx +102 -0
- package/src/components/command/command.tsx +76 -0
- package/src/components/command/index.ts +16 -0
- package/src/components/currency-input/currency-input.tsx +192 -0
- package/src/components/currency-input/index.ts +6 -0
- package/src/components/data-table/cells.stories.tsx +120 -0
- package/src/components/data-table/cells.tsx +165 -0
- package/src/components/data-table/data-table.stories.tsx +371 -0
- package/src/components/data-table/data-table.tsx +556 -0
- package/src/components/data-table/index.ts +22 -0
- package/src/components/data-table/make-actions-column.ts +30 -0
- package/src/components/data-table/states/data-table-empty.tsx +40 -0
- package/src/components/data-table/states/data-table-error.tsx +30 -0
- package/src/components/data-table/states/data-table-skeleton.tsx +42 -0
- package/src/components/data-table/use-infinite-scroll-sentinel.ts +51 -0
- package/src/components/date-picker/calendar-internals.tsx +138 -0
- package/src/components/date-picker/date-picker.stories.tsx +78 -0
- package/src/components/date-picker/date-picker.test.tsx +71 -0
- package/src/components/date-picker/date-picker.tsx +140 -0
- package/src/components/date-picker/index.ts +2 -0
- package/src/components/date-time-picker/date-time-picker.stories.tsx +96 -0
- package/src/components/date-time-picker/date-time-picker.test.tsx +87 -0
- package/src/components/date-time-picker/date-time-picker.tsx +203 -0
- package/src/components/date-time-picker/index.ts +2 -0
- package/src/components/empty-state/empty-state.tsx +63 -0
- package/src/components/empty-state/index.ts +2 -0
- package/src/components/error-animation/error-animation.stories.tsx +50 -0
- package/src/components/error-animation/error-animation.test.tsx +22 -0
- package/src/components/error-animation/error-animation.tsx +26 -0
- package/src/components/error-animation/index.ts +2 -0
- package/src/components/field/field.tsx +142 -0
- package/src/components/field/index.ts +8 -0
- package/src/components/file-field/file-field.tsx +285 -0
- package/src/components/file-field/index.ts +2 -0
- package/src/components/file-viewer/fallback-card.tsx +68 -0
- package/src/components/file-viewer/file-viewer.stories.tsx +67 -0
- package/src/components/file-viewer/file-viewer.test.tsx +81 -0
- package/src/components/file-viewer/file-viewer.tsx +116 -0
- package/src/components/file-viewer/image-view.tsx +38 -0
- package/src/components/file-viewer/index.ts +7 -0
- package/src/components/file-viewer/pdf-view.tsx +122 -0
- package/src/components/file-viewer/video-view.tsx +16 -0
- package/src/components/image-attachment/image-attachment.stories.tsx +39 -0
- package/src/components/image-attachment/image-attachment.test.tsx +54 -0
- package/src/components/image-attachment/image-attachment.tsx +77 -0
- package/src/components/image-attachment/index.ts +3 -0
- package/src/components/image-attachment/is-previewable-image-name.ts +14 -0
- package/src/components/image-lightbox/image-lightbox.stories.tsx +38 -0
- package/src/components/image-lightbox/image-lightbox.test.tsx +72 -0
- package/src/components/image-lightbox/image-lightbox.tsx +92 -0
- package/src/components/image-lightbox/index.ts +2 -0
- package/src/components/input/index.ts +2 -0
- package/src/components/input/input.stories.tsx +71 -0
- package/src/components/input/input.test.tsx +36 -0
- package/src/components/input/input.tsx +76 -0
- package/src/components/kbd/index.ts +2 -0
- package/src/components/kbd/kbd.stories.tsx +34 -0
- package/src/components/kbd/kbd.tsx +27 -0
- package/src/components/menu/index.ts +2 -0
- package/src/components/menu/menu.tsx +121 -0
- package/src/components/modal/index.ts +10 -0
- package/src/components/modal/modal.stories.tsx +153 -0
- package/src/components/modal/modal.test.tsx +93 -0
- package/src/components/modal/modal.tsx +149 -0
- package/src/components/multi-select/index.ts +2 -0
- package/src/components/multi-select/multi-select.stories.tsx +211 -0
- package/src/components/multi-select/multi-select.test.tsx +151 -0
- package/src/components/multi-select/multi-select.tsx +339 -0
- package/src/components/nav/index.ts +7 -0
- package/src/components/nav/nav.stories.tsx +139 -0
- package/src/components/nav/nav.tsx +246 -0
- package/src/components/popover/index.ts +8 -0
- package/src/components/popover/popover.stories.tsx +92 -0
- package/src/components/popover/popover.tsx +135 -0
- package/src/components/progress-bar/index.ts +2 -0
- package/src/components/progress-bar/progress-bar.stories.tsx +75 -0
- package/src/components/progress-bar/progress-bar.test.tsx +29 -0
- package/src/components/progress-bar/progress-bar.tsx +57 -0
- package/src/components/radio-group/index.ts +2 -0
- package/src/components/radio-group/radio-group.stories.tsx +113 -0
- package/src/components/radio-group/radio-group.test.tsx +87 -0
- package/src/components/radio-group/radio-group.tsx +104 -0
- package/src/components/record-filter/attribute-picker.tsx +74 -0
- package/src/components/record-filter/chip-value.tsx +171 -0
- package/src/components/record-filter/filter-row.tsx +262 -0
- package/src/components/record-filter/filter-value-input.tsx +351 -0
- package/src/components/record-filter/group-add-controls.tsx +77 -0
- package/src/components/record-filter/group-body.tsx +173 -0
- package/src/components/record-filter/group-mutations.test.ts +104 -0
- package/src/components/record-filter/group-mutations.ts +116 -0
- package/src/components/record-filter/index.ts +58 -0
- package/src/components/record-filter/nested-group.tsx +138 -0
- package/src/components/record-filter/operator-picker.tsx +62 -0
- package/src/components/record-filter/operators.test.ts +105 -0
- package/src/components/record-filter/operators.ts +97 -0
- package/src/components/record-filter/record-filter-builder-panel.tsx +236 -0
- package/src/components/record-filter/record-filter-button.tsx +126 -0
- package/src/components/record-filter/record-filter-chips.tsx +527 -0
- package/src/components/record-filter/record-filter-editor.tsx +91 -0
- package/src/components/record-filter/record-filter.stories.tsx +196 -0
- package/src/components/record-filter/record-filter.test.tsx +437 -0
- package/src/components/record-filter/show-details-store.ts +72 -0
- package/src/components/record-filter/types.ts +150 -0
- package/src/components/segmented-control/index.ts +6 -0
- package/src/components/segmented-control/segmented-control.stories.tsx +67 -0
- package/src/components/segmented-control/segmented-control.test.tsx +47 -0
- package/src/components/segmented-control/segmented-control.tsx +105 -0
- package/src/components/select/index.ts +2 -0
- package/src/components/select/select.stories.tsx +76 -0
- package/src/components/select/select.test.tsx +56 -0
- package/src/components/select/select.tsx +159 -0
- package/src/components/separator/index.ts +2 -0
- package/src/components/separator/separator.stories.tsx +73 -0
- package/src/components/separator/separator.test.tsx +29 -0
- package/src/components/separator/separator.tsx +51 -0
- package/src/components/skeleton/index.ts +2 -0
- package/src/components/skeleton/skeleton.stories.tsx +68 -0
- package/src/components/skeleton/skeleton.test.tsx +38 -0
- package/src/components/skeleton/skeleton.tsx +43 -0
- package/src/components/success-animation/index.ts +2 -0
- package/src/components/success-animation/success-animation.stories.tsx +50 -0
- package/src/components/success-animation/success-animation.test.tsx +22 -0
- package/src/components/success-animation/success-animation.tsx +26 -0
- package/src/components/switch/index.ts +2 -0
- package/src/components/switch/switch.stories.tsx +60 -0
- package/src/components/switch/switch.test.tsx +50 -0
- package/src/components/switch/switch.tsx +49 -0
- package/src/components/table/index.ts +25 -0
- package/src/components/table/table.stories.tsx +306 -0
- package/src/components/table/table.test.tsx +115 -0
- package/src/components/table/table.tsx +543 -0
- package/src/components/tabs/index.ts +8 -0
- package/src/components/tabs/tabs.tsx +141 -0
- package/src/components/tag-input/index.ts +2 -0
- package/src/components/tag-input/tag-input.stories.tsx +73 -0
- package/src/components/tag-input/tag-input.test.tsx +80 -0
- package/src/components/tag-input/tag-input.tsx +193 -0
- package/src/components/textarea/index.ts +2 -0
- package/src/components/textarea/textarea.stories.tsx +75 -0
- package/src/components/textarea/textarea.test.tsx +41 -0
- package/src/components/textarea/textarea.tsx +86 -0
- package/src/components/time-field/index.ts +2 -0
- package/src/components/time-field/time-field.stories.tsx +76 -0
- package/src/components/time-field/time-field.test.tsx +51 -0
- package/src/components/time-field/time-field.tsx +98 -0
- package/src/components/tooltip/index.ts +2 -0
- package/src/components/tooltip/tooltip.stories.tsx +176 -0
- package/src/components/tooltip/tooltip.test.tsx +52 -0
- package/src/components/tooltip/tooltip.tsx +56 -0
- package/src/index.ts +47 -0
- package/src/lib/cn.ts +6 -0
- package/src/lib/commands/discovery-store.test.tsx +76 -0
- package/src/lib/commands/discovery-store.ts +110 -0
- package/src/lib/commands/index.ts +9 -0
- package/src/lib/commands/match-event.test.ts +99 -0
- package/src/lib/commands/match-event.ts +31 -0
- package/src/lib/commands/parse-combo.test.ts +65 -0
- package/src/lib/commands/parse-combo.ts +48 -0
- package/src/lib/commands/platform.ts +27 -0
- package/src/lib/commands/protected-combos.test.ts +18 -0
- package/src/lib/commands/protected-combos.ts +38 -0
- package/src/lib/commands/registry.test.ts +97 -0
- package/src/lib/commands/registry.ts +59 -0
- package/src/lib/commands/use-command.test.tsx +91 -0
- package/src/lib/commands/use-command.ts +32 -0
- package/src/lib/commands/use-escape-key.test.tsx +97 -0
- package/src/lib/commands/use-escape-key.ts +62 -0
- package/src/styles.css +321 -0
- package/src/test/setup.ts +9 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { createContext, type ReactNode, useContext } from 'react'
|
|
2
|
+
import { cn } from '../../lib/cn'
|
|
3
|
+
import { Badge } from '../badge/badge'
|
|
4
|
+
import { arityOf } from './operators'
|
|
5
|
+
import type { ComparisonOperator, FilterField, FilterValue } from './types'
|
|
6
|
+
|
|
7
|
+
/* =========================================================================
|
|
8
|
+
ChipValue — renders the value cell shown inside an active filter chip.
|
|
9
|
+
|
|
10
|
+
Arity orchestration lives here (single value vs list with "+N" overflow
|
|
11
|
+
vs nothing). Per-item rendering can be overridden via `renderItem` —
|
|
12
|
+
LUM-34 wires this to the page-layout control registry's Display so
|
|
13
|
+
chip rendering matches what's on the record-detail page (proper
|
|
14
|
+
relation titles, formatted dates, etc.). Lib default keeps the legacy
|
|
15
|
+
enum-Badge + truncate-text behaviour for callers without the registry.
|
|
16
|
+
========================================================================= */
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Per-value-item renderer. Called once per value (twice for arity 'n'
|
|
20
|
+
* with two items, etc.) AFTER arity orchestration. Return `undefined`
|
|
21
|
+
* to fall through to the lib default for that item.
|
|
22
|
+
*/
|
|
23
|
+
export type ChipValueItemRenderer = (props: {
|
|
24
|
+
field: FilterField
|
|
25
|
+
operator: ComparisonOperator
|
|
26
|
+
value: string
|
|
27
|
+
}) => ReactNode | undefined
|
|
28
|
+
|
|
29
|
+
/* =========================================================================
|
|
30
|
+
Context — same pattern as RenderValueInputProvider. A top-level
|
|
31
|
+
provider lets every <ChipValue> in the tree inherit one renderer
|
|
32
|
+
without per-mount wiring. The provider skips wrapping when
|
|
33
|
+
`renderer === undefined` so intermediate components don't shadow an
|
|
34
|
+
outer provider with `undefined`.
|
|
35
|
+
========================================================================= */
|
|
36
|
+
|
|
37
|
+
const RenderChipItemContext = createContext<ChipValueItemRenderer | undefined>(undefined)
|
|
38
|
+
|
|
39
|
+
export function RenderChipItemProvider({
|
|
40
|
+
renderer,
|
|
41
|
+
children,
|
|
42
|
+
}: {
|
|
43
|
+
renderer: ChipValueItemRenderer | undefined
|
|
44
|
+
children: ReactNode
|
|
45
|
+
}) {
|
|
46
|
+
if (renderer === undefined) return <>{children}</>
|
|
47
|
+
return (
|
|
48
|
+
<RenderChipItemContext.Provider value={renderer}>{children}</RenderChipItemContext.Provider>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function useRenderChipItem(): ChipValueItemRenderer | undefined {
|
|
53
|
+
return useContext(RenderChipItemContext)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type ChipValueProps = {
|
|
57
|
+
field: FilterField
|
|
58
|
+
operator: ComparisonOperator
|
|
59
|
+
value: FilterValue
|
|
60
|
+
/** Override per-value-item rendering (LUM-34: registry dispatch). */
|
|
61
|
+
renderItem?: ChipValueItemRenderer
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function ChipValue({ field, operator, value, renderItem }: ChipValueProps) {
|
|
65
|
+
const arity = arityOf(operator)
|
|
66
|
+
if (arity === 0) return null
|
|
67
|
+
|
|
68
|
+
if (arity === 'n') {
|
|
69
|
+
const arr = Array.isArray(value)
|
|
70
|
+
? value
|
|
71
|
+
: typeof value === 'string' && value.length > 0
|
|
72
|
+
? value.split(',').filter(Boolean)
|
|
73
|
+
: []
|
|
74
|
+
if (arr.length === 0) return <Em>—</Em>
|
|
75
|
+
const shown = arr.slice(0, 2)
|
|
76
|
+
return (
|
|
77
|
+
<span className="inline-flex items-center gap-1">
|
|
78
|
+
{shown.map((v) => (
|
|
79
|
+
<ChipItem
|
|
80
|
+
key={v}
|
|
81
|
+
field={field}
|
|
82
|
+
operator={operator}
|
|
83
|
+
value={v}
|
|
84
|
+
{...(renderItem !== undefined ? { renderItem } : {})}
|
|
85
|
+
/>
|
|
86
|
+
))}
|
|
87
|
+
{arr.length > 2 ? (
|
|
88
|
+
<span className="font-mono text-[11px] text-ink-3">+{arr.length - 2}</span>
|
|
89
|
+
) : null}
|
|
90
|
+
</span>
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// arity 1
|
|
95
|
+
if (value === '' || value == null) return <Em>—</Em>
|
|
96
|
+
return (
|
|
97
|
+
<ChipItem
|
|
98
|
+
field={field}
|
|
99
|
+
operator={operator}
|
|
100
|
+
value={String(value)}
|
|
101
|
+
{...(renderItem !== undefined ? { renderItem } : {})}
|
|
102
|
+
/>
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function ChipItem({
|
|
107
|
+
field,
|
|
108
|
+
operator,
|
|
109
|
+
value,
|
|
110
|
+
renderItem,
|
|
111
|
+
}: {
|
|
112
|
+
field: FilterField
|
|
113
|
+
operator: ComparisonOperator
|
|
114
|
+
value: string
|
|
115
|
+
renderItem?: ChipValueItemRenderer
|
|
116
|
+
}) {
|
|
117
|
+
// Explicit prop wins; otherwise fall back to context (set by a
|
|
118
|
+
// top-level RenderChipItemProvider); finally the lib default below.
|
|
119
|
+
const contextRenderer = useRenderChipItem()
|
|
120
|
+
const effectiveRenderer = renderItem ?? contextRenderer
|
|
121
|
+
if (effectiveRenderer) {
|
|
122
|
+
const custom = effectiveRenderer({ field, operator, value })
|
|
123
|
+
if (custom !== undefined) return <>{custom}</>
|
|
124
|
+
}
|
|
125
|
+
// Lib default: enum → Badge with variant lookup; other → truncated mono text.
|
|
126
|
+
if (field.type === 'enum') {
|
|
127
|
+
const variant = field.options?.find((o) => o.value === value)?.variant
|
|
128
|
+
const label = field.options?.find((o) => o.value === value)?.label ?? value
|
|
129
|
+
return <Badge variant={variant}>{label}</Badge>
|
|
130
|
+
}
|
|
131
|
+
return (
|
|
132
|
+
<span className={cn('font-mono text-[12px] text-ink max-w-[10rem] truncate')}>
|
|
133
|
+
{formatValue(field, operator, value)}
|
|
134
|
+
</span>
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function Em({ children }: { children: string }) {
|
|
139
|
+
return <span className="font-mono text-[12px] text-ink-4">{children}</span>
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function formatValue(
|
|
143
|
+
field: FilterField,
|
|
144
|
+
operator: ComparisonOperator,
|
|
145
|
+
value: FilterValue,
|
|
146
|
+
): string {
|
|
147
|
+
const arity = arityOf(operator)
|
|
148
|
+
if (arity === 0) return ''
|
|
149
|
+
if (arity === 'n') {
|
|
150
|
+
const arr = Array.isArray(value)
|
|
151
|
+
? value
|
|
152
|
+
: typeof value === 'string' && value.length > 0
|
|
153
|
+
? value.split(',')
|
|
154
|
+
: []
|
|
155
|
+
if (arr.length === 0) return '—'
|
|
156
|
+
if (field.type === 'enum') {
|
|
157
|
+
const labels = arr.map((v) => field.options?.find((x) => x.value === v)?.label ?? v)
|
|
158
|
+
return labels.length <= 2
|
|
159
|
+
? labels.join(', ')
|
|
160
|
+
: `${labels[0]}, ${labels[1]} +${labels.length - 2}`
|
|
161
|
+
}
|
|
162
|
+
return arr.length <= 2 ? arr.join(', ') : `${arr[0]}, ${arr[1]} +${arr.length - 2}`
|
|
163
|
+
}
|
|
164
|
+
if (field.type === 'boolean') return value === true || value === 'true' ? 'true' : 'false'
|
|
165
|
+
if (value === '' || value == null) return '—'
|
|
166
|
+
if (field.type === 'number' || field.type === 'integer') {
|
|
167
|
+
const n = Number(value)
|
|
168
|
+
return Number.isFinite(n) ? n.toLocaleString() : String(value)
|
|
169
|
+
}
|
|
170
|
+
return String(value)
|
|
171
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { useEffect } from 'react'
|
|
2
|
+
import { cn } from '../../lib/cn'
|
|
3
|
+
import { AttributePicker } from './attribute-picker'
|
|
4
|
+
import { FilterValueInput } from './filter-value-input'
|
|
5
|
+
import { OperatorPicker } from './operator-picker'
|
|
6
|
+
import { defaultValueFor, operatorById, operatorsForType } from './operators'
|
|
7
|
+
import type {
|
|
8
|
+
ComparisonOperator,
|
|
9
|
+
FilterElement,
|
|
10
|
+
FilterField,
|
|
11
|
+
FilterValue,
|
|
12
|
+
LogicalOperator,
|
|
13
|
+
} from './types'
|
|
14
|
+
|
|
15
|
+
/* =========================================================================
|
|
16
|
+
FilterRow — one element in the builder body. Layout:
|
|
17
|
+
|
|
18
|
+
[ joiner gutter ] [ AttributePicker ] [ operator ] [ value-input ] … [ × ]
|
|
19
|
+
|
|
20
|
+
The first cell is always an inline `AttributePicker` (Combobox). When
|
|
21
|
+
the row's field is unset (empty path), only the picker renders — the
|
|
22
|
+
operator/value/× cells appear as soon as a field is chosen. This is
|
|
23
|
+
how "+ Add filter" produces a fresh row that auto-focuses the picker
|
|
24
|
+
with its dropdown open.
|
|
25
|
+
========================================================================= */
|
|
26
|
+
|
|
27
|
+
export type FilterRowProps = {
|
|
28
|
+
index: number
|
|
29
|
+
joiner: LogicalOperator
|
|
30
|
+
/** Resolved field metadata. `undefined` means the row's field is unset. */
|
|
31
|
+
field?: FilterField
|
|
32
|
+
element: FilterElement
|
|
33
|
+
fields: ReadonlyArray<FilterField>
|
|
34
|
+
/** Auto-focus the AttributePicker on mount (e.g. for newly added rows). */
|
|
35
|
+
autoFocus?: boolean
|
|
36
|
+
/** Auto-focus the OperatorPicker on mount. Used right after a pending
|
|
37
|
+
* row's field is picked: the row remounts (its `key` changes), and we
|
|
38
|
+
* want focus to advance from the AttributePicker to the operator. */
|
|
39
|
+
autoFocusOperator?: boolean
|
|
40
|
+
/** Fires once after a mount that consumed `autoFocusOperator`, so the
|
|
41
|
+
* parent can clear its tracking index. */
|
|
42
|
+
onAutoFocusOperatorConsumed?: () => void
|
|
43
|
+
/** Auto-focus the value cell on mount. Forwarded into FilterValueInput,
|
|
44
|
+
* whose key-based remount on operator change makes this re-fire reliably
|
|
45
|
+
* across leaf types. */
|
|
46
|
+
autoFocusValue?: boolean
|
|
47
|
+
/** Fires once after a mount that consumed `autoFocusValue`. */
|
|
48
|
+
onAutoFocusValueConsumed?: () => void
|
|
49
|
+
/** Fires when the user submits the row from a plain text/number value
|
|
50
|
+
* Input (Enter). Used by the builder panel to chain "commit + add new
|
|
51
|
+
* pending row". The chip mini-editor passes its own form-submit handler
|
|
52
|
+
* here if it wants the same behaviour. */
|
|
53
|
+
onSubmit?: () => void
|
|
54
|
+
onChangeField: (next: FilterField) => void
|
|
55
|
+
onChangeOperator: (next: ComparisonOperator) => void
|
|
56
|
+
onChangeValue: (next: FilterValue) => void
|
|
57
|
+
onRemove: () => void
|
|
58
|
+
/** Optional "wrap in group" affordance — when present, a hover-only icon
|
|
59
|
+
* appears next to the remove button. Used by the recursive popover panel
|
|
60
|
+
* when `isComplexEnabled` is on. */
|
|
61
|
+
onWrap?: () => void
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function FilterRow({
|
|
65
|
+
index,
|
|
66
|
+
joiner,
|
|
67
|
+
field,
|
|
68
|
+
element,
|
|
69
|
+
fields,
|
|
70
|
+
autoFocus = false,
|
|
71
|
+
autoFocusOperator = false,
|
|
72
|
+
onAutoFocusOperatorConsumed,
|
|
73
|
+
autoFocusValue = false,
|
|
74
|
+
onAutoFocusValueConsumed,
|
|
75
|
+
onSubmit,
|
|
76
|
+
onChangeField,
|
|
77
|
+
onChangeOperator,
|
|
78
|
+
onChangeValue,
|
|
79
|
+
onRemove,
|
|
80
|
+
onWrap,
|
|
81
|
+
}: FilterRowProps) {
|
|
82
|
+
// One-shot: tell the parent to clear its tracking index so a future remount
|
|
83
|
+
// at the same index doesn't re-trigger.
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (autoFocusOperator) onAutoFocusOperatorConsumed?.()
|
|
86
|
+
if (autoFocusValue) onAutoFocusValueConsumed?.()
|
|
87
|
+
// Mount-only — these are hand-off signals, not sustained props.
|
|
88
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
89
|
+
}, [])
|
|
90
|
+
return (
|
|
91
|
+
<div
|
|
92
|
+
className={cn(
|
|
93
|
+
'group flex items-center gap-2 px-1.5 py-1 rounded-sm',
|
|
94
|
+
'hover:bg-[color-mix(in_oklab,var(--color-surface-2)_50%,var(--color-surface))]',
|
|
95
|
+
)}
|
|
96
|
+
>
|
|
97
|
+
{/* Joiner gutter — narrow fixed column to keep all rows aligned. The
|
|
98
|
+
three editor cells below flex to fill the remaining width. */}
|
|
99
|
+
<span className="w-12 shrink-0 inline-flex justify-end pr-1">
|
|
100
|
+
<span
|
|
101
|
+
className={cn(
|
|
102
|
+
'font-mono text-[10.5px] tracking-[0.08em]',
|
|
103
|
+
index === 0 ? 'text-ink-4' : 'text-accent-ink',
|
|
104
|
+
)}
|
|
105
|
+
>
|
|
106
|
+
{index === 0 ? 'WHERE' : joiner.toUpperCase()}
|
|
107
|
+
</span>
|
|
108
|
+
</span>
|
|
109
|
+
|
|
110
|
+
{/* Field picker — flex column. min-w guarantees the typeahead stays
|
|
111
|
+
legible at typical popover widths; basis-0 lets flex distribute
|
|
112
|
+
excess space proportionally. */}
|
|
113
|
+
<div className="flex-1 basis-0 min-w-[140px]">
|
|
114
|
+
<AttributePicker
|
|
115
|
+
fields={fields}
|
|
116
|
+
value={element.field}
|
|
117
|
+
onChange={onChangeField}
|
|
118
|
+
autoFocus={autoFocus}
|
|
119
|
+
className="w-full"
|
|
120
|
+
/>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
{/* Operator + value — only render once a field is picked. */}
|
|
124
|
+
{field ? (
|
|
125
|
+
<>
|
|
126
|
+
<div className="flex-1 basis-0 min-w-[110px]">
|
|
127
|
+
<OperatorPicker
|
|
128
|
+
fieldType={field.type}
|
|
129
|
+
value={element.operator}
|
|
130
|
+
onChange={onChangeOperator}
|
|
131
|
+
autoFocus={autoFocusOperator}
|
|
132
|
+
className="w-full"
|
|
133
|
+
{...(field.filterOperators !== undefined
|
|
134
|
+
? { operators: field.filterOperators }
|
|
135
|
+
: {})}
|
|
136
|
+
/>
|
|
137
|
+
</div>
|
|
138
|
+
{/* Value gets ~2× the share of field/operator since values like
|
|
139
|
+
chip lists or date pickers are widest. */}
|
|
140
|
+
<div
|
|
141
|
+
className="flex-[2] basis-0 min-w-0"
|
|
142
|
+
onKeyDown={(e) => {
|
|
143
|
+
// Enter inside a plain text/number <input> commits the row
|
|
144
|
+
// (state already lives in `filter`) and lets the parent add a
|
|
145
|
+
// fresh pending row. RAC controls (Combobox, Select, TagInput,
|
|
146
|
+
// BadgeMultiSelect) all `preventDefault` on Enter for their
|
|
147
|
+
// own selection/commit semantics, so `defaultPrevented` filters
|
|
148
|
+
// them out. DateSegment renders as a <div role="spinbutton">,
|
|
149
|
+
// not INPUT — so the tagName check excludes it too.
|
|
150
|
+
if (e.key !== 'Enter' || e.defaultPrevented) return
|
|
151
|
+
const t = e.target as HTMLElement
|
|
152
|
+
if (t.tagName !== 'INPUT') return
|
|
153
|
+
const inputType = (t as HTMLInputElement).type
|
|
154
|
+
if (inputType !== 'text' && inputType !== 'number') return
|
|
155
|
+
if (!onSubmit) return
|
|
156
|
+
e.preventDefault()
|
|
157
|
+
onSubmit()
|
|
158
|
+
}}
|
|
159
|
+
>
|
|
160
|
+
<FilterValueInput
|
|
161
|
+
field={field}
|
|
162
|
+
operator={element.operator}
|
|
163
|
+
value={element.value}
|
|
164
|
+
onChange={onChangeValue}
|
|
165
|
+
autoFocus={autoFocusValue}
|
|
166
|
+
/>
|
|
167
|
+
</div>
|
|
168
|
+
</>
|
|
169
|
+
) : (
|
|
170
|
+
// Spacer reserves the operator + value flex share so the field
|
|
171
|
+
// picker doesn't balloon across the whole row before a field is
|
|
172
|
+
// chosen.
|
|
173
|
+
<div className="flex-[3] basis-0 min-w-0" />
|
|
174
|
+
)}
|
|
175
|
+
|
|
176
|
+
{onWrap && field ? (
|
|
177
|
+
<button
|
|
178
|
+
type="button"
|
|
179
|
+
onClick={onWrap}
|
|
180
|
+
aria-label="Wrap in group"
|
|
181
|
+
title="Wrap in a group with the opposite operator"
|
|
182
|
+
className={cn(
|
|
183
|
+
'shrink-0 size-6 inline-flex items-center justify-center rounded-sm',
|
|
184
|
+
'text-ink-4 hover:text-ink hover:bg-surface-2 cursor-pointer outline-none',
|
|
185
|
+
'opacity-0 group-hover:opacity-100 focus-visible:opacity-100',
|
|
186
|
+
)}
|
|
187
|
+
>
|
|
188
|
+
<svg
|
|
189
|
+
aria-hidden
|
|
190
|
+
viewBox="0 0 14 14"
|
|
191
|
+
className="size-3.5"
|
|
192
|
+
fill="none"
|
|
193
|
+
stroke="currentColor"
|
|
194
|
+
strokeWidth="1.4"
|
|
195
|
+
strokeLinecap="round"
|
|
196
|
+
strokeLinejoin="round"
|
|
197
|
+
>
|
|
198
|
+
<path d="M5 3a3 3 0 0 0-3 3v2a3 3 0 0 0 3 3M9 3a3 3 0 0 1 3 3v2a3 3 0 0 1-3 3" />
|
|
199
|
+
</svg>
|
|
200
|
+
</button>
|
|
201
|
+
) : null}
|
|
202
|
+
<button
|
|
203
|
+
type="button"
|
|
204
|
+
onClick={onRemove}
|
|
205
|
+
aria-label={field ? 'Remove filter' : 'Cancel'}
|
|
206
|
+
className={cn(
|
|
207
|
+
'shrink-0 size-6 inline-flex items-center justify-center rounded-sm',
|
|
208
|
+
'text-ink-4 hover:text-danger hover:bg-surface-2 cursor-pointer outline-none',
|
|
209
|
+
field ? 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100' : 'opacity-100',
|
|
210
|
+
)}
|
|
211
|
+
>
|
|
212
|
+
<svg
|
|
213
|
+
aria-hidden
|
|
214
|
+
viewBox="0 0 14 14"
|
|
215
|
+
className="size-3.5"
|
|
216
|
+
fill="none"
|
|
217
|
+
stroke="currentColor"
|
|
218
|
+
strokeWidth="1.4"
|
|
219
|
+
strokeLinecap="round"
|
|
220
|
+
strokeLinejoin="round"
|
|
221
|
+
>
|
|
222
|
+
<path d="M3 4h8M5 4V3a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v1M4.5 4l.5 7a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1l.5-7" />
|
|
223
|
+
</svg>
|
|
224
|
+
</button>
|
|
225
|
+
</div>
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/* =========================================================================
|
|
230
|
+
When the user changes the field on a row, decide whether the existing
|
|
231
|
+
operator is still valid. If yes, keep the value. If no, swap to the
|
|
232
|
+
first operator for the new type and reset the value to its default.
|
|
233
|
+
Exported so the builder + chip mini-editor can share the rule.
|
|
234
|
+
========================================================================= */
|
|
235
|
+
export function rebaseRowOnFieldChange(element: FilterElement, next: FilterField): FilterElement {
|
|
236
|
+
const newOps = operatorsForType(next.type)
|
|
237
|
+
const stillValid = newOps.find((o) => o.id === element.operator)
|
|
238
|
+
const nextOp = stillValid ?? newOps[0]
|
|
239
|
+
if (!nextOp) {
|
|
240
|
+
return { ...element, field: next.path }
|
|
241
|
+
}
|
|
242
|
+
return {
|
|
243
|
+
field: next.path,
|
|
244
|
+
operator: nextOp.id,
|
|
245
|
+
value: stillValid ? element.value : defaultValueFor(next.type, nextOp.arity),
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function rebaseRowOnOperatorChange(
|
|
250
|
+
element: FilterElement,
|
|
251
|
+
field: FilterField,
|
|
252
|
+
nextOperator: ComparisonOperator,
|
|
253
|
+
): FilterElement {
|
|
254
|
+
const next = operatorById(nextOperator)
|
|
255
|
+
const prev = operatorById(element.operator)
|
|
256
|
+
const arityChanged = next?.arity !== prev?.arity
|
|
257
|
+
return {
|
|
258
|
+
...element,
|
|
259
|
+
operator: nextOperator,
|
|
260
|
+
value: arityChanged ? defaultValueFor(field.type, next?.arity ?? 1) : element.value,
|
|
261
|
+
}
|
|
262
|
+
}
|