@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.
Files changed (226) hide show
  1. package/LICENSE +105 -0
  2. package/dist/chunk-IKAX76MB.js +113 -0
  3. package/dist/chunk-IKAX76MB.js.map +1 -0
  4. package/dist/chunk-MWAXK66H.cjs +119 -0
  5. package/dist/chunk-MWAXK66H.cjs.map +1 -0
  6. package/dist/index.cjs +6246 -0
  7. package/dist/index.cjs.map +1 -0
  8. package/dist/index.d.cts +2126 -0
  9. package/dist/index.d.ts +2126 -0
  10. package/dist/index.js +6069 -0
  11. package/dist/index.js.map +1 -0
  12. package/dist/pdf-view-3ZMMS6ML.cjs +95 -0
  13. package/dist/pdf-view-3ZMMS6ML.cjs.map +1 -0
  14. package/dist/pdf-view-BV5ZD6OC.js +93 -0
  15. package/dist/pdf-view-BV5ZD6OC.js.map +1 -0
  16. package/package.json +96 -0
  17. package/src/components/attachment-chip/attachment-chip.tsx +129 -0
  18. package/src/components/attachment-chip/index.ts +2 -0
  19. package/src/components/attribute-type-icon/attribute-type-icon.stories.tsx +70 -0
  20. package/src/components/attribute-type-icon/attribute-type-icon.tsx +125 -0
  21. package/src/components/attribute-type-icon/index.ts +8 -0
  22. package/src/components/avatar/avatar.stories.tsx +42 -0
  23. package/src/components/avatar/avatar.tsx +48 -0
  24. package/src/components/avatar/index.ts +2 -0
  25. package/src/components/avatar-group/avatar-group.test.tsx +38 -0
  26. package/src/components/avatar-group/avatar-group.tsx +60 -0
  27. package/src/components/avatar-group/index.ts +2 -0
  28. package/src/components/badge/badge.stories.tsx +62 -0
  29. package/src/components/badge/badge.test.tsx +36 -0
  30. package/src/components/badge/badge.tsx +54 -0
  31. package/src/components/badge/index.ts +2 -0
  32. package/src/components/badge-multi-select/badge-multi-select.stories.tsx +177 -0
  33. package/src/components/badge-multi-select/badge-multi-select.test.tsx +155 -0
  34. package/src/components/badge-multi-select/badge-multi-select.tsx +272 -0
  35. package/src/components/badge-multi-select/index.ts +2 -0
  36. package/src/components/breadcrumbs/breadcrumbs.stories.tsx +43 -0
  37. package/src/components/breadcrumbs/breadcrumbs.tsx +86 -0
  38. package/src/components/breadcrumbs/index.ts +2 -0
  39. package/src/components/button/button.stories.tsx +290 -0
  40. package/src/components/button/button.test.tsx +59 -0
  41. package/src/components/button/button.tsx +86 -0
  42. package/src/components/button/index.ts +2 -0
  43. package/src/components/card/card.stories.tsx +351 -0
  44. package/src/components/card/card.test.tsx +141 -0
  45. package/src/components/card/card.tsx +173 -0
  46. package/src/components/card/index.ts +13 -0
  47. package/src/components/checkbox/checkbox-group.tsx +52 -0
  48. package/src/components/checkbox/checkbox.stories.tsx +48 -0
  49. package/src/components/checkbox/checkbox.test.tsx +50 -0
  50. package/src/components/checkbox/checkbox.tsx +71 -0
  51. package/src/components/checkbox/index.ts +4 -0
  52. package/src/components/combobox/combobox.stories.tsx +97 -0
  53. package/src/components/combobox/combobox.test.tsx +123 -0
  54. package/src/components/combobox/combobox.tsx +239 -0
  55. package/src/components/combobox/index.ts +2 -0
  56. package/src/components/command/command-hint.tsx +46 -0
  57. package/src/components/command/command.stories.tsx +88 -0
  58. package/src/components/command/command.test.tsx +102 -0
  59. package/src/components/command/command.tsx +76 -0
  60. package/src/components/command/index.ts +16 -0
  61. package/src/components/currency-input/currency-input.tsx +192 -0
  62. package/src/components/currency-input/index.ts +6 -0
  63. package/src/components/data-table/cells.stories.tsx +120 -0
  64. package/src/components/data-table/cells.tsx +165 -0
  65. package/src/components/data-table/data-table.stories.tsx +371 -0
  66. package/src/components/data-table/data-table.tsx +556 -0
  67. package/src/components/data-table/index.ts +22 -0
  68. package/src/components/data-table/make-actions-column.ts +30 -0
  69. package/src/components/data-table/states/data-table-empty.tsx +40 -0
  70. package/src/components/data-table/states/data-table-error.tsx +30 -0
  71. package/src/components/data-table/states/data-table-skeleton.tsx +42 -0
  72. package/src/components/data-table/use-infinite-scroll-sentinel.ts +51 -0
  73. package/src/components/date-picker/calendar-internals.tsx +138 -0
  74. package/src/components/date-picker/date-picker.stories.tsx +78 -0
  75. package/src/components/date-picker/date-picker.test.tsx +71 -0
  76. package/src/components/date-picker/date-picker.tsx +140 -0
  77. package/src/components/date-picker/index.ts +2 -0
  78. package/src/components/date-time-picker/date-time-picker.stories.tsx +96 -0
  79. package/src/components/date-time-picker/date-time-picker.test.tsx +87 -0
  80. package/src/components/date-time-picker/date-time-picker.tsx +203 -0
  81. package/src/components/date-time-picker/index.ts +2 -0
  82. package/src/components/empty-state/empty-state.tsx +63 -0
  83. package/src/components/empty-state/index.ts +2 -0
  84. package/src/components/error-animation/error-animation.stories.tsx +50 -0
  85. package/src/components/error-animation/error-animation.test.tsx +22 -0
  86. package/src/components/error-animation/error-animation.tsx +26 -0
  87. package/src/components/error-animation/index.ts +2 -0
  88. package/src/components/field/field.tsx +142 -0
  89. package/src/components/field/index.ts +8 -0
  90. package/src/components/file-field/file-field.tsx +285 -0
  91. package/src/components/file-field/index.ts +2 -0
  92. package/src/components/file-viewer/fallback-card.tsx +68 -0
  93. package/src/components/file-viewer/file-viewer.stories.tsx +67 -0
  94. package/src/components/file-viewer/file-viewer.test.tsx +81 -0
  95. package/src/components/file-viewer/file-viewer.tsx +116 -0
  96. package/src/components/file-viewer/image-view.tsx +38 -0
  97. package/src/components/file-viewer/index.ts +7 -0
  98. package/src/components/file-viewer/pdf-view.tsx +122 -0
  99. package/src/components/file-viewer/video-view.tsx +16 -0
  100. package/src/components/image-attachment/image-attachment.stories.tsx +39 -0
  101. package/src/components/image-attachment/image-attachment.test.tsx +54 -0
  102. package/src/components/image-attachment/image-attachment.tsx +77 -0
  103. package/src/components/image-attachment/index.ts +3 -0
  104. package/src/components/image-attachment/is-previewable-image-name.ts +14 -0
  105. package/src/components/image-lightbox/image-lightbox.stories.tsx +38 -0
  106. package/src/components/image-lightbox/image-lightbox.test.tsx +72 -0
  107. package/src/components/image-lightbox/image-lightbox.tsx +92 -0
  108. package/src/components/image-lightbox/index.ts +2 -0
  109. package/src/components/input/index.ts +2 -0
  110. package/src/components/input/input.stories.tsx +71 -0
  111. package/src/components/input/input.test.tsx +36 -0
  112. package/src/components/input/input.tsx +76 -0
  113. package/src/components/kbd/index.ts +2 -0
  114. package/src/components/kbd/kbd.stories.tsx +34 -0
  115. package/src/components/kbd/kbd.tsx +27 -0
  116. package/src/components/menu/index.ts +2 -0
  117. package/src/components/menu/menu.tsx +121 -0
  118. package/src/components/modal/index.ts +10 -0
  119. package/src/components/modal/modal.stories.tsx +153 -0
  120. package/src/components/modal/modal.test.tsx +93 -0
  121. package/src/components/modal/modal.tsx +149 -0
  122. package/src/components/multi-select/index.ts +2 -0
  123. package/src/components/multi-select/multi-select.stories.tsx +211 -0
  124. package/src/components/multi-select/multi-select.test.tsx +151 -0
  125. package/src/components/multi-select/multi-select.tsx +339 -0
  126. package/src/components/nav/index.ts +7 -0
  127. package/src/components/nav/nav.stories.tsx +139 -0
  128. package/src/components/nav/nav.tsx +246 -0
  129. package/src/components/popover/index.ts +8 -0
  130. package/src/components/popover/popover.stories.tsx +92 -0
  131. package/src/components/popover/popover.tsx +135 -0
  132. package/src/components/progress-bar/index.ts +2 -0
  133. package/src/components/progress-bar/progress-bar.stories.tsx +75 -0
  134. package/src/components/progress-bar/progress-bar.test.tsx +29 -0
  135. package/src/components/progress-bar/progress-bar.tsx +57 -0
  136. package/src/components/radio-group/index.ts +2 -0
  137. package/src/components/radio-group/radio-group.stories.tsx +113 -0
  138. package/src/components/radio-group/radio-group.test.tsx +87 -0
  139. package/src/components/radio-group/radio-group.tsx +104 -0
  140. package/src/components/record-filter/attribute-picker.tsx +74 -0
  141. package/src/components/record-filter/chip-value.tsx +171 -0
  142. package/src/components/record-filter/filter-row.tsx +262 -0
  143. package/src/components/record-filter/filter-value-input.tsx +351 -0
  144. package/src/components/record-filter/group-add-controls.tsx +77 -0
  145. package/src/components/record-filter/group-body.tsx +173 -0
  146. package/src/components/record-filter/group-mutations.test.ts +104 -0
  147. package/src/components/record-filter/group-mutations.ts +116 -0
  148. package/src/components/record-filter/index.ts +58 -0
  149. package/src/components/record-filter/nested-group.tsx +138 -0
  150. package/src/components/record-filter/operator-picker.tsx +62 -0
  151. package/src/components/record-filter/operators.test.ts +105 -0
  152. package/src/components/record-filter/operators.ts +97 -0
  153. package/src/components/record-filter/record-filter-builder-panel.tsx +236 -0
  154. package/src/components/record-filter/record-filter-button.tsx +126 -0
  155. package/src/components/record-filter/record-filter-chips.tsx +527 -0
  156. package/src/components/record-filter/record-filter-editor.tsx +91 -0
  157. package/src/components/record-filter/record-filter.stories.tsx +196 -0
  158. package/src/components/record-filter/record-filter.test.tsx +437 -0
  159. package/src/components/record-filter/show-details-store.ts +72 -0
  160. package/src/components/record-filter/types.ts +150 -0
  161. package/src/components/segmented-control/index.ts +6 -0
  162. package/src/components/segmented-control/segmented-control.stories.tsx +67 -0
  163. package/src/components/segmented-control/segmented-control.test.tsx +47 -0
  164. package/src/components/segmented-control/segmented-control.tsx +105 -0
  165. package/src/components/select/index.ts +2 -0
  166. package/src/components/select/select.stories.tsx +76 -0
  167. package/src/components/select/select.test.tsx +56 -0
  168. package/src/components/select/select.tsx +159 -0
  169. package/src/components/separator/index.ts +2 -0
  170. package/src/components/separator/separator.stories.tsx +73 -0
  171. package/src/components/separator/separator.test.tsx +29 -0
  172. package/src/components/separator/separator.tsx +51 -0
  173. package/src/components/skeleton/index.ts +2 -0
  174. package/src/components/skeleton/skeleton.stories.tsx +68 -0
  175. package/src/components/skeleton/skeleton.test.tsx +38 -0
  176. package/src/components/skeleton/skeleton.tsx +43 -0
  177. package/src/components/success-animation/index.ts +2 -0
  178. package/src/components/success-animation/success-animation.stories.tsx +50 -0
  179. package/src/components/success-animation/success-animation.test.tsx +22 -0
  180. package/src/components/success-animation/success-animation.tsx +26 -0
  181. package/src/components/switch/index.ts +2 -0
  182. package/src/components/switch/switch.stories.tsx +60 -0
  183. package/src/components/switch/switch.test.tsx +50 -0
  184. package/src/components/switch/switch.tsx +49 -0
  185. package/src/components/table/index.ts +25 -0
  186. package/src/components/table/table.stories.tsx +306 -0
  187. package/src/components/table/table.test.tsx +115 -0
  188. package/src/components/table/table.tsx +543 -0
  189. package/src/components/tabs/index.ts +8 -0
  190. package/src/components/tabs/tabs.tsx +141 -0
  191. package/src/components/tag-input/index.ts +2 -0
  192. package/src/components/tag-input/tag-input.stories.tsx +73 -0
  193. package/src/components/tag-input/tag-input.test.tsx +80 -0
  194. package/src/components/tag-input/tag-input.tsx +193 -0
  195. package/src/components/textarea/index.ts +2 -0
  196. package/src/components/textarea/textarea.stories.tsx +75 -0
  197. package/src/components/textarea/textarea.test.tsx +41 -0
  198. package/src/components/textarea/textarea.tsx +86 -0
  199. package/src/components/time-field/index.ts +2 -0
  200. package/src/components/time-field/time-field.stories.tsx +76 -0
  201. package/src/components/time-field/time-field.test.tsx +51 -0
  202. package/src/components/time-field/time-field.tsx +98 -0
  203. package/src/components/tooltip/index.ts +2 -0
  204. package/src/components/tooltip/tooltip.stories.tsx +176 -0
  205. package/src/components/tooltip/tooltip.test.tsx +52 -0
  206. package/src/components/tooltip/tooltip.tsx +56 -0
  207. package/src/index.ts +47 -0
  208. package/src/lib/cn.ts +6 -0
  209. package/src/lib/commands/discovery-store.test.tsx +76 -0
  210. package/src/lib/commands/discovery-store.ts +110 -0
  211. package/src/lib/commands/index.ts +9 -0
  212. package/src/lib/commands/match-event.test.ts +99 -0
  213. package/src/lib/commands/match-event.ts +31 -0
  214. package/src/lib/commands/parse-combo.test.ts +65 -0
  215. package/src/lib/commands/parse-combo.ts +48 -0
  216. package/src/lib/commands/platform.ts +27 -0
  217. package/src/lib/commands/protected-combos.test.ts +18 -0
  218. package/src/lib/commands/protected-combos.ts +38 -0
  219. package/src/lib/commands/registry.test.ts +97 -0
  220. package/src/lib/commands/registry.ts +59 -0
  221. package/src/lib/commands/use-command.test.tsx +91 -0
  222. package/src/lib/commands/use-command.ts +32 -0
  223. package/src/lib/commands/use-escape-key.test.tsx +97 -0
  224. package/src/lib/commands/use-escape-key.ts +62 -0
  225. package/src/styles.css +321 -0
  226. package/src/test/setup.ts +9 -0
@@ -0,0 +1,351 @@
1
+ import { type CalendarDate, parseDate } from '@internationalized/date'
2
+ import { createContext, type ReactNode, useContext } from 'react'
3
+ import { cn } from '../../lib/cn'
4
+ import { Badge } from '../badge/badge'
5
+ import {
6
+ BadgeMultiSelect,
7
+ type BadgeMultiSelectOption,
8
+ } from '../badge-multi-select/badge-multi-select'
9
+ import { DatePicker } from '../date-picker/date-picker'
10
+ import { Input } from '../input/input'
11
+ import { SegmentedControl } from '../segmented-control/segmented-control'
12
+ import { Select, type SelectOption } from '../select/select'
13
+ import { TagInput } from '../tag-input/tag-input'
14
+ import { arityOf } from './operators'
15
+ import type { ComparisonOperator, FilterField, FilterValue } from './types'
16
+
17
+ /* =========================================================================
18
+ FilterValueInput — type-aware value editor for a single filter element.
19
+ Routes to the right primitive based on `(field.type, operator.arity)`:
20
+
21
+ arity 0 → null (no input — for empty / not_empty)
22
+ arity 'n' (in / not_in)
23
+ enum → BadgeMultiSelect
24
+ other → TagInput (free-form list)
25
+ arity 1
26
+ enum → Select with Badge in renderItem
27
+ boolean → SegmentedControl (true / false)
28
+ datetime → DatePicker
29
+ other → Input (text or number)
30
+ ========================================================================= */
31
+
32
+ /**
33
+ * Render-prop signature for overriding the default value editor. Called
34
+ * with everything `FilterValueInput` knows about the leaf. Return a
35
+ * `ReactNode` to take over rendering, or `undefined` to fall through to
36
+ * the built-in branches (the common pattern: override one specific
37
+ * `field.type`, return `undefined` for the rest).
38
+ *
39
+ * Used to plug in product-specific editors the lib can't know about —
40
+ * e.g. a related-record picker that hits the SDK for typeahead.
41
+ */
42
+ export type FilterValueInputRenderer = (props: {
43
+ field: FilterField
44
+ operator: ComparisonOperator
45
+ value: FilterValue
46
+ onChange: (next: FilterValue) => void
47
+ autoFocus?: boolean
48
+ isDisabled?: boolean
49
+ }) => ReactNode | undefined
50
+
51
+ /* =========================================================================
52
+ Context — the renderer is injected via React context rather than a
53
+ prop drilled through five levels of intermediates. Top-level entry
54
+ points (RecordFilterEditor, RecordFilterButton, RecordFilterChips,
55
+ RecordFilterBuilderPanel) wrap their tree in `RenderValueInputProvider`;
56
+ `FilterValueInput` reads from `useRenderValueInput()`. Adding future
57
+ slots (operator picker, attribute picker) follows the same shape
58
+ without growing the public prop surface of internal components.
59
+ ========================================================================= */
60
+
61
+ const RenderValueInputContext = createContext<FilterValueInputRenderer | undefined>(undefined)
62
+
63
+ export function RenderValueInputProvider({
64
+ renderer,
65
+ children,
66
+ }: {
67
+ renderer: FilterValueInputRenderer | undefined
68
+ children: ReactNode
69
+ }) {
70
+ // Skip wrapping when there's no renderer so intermediate components
71
+ // (e.g. RecordFilterEditor when invoked inside RecordFilterBuilderPanel)
72
+ // don't shadow the outer provider's value with `undefined`. The closest
73
+ // provider wins in React's context model, so an undefined-wrapping
74
+ // intermediate would silently break the chain from outer entry points.
75
+ if (renderer === undefined) return <>{children}</>
76
+ return (
77
+ <RenderValueInputContext.Provider value={renderer}>{children}</RenderValueInputContext.Provider>
78
+ )
79
+ }
80
+
81
+ export function useRenderValueInput(): FilterValueInputRenderer | undefined {
82
+ return useContext(RenderValueInputContext)
83
+ }
84
+
85
+ export type FilterValueInputProps = {
86
+ field: FilterField
87
+ operator: ComparisonOperator
88
+ value: FilterValue
89
+ onChange: (next: FilterValue) => void
90
+ isDisabled?: boolean
91
+ /** When true, focus the routed leaf on mount. The wrapping `<div>` is
92
+ * keyed on `${field.path}-${operator}`, so an operator change remounts
93
+ * the leaf and re-fires its `autoFocus` reliably across all six leaf
94
+ * branches. Committed values come from props so they survive remount;
95
+ * mid-typed drafts (TagInput buffer, DatePicker partial segment,
96
+ * Combobox search) are reset — acceptable since the user took the
97
+ * deliberate operator-change action. */
98
+ autoFocus?: boolean
99
+ className?: string
100
+ }
101
+
102
+ export function FilterValueInput({
103
+ field,
104
+ operator,
105
+ value,
106
+ onChange,
107
+ isDisabled,
108
+ autoFocus = false,
109
+ className,
110
+ }: FilterValueInputProps) {
111
+ const renderValueInput = useRenderValueInput()
112
+ const arity = arityOf(operator)
113
+ if (arity === 0) return null
114
+
115
+ // Context-provided override (e.g. relation picker). Called first so
116
+ // it can claim any (field.type, operator) combination; returning
117
+ // `undefined` falls through to the built-in branches below.
118
+ if (renderValueInput) {
119
+ const custom = renderValueInput({
120
+ field,
121
+ operator,
122
+ value,
123
+ onChange,
124
+ autoFocus,
125
+ ...(isDisabled !== undefined ? { isDisabled } : {}),
126
+ })
127
+ if (custom !== undefined) {
128
+ return <div key={`${field.path}-${operator}`}>{custom}</div>
129
+ }
130
+ }
131
+
132
+ const leaf = (() => {
133
+ /* ---------- multi-value (in / not_in) ---------- */
134
+ if (arity === 'n') {
135
+ if (field.type === 'enum') {
136
+ return (
137
+ <EnumMultiValue
138
+ field={field}
139
+ value={asStringArray(value)}
140
+ onChange={(next) => onChange(next)}
141
+ autoFocus={autoFocus}
142
+ {...(isDisabled !== undefined ? { isDisabled } : {})}
143
+ {...(className !== undefined ? { className } : {})}
144
+ />
145
+ )
146
+ }
147
+ return (
148
+ <TagInput
149
+ size="sm"
150
+ value={asStringArray(value)}
151
+ onChange={(next) => onChange(next)}
152
+ inputType={
153
+ field.type === 'number' || field.type === 'integer' || field.type === 'currency'
154
+ ? 'number'
155
+ : 'text'
156
+ }
157
+ placeholder="Type, Enter to add"
158
+ autoFocus={autoFocus}
159
+ {...(isDisabled !== undefined ? { isDisabled } : {})}
160
+ {...(className !== undefined ? { className } : {})}
161
+ />
162
+ )
163
+ }
164
+
165
+ /* ---------- single value ---------- */
166
+ if (field.type === 'enum') {
167
+ return (
168
+ <EnumSingleValue
169
+ field={field}
170
+ value={asString(value)}
171
+ onChange={(next) => onChange(next)}
172
+ autoFocus={autoFocus}
173
+ {...(isDisabled !== undefined ? { isDisabled } : {})}
174
+ {...(className !== undefined ? { className } : {})}
175
+ />
176
+ )
177
+ }
178
+
179
+ if (field.type === 'boolean') {
180
+ return (
181
+ <SegmentedControl<'true' | 'false'>
182
+ size="md"
183
+ aria-label="Boolean value"
184
+ options={[
185
+ { id: 'true', label: 'true' },
186
+ { id: 'false', label: 'false' },
187
+ ]}
188
+ value={asBoolean(value) ? 'true' : 'false'}
189
+ onChange={(v) => onChange(v === 'true')}
190
+ autoFocus={autoFocus}
191
+ {...(isDisabled !== undefined ? { isDisabled } : {})}
192
+ {...(className !== undefined ? { className } : {})}
193
+ />
194
+ )
195
+ }
196
+
197
+ if (field.type === 'datetime') {
198
+ const stringValue = asString(value)
199
+ let parsed: CalendarDate | null = null
200
+ try {
201
+ parsed = stringValue ? parseDate(stringValue) : null
202
+ } catch {
203
+ parsed = null
204
+ }
205
+ return (
206
+ <DatePicker
207
+ size="sm"
208
+ aria-label={field.label}
209
+ value={parsed}
210
+ onChange={(next) => onChange(next ? next.toString() : '')}
211
+ autoFocus={autoFocus}
212
+ {...(isDisabled !== undefined ? { isDisabled } : {})}
213
+ className={cn('w-full', className)}
214
+ />
215
+ )
216
+ }
217
+
218
+ // string / number / integer
219
+ return (
220
+ <Input
221
+ size="sm"
222
+ aria-label={field.label}
223
+ value={asString(value)}
224
+ onChange={(next) => onChange(next)}
225
+ placeholder={placeholderFor(field, operator)}
226
+ inputProps={{
227
+ type:
228
+ field.type === 'number' || field.type === 'integer' || field.type === 'currency'
229
+ ? 'number'
230
+ : (field.inputType ?? 'text'),
231
+ ...(autoFocus ? { autoFocus: true } : {}),
232
+ }}
233
+ {...(isDisabled !== undefined ? { isDisabled } : {})}
234
+ className={cn('w-full', className)}
235
+ />
236
+ )
237
+ })()
238
+
239
+ // Key forces a fresh mount on every operator change so the leaf's
240
+ // `autoFocus` re-fires reliably even when the leaf component type is
241
+ // unchanged (e.g. `eq` → `not_eq`, both string-arity-1 → both `Input`).
242
+ return <div key={`${field.path}-${operator}`}>{leaf}</div>
243
+ }
244
+
245
+ /* ----- enum single via lib `Select` with Badge in renderItem ----------- */
246
+ function EnumSingleValue({
247
+ field,
248
+ value,
249
+ onChange,
250
+ isDisabled,
251
+ autoFocus,
252
+ className,
253
+ }: {
254
+ field: FilterField
255
+ value: string
256
+ onChange: (next: string) => void
257
+ isDisabled?: boolean
258
+ autoFocus?: boolean
259
+ className?: string
260
+ }) {
261
+ const options: SelectOption<string>[] = (field.options ?? []).map((o) => ({
262
+ id: o.value,
263
+ label: o.label,
264
+ }))
265
+ return (
266
+ <Select<string>
267
+ size="sm"
268
+ aria-label={field.label}
269
+ options={options}
270
+ selectedKey={value === '' ? null : value}
271
+ onSelectionChange={(next) => onChange(next == null ? '' : String(next))}
272
+ renderItem={(o) => {
273
+ const variant = field.options?.find((x) => x.value === o.id)?.variant
274
+ return <Badge variant={variant}>{o.label}</Badge>
275
+ }}
276
+ {...(isDisabled !== undefined ? { isDisabled } : {})}
277
+ {...(autoFocus ? { autoFocus: true } : {})}
278
+ className={cn('w-full', className)}
279
+ />
280
+ )
281
+ }
282
+
283
+ /* ----- enum multi via lib `BadgeMultiSelect` --------------------------- */
284
+ function EnumMultiValue({
285
+ field,
286
+ value,
287
+ onChange,
288
+ isDisabled,
289
+ autoFocus,
290
+ className,
291
+ }: {
292
+ field: FilterField
293
+ value: string[]
294
+ onChange: (next: string[]) => void
295
+ isDisabled?: boolean
296
+ autoFocus?: boolean
297
+ className?: string
298
+ }) {
299
+ const options: BadgeMultiSelectOption<string>[] = (field.options ?? []).map((o) => ({
300
+ id: o.value,
301
+ label: o.label,
302
+ ...(o.variant ? { variant: o.variant } : {}),
303
+ }))
304
+ return (
305
+ <BadgeMultiSelect<string>
306
+ size="sm"
307
+ options={options}
308
+ selectedKeys={value}
309
+ onSelectionChange={(keys) => onChange(Array.from(keys))}
310
+ placeholder="Select values…"
311
+ {...(isDisabled !== undefined ? { isDisabled } : {})}
312
+ {...(autoFocus ? { autoFocus: true } : {})}
313
+ className={cn('w-full', className)}
314
+ />
315
+ )
316
+ }
317
+
318
+ /* ----- value coercers --------------------------------------------------- */
319
+
320
+ function asString(v: FilterValue): string {
321
+ if (v == null) return ''
322
+ if (typeof v === 'string') return v
323
+ if (typeof v === 'boolean') return v ? 'true' : 'false'
324
+ if (Array.isArray(v)) return v.join(',')
325
+ return ''
326
+ }
327
+
328
+ function asStringArray(v: FilterValue): string[] {
329
+ if (Array.isArray(v)) return v
330
+ if (typeof v === 'string' && v.length > 0)
331
+ return v
332
+ .split(',')
333
+ .map((s) => s.trim())
334
+ .filter(Boolean)
335
+ return []
336
+ }
337
+
338
+ function asBoolean(v: FilterValue): boolean {
339
+ if (typeof v === 'boolean') return v
340
+ if (typeof v === 'string') return v === 'true'
341
+ return false
342
+ }
343
+
344
+ function placeholderFor(field: FilterField, operator: ComparisonOperator): string {
345
+ if (field.type === 'datetime') return 'yyyy-mm-dd'
346
+ if (field.type === 'number' || field.type === 'integer' || field.type === 'currency') return '0'
347
+ if (operator === 'starts_with') return 'starts with…'
348
+ if (operator === 'ends_with') return 'ends with…'
349
+ if (operator === 'contains') return 'contains…'
350
+ return 'value'
351
+ }
@@ -0,0 +1,77 @@
1
+ import { cn } from '../../lib/cn'
2
+
3
+ /* =========================================================================
4
+ GroupAddControls — "+ Add filter" / "+ Add group" footer rendered at the
5
+ bottom of any group body. The "+ Add group" affordance is gated behind
6
+ `isComplexEnabled`; in flat mode (the back-compat default) only "+ Add
7
+ filter" appears, exactly matching the pre-nesting builder panel.
8
+
9
+ "+ Add filter" follows the existing pattern: it adds a *pending* row
10
+ (field === '') whose AttributePicker auto-focuses on mount, rather than
11
+ opening a separate popover. Keeps consistency with `RecordFilterChips`
12
+ and avoids a second popover layer inside the builder.
13
+ ========================================================================= */
14
+
15
+ export type GroupAddControlsProps = {
16
+ onAddFilter: () => void
17
+ onAddGroup?: () => void
18
+ isComplexEnabled: boolean
19
+ }
20
+
21
+ export function GroupAddControls({
22
+ onAddFilter,
23
+ onAddGroup,
24
+ isComplexEnabled,
25
+ }: GroupAddControlsProps) {
26
+ return (
27
+ <div className="flex items-center gap-1 pl-12 pt-1">
28
+ <button
29
+ type="button"
30
+ onClick={onAddFilter}
31
+ className={cn(
32
+ 'inline-flex items-center gap-1.5 px-2 py-1 rounded-sm',
33
+ 'text-ink-2 hover:bg-surface-2 hover:text-ink cursor-pointer outline-none',
34
+ )}
35
+ >
36
+ <svg
37
+ aria-hidden
38
+ viewBox="0 0 12 12"
39
+ className="size-3"
40
+ fill="none"
41
+ stroke="currentColor"
42
+ strokeWidth="1.5"
43
+ strokeLinecap="round"
44
+ >
45
+ <path d="M6 2v8M2 6h8" />
46
+ </svg>
47
+ <span className="font-sans text-[12.5px]">Add filter</span>
48
+ </button>
49
+ {isComplexEnabled && onAddGroup ? (
50
+ <button
51
+ type="button"
52
+ onClick={onAddGroup}
53
+ title="Add a nested group with its own logical operator"
54
+ className={cn(
55
+ 'inline-flex items-center gap-1.5 px-2 py-1 rounded-sm',
56
+ 'text-ink-2 hover:bg-surface-2 hover:text-ink cursor-pointer outline-none',
57
+ )}
58
+ >
59
+ <svg
60
+ aria-hidden
61
+ viewBox="0 0 14 14"
62
+ className="size-3"
63
+ fill="none"
64
+ stroke="currentColor"
65
+ strokeWidth="1.5"
66
+ strokeLinecap="round"
67
+ strokeLinejoin="round"
68
+ >
69
+ <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" />
70
+ <path d="M7 5v4M5 7h4" />
71
+ </svg>
72
+ <span className="font-sans text-[12.5px]">Add group</span>
73
+ </button>
74
+ ) : null}
75
+ </div>
76
+ )
77
+ }
@@ -0,0 +1,173 @@
1
+ import { FilterRow, rebaseRowOnFieldChange, rebaseRowOnOperatorChange } from './filter-row'
2
+ import { GroupAddControls } from './group-add-controls'
3
+ import {
4
+ addPendingLeaf,
5
+ addSubgroup,
6
+ removeLeaf,
7
+ removeSubgroup,
8
+ replaceLeaf,
9
+ updateLeaf,
10
+ updateSubgroup,
11
+ wrapLeafInGroup,
12
+ } from './group-mutations'
13
+ import { NestedGroup } from './nested-group'
14
+ import { arityOf } from './operators'
15
+ import type { FilterField, FilterGroup } from './types'
16
+
17
+ /* =========================================================================
18
+ GroupBody — body of a single FilterGroup, used at the root of the popover
19
+ panel and recursively inside `NestedGroup`. Renders:
20
+
21
+ 1. one `FilterRow` per leaf (`group.elements`)
22
+ 2. one `NestedGroup` per sub-group (`group.groups`) — only when
23
+ `isComplexEnabled`; otherwise sub-groups are silently skipped (the
24
+ chip row still surfaces them as a read-only pill)
25
+ 3. a `GroupAddControls` footer with "+ Add filter" (always) and
26
+ "+ Add group" (only when `isComplexEnabled`)
27
+
28
+ Auto-focus signals are only wired at the root — nested groups don't get
29
+ the field→operator→value focus chain. This is a deliberate scope cut:
30
+ adding to a nested group is a click action, and tracking root-vs-nested
31
+ focus paths through arbitrary depth wasn't worth the v1 complexity.
32
+ ========================================================================= */
33
+
34
+ export type GroupBodyProps = {
35
+ group: FilterGroup
36
+ fields: ReadonlyArray<FilterField>
37
+ fieldsByPath: Record<string, FilterField>
38
+ onChange: (next: FilterGroup) => void
39
+ isRoot: boolean
40
+ isComplexEnabled: boolean
41
+ showFieldIcons: boolean
42
+ showJoinerToggle: boolean
43
+ /** Root-only auto-focus index for the AttributePicker. */
44
+ autoFocusIndex?: number | null
45
+ setAutoFocusIndex?: (next: number | null) => void
46
+ /** Root-only auto-focus index for the OperatorPicker. */
47
+ autoFocusOperatorIndex?: number | null
48
+ setAutoFocusOperatorIndex?: (next: number | null) => void
49
+ /** Root-only auto-focus index for the value cell. */
50
+ autoFocusValueIndex?: number | null
51
+ setAutoFocusValueIndex?: (next: number | null) => void
52
+ /** Optional submit chain — Enter on a text/number value adds a new pending
53
+ * row at the end of *this* group. Forwarded by the panel for the root. */
54
+ onSubmitLeaf?: () => void
55
+ /** Empty-state copy override. Defaults vary per `isRoot`. */
56
+ emptyText?: string
57
+ }
58
+
59
+ export function GroupBody({
60
+ group,
61
+ fields,
62
+ fieldsByPath,
63
+ onChange,
64
+ isRoot,
65
+ isComplexEnabled,
66
+ showFieldIcons,
67
+ showJoinerToggle,
68
+ autoFocusIndex = null,
69
+ setAutoFocusIndex,
70
+ autoFocusOperatorIndex = null,
71
+ setAutoFocusOperatorIndex,
72
+ autoFocusValueIndex = null,
73
+ setAutoFocusValueIndex,
74
+ onSubmitLeaf,
75
+ emptyText,
76
+ }: GroupBodyProps) {
77
+ const elements = group.elements ?? []
78
+ const subgroups = group.groups ?? []
79
+
80
+ const handleAddFilter = () => {
81
+ const nextIndex = elements.length
82
+ onChange(addPendingLeaf(group))
83
+ setAutoFocusIndex?.(nextIndex)
84
+ }
85
+ const handleAddGroup = () => onChange(addSubgroup(group))
86
+
87
+ const handleRemoveLeaf = (i: number) => {
88
+ if (autoFocusIndex === i) setAutoFocusIndex?.(null)
89
+ if (autoFocusOperatorIndex === i) setAutoFocusOperatorIndex?.(null)
90
+ if (autoFocusValueIndex === i) setAutoFocusValueIndex?.(null)
91
+ onChange(removeLeaf(group, i))
92
+ }
93
+
94
+ const isEmpty = elements.length === 0 && (!isComplexEnabled || subgroups.length === 0)
95
+
96
+ return (
97
+ <div className="flex flex-col gap-1">
98
+ {isEmpty ? (
99
+ <div
100
+ className={
101
+ (isRoot ? 'px-3 py-6' : 'px-2 py-3') +
102
+ ' text-center font-mono text-[11.5px] text-ink-4'
103
+ }
104
+ >
105
+ {emptyText ?? (isRoot
106
+ ? 'No filters. Add one to narrow the list.'
107
+ : 'Empty group. Add a filter or another group.')}
108
+ </div>
109
+ ) : null}
110
+
111
+ {elements.map((el, i) => {
112
+ const field = fieldsByPath[el.field]
113
+ return (
114
+ <FilterRow
115
+ // biome-ignore lint/suspicious/noArrayIndexKey: filter elements have no stable id; index disambiguates duplicate-field rows
116
+ key={`${el.field}-${i}`}
117
+ index={i}
118
+ joiner={group.logicalOperator}
119
+ {...(field ? { field } : {})}
120
+ element={el}
121
+ fields={fields}
122
+ autoFocus={autoFocusIndex === i}
123
+ autoFocusOperator={autoFocusOperatorIndex === i}
124
+ onAutoFocusOperatorConsumed={() => setAutoFocusOperatorIndex?.(null)}
125
+ autoFocusValue={autoFocusValueIndex === i}
126
+ onAutoFocusValueConsumed={() => setAutoFocusValueIndex?.(null)}
127
+ {...(onSubmitLeaf ? { onSubmit: onSubmitLeaf } : {})}
128
+ onChangeField={(picked) => {
129
+ if (autoFocusIndex === i) setAutoFocusIndex?.(null)
130
+ setAutoFocusOperatorIndex?.(i)
131
+ onChange(replaceLeaf(group, i, rebaseRowOnFieldChange(el, picked)))
132
+ }}
133
+ onChangeOperator={(opId) => {
134
+ if (!field) return
135
+ if (arityOf(opId) !== 0) setAutoFocusValueIndex?.(i)
136
+ onChange(replaceLeaf(group, i, rebaseRowOnOperatorChange(el, field, opId)))
137
+ }}
138
+ onChangeValue={(v) => onChange(updateLeaf(group, i, { value: v }))}
139
+ onRemove={() => handleRemoveLeaf(i)}
140
+ {...(isComplexEnabled && field
141
+ ? { onWrap: () => onChange(wrapLeafInGroup(group, i)) }
142
+ : {})}
143
+ />
144
+ )
145
+ })}
146
+
147
+ {isComplexEnabled
148
+ ? subgroups.map((sub, i) => (
149
+ <NestedGroup
150
+ // biome-ignore lint/suspicious/noArrayIndexKey: sub-groups have no stable id
151
+ key={`group-${i}`}
152
+ index={elements.length + i}
153
+ joiner={group.logicalOperator}
154
+ group={sub}
155
+ fields={fields}
156
+ fieldsByPath={fieldsByPath}
157
+ onChange={(next) => onChange(updateSubgroup(group, i, next))}
158
+ onRemove={() => onChange(removeSubgroup(group, i))}
159
+ showFieldIcons={showFieldIcons}
160
+ showJoinerToggle={showJoinerToggle}
161
+ isComplexEnabled={isComplexEnabled}
162
+ />
163
+ ))
164
+ : null}
165
+
166
+ <GroupAddControls
167
+ onAddFilter={handleAddFilter}
168
+ onAddGroup={handleAddGroup}
169
+ isComplexEnabled={isComplexEnabled}
170
+ />
171
+ </div>
172
+ )
173
+ }
@@ -0,0 +1,104 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ addLeaf,
4
+ addSubgroup,
5
+ countLeaves,
6
+ removeLeaf,
7
+ removeSubgroup,
8
+ wrapLeafInGroup,
9
+ } from './group-mutations'
10
+ import type { FilterField, FilterGroup } from './types'
11
+
12
+ const stringField: FilterField = {
13
+ path: 'name',
14
+ label: 'Name',
15
+ shortLabel: 'Name',
16
+ type: 'string',
17
+ }
18
+
19
+ const root = (): FilterGroup => ({
20
+ logicalOperator: 'and',
21
+ elements: [
22
+ { field: 'name', operator: 'contains', value: 'a' },
23
+ { field: 'mrr', operator: 'gt', value: '1000' },
24
+ ],
25
+ groups: [],
26
+ })
27
+
28
+ describe('group-mutations', () => {
29
+ it('addLeaf appends a new element with the first valid operator and default value', () => {
30
+ const before: FilterGroup = { logicalOperator: 'and', elements: [], groups: [] }
31
+ const after = addLeaf(before, stringField)
32
+ expect(after.elements).toHaveLength(1)
33
+ expect(after.elements?.[0]?.field).toBe('name')
34
+ expect(after.elements?.[0]?.operator).toBe('eq')
35
+ // Inputs are not mutated.
36
+ expect(before.elements).toHaveLength(0)
37
+ })
38
+
39
+ it('addSubgroup uses the opposite joiner of its parent by default', () => {
40
+ const before: FilterGroup = { logicalOperator: 'and', elements: [], groups: [] }
41
+ const after = addSubgroup(before)
42
+ expect(after.groups).toHaveLength(1)
43
+ expect(after.groups?.[0]?.logicalOperator).toBe('or')
44
+ })
45
+
46
+ it('removeLeaf deletes by index and is idempotent on an out-of-range index', () => {
47
+ const before = root()
48
+ const after = removeLeaf(before, 0)
49
+ expect(after.elements).toHaveLength(1)
50
+ expect(after.elements?.[0]?.field).toBe('mrr')
51
+
52
+ const noop = removeLeaf(after, 99)
53
+ expect(noop.elements).toHaveLength(1)
54
+ })
55
+
56
+ it('removeSubgroup deletes by index without touching elements', () => {
57
+ const before: FilterGroup = {
58
+ logicalOperator: 'and',
59
+ elements: [{ field: 'name', operator: 'eq', value: 'a' }],
60
+ groups: [
61
+ { logicalOperator: 'or', elements: [], groups: [] },
62
+ { logicalOperator: 'or', elements: [], groups: [] },
63
+ ],
64
+ }
65
+ const after = removeSubgroup(before, 0)
66
+ expect(after.groups).toHaveLength(1)
67
+ expect(after.elements).toHaveLength(1)
68
+ })
69
+
70
+ it('wrapLeafInGroup moves the leaf into a new sub-group with the opposite joiner', () => {
71
+ const before = root()
72
+ const after = wrapLeafInGroup(before, 0)
73
+ expect(after.elements).toHaveLength(1)
74
+ expect(after.elements?.[0]?.field).toBe('mrr')
75
+ expect(after.groups).toHaveLength(1)
76
+ expect(after.groups?.[0]?.logicalOperator).toBe('or')
77
+ expect(after.groups?.[0]?.elements).toHaveLength(1)
78
+ expect(after.groups?.[0]?.elements?.[0]?.field).toBe('name')
79
+ })
80
+
81
+ it('countLeaves recurses through nested groups', () => {
82
+ const tree: FilterGroup = {
83
+ logicalOperator: 'and',
84
+ elements: [{ field: 'a', operator: 'eq', value: '' }],
85
+ groups: [
86
+ {
87
+ logicalOperator: 'or',
88
+ elements: [
89
+ { field: 'b', operator: 'eq', value: '' },
90
+ { field: 'c', operator: 'eq', value: '' },
91
+ ],
92
+ groups: [
93
+ {
94
+ logicalOperator: 'and',
95
+ elements: [{ field: 'd', operator: 'eq', value: '' }],
96
+ groups: [],
97
+ },
98
+ ],
99
+ },
100
+ ],
101
+ }
102
+ expect(countLeaves(tree)).toBe(4)
103
+ })
104
+ })