@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,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
+ }