@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,527 @@
|
|
|
1
|
+
import { Fragment, type RefObject, useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
+
import { cn } from '../../lib/cn'
|
|
3
|
+
import { PopoverContent } from '../popover/popover'
|
|
4
|
+
import { AttributePicker } from './attribute-picker'
|
|
5
|
+
import { ChipValue, type ChipValueItemRenderer } from './chip-value'
|
|
6
|
+
import { rebaseRowOnFieldChange, rebaseRowOnOperatorChange } from './filter-row'
|
|
7
|
+
import {
|
|
8
|
+
FilterValueInput,
|
|
9
|
+
type FilterValueInputRenderer,
|
|
10
|
+
RenderValueInputProvider,
|
|
11
|
+
} from './filter-value-input'
|
|
12
|
+
import { countLeaves } from './group-mutations'
|
|
13
|
+
import { OperatorPicker } from './operator-picker'
|
|
14
|
+
import { operatorById, operatorsForType } from './operators'
|
|
15
|
+
import { useShowDetails } from './show-details-store'
|
|
16
|
+
import { AttributeTypeIcon } from '../attribute-type-icon'
|
|
17
|
+
import type { ComparisonOperator, FilterField, FilterGroup, FilterValue } from './types'
|
|
18
|
+
|
|
19
|
+
/* =========================================================================
|
|
20
|
+
RecordFilterChips — chip row above the table. Renders one chip per
|
|
21
|
+
active filter, with AND/OR joiner pills between them and an inline
|
|
22
|
+
"+ Add filter" affordance at the end. A "« Collapse" text-button sits
|
|
23
|
+
above the chips to fold the row back to the icon-only `RecordFilterButton`.
|
|
24
|
+
|
|
25
|
+
"+ Add filter" appends a pending element with `field: ''` directly into
|
|
26
|
+
the FilterGroup state and renders an inline auto-focused AttributePicker
|
|
27
|
+
in its place. Once the user picks a field, the picker becomes a real
|
|
28
|
+
chip. Same pattern is used inside `ChipMiniEditor` for editing the
|
|
29
|
+
field of an existing chip.
|
|
30
|
+
|
|
31
|
+
When collapsed (`!showDetails`), returns null — the `RecordFilterButton`
|
|
32
|
+
takes over.
|
|
33
|
+
========================================================================= */
|
|
34
|
+
|
|
35
|
+
export type RecordFilterChipsProps = {
|
|
36
|
+
fields: ReadonlyArray<FilterField>
|
|
37
|
+
filter: FilterGroup
|
|
38
|
+
onChange: (next: FilterGroup) => void
|
|
39
|
+
showFieldIcons?: boolean
|
|
40
|
+
showJoinerToggle?: boolean
|
|
41
|
+
/** localStorage key used to share the collapsed/expanded state with the
|
|
42
|
+
* RecordFilterButton. Pass the same value to both components. */
|
|
43
|
+
storageKey?: string
|
|
44
|
+
/** Optional override for the value editor inside the chip's mini-editor. */
|
|
45
|
+
renderValueInput?: FilterValueInputRenderer
|
|
46
|
+
/** Optional override for per-value-item rendering inside chip pills.
|
|
47
|
+
* Called once per value (or once per item for arity 'n'); returning
|
|
48
|
+
* `undefined` falls through to the lib default. LUM-34 wires this to
|
|
49
|
+
* the page-layout control registry's Display. */
|
|
50
|
+
renderChipItem?: ChipValueItemRenderer
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function RecordFilterChips({
|
|
54
|
+
fields,
|
|
55
|
+
filter,
|
|
56
|
+
onChange,
|
|
57
|
+
showFieldIcons = true,
|
|
58
|
+
showJoinerToggle = true,
|
|
59
|
+
storageKey,
|
|
60
|
+
renderValueInput,
|
|
61
|
+
renderChipItem,
|
|
62
|
+
}: RecordFilterChipsProps) {
|
|
63
|
+
const fieldsByPath = useMemo(() => Object.fromEntries(fields.map((f) => [f.path, f])), [fields])
|
|
64
|
+
const elements = filter.elements ?? []
|
|
65
|
+
const subgroups = filter.groups ?? []
|
|
66
|
+
const [showDetails, toggleDetails] = useShowDetails(storageKey)
|
|
67
|
+
// Index of the chip whose AttributePicker should auto-focus this render.
|
|
68
|
+
const [autoFocusIndex, setAutoFocusIndex] = useState<number | null>(null)
|
|
69
|
+
// Index of the chip whose mini-editor should auto-open on mount — set when
|
|
70
|
+
// a pending row's AttributePicker resolves, so the user lands directly on
|
|
71
|
+
// the value editor (operator is already pre-selected by
|
|
72
|
+
// rebaseRowOnFieldChange).
|
|
73
|
+
const [autoOpenIndex, setAutoOpenIndex] = useState<number | null>(null)
|
|
74
|
+
|
|
75
|
+
if (!showDetails) return null
|
|
76
|
+
|
|
77
|
+
const addPending = () => {
|
|
78
|
+
const nextIndex = elements.length
|
|
79
|
+
onChange({
|
|
80
|
+
...filter,
|
|
81
|
+
elements: [...elements, { field: '', operator: 'eq', value: '' }],
|
|
82
|
+
})
|
|
83
|
+
setAutoFocusIndex(nextIndex)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<RenderValueInputProvider renderer={renderValueInput}>
|
|
88
|
+
<div className="flex flex-col items-start gap-1.5">
|
|
89
|
+
<CollapseToggle onClick={toggleDetails} />
|
|
90
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
91
|
+
{elements.map((el, i) => {
|
|
92
|
+
const field = fieldsByPath[el.field]
|
|
93
|
+
return (
|
|
94
|
+
<Fragment
|
|
95
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: filter elements have no stable id; index disambiguates duplicate-field rows
|
|
96
|
+
key={`${el.field}-${i}`}
|
|
97
|
+
>
|
|
98
|
+
{i > 0 ? (
|
|
99
|
+
showJoinerToggle ? (
|
|
100
|
+
<button
|
|
101
|
+
type="button"
|
|
102
|
+
onClick={() =>
|
|
103
|
+
onChange({
|
|
104
|
+
...filter,
|
|
105
|
+
logicalOperator: filter.logicalOperator === 'and' ? 'or' : 'and',
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
title="Toggle joiner"
|
|
109
|
+
className={cn(
|
|
110
|
+
'inline-flex items-center px-1.5 py-0.5 rounded-sm border border-dashed border-rule',
|
|
111
|
+
'font-mono text-[10.5px] tracking-[0.08em] text-ink-3',
|
|
112
|
+
'hover:text-ink hover:border-ink-4 cursor-pointer outline-none',
|
|
113
|
+
)}
|
|
114
|
+
>
|
|
115
|
+
{filter.logicalOperator.toUpperCase()}
|
|
116
|
+
</button>
|
|
117
|
+
) : (
|
|
118
|
+
<span className="font-mono text-[10.5px] tracking-[0.08em] text-ink-4 px-1">
|
|
119
|
+
{filter.logicalOperator.toUpperCase()}
|
|
120
|
+
</span>
|
|
121
|
+
)
|
|
122
|
+
) : null}
|
|
123
|
+
|
|
124
|
+
{field ? (
|
|
125
|
+
<Chip
|
|
126
|
+
index={i}
|
|
127
|
+
field={field}
|
|
128
|
+
element={el}
|
|
129
|
+
fields={fields}
|
|
130
|
+
showFieldIcons={showFieldIcons}
|
|
131
|
+
autoOpen={autoOpenIndex === i}
|
|
132
|
+
onAutoOpenConsumed={() => setAutoOpenIndex(null)}
|
|
133
|
+
{...(renderChipItem !== undefined ? { renderChipItem } : {})}
|
|
134
|
+
onChangeField={(picked) =>
|
|
135
|
+
onChange({
|
|
136
|
+
...filter,
|
|
137
|
+
elements: elements.map((e, idx) =>
|
|
138
|
+
idx === i ? rebaseRowOnFieldChange(e, picked) : e,
|
|
139
|
+
),
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
onChangeOperator={(opId) =>
|
|
143
|
+
onChange({
|
|
144
|
+
...filter,
|
|
145
|
+
elements: elements.map((e, idx) =>
|
|
146
|
+
idx === i ? rebaseRowOnOperatorChange(e, field, opId) : e,
|
|
147
|
+
),
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
onChangeValue={(v) =>
|
|
151
|
+
onChange({
|
|
152
|
+
...filter,
|
|
153
|
+
elements: elements.map((e, idx) => (idx === i ? { ...e, value: v } : e)),
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
onRemove={() =>
|
|
157
|
+
onChange({
|
|
158
|
+
...filter,
|
|
159
|
+
elements: elements.filter((_, idx) => idx !== i),
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
/>
|
|
163
|
+
) : (
|
|
164
|
+
// Pending chip — inline AttributePicker.
|
|
165
|
+
<div className="inline-flex items-center gap-1">
|
|
166
|
+
<AttributePicker
|
|
167
|
+
fields={fields}
|
|
168
|
+
value=""
|
|
169
|
+
autoFocus={autoFocusIndex === i}
|
|
170
|
+
onChange={(picked) => {
|
|
171
|
+
if (autoFocusIndex === i) setAutoFocusIndex(null)
|
|
172
|
+
// Hand off to the resolved Chip: open its mini-editor
|
|
173
|
+
// immediately so the user can fill the value.
|
|
174
|
+
setAutoOpenIndex(i)
|
|
175
|
+
onChange({
|
|
176
|
+
...filter,
|
|
177
|
+
elements: elements.map((e, idx) =>
|
|
178
|
+
idx === i ? rebaseRowOnFieldChange(e, picked) : e,
|
|
179
|
+
),
|
|
180
|
+
})
|
|
181
|
+
}}
|
|
182
|
+
/>
|
|
183
|
+
<button
|
|
184
|
+
type="button"
|
|
185
|
+
aria-label="Cancel"
|
|
186
|
+
onClick={() => {
|
|
187
|
+
if (autoFocusIndex === i) setAutoFocusIndex(null)
|
|
188
|
+
onChange({
|
|
189
|
+
...filter,
|
|
190
|
+
elements: elements.filter((_, idx) => idx !== i),
|
|
191
|
+
})
|
|
192
|
+
}}
|
|
193
|
+
className={cn(
|
|
194
|
+
'size-5 inline-flex items-center justify-center rounded-[3px]',
|
|
195
|
+
'text-ink-4 hover:text-ink hover:bg-surface-2 cursor-pointer outline-none',
|
|
196
|
+
)}
|
|
197
|
+
>
|
|
198
|
+
<svg
|
|
199
|
+
aria-hidden
|
|
200
|
+
viewBox="0 0 10 10"
|
|
201
|
+
className="size-2"
|
|
202
|
+
fill="none"
|
|
203
|
+
stroke="currentColor"
|
|
204
|
+
strokeWidth="1.4"
|
|
205
|
+
strokeLinecap="round"
|
|
206
|
+
>
|
|
207
|
+
<path d="M2 2l6 6M8 2l-6 6" />
|
|
208
|
+
</svg>
|
|
209
|
+
</button>
|
|
210
|
+
</div>
|
|
211
|
+
)}
|
|
212
|
+
</Fragment>
|
|
213
|
+
)
|
|
214
|
+
})}
|
|
215
|
+
|
|
216
|
+
{subgroups.map((sub, i) => {
|
|
217
|
+
const showLeadingJoiner = elements.length > 0 || i > 0
|
|
218
|
+
return (
|
|
219
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: sub-groups have no stable id
|
|
220
|
+
<Fragment key={`group-${i}`}>
|
|
221
|
+
{showLeadingJoiner ? (
|
|
222
|
+
<span className="font-mono text-[10.5px] tracking-[0.08em] text-ink-4 px-1">
|
|
223
|
+
{filter.logicalOperator.toUpperCase()}
|
|
224
|
+
</span>
|
|
225
|
+
) : null}
|
|
226
|
+
<GroupPill group={sub} />
|
|
227
|
+
</Fragment>
|
|
228
|
+
)
|
|
229
|
+
})}
|
|
230
|
+
|
|
231
|
+
<InlineAddFilter onClick={addPending} />
|
|
232
|
+
</div>
|
|
233
|
+
</div>
|
|
234
|
+
</RenderValueInputProvider>
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/* ---------- GroupPill -----------------------------------------------------
|
|
239
|
+
Read-only pill that surfaces a sub-group's existence in the chip row.
|
|
240
|
+
By design, group editing only happens inside the builder popover — so
|
|
241
|
+
this pill points users there with a `title` hint and no click handler.
|
|
242
|
+
------------------------------------------------------------------------- */
|
|
243
|
+
function GroupPill({ group }: { group: FilterGroup }) {
|
|
244
|
+
const count = countLeaves(group)
|
|
245
|
+
return (
|
|
246
|
+
<span
|
|
247
|
+
className={cn(
|
|
248
|
+
'inline-flex items-center gap-1.5 px-2 py-1 rounded-sm border border-dashed border-rule',
|
|
249
|
+
'bg-[color-mix(in_oklab,var(--color-surface-2)_60%,var(--color-surface))]',
|
|
250
|
+
)}
|
|
251
|
+
title="Nested group — open the filter popover to edit"
|
|
252
|
+
>
|
|
253
|
+
<svg
|
|
254
|
+
aria-hidden
|
|
255
|
+
viewBox="0 0 14 14"
|
|
256
|
+
className="size-3 text-ink-3"
|
|
257
|
+
fill="none"
|
|
258
|
+
stroke="currentColor"
|
|
259
|
+
strokeWidth="1.4"
|
|
260
|
+
strokeLinecap="round"
|
|
261
|
+
strokeLinejoin="round"
|
|
262
|
+
>
|
|
263
|
+
<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" />
|
|
264
|
+
</svg>
|
|
265
|
+
<span className="font-mono text-[10.5px] uppercase tracking-[0.08em] text-ink-3">
|
|
266
|
+
{group.logicalOperator} group
|
|
267
|
+
</span>
|
|
268
|
+
<span className="font-mono text-[11px] text-ink-4">· {count}</span>
|
|
269
|
+
</span>
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/* ---------- Collapse toggle ----------------------------------------------- */
|
|
274
|
+
function CollapseToggle({ onClick }: { onClick: () => void }) {
|
|
275
|
+
return (
|
|
276
|
+
<button
|
|
277
|
+
type="button"
|
|
278
|
+
onClick={onClick}
|
|
279
|
+
className={cn(
|
|
280
|
+
'inline-flex items-center gap-1',
|
|
281
|
+
'font-mono text-[11px] uppercase tracking-[0.08em]',
|
|
282
|
+
'text-ink-3 hover:text-ink cursor-pointer outline-none',
|
|
283
|
+
)}
|
|
284
|
+
>
|
|
285
|
+
<svg
|
|
286
|
+
aria-hidden
|
|
287
|
+
viewBox="0 0 14 14"
|
|
288
|
+
className="size-3"
|
|
289
|
+
fill="none"
|
|
290
|
+
stroke="currentColor"
|
|
291
|
+
strokeWidth="1.5"
|
|
292
|
+
strokeLinecap="round"
|
|
293
|
+
strokeLinejoin="round"
|
|
294
|
+
>
|
|
295
|
+
<path d="m8 3-3 4 3 4M11 3l-3 4 3 4" />
|
|
296
|
+
</svg>
|
|
297
|
+
Collapse
|
|
298
|
+
</button>
|
|
299
|
+
)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/* ---------- Chip ---------------------------------------------------------- */
|
|
303
|
+
function Chip({
|
|
304
|
+
index,
|
|
305
|
+
field,
|
|
306
|
+
element,
|
|
307
|
+
fields,
|
|
308
|
+
showFieldIcons,
|
|
309
|
+
autoOpen = false,
|
|
310
|
+
onAutoOpenConsumed,
|
|
311
|
+
onChangeField,
|
|
312
|
+
onChangeOperator,
|
|
313
|
+
onChangeValue,
|
|
314
|
+
onRemove,
|
|
315
|
+
renderChipItem,
|
|
316
|
+
}: {
|
|
317
|
+
index: number
|
|
318
|
+
field: FilterField
|
|
319
|
+
element: { field: string; operator: ComparisonOperator; value: FilterValue }
|
|
320
|
+
fields: ReadonlyArray<FilterField>
|
|
321
|
+
showFieldIcons: boolean
|
|
322
|
+
/** When true, the mini-editor opens immediately on mount. Used to chain
|
|
323
|
+
* "+ Add filter → pick attribute → land on value editor" without an
|
|
324
|
+
* intermediate click. */
|
|
325
|
+
autoOpen?: boolean
|
|
326
|
+
onAutoOpenConsumed?: () => void
|
|
327
|
+
onChangeField: (next: FilterField) => void
|
|
328
|
+
onChangeOperator: (next: ComparisonOperator) => void
|
|
329
|
+
onChangeValue: (next: FilterValue) => void
|
|
330
|
+
onRemove: () => void
|
|
331
|
+
renderChipItem?: ChipValueItemRenderer
|
|
332
|
+
}) {
|
|
333
|
+
const ref = useRef<HTMLSpanElement>(null)
|
|
334
|
+
const [editing, setEditing] = useState(autoOpen)
|
|
335
|
+
// Tell the parent to clear its `autoOpenIndex` so a subsequent chip
|
|
336
|
+
// mounting at the same index doesn't inherit the open flag.
|
|
337
|
+
useEffect(() => {
|
|
338
|
+
if (autoOpen) onAutoOpenConsumed?.()
|
|
339
|
+
// We deliberately only run this on mount — autoOpen is a hand-off signal,
|
|
340
|
+
// not a sustained prop.
|
|
341
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
342
|
+
}, [])
|
|
343
|
+
const op = operatorById(element.operator) ?? operatorsForType(field.type)[0]
|
|
344
|
+
|
|
345
|
+
return (
|
|
346
|
+
<>
|
|
347
|
+
{/* biome-ignore lint/a11y/useSemanticElements: chip contains a nested remove <button>; HTML disallows nested <button>, so we use an aria-button span */}
|
|
348
|
+
<span
|
|
349
|
+
ref={ref}
|
|
350
|
+
role="button"
|
|
351
|
+
tabIndex={0}
|
|
352
|
+
aria-label={`Edit filter ${index + 1}: ${field.label}`}
|
|
353
|
+
onClick={() => setEditing((v) => !v)}
|
|
354
|
+
onKeyDown={(e) => {
|
|
355
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
356
|
+
e.preventDefault()
|
|
357
|
+
setEditing((v) => !v)
|
|
358
|
+
}
|
|
359
|
+
}}
|
|
360
|
+
className={cn(
|
|
361
|
+
'group inline-flex items-center gap-1.5 pl-2 pr-1 py-1 rounded-sm border cursor-pointer',
|
|
362
|
+
editing
|
|
363
|
+
? 'border-accent shadow-[var(--shadow-focus)] bg-surface'
|
|
364
|
+
: 'border-rule bg-surface hover:border-ink-4',
|
|
365
|
+
)}
|
|
366
|
+
>
|
|
367
|
+
{showFieldIcons ? (
|
|
368
|
+
<span className="text-ink-3">
|
|
369
|
+
<AttributeTypeIcon type={field.type} />
|
|
370
|
+
</span>
|
|
371
|
+
) : null}
|
|
372
|
+
<span className="font-sans text-[12.5px] text-ink-2">
|
|
373
|
+
{field.parentLabel ? <span className="text-ink-4">{field.parentLabel} / </span> : null}
|
|
374
|
+
<span className="text-ink">{field.shortLabel}</span>
|
|
375
|
+
</span>
|
|
376
|
+
<span className="font-mono text-[11px] text-ink-3">{op?.label}</span>
|
|
377
|
+
<ChipValue
|
|
378
|
+
field={field}
|
|
379
|
+
operator={element.operator}
|
|
380
|
+
value={element.value}
|
|
381
|
+
{...(renderChipItem !== undefined ? { renderItem: renderChipItem } : {})}
|
|
382
|
+
/>
|
|
383
|
+
<button
|
|
384
|
+
type="button"
|
|
385
|
+
aria-label="Remove filter"
|
|
386
|
+
onClick={(e) => {
|
|
387
|
+
e.stopPropagation()
|
|
388
|
+
onRemove()
|
|
389
|
+
if (editing) setEditing(false)
|
|
390
|
+
}}
|
|
391
|
+
className={cn(
|
|
392
|
+
'ml-0.5 size-4 inline-flex items-center justify-center rounded-[3px]',
|
|
393
|
+
'text-ink-4 hover:text-ink hover:bg-surface-2 cursor-pointer outline-none',
|
|
394
|
+
)}
|
|
395
|
+
>
|
|
396
|
+
<svg
|
|
397
|
+
aria-hidden
|
|
398
|
+
viewBox="0 0 10 10"
|
|
399
|
+
className="size-2"
|
|
400
|
+
fill="none"
|
|
401
|
+
stroke="currentColor"
|
|
402
|
+
strokeWidth="1.4"
|
|
403
|
+
strokeLinecap="round"
|
|
404
|
+
>
|
|
405
|
+
<path d="M2 2l6 6M8 2l-6 6" />
|
|
406
|
+
</svg>
|
|
407
|
+
</button>
|
|
408
|
+
</span>
|
|
409
|
+
|
|
410
|
+
<PopoverContent
|
|
411
|
+
triggerRef={ref as RefObject<Element | null>}
|
|
412
|
+
isOpen={editing}
|
|
413
|
+
onOpenChange={setEditing}
|
|
414
|
+
>
|
|
415
|
+
<ChipMiniEditor
|
|
416
|
+
field={field}
|
|
417
|
+
element={element}
|
|
418
|
+
fields={fields}
|
|
419
|
+
onChangeField={onChangeField}
|
|
420
|
+
onChangeOperator={onChangeOperator}
|
|
421
|
+
onChangeValue={onChangeValue}
|
|
422
|
+
onClose={() => setEditing(false)}
|
|
423
|
+
/>
|
|
424
|
+
</PopoverContent>
|
|
425
|
+
</>
|
|
426
|
+
)
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/* ---------- ChipMiniEditor -----------------------------------------------
|
|
430
|
+
Single horizontal row: AttributePicker · OperatorPicker · FilterValueInput
|
|
431
|
+
· Save. No header — clicking outside or pressing Escape closes the popover
|
|
432
|
+
too (RAC handles both). Uses the same flex shares as the builder panel's
|
|
433
|
+
filter row so the cells stay coherent across surfaces.
|
|
434
|
+
|
|
435
|
+
Enter to save: the row is wrapped in a `<form>`. A plain text Input
|
|
436
|
+
bubbles Enter up as a native form submit — closing the popover. RAC's
|
|
437
|
+
Combobox/Select/TagInput/DatePicker all `preventDefault` on Enter for
|
|
438
|
+
their own selection/commit semantics, so the form only submits when no
|
|
439
|
+
child claimed the keypress. The trailing Save button is `type="submit"`
|
|
440
|
+
and triggers the same path.
|
|
441
|
+
----------------------------------------------------------------------- */
|
|
442
|
+
function ChipMiniEditor({
|
|
443
|
+
field,
|
|
444
|
+
element,
|
|
445
|
+
fields,
|
|
446
|
+
onChangeField,
|
|
447
|
+
onChangeOperator,
|
|
448
|
+
onChangeValue,
|
|
449
|
+
onClose,
|
|
450
|
+
}: {
|
|
451
|
+
field: FilterField
|
|
452
|
+
element: { field: string; operator: ComparisonOperator; value: FilterValue }
|
|
453
|
+
fields: ReadonlyArray<FilterField>
|
|
454
|
+
onChangeField: (next: FilterField) => void
|
|
455
|
+
onChangeOperator: (next: ComparisonOperator) => void
|
|
456
|
+
onChangeValue: (next: FilterValue) => void
|
|
457
|
+
onClose: () => void
|
|
458
|
+
}) {
|
|
459
|
+
return (
|
|
460
|
+
<form
|
|
461
|
+
onSubmit={(e) => {
|
|
462
|
+
e.preventDefault()
|
|
463
|
+
onClose()
|
|
464
|
+
}}
|
|
465
|
+
className="w-[min(560px,92vw)] p-2 flex items-center gap-1.5"
|
|
466
|
+
>
|
|
467
|
+
<div className="flex-1 basis-0 min-w-[140px]">
|
|
468
|
+
<AttributePicker fields={fields} value={element.field} onChange={onChangeField} />
|
|
469
|
+
</div>
|
|
470
|
+
<div className="flex-1 basis-0 min-w-[110px]">
|
|
471
|
+
<OperatorPicker
|
|
472
|
+
fieldType={field.type}
|
|
473
|
+
value={element.operator}
|
|
474
|
+
onChange={onChangeOperator}
|
|
475
|
+
autoFocus
|
|
476
|
+
/>
|
|
477
|
+
</div>
|
|
478
|
+
<div className="flex-[2] basis-0 min-w-0">
|
|
479
|
+
<FilterValueInput
|
|
480
|
+
field={field}
|
|
481
|
+
operator={element.operator}
|
|
482
|
+
value={element.value}
|
|
483
|
+
onChange={onChangeValue}
|
|
484
|
+
/>
|
|
485
|
+
</div>
|
|
486
|
+
<button
|
|
487
|
+
type="submit"
|
|
488
|
+
className={cn(
|
|
489
|
+
'shrink-0 inline-flex items-center px-2.5 py-1.5 rounded-md',
|
|
490
|
+
'bg-ink text-surface font-mono text-[12px]',
|
|
491
|
+
'hover:bg-accent-ink cursor-pointer outline-none',
|
|
492
|
+
'focus-visible:shadow-[var(--shadow-focus)]',
|
|
493
|
+
)}
|
|
494
|
+
>
|
|
495
|
+
Save
|
|
496
|
+
</button>
|
|
497
|
+
</form>
|
|
498
|
+
)
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/* ---------- InlineAddFilter ---------------------------------------------- */
|
|
502
|
+
function InlineAddFilter({ onClick }: { onClick: () => void }) {
|
|
503
|
+
return (
|
|
504
|
+
<button
|
|
505
|
+
type="button"
|
|
506
|
+
data-filter-trigger="expanded"
|
|
507
|
+
onClick={onClick}
|
|
508
|
+
className={cn(
|
|
509
|
+
'inline-flex items-center gap-1 px-2 py-1 rounded-sm',
|
|
510
|
+
'text-ink-3 hover:bg-surface-2 hover:text-ink cursor-pointer outline-none',
|
|
511
|
+
)}
|
|
512
|
+
>
|
|
513
|
+
<svg
|
|
514
|
+
aria-hidden
|
|
515
|
+
viewBox="0 0 12 12"
|
|
516
|
+
className="size-3"
|
|
517
|
+
fill="none"
|
|
518
|
+
stroke="currentColor"
|
|
519
|
+
strokeWidth="1.5"
|
|
520
|
+
strokeLinecap="round"
|
|
521
|
+
>
|
|
522
|
+
<path d="M6 2v8M2 6h8" />
|
|
523
|
+
</svg>
|
|
524
|
+
<span className="font-sans text-[12.5px]">Add filter</span>
|
|
525
|
+
</button>
|
|
526
|
+
)
|
|
527
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from 'react'
|
|
2
|
+
import { type FilterValueInputRenderer, RenderValueInputProvider } from './filter-value-input'
|
|
3
|
+
import { GroupBody } from './group-body'
|
|
4
|
+
import { addPendingLeaf } from './group-mutations'
|
|
5
|
+
import type { FilterField, FilterGroup } from './types'
|
|
6
|
+
|
|
7
|
+
/* =========================================================================
|
|
8
|
+
RecordFilterEditor — the recursive AND/OR filter editor with no popover
|
|
9
|
+
chrome. Owns the auto-focus state for the field → operator → value chain
|
|
10
|
+
and seeds an initial pending row when mounted empty so the user can start
|
|
11
|
+
typing immediately. Used inline (e.g. as the always-open editor in the
|
|
12
|
+
Query Builder) and as the body of `RecordFilterBuilderPanel`.
|
|
13
|
+
|
|
14
|
+
Pure presentation of one `FilterGroup`; persistence and toolbar chrome
|
|
15
|
+
(joiner toggle, close, footer, save-as-view) are the caller's concern.
|
|
16
|
+
========================================================================= */
|
|
17
|
+
|
|
18
|
+
export type RecordFilterEditorProps = {
|
|
19
|
+
fields: ReadonlyArray<FilterField>
|
|
20
|
+
filter: FilterGroup
|
|
21
|
+
onChange: (next: FilterGroup) => void
|
|
22
|
+
/** Show "+ Add group", "Wrap in group", and render nested sub-groups. */
|
|
23
|
+
isComplexEnabled?: boolean
|
|
24
|
+
showFieldIcons?: boolean
|
|
25
|
+
showJoinerToggle?: boolean
|
|
26
|
+
/** Drop in a pending row on mount when the filter is empty so the user
|
|
27
|
+
* lands on an auto-focused AttributePicker. Defaults to `true`. */
|
|
28
|
+
seedPendingRow?: boolean
|
|
29
|
+
/**
|
|
30
|
+
* Optional override for the value editor. Lets product code plug in
|
|
31
|
+
* editors the lib can't know about (e.g. a record picker that hits the
|
|
32
|
+
* SDK). Return `undefined` from the renderer to fall through to the
|
|
33
|
+
* built-in branches per `(field.type, operator)` combination.
|
|
34
|
+
*/
|
|
35
|
+
renderValueInput?: FilterValueInputRenderer
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function RecordFilterEditor({
|
|
39
|
+
fields,
|
|
40
|
+
filter,
|
|
41
|
+
onChange,
|
|
42
|
+
isComplexEnabled = false,
|
|
43
|
+
showFieldIcons = true,
|
|
44
|
+
showJoinerToggle = true,
|
|
45
|
+
seedPendingRow = true,
|
|
46
|
+
renderValueInput,
|
|
47
|
+
}: RecordFilterEditorProps) {
|
|
48
|
+
const fieldsByPath = useMemo(() => Object.fromEntries(fields.map((f) => [f.path, f])), [fields])
|
|
49
|
+
const elements = filter.elements ?? []
|
|
50
|
+
const subgroups = filter.groups ?? []
|
|
51
|
+
|
|
52
|
+
const [autoFocusIndex, setAutoFocusIndex] = useState<number | null>(null)
|
|
53
|
+
const [autoFocusOperatorIndex, setAutoFocusOperatorIndex] = useState<number | null>(null)
|
|
54
|
+
const [autoFocusValueIndex, setAutoFocusValueIndex] = useState<number | null>(null)
|
|
55
|
+
|
|
56
|
+
const addPendingRow = () => {
|
|
57
|
+
const nextIndex = (filter.elements ?? []).length
|
|
58
|
+
onChange(addPendingLeaf(filter))
|
|
59
|
+
setAutoFocusIndex(nextIndex)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Mount-only seed. Re-running on `elements` changes would re-add a pending
|
|
63
|
+
// row whenever the user clears all filters.
|
|
64
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: mount-only by design
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (!seedPendingRow) return
|
|
67
|
+
if (elements.length === 0 && subgroups.length === 0) addPendingRow()
|
|
68
|
+
}, [])
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<RenderValueInputProvider renderer={renderValueInput}>
|
|
72
|
+
<GroupBody
|
|
73
|
+
group={filter}
|
|
74
|
+
fields={fields}
|
|
75
|
+
fieldsByPath={fieldsByPath}
|
|
76
|
+
onChange={onChange}
|
|
77
|
+
isRoot
|
|
78
|
+
isComplexEnabled={isComplexEnabled}
|
|
79
|
+
showFieldIcons={showFieldIcons}
|
|
80
|
+
showJoinerToggle={showJoinerToggle}
|
|
81
|
+
autoFocusIndex={autoFocusIndex}
|
|
82
|
+
setAutoFocusIndex={setAutoFocusIndex}
|
|
83
|
+
autoFocusOperatorIndex={autoFocusOperatorIndex}
|
|
84
|
+
setAutoFocusOperatorIndex={setAutoFocusOperatorIndex}
|
|
85
|
+
autoFocusValueIndex={autoFocusValueIndex}
|
|
86
|
+
setAutoFocusValueIndex={setAutoFocusValueIndex}
|
|
87
|
+
onSubmitLeaf={addPendingRow}
|
|
88
|
+
/>
|
|
89
|
+
</RenderValueInputProvider>
|
|
90
|
+
)
|
|
91
|
+
}
|