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