@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,116 @@
1
+ import { defaultValueFor, operatorsForType } from './operators'
2
+ import type { FilterElement, FilterField, FilterGroup, LogicalOperator } from './types'
3
+
4
+ /* =========================================================================
5
+ group-mutations — pure immutable helpers that produce a new `FilterGroup`
6
+ from the previous one + an action. The recursive popover panel composes
7
+ these instead of inlining `{ ...g, elements: g.elements.map(...) }`
8
+ spaghetti.
9
+
10
+ Invariants:
11
+ - Inputs are never mutated.
12
+ - Index parameters refer to position within the relevant sub-array
13
+ (`elements` for leaves, `groups` for sub-groups). The two arrays are
14
+ addressed independently — the renderer treats them as ordered children
15
+ by concatenation (leaves first, then groups).
16
+ ========================================================================= */
17
+
18
+ export function addLeaf(group: FilterGroup, field: FilterField): FilterGroup {
19
+ const ops = operatorsForType(field.type)
20
+ const op = ops[0]
21
+ if (!op) return group
22
+ const next: FilterElement = {
23
+ field: field.path,
24
+ operator: op.id,
25
+ value: defaultValueFor(field.type, op.arity),
26
+ }
27
+ return { ...group, elements: [...(group.elements ?? []), next] }
28
+ }
29
+
30
+ export function addPendingLeaf(group: FilterGroup): FilterGroup {
31
+ const next: FilterElement = { field: '', operator: 'eq', value: '' }
32
+ return { ...group, elements: [...(group.elements ?? []), next] }
33
+ }
34
+
35
+ export function addSubgroup(
36
+ group: FilterGroup,
37
+ joiner: LogicalOperator = oppositeJoiner(group.logicalOperator),
38
+ ): FilterGroup {
39
+ const sub: FilterGroup = { logicalOperator: joiner, elements: [], groups: [] }
40
+ return { ...group, groups: [...(group.groups ?? []), sub] }
41
+ }
42
+
43
+ export function removeLeaf(group: FilterGroup, index: number): FilterGroup {
44
+ const elements = group.elements ?? []
45
+ return { ...group, elements: elements.filter((_, i) => i !== index) }
46
+ }
47
+
48
+ export function removeSubgroup(group: FilterGroup, index: number): FilterGroup {
49
+ const groups = group.groups ?? []
50
+ return { ...group, groups: groups.filter((_, i) => i !== index) }
51
+ }
52
+
53
+ export function updateLeaf(
54
+ group: FilterGroup,
55
+ index: number,
56
+ patch: Partial<FilterElement> | FilterElement,
57
+ ): FilterGroup {
58
+ const elements = group.elements ?? []
59
+ return {
60
+ ...group,
61
+ elements: elements.map((el, i) => (i === index ? { ...el, ...patch } : el)),
62
+ }
63
+ }
64
+
65
+ export function replaceLeaf(
66
+ group: FilterGroup,
67
+ index: number,
68
+ next: FilterElement,
69
+ ): FilterGroup {
70
+ const elements = group.elements ?? []
71
+ return { ...group, elements: elements.map((el, i) => (i === index ? next : el)) }
72
+ }
73
+
74
+ export function updateSubgroup(
75
+ group: FilterGroup,
76
+ index: number,
77
+ next: FilterGroup,
78
+ ): FilterGroup {
79
+ const groups = group.groups ?? []
80
+ return { ...group, groups: groups.map((g, i) => (i === index ? next : g)) }
81
+ }
82
+
83
+ /* Wrap an existing leaf in a new sub-group with the opposite joiner.
84
+ Leaves the rest of the group untouched. The leaf is moved into the new
85
+ sub-group's `elements`. Used by the "wrap in group" hover action. */
86
+ export function wrapLeafInGroup(group: FilterGroup, index: number): FilterGroup {
87
+ const elements = group.elements ?? []
88
+ const leaf = elements[index]
89
+ if (!leaf) return group
90
+ const sub: FilterGroup = {
91
+ logicalOperator: oppositeJoiner(group.logicalOperator),
92
+ elements: [leaf],
93
+ groups: [],
94
+ }
95
+ return {
96
+ ...group,
97
+ elements: elements.filter((_, i) => i !== index),
98
+ groups: [...(group.groups ?? []), sub],
99
+ }
100
+ }
101
+
102
+ export function setJoiner(group: FilterGroup, joiner: LogicalOperator): FilterGroup {
103
+ return { ...group, logicalOperator: joiner }
104
+ }
105
+
106
+ /* Recursively count leaf filters in a group. Used by the chip pill badge
107
+ and for filter-button counts when nested groups are present. */
108
+ export function countLeaves(group: FilterGroup): number {
109
+ const direct = (group.elements ?? []).length
110
+ const nested = (group.groups ?? []).reduce((n, g) => n + countLeaves(g), 0)
111
+ return direct + nested
112
+ }
113
+
114
+ function oppositeJoiner(j: LogicalOperator): LogicalOperator {
115
+ return j === 'and' ? 'or' : 'and'
116
+ }
@@ -0,0 +1,58 @@
1
+ export { AttributePicker, type AttributePickerProps } from './attribute-picker'
2
+ export {
3
+ ChipValue,
4
+ type ChipValueItemRenderer,
5
+ type ChipValueProps,
6
+ formatValue,
7
+ RenderChipItemProvider,
8
+ useRenderChipItem,
9
+ } from './chip-value'
10
+ export {
11
+ FilterRow,
12
+ type FilterRowProps,
13
+ rebaseRowOnFieldChange,
14
+ rebaseRowOnOperatorChange,
15
+ } from './filter-row'
16
+ export {
17
+ addLeaf as addFilterLeaf,
18
+ addPendingLeaf,
19
+ addSubgroup,
20
+ countLeaves,
21
+ removeLeaf,
22
+ removeSubgroup,
23
+ replaceLeaf,
24
+ setJoiner,
25
+ updateLeaf,
26
+ updateSubgroup,
27
+ wrapLeafInGroup,
28
+ } from './group-mutations'
29
+ export {
30
+ FilterValueInput,
31
+ type FilterValueInputProps,
32
+ type FilterValueInputRenderer,
33
+ RenderValueInputProvider,
34
+ useRenderValueInput,
35
+ } from './filter-value-input'
36
+ export { OperatorPicker, type OperatorPickerProps } from './operator-picker'
37
+ export { arityOf, defaultValueFor, OPERATORS, operatorById, operatorsForType } from './operators'
38
+ export {
39
+ RecordFilterBuilderPanel,
40
+ type RecordFilterBuilderPanelProps,
41
+ } from './record-filter-builder-panel'
42
+ export { RecordFilterEditor, type RecordFilterEditorProps } from './record-filter-editor'
43
+ export { RecordFilterButton, type RecordFilterButtonProps } from './record-filter-button'
44
+ export { RecordFilterChips, type RecordFilterChipsProps } from './record-filter-chips'
45
+ export { useShowDetails } from './show-details-store'
46
+ export type {
47
+ ComparisonOperator,
48
+ FilterElement,
49
+ FilterField,
50
+ FilterFieldOption,
51
+ FilterFieldRelation,
52
+ FilterFieldType,
53
+ FilterGroup,
54
+ FilterValue,
55
+ LogicalOperator,
56
+ OperatorArity,
57
+ OperatorDescriptor,
58
+ } from './types'
@@ -0,0 +1,138 @@
1
+ import { cn } from '../../lib/cn'
2
+ import { SegmentedControl } from '../segmented-control/segmented-control'
3
+ import { GroupBody } from './group-body'
4
+ import type { FilterField, FilterGroup, LogicalOperator } from './types'
5
+
6
+ /* =========================================================================
7
+ NestedGroup — non-root group rendered as a bordered, indented panel.
8
+ Layout:
9
+
10
+ [WHERE/AND/OR ] [ ┌─ Group · AND/OR ───────────── × ┐ ]
11
+ [ FilterRow ]
12
+ [ FilterRow ]
13
+ [ + Add filter + Add group ]
14
+ [ └────────────────────────────────┘ ]
15
+
16
+ The left gutter aligns with sibling FilterRows so the joiner ("AND" /
17
+ "OR" or the leading "WHERE") sits in the same column. The bordered panel
18
+ carries the group's own AND/OR header + remove button; its body recurses
19
+ into another `GroupBody`.
20
+ ========================================================================= */
21
+
22
+ export type NestedGroupProps = {
23
+ /** Sibling index inside the parent group's combined children list (leaves
24
+ * first, groups last). Drives the joiner label. */
25
+ index: number
26
+ /** Parent group's logical operator — that's what joins this sub-group to
27
+ * its siblings. */
28
+ joiner: LogicalOperator
29
+ group: FilterGroup
30
+ fields: ReadonlyArray<FilterField>
31
+ fieldsByPath: Record<string, FilterField>
32
+ onChange: (next: FilterGroup) => void
33
+ onRemove: () => void
34
+ showFieldIcons: boolean
35
+ showJoinerToggle: boolean
36
+ isComplexEnabled: boolean
37
+ }
38
+
39
+ export function NestedGroup({
40
+ index,
41
+ joiner,
42
+ group,
43
+ fields,
44
+ fieldsByPath,
45
+ onChange,
46
+ onRemove,
47
+ showFieldIcons,
48
+ showJoinerToggle,
49
+ isComplexEnabled,
50
+ }: NestedGroupProps) {
51
+ const elementCount = (group.elements?.length ?? 0) + (group.groups?.length ?? 0)
52
+ const showJoinerSegment = showJoinerToggle && elementCount >= 2
53
+
54
+ return (
55
+ <div className="flex items-stretch gap-2 px-1.5">
56
+ {/* Joiner gutter — same width as a FilterRow's gutter so siblings line
57
+ up vertically. `px-1.5` matches FilterRow's horizontal padding so
58
+ the gutter labels and the group-panel left edge align with sibling
59
+ rows. `pt-2` aligns the label with the group header text. */}
60
+ <span className="w-12 shrink-0 inline-flex justify-end pr-1 pt-2">
61
+ <span
62
+ className={cn(
63
+ 'font-mono text-[10.5px] tracking-[0.08em]',
64
+ index === 0 ? 'text-ink-4' : 'text-accent-ink',
65
+ )}
66
+ >
67
+ {index === 0 ? 'WHERE' : joiner.toUpperCase()}
68
+ </span>
69
+ </span>
70
+
71
+ <div
72
+ className={cn(
73
+ 'flex-1 min-w-0 rounded-sm border border-rule',
74
+ 'bg-[color-mix(in_oklab,var(--color-surface-2)_60%,var(--color-surface))]',
75
+ )}
76
+ >
77
+ {/* Group header — eyebrow + AND/OR + remove */}
78
+ <div className="flex items-center justify-between px-2.5 py-1.5 border-b border-rule">
79
+ <div className="flex items-center gap-2">
80
+ <span className="font-mono text-[10px] uppercase tracking-[0.08em] text-ink-4">
81
+ Group
82
+ </span>
83
+ {showJoinerSegment ? (
84
+ <SegmentedControl<LogicalOperator>
85
+ aria-label="Group joiner"
86
+ options={[
87
+ { id: 'and', label: 'AND' },
88
+ { id: 'or', label: 'OR' },
89
+ ]}
90
+ value={group.logicalOperator}
91
+ onChange={(next) => onChange({ ...group, logicalOperator: next })}
92
+ />
93
+ ) : (
94
+ <span className="font-mono text-[10.5px] tracking-[0.08em] text-ink-3 uppercase">
95
+ {group.logicalOperator}
96
+ </span>
97
+ )}
98
+ </div>
99
+ <button
100
+ type="button"
101
+ onClick={onRemove}
102
+ aria-label="Remove group"
103
+ className={cn(
104
+ 'size-6 inline-flex items-center justify-center rounded-sm',
105
+ 'text-ink-4 hover:text-danger hover:bg-surface cursor-pointer outline-none',
106
+ )}
107
+ >
108
+ <svg
109
+ aria-hidden
110
+ viewBox="0 0 14 14"
111
+ className="size-3.5"
112
+ fill="none"
113
+ stroke="currentColor"
114
+ strokeWidth="1.4"
115
+ strokeLinecap="round"
116
+ strokeLinejoin="round"
117
+ >
118
+ <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" />
119
+ </svg>
120
+ </button>
121
+ </div>
122
+
123
+ <div className="px-1.5 py-1.5">
124
+ <GroupBody
125
+ group={group}
126
+ fields={fields}
127
+ fieldsByPath={fieldsByPath}
128
+ onChange={onChange}
129
+ isRoot={false}
130
+ isComplexEnabled={isComplexEnabled}
131
+ showFieldIcons={showFieldIcons}
132
+ showJoinerToggle={showJoinerToggle}
133
+ />
134
+ </div>
135
+ </div>
136
+ </div>
137
+ )
138
+ }
@@ -0,0 +1,62 @@
1
+ import { cn } from '../../lib/cn'
2
+ import type { FieldSize } from '../field/field'
3
+ import { Select, type SelectOption } from '../select/select'
4
+ import { operatorsForType } from './operators'
5
+ import type { ComparisonOperator, FilterFieldType } from './types'
6
+
7
+ /* =========================================================================
8
+ OperatorPicker — small dropdown of operators valid for the given field
9
+ type. Wraps lib `Select` and inherits its compact `sm` chrome so it lines
10
+ up byte-for-byte with the AttributePicker beside it.
11
+ ========================================================================= */
12
+
13
+ export type OperatorPickerProps = {
14
+ fieldType: FilterFieldType
15
+ value: ComparisonOperator
16
+ onChange: (next: ComparisonOperator) => void
17
+ isDisabled?: boolean
18
+ /** Auto-focus the trigger on mount. Used by the chip mini-editor so focus
19
+ * lands on the operator (the field is already chosen, the operator is the
20
+ * natural next thing to tweak). */
21
+ autoFocus?: boolean
22
+ /** Field size. Defaults to `sm` to match the filter row. */
23
+ size?: FieldSize
24
+ /** Visual override for the trigger wrapper (sizing). */
25
+ className?: string
26
+ /** Per-field allowlist override (LUM-34). When provided, picks operators
27
+ * from this list instead of `operatorsForType(fieldType)`. */
28
+ operators?: ReadonlyArray<ComparisonOperator>
29
+ }
30
+
31
+ export function OperatorPicker({
32
+ fieldType,
33
+ value,
34
+ onChange,
35
+ isDisabled,
36
+ autoFocus,
37
+ size = 'sm',
38
+ className,
39
+ operators,
40
+ }: OperatorPickerProps) {
41
+ const allForType = operatorsForType(fieldType)
42
+ const ops = operators ? allForType.filter((op) => operators.includes(op.id)) : allForType
43
+ const options: SelectOption<ComparisonOperator>[] = ops.map((op) => ({
44
+ id: op.id,
45
+ label: op.label,
46
+ }))
47
+
48
+ return (
49
+ <Select<ComparisonOperator>
50
+ aria-label="Operator"
51
+ options={options}
52
+ selectedKey={value}
53
+ onSelectionChange={(next) => onChange(next as ComparisonOperator)}
54
+ size={size}
55
+ {...(isDisabled !== undefined ? { isDisabled } : {})}
56
+ {...(autoFocus ? { autoFocus: true } : {})}
57
+ className={cn('w-full', className)}
58
+ // `min-w-0` lets the trigger shrink so its <SelectValue> can truncate.
59
+ triggerClassName="min-w-0"
60
+ />
61
+ )
62
+ }
@@ -0,0 +1,105 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { arityOf, defaultValueFor, OPERATORS, operatorById, operatorsForType } from './operators'
4
+
5
+ describe('operatorsForType', () => {
6
+ it('includes eq/ne for every primitive type', () => {
7
+ for (const t of ['string', 'number', 'integer', 'datetime', 'enum', 'boolean'] as const) {
8
+ const ops = operatorsForType(t).map((o) => o.id)
9
+ expect(ops).toContain('eq')
10
+ expect(ops).toContain('ne')
11
+ }
12
+ })
13
+
14
+ it('only allows starts_with / ends_with on strings', () => {
15
+ for (const op of ['starts_with', 'ends_with'] as const) {
16
+ const types = OPERATORS.find((o) => o.id === op)?.types ?? []
17
+ expect(types).toEqual(['string'])
18
+ }
19
+ })
20
+
21
+ it('allows in / not_in on strings, numbers, and enums (not datetime/boolean/array)', () => {
22
+ for (const op of ['in', 'not_in'] as const) {
23
+ const types = new Set(OPERATORS.find((o) => o.id === op)?.types ?? [])
24
+ expect(types.has('string')).toBe(true)
25
+ expect(types.has('number')).toBe(true)
26
+ expect(types.has('integer')).toBe(true)
27
+ expect(types.has('enum')).toBe(true)
28
+ expect(types.has('datetime')).toBe(false)
29
+ expect(types.has('boolean')).toBe(false)
30
+ expect(types.has('array')).toBe(false)
31
+ }
32
+ })
33
+
34
+ it('only allows gte/lte on datetime', () => {
35
+ for (const op of ['gte', 'lte'] as const) {
36
+ const types = OPERATORS.find((o) => o.id === op)?.types ?? []
37
+ expect(types).toEqual(['datetime'])
38
+ }
39
+ })
40
+
41
+ it('allows contains/empty/not_empty on arrays', () => {
42
+ const arrayOps = operatorsForType('array').map((o) => o.id)
43
+ expect(arrayOps).toContain('contains')
44
+ expect(arrayOps).toContain('empty')
45
+ expect(arrayOps).toContain('not_empty')
46
+ // No magnitude/text-shape operators on arrays.
47
+ expect(arrayOps).not.toContain('gt')
48
+ expect(arrayOps).not.toContain('starts_with')
49
+ })
50
+
51
+ it('allows identity operators on user, not text/numeric shapes', () => {
52
+ const userOps = operatorsForType('user').map((o) => o.id)
53
+ expect(userOps).toEqual(
54
+ expect.arrayContaining(['eq', 'ne', 'in', 'not_in', 'empty', 'not_empty']),
55
+ )
56
+ expect(userOps).not.toContain('contains')
57
+ expect(userOps).not.toContain('starts_with')
58
+ expect(userOps).not.toContain('gt')
59
+ })
60
+
61
+ it('overrides labels per field type where natural', () => {
62
+ const numberEq = operatorsForType('number').find((o) => o.id === 'eq')
63
+ expect(numberEq?.label).toBe('equals')
64
+
65
+ const datetimeGt = operatorsForType('datetime').find((o) => o.id === 'gt')
66
+ expect(datetimeGt?.label).toBe('is after')
67
+ })
68
+ })
69
+
70
+ describe('arityOf', () => {
71
+ it('returns 0 for empty/not_empty', () => {
72
+ expect(arityOf('empty')).toBe(0)
73
+ expect(arityOf('not_empty')).toBe(0)
74
+ })
75
+ it("returns 'n' for in/not_in", () => {
76
+ expect(arityOf('in')).toBe('n')
77
+ expect(arityOf('not_in')).toBe('n')
78
+ })
79
+ it('returns 1 for the rest', () => {
80
+ expect(arityOf('eq')).toBe(1)
81
+ expect(arityOf('contains')).toBe(1)
82
+ expect(arityOf('gt')).toBe(1)
83
+ })
84
+ })
85
+
86
+ describe('defaultValueFor', () => {
87
+ it('returns null for arity 0', () => {
88
+ expect(defaultValueFor('string', 0)).toBeNull()
89
+ })
90
+ it("returns [] for arity 'n'", () => {
91
+ expect(defaultValueFor('enum', 'n')).toEqual([])
92
+ })
93
+ it('returns true for boolean fields, empty string otherwise', () => {
94
+ expect(defaultValueFor('boolean', 1)).toBe(true)
95
+ expect(defaultValueFor('string', 1)).toBe('')
96
+ expect(defaultValueFor('number', 1)).toBe('')
97
+ })
98
+ })
99
+
100
+ describe('operatorById', () => {
101
+ it('finds known operators', () => {
102
+ expect(operatorById('eq')?.label).toBe('is')
103
+ expect(operatorById('contains')?.arity).toBe(1)
104
+ })
105
+ })
@@ -0,0 +1,97 @@
1
+ import type {
2
+ ComparisonOperator,
3
+ FilterFieldType,
4
+ FilterValue,
5
+ OperatorArity,
6
+ OperatorDescriptor,
7
+ } from './types'
8
+
9
+ /* =========================================================================
10
+ Operator catalogue — single source of truth for which operators apply
11
+ to which field types and how many values each takes. Mirrors the
12
+ backend enum at `core-model/comparison-operator.go` and the design's
13
+ `OPERATORS` table.
14
+ ========================================================================= */
15
+
16
+ export const OPERATORS: ReadonlyArray<OperatorDescriptor> = [
17
+ {
18
+ id: 'eq',
19
+ label: 'is',
20
+ arity: 1,
21
+ types: ['string', 'number', 'integer', 'currency', 'datetime', 'enum', 'boolean', 'relation', 'user'],
22
+ },
23
+ {
24
+ id: 'ne',
25
+ label: 'is not',
26
+ arity: 1,
27
+ types: ['string', 'number', 'integer', 'currency', 'datetime', 'enum', 'boolean', 'relation', 'user'],
28
+ },
29
+ {
30
+ id: 'in',
31
+ label: 'is any of',
32
+ arity: 'n',
33
+ types: ['string', 'number', 'integer', 'currency', 'enum', 'relation', 'user'],
34
+ },
35
+ {
36
+ id: 'not_in',
37
+ label: 'is none of',
38
+ arity: 'n',
39
+ types: ['string', 'number', 'integer', 'currency', 'enum', 'relation', 'user'],
40
+ },
41
+ { id: 'contains', label: 'contains', arity: 1, types: ['string', 'array'] },
42
+ { id: 'starts_with', label: 'starts with', arity: 1, types: ['string'] },
43
+ { id: 'ends_with', label: 'ends with', arity: 1, types: ['string'] },
44
+ { id: 'gt', label: 'is greater than', arity: 1, types: ['number', 'integer', 'currency', 'datetime'] },
45
+ { id: 'gte', label: 'is on or after', arity: 1, types: ['datetime'] },
46
+ { id: 'lt', label: 'is less than', arity: 1, types: ['number', 'integer', 'currency', 'datetime'] },
47
+ { id: 'lte', label: 'is on or before', arity: 1, types: ['datetime'] },
48
+ {
49
+ id: 'empty',
50
+ label: 'is empty',
51
+ arity: 0,
52
+ types: ['string', 'number', 'integer', 'currency', 'datetime', 'enum', 'boolean', 'array', 'relation', 'user', 'file'],
53
+ },
54
+ {
55
+ id: 'not_empty',
56
+ label: 'is not empty',
57
+ arity: 0,
58
+ types: ['string', 'number', 'integer', 'currency', 'datetime', 'enum', 'boolean', 'array', 'relation', 'user', 'file'],
59
+ },
60
+ ]
61
+
62
+ const OP_LABEL_OVERRIDES: Partial<
63
+ Record<FilterFieldType, Partial<Record<ComparisonOperator, string>>>
64
+ > = {
65
+ number: { gt: 'is greater than', lt: 'is less than', eq: 'equals', ne: 'does not equal' },
66
+ integer: { gt: 'is greater than', lt: 'is less than', eq: 'equals', ne: 'does not equal' },
67
+ currency: { gt: 'is greater than', lt: 'is less than', eq: 'equals', ne: 'does not equal' },
68
+ datetime: { eq: 'is on', ne: 'is not on', gt: 'is after', lt: 'is before' },
69
+ array: { contains: 'contains', empty: 'is empty', not_empty: 'is not empty' },
70
+ boolean: { eq: 'is', ne: 'is not' },
71
+ }
72
+
73
+ export function operatorsForType(type: FilterFieldType): ReadonlyArray<OperatorDescriptor> {
74
+ return OPERATORS.filter((op) => op.types.includes(type)).map((op) => {
75
+ const override = OP_LABEL_OVERRIDES[type]?.[op.id]
76
+ return override ? { ...op, label: override } : op
77
+ })
78
+ }
79
+
80
+ export function operatorById(id: ComparisonOperator): OperatorDescriptor | undefined {
81
+ return OPERATORS.find((o) => o.id === id)
82
+ }
83
+
84
+ export function arityOf(operator: ComparisonOperator): OperatorArity {
85
+ return operatorById(operator)?.arity ?? 1
86
+ }
87
+
88
+ /* =========================================================================
89
+ Default value for a fresh row, given the field type and the chosen
90
+ operator's arity. Keeps the value shape consistent with `FilterValue`.
91
+ ========================================================================= */
92
+ export function defaultValueFor(type: FilterFieldType, arity: OperatorArity): FilterValue {
93
+ if (arity === 0) return null
94
+ if (arity === 'n') return []
95
+ if (type === 'boolean') return true
96
+ return ''
97
+ }