@proteos/ui 0.54.1 → 0.56.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 (33) hide show
  1. package/dist/index.cjs +679 -321
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.d.cts +84 -9
  4. package/dist/index.d.ts +84 -9
  5. package/dist/index.js +677 -324
  6. package/dist/index.js.map +1 -1
  7. package/package.json +4 -4
  8. package/src/components/badge/badge.stories.tsx +37 -1
  9. package/src/components/badge/badge.test.tsx +39 -2
  10. package/src/components/badge/badge.tsx +65 -1
  11. package/src/components/badge/index.ts +2 -2
  12. package/src/components/badge-group/badge-group.stories.tsx +75 -0
  13. package/src/components/badge-group/badge-group.test.tsx +121 -0
  14. package/src/components/badge-group/badge-group.tsx +208 -0
  15. package/src/components/badge-group/index.ts +7 -0
  16. package/src/components/badge-multi-select/badge-multi-select.stories.tsx +25 -0
  17. package/src/components/badge-multi-select/badge-multi-select.test.tsx +110 -1
  18. package/src/components/badge-multi-select/badge-multi-select.tsx +190 -128
  19. package/src/components/data-table/data-table.stories.tsx +53 -2
  20. package/src/components/data-table/data-table.tsx +45 -25
  21. package/src/components/field/listbox-check.tsx +44 -0
  22. package/src/components/field/use-chip-row-wrap.ts +45 -0
  23. package/src/components/multi-select/multi-select.tsx +8 -39
  24. package/src/components/record-filter/chip-value.tsx +6 -5
  25. package/src/components/record-filter/filter-value-input.tsx +11 -9
  26. package/src/components/record-filter/operators.test.ts +67 -12
  27. package/src/components/record-filter/operators.ts +43 -2
  28. package/src/components/record-filter/types.ts +6 -1
  29. package/src/components/table/table.test.tsx +88 -9
  30. package/src/components/table/table.tsx +139 -13
  31. package/src/components/tag-input/tag-input.test.tsx +67 -1
  32. package/src/components/tag-input/tag-input.tsx +98 -64
  33. package/src/index.ts +1 -0
@@ -107,6 +107,14 @@ export type DataTableProps<TRow> = {
107
107
  enableColumnRemoval?: boolean
108
108
  onReorderColumn?: (params: { id: string; toIndex: number }) => void
109
109
  onRemoveColumn?: (id: string) => void
110
+ /**
111
+ * Enable a resize handle on each column header's trailing separator.
112
+ * Widths are controlled: `onResizeColumn` reports the column id and its
113
+ * new pixel width while dragging; the parent feeds it back through the
114
+ * column defs' `size`. Each `ColumnDef` must carry a stable `id`.
115
+ */
116
+ enableColumnResizing?: boolean
117
+ onResizeColumn?: (params: { id: string; width: number }) => void
110
118
  /**
111
119
  * Visual chrome around the table. `panel` (default) wraps the table in a
112
120
  * bordered, rounded, soft-shadowed card. `bare` strips that chrome so the
@@ -135,8 +143,10 @@ export type DataTableProps<TRow> = {
135
143
  */
136
144
  bodyBefore?: ReactNode
137
145
  /**
138
- * Slot rendered BELOW the body. Used for controls like an Add row button
139
- * at slot='end'. Sits inside the scrollable area below the rows.
146
+ * Trailing row rendered directly below the last row — an Add row control at
147
+ * slot='end', say. Row-height, edge-to-edge, and sticky to the bottom of
148
+ * the scroll viewport, so it stays reachable while the end of a long list
149
+ * is scrolled out of view.
140
150
  */
141
151
  bodyAfter?: ReactNode
142
152
  /**
@@ -181,7 +191,7 @@ type ListItem<TRow> =
181
191
  const DEFAULT_ROW_HEIGHT = 32
182
192
 
183
193
  /** Column id of the leading checkbox column added by `selectionMode`. */
184
- export const SELECTION_COLUMN_ID = '__select__'
194
+ export const SELECTION_COLUMN_ID = '__select__'
185
195
 
186
196
  export function DataTable<TRow>({
187
197
  query,
@@ -202,6 +212,8 @@ export function DataTable<TRow>({
202
212
  enableColumnRemoval = false,
203
213
  onReorderColumn,
204
214
  onRemoveColumn,
215
+ enableColumnResizing = false,
216
+ onResizeColumn,
205
217
  variant = 'panel',
206
218
  rowWrapper,
207
219
  extraRows,
@@ -457,8 +469,6 @@ export function DataTable<TRow>({
457
469
  !query.isLoading && !query.error && tableRowCount === 0 && !query.isFetchingNextPage
458
470
  const showInitialLoading = query.isLoading && tableRowCount === 0
459
471
  const showError = !!query.error && tableRowCount === 0
460
- const showEndMarker =
461
- !query.isLoading && !query.isFetchingNextPage && !query.hasNextPage && tableRowCount > 0
462
472
 
463
473
  const totalSize = virtualizer.getTotalSize()
464
474
  const virtualItems = virtualizer.getVirtualItems()
@@ -475,7 +485,13 @@ export function DataTable<TRow>({
475
485
  {/* Single both-axes scroll container. Horizontal wheel/trackpad scrolls
476
486
  the columns; vertical scrolls the rows. The sticky header pins to the
477
487
  top, and any `sticky left-0` gutter column freezes to the left. */}
478
- <TableScroller ref={scrollerRef}>
488
+ <TableScroller
489
+ ref={scrollerRef}
490
+ // Scroll padding keeps a row scrolled (or focused) into view clear of
491
+ // the sticky header and, when present, the sticky trailing row —
492
+ // otherwise a freshly added last row lands underneath the strip.
493
+ className={cn('scroll-pt-[36px]', bodyAfter ? 'scroll-pb-8' : undefined)}
494
+ >
479
495
  {/* Content track — owns the horizontal min-width so the header + rows
480
496
  scroll horizontally together as one unit. */}
481
497
  <div className="flex flex-col" style={{ minWidth: minPanelWidth }}>
@@ -525,6 +541,12 @@ export function DataTable<TRow>({
525
541
  {...(enableColumnRemoval && onRemoveColumn && header.column.id
526
542
  ? { onRemove: () => onRemoveColumn(header.column.id) }
527
543
  : {})}
544
+ {...(enableColumnResizing && onResizeColumn && header.column.id
545
+ ? {
546
+ onResize: (width: number) =>
547
+ onResizeColumn({ id: header.column.id, width }),
548
+ }
549
+ : {})}
528
550
  >
529
551
  {headerNode}
530
552
  </TableHeaderCell>
@@ -585,18 +607,30 @@ export function DataTable<TRow>({
585
607
  </div>
586
608
  )}
587
609
 
588
- {/* Sentinel — visually empty, positioned just above the loader/end
589
- marker so it intersects ~200 px before the bottom. */}
610
+ {/* Sentinel — visually empty, positioned just above the loader so
611
+ it intersects ~200 px before the bottom. */}
590
612
  {!showEmpty && (
591
613
  <div ref={sentinelRef} aria-hidden="true" style={{ height: 1, width: '100%' }} />
592
614
  )}
593
615
 
594
616
  {query.isFetchingNextPage ? <LoaderRow /> : null}
595
- {showEndMarker ? <EndMarker count={tableRowCount} /> : null}
596
617
  </div>
597
618
  )}
598
619
 
599
- {bodyAfter}
620
+ {bodyAfter ? (
621
+ // Sticky to the scroller's bottom edge; `bg-bg` covers the rows
622
+ // sliding underneath. z-[4] — like the sticky header — stays above an
623
+ // editing row (z-[3]), so an editor scrolled under the strip is
624
+ // covered rather than drawn over it.
625
+ <div
626
+ className={cn(
627
+ 'sticky bottom-0 z-[4] flex items-center h-8 px-0.5',
628
+ 'border-t border-rule-2 bg-bg',
629
+ )}
630
+ >
631
+ {bodyAfter}
632
+ </div>
633
+ ) : null}
600
634
  </div>
601
635
  </TableScroller>
602
636
  </Table>
@@ -705,7 +739,7 @@ function DataRow<TRow>({
705
739
  )
706
740
  }
707
741
 
708
- /* ---------- Loader & end marker ----------------------------------------- */
742
+ /* ---------- Loader ------------------------------------------------------- */
709
743
 
710
744
  function LoaderRow() {
711
745
  return (
@@ -732,17 +766,3 @@ function LoaderRow() {
732
766
  </div>
733
767
  )
734
768
  }
735
-
736
- function EndMarker({ count }: { count: number }) {
737
- return (
738
- <div
739
- className={cn(
740
- 'flex items-center justify-center',
741
- 'h-8 font-mono text-[10.5px] uppercase tracking-[0.08em]',
742
- 'text-ink-4',
743
- )}
744
- >
745
- end · {count.toLocaleString()} rows
746
- </div>
747
- )
748
- }
@@ -0,0 +1,44 @@
1
+ import { tv, type VariantProps } from 'tailwind-variants'
2
+
3
+ /* =========================================================================
4
+ ListboxCheck — the selection box at the head of a multi-select listbox
5
+ row. Empty box at rest, ink fill with a tick once the row is selected.
6
+ Reads the row's RAC `data-selected` through the `group` class, so the
7
+ host ListBoxItem must carry `group`. Shared by MultiSelect and
8
+ BadgeMultiSelect so both pickers mark selection identically.
9
+ ========================================================================= */
10
+
11
+ export const listboxCheckStyles = tv({
12
+ slots: {
13
+ box: [
14
+ 'relative inline-flex items-center justify-center shrink-0',
15
+ 'size-[16px] rounded-[3px] border border-ink-4 bg-surface',
16
+ 'transition-[background-color,border-color] duration-150',
17
+ 'group-data-[selected]:bg-ink group-data-[selected]:border-ink',
18
+ ],
19
+ tick: [
20
+ 'size-3 text-surface transition-opacity duration-150',
21
+ 'opacity-0 group-data-[selected]:opacity-100',
22
+ ],
23
+ },
24
+ })
25
+
26
+ export type ListboxCheckVariants = VariantProps<typeof listboxCheckStyles>
27
+
28
+ export function ListboxCheck() {
29
+ const { box, tick } = listboxCheckStyles()
30
+ return (
31
+ <span aria-hidden className={box()}>
32
+ <svg viewBox="0 0 12 12" className={tick()} aria-hidden>
33
+ <path
34
+ d="M2.5 6.25 5 8.75 9.5 3.75"
35
+ stroke="currentColor"
36
+ strokeWidth="1.6"
37
+ strokeLinecap="round"
38
+ strokeLinejoin="round"
39
+ fill="none"
40
+ />
41
+ </svg>
42
+ </span>
43
+ )
44
+ }
@@ -0,0 +1,45 @@
1
+ import { type RefObject, useLayoutEffect, useState } from 'react'
2
+
3
+ /* =========================================================================
4
+ useChipRowWrap — does a `flex-wrap` row of chips currently need more than
5
+ one line? Drives the inline cell editors: they stay one row tall while
6
+ the chips fit, and grow into the two-row pop-out only once they wrap.
7
+
8
+ Measured from the children's boxes: a child whose top sits at or below
9
+ the first child's bottom is on a later line. Wrapping depends on the
10
+ container's width, not its height, so the answer is the same whether the
11
+ box is currently short (clipping) or tall. Re-measured when `signature`
12
+ (the chip identity) changes and whenever the container resizes. `false`
13
+ without layout (jsdom) and while `isEnabled` is off.
14
+ ========================================================================= */
15
+
16
+ export function useChipRowWrap(
17
+ ref: RefObject<HTMLElement | null>,
18
+ isEnabled: boolean,
19
+ signature: string,
20
+ ): boolean {
21
+ const [hasWrapped, setHasWrapped] = useState(false)
22
+
23
+ // biome-ignore lint/correctness/useExhaustiveDependencies: `signature` (chip identity) is the re-measure trigger; it is not read inside.
24
+ useLayoutEffect(() => {
25
+ if (!isEnabled) return
26
+ const element = ref.current
27
+ if (!element) return
28
+ const measure = () => {
29
+ const children = Array.from(element.children)
30
+ const first = children[0]?.getBoundingClientRect()
31
+ if (!first || first.height === 0) {
32
+ setHasWrapped(false)
33
+ return
34
+ }
35
+ setHasWrapped(children.some((child) => child.getBoundingClientRect().top >= first.bottom - 1))
36
+ }
37
+ measure()
38
+ if (typeof ResizeObserver === 'undefined') return
39
+ const observer = new ResizeObserver(measure)
40
+ observer.observe(element)
41
+ return () => observer.disconnect()
42
+ }, [ref, isEnabled, signature])
43
+
44
+ return hasWrapped
45
+ }
@@ -13,6 +13,7 @@ import {
13
13
  fieldMinHeight,
14
14
  fieldTextStyles,
15
15
  } from '../field/field'
16
+ import { ListboxCheck } from '../field/listbox-check'
16
17
 
17
18
  /* =========================================================================
18
19
  MultiSelect — pick many from a list. Trigger shows selected items as
@@ -180,7 +181,9 @@ export function MultiSelect<T extends Key>({
180
181
  placement="bottom start"
181
182
  className={cn(
182
183
  'w-[var(--trigger-width)]',
183
- 'rounded-md border border-rule bg-surface p-1',
184
+ // The popover element itself takes focus when opened programmatically
185
+ // (defaultOpen) — no browser outline around the whole panel.
186
+ 'outline-none rounded-md border border-rule bg-surface p-1',
184
187
  'shadow-[var(--shadow-raised)]',
185
188
  'entering:animate-in entering:fade-in-0 entering:slide-in-from-top-1',
186
189
  'exiting:animate-out exiting:fade-out-0 exiting:slide-out-to-top-1',
@@ -205,6 +208,9 @@ export function MultiSelect<T extends Key>({
205
208
  <ListBox
206
209
  aria-label={typeof label === 'string' ? label : 'Options'}
207
210
  selectionMode="multiple"
211
+ // RAC clears a multi-selection on Escape by default — here Escape
212
+ // must close the list (useOverlay) and keep the selection.
213
+ escapeKeyBehavior="none"
208
214
  selectedKeys={selectedSet}
209
215
  onSelectionChange={(keys) => {
210
216
  if (keys === 'all') {
@@ -232,11 +238,10 @@ export function MultiSelect<T extends Key>({
232
238
  'cursor-pointer outline-none select-none',
233
239
  'data-[hovered]:bg-surface-2 data-[hovered]:text-ink',
234
240
  'data-[focused]:bg-surface-2 data-[focused]:text-ink',
235
- 'data-[selected]:bg-surface-3 data-[selected]:text-ink',
236
241
  'data-[disabled]:opacity-45 data-[disabled]:cursor-not-allowed',
237
242
  )}
238
243
  >
239
- <Checkmark />
244
+ <ListboxCheck />
240
245
  <span className="flex-1 truncate">{option.content ?? option.label}</span>
241
246
  </ListBoxItem>
242
247
  ))}
@@ -301,39 +306,3 @@ function Chip({
301
306
  </span>
302
307
  )
303
308
  }
304
-
305
- /* =========================================================================
306
- Internal: empty box → indigo tick when the row is selected.
307
- ========================================================================= */
308
-
309
- function Checkmark() {
310
- return (
311
- <span
312
- aria-hidden
313
- className={cn(
314
- 'relative inline-flex items-center justify-center shrink-0',
315
- 'size-[16px] rounded-[3px] border border-ink-4 bg-surface',
316
- 'transition-[background-color,border-color] duration-150',
317
- 'group-data-[selected]:bg-ink group-data-[selected]:border-ink',
318
- )}
319
- >
320
- <svg
321
- viewBox="0 0 12 12"
322
- className={cn(
323
- 'size-3 text-surface transition-opacity duration-150',
324
- 'opacity-0 group-data-[selected]:opacity-100',
325
- )}
326
- aria-hidden
327
- >
328
- <path
329
- d="M2.5 6.25 5 8.75 9.5 3.75"
330
- stroke="currentColor"
331
- strokeWidth="1.6"
332
- strokeLinecap="round"
333
- strokeLinejoin="round"
334
- fill="none"
335
- />
336
- </svg>
337
- </span>
338
- )
339
- }
@@ -1,7 +1,7 @@
1
1
  import { createContext, type ReactNode, useContext } from 'react'
2
2
  import { cn } from '../../lib/cn'
3
3
  import { Badge } from '../badge/badge'
4
- import { arityOf } from './operators'
4
+ import { arityOf, isEnumLike } from './operators'
5
5
  import type { ComparisonOperator, FilterField, FilterValue } from './types'
6
6
 
7
7
  /* =========================================================================
@@ -122,8 +122,9 @@ function ChipItem({
122
122
  const custom = effectiveRenderer({ field, operator, value })
123
123
  if (custom !== undefined) return <>{custom}</>
124
124
  }
125
- // Lib default: enum → Badge with variant lookup; other → truncated mono text.
126
- if (field.type === 'enum') {
125
+ // Lib default: enum-like → Badge with variant lookup; other → truncated
126
+ // mono text.
127
+ if (isEnumLike(field)) {
127
128
  const option = findEnumOption(field, value)
128
129
  return (
129
130
  <Badge
@@ -169,7 +170,7 @@ export function formatValue(
169
170
  ? value.split(',')
170
171
  : []
171
172
  if (arr.length === 0) return '—'
172
- if (field.type === 'enum') {
173
+ if (isEnumLike(field)) {
173
174
  const labels = arr.map((v) => findEnumOption(field, v)?.label ?? v)
174
175
  return labels.length <= 2
175
176
  ? labels.join(', ')
@@ -184,7 +185,7 @@ export function formatValue(
184
185
  // badge below already do this; a single value read as the raw slug, so the
185
186
  // same filter said "is technical_decider" or "is any of Technical Decider"
186
187
  // depending only on the operator.
187
- if (field.type === 'enum') {
188
+ if (isEnumLike(field)) {
188
189
  return findEnumOption(field, String(value))?.label ?? String(value)
189
190
  }
190
191
  if (field.type === 'number' || field.type === 'integer') {
@@ -11,7 +11,7 @@ import { Input } from '../input/input'
11
11
  import { SegmentedControl } from '../segmented-control/segmented-control'
12
12
  import { Select, type SelectOption } from '../select/select'
13
13
  import { TagInput } from '../tag-input/tag-input'
14
- import { arityOf } from './operators'
14
+ import { arityOf, isEnumLike } from './operators'
15
15
  import type { ComparisonOperator, FilterField, FilterValue } from './types'
16
16
 
17
17
  /* =========================================================================
@@ -19,11 +19,11 @@ import type { ComparisonOperator, FilterField, FilterValue } from './types'
19
19
  Routes to the right primitive based on `(field.type, operator.arity)`:
20
20
 
21
21
  arity 0 → null (no input — for empty / not_empty)
22
- arity 'n' (in / not_in)
23
- enum → BadgeMultiSelect
24
- other → TagInput (free-form list)
22
+ arity 'n' (in / not_in / contains_all)
23
+ enum-like → BadgeMultiSelect (an enum, or an array of enum items)
24
+ other → TagInput (free-form list)
25
25
  arity 1
26
- enum → Select with Badge in renderItem
26
+ enum-like → Select with Badge in renderItem
27
27
  boolean → SegmentedControl (true / false)
28
28
  datetime → DatePicker
29
29
  other → Input (text or number)
@@ -130,9 +130,9 @@ export function FilterValueInput({
130
130
  }
131
131
 
132
132
  const leaf = (() => {
133
- /* ---------- multi-value (in / not_in) ---------- */
133
+ /* ---------- multi-value (in / not_in / contains_all) ---------- */
134
134
  if (arity === 'n') {
135
- if (field.type === 'enum') {
135
+ if (isEnumLike(field)) {
136
136
  return (
137
137
  <EnumMultiValue
138
138
  field={field}
@@ -163,7 +163,7 @@ export function FilterValueInput({
163
163
  }
164
164
 
165
165
  /* ---------- single value ---------- */
166
- if (field.type === 'enum') {
166
+ if (isEnumLike(field)) {
167
167
  return (
168
168
  <EnumSingleValue
169
169
  field={field}
@@ -356,6 +356,8 @@ function placeholderFor(field: FilterField, operator: ComparisonOperator): strin
356
356
  if (field.type === 'number' || field.type === 'integer' || field.type === 'currency') return '0'
357
357
  if (operator === 'starts_with') return 'starts with…'
358
358
  if (operator === 'ends_with') return 'ends with…'
359
- if (operator === 'contains') return 'contains…'
359
+ if (operator === 'contains') return field.type === 'array' ? 'has…' : 'contains…'
360
+ if (operator === 'not_contains')
361
+ return field.type === 'array' ? 'does not have…' : 'does not contain…'
360
362
  return 'value'
361
363
  }
@@ -1,6 +1,13 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
 
3
- import { arityOf, defaultValueFor, OPERATORS, operatorById, operatorsForType } from './operators'
3
+ import {
4
+ arityOf,
5
+ defaultValueFor,
6
+ isEnumLike,
7
+ OPERATORS,
8
+ operatorById,
9
+ operatorsForType,
10
+ } from './operators'
4
11
 
5
12
  describe('operatorsForType', () => {
6
13
  it('includes eq/ne for every primitive type', () => {
@@ -36,16 +43,32 @@ describe('operatorsForType', () => {
36
43
  expect(ops).not.toContain('gt')
37
44
  })
38
45
 
39
- it('allows in / not_in on strings, numbers, and enums (not datetime/boolean/array)', () => {
46
+ it('allows in / not_in on strings, numbers, enums and arrays (not datetime/boolean)', () => {
40
47
  for (const op of ['in', 'not_in'] as const) {
41
48
  const types = new Set(OPERATORS.find((o) => o.id === op)?.types ?? [])
42
49
  expect(types.has('string')).toBe(true)
43
50
  expect(types.has('number')).toBe(true)
44
51
  expect(types.has('integer')).toBe(true)
45
52
  expect(types.has('enum')).toBe(true)
53
+ expect(types.has('array')).toBe(true)
46
54
  expect(types.has('datetime')).toBe(false)
47
55
  expect(types.has('boolean')).toBe(false)
48
- expect(types.has('array')).toBe(false)
56
+ }
57
+ })
58
+
59
+ it('allows not_contains wherever contains is allowed', () => {
60
+ expect(OPERATORS.find((o) => o.id === 'not_contains')?.types).toEqual(
61
+ OPERATORS.find((o) => o.id === 'contains')?.types,
62
+ )
63
+ expect(operatorsForType('string').find((o) => o.id === 'not_contains')?.label).toBe(
64
+ 'does not contain',
65
+ )
66
+ })
67
+
68
+ it('allows contains_all on arrays only', () => {
69
+ expect(OPERATORS.find((o) => o.id === 'contains_all')?.types).toEqual(['array'])
70
+ for (const t of ['string', 'number', 'enum', 'contact-address'] as const) {
71
+ expect(operatorsForType(t).map((o) => o.id)).not.toContain('contains_all')
49
72
  }
50
73
  })
51
74
 
@@ -56,14 +79,32 @@ describe('operatorsForType', () => {
56
79
  }
57
80
  })
58
81
 
59
- it('allows contains/empty/not_empty on arrays', () => {
60
- const arrayOps = operatorsForType('array').map((o) => o.id)
61
- expect(arrayOps).toContain('contains')
62
- expect(arrayOps).toContain('empty')
63
- expect(arrayOps).toContain('not_empty')
64
- // No magnitude/text-shape operators on arrays.
65
- expect(arrayOps).not.toContain('gt')
66
- expect(arrayOps).not.toContain('starts_with')
82
+ it('offers exactly the set-membership operators on arrays, positive-first', () => {
83
+ const arrayOps = operatorsForType('array')
84
+ expect(arrayOps.map((o) => o.id)).toEqual([
85
+ 'contains',
86
+ 'not_contains',
87
+ 'in',
88
+ 'contains_all',
89
+ 'not_in',
90
+ 'empty',
91
+ 'not_empty',
92
+ ])
93
+ // Labelled in the words every CRM uses for membership.
94
+ expect(arrayOps.map((o) => o.label)).toEqual([
95
+ 'has',
96
+ 'does not have',
97
+ 'has any of',
98
+ 'has all of',
99
+ 'has none of',
100
+ 'is empty',
101
+ 'is not empty',
102
+ ])
103
+ // No magnitude / equality / text-shape operators on arrays.
104
+ const ids = arrayOps.map((o) => o.id)
105
+ expect(ids).not.toContain('eq')
106
+ expect(ids).not.toContain('gt')
107
+ expect(ids).not.toContain('starts_with')
67
108
  })
68
109
 
69
110
  it('allows identity operators on user, not text/numeric shapes', () => {
@@ -90,17 +131,31 @@ describe('arityOf', () => {
90
131
  expect(arityOf('empty')).toBe(0)
91
132
  expect(arityOf('not_empty')).toBe(0)
92
133
  })
93
- it("returns 'n' for in/not_in", () => {
134
+ it("returns 'n' for in/not_in/contains_all", () => {
94
135
  expect(arityOf('in')).toBe('n')
95
136
  expect(arityOf('not_in')).toBe('n')
137
+ expect(arityOf('contains_all')).toBe('n')
96
138
  })
97
139
  it('returns 1 for the rest', () => {
98
140
  expect(arityOf('eq')).toBe(1)
99
141
  expect(arityOf('contains')).toBe(1)
142
+ expect(arityOf('not_contains')).toBe(1)
100
143
  expect(arityOf('gt')).toBe(1)
101
144
  })
102
145
  })
103
146
 
147
+ describe('isEnumLike', () => {
148
+ it('is true for an enum and for an array carrying item options', () => {
149
+ expect(isEnumLike({ type: 'enum', options: [] })).toBe(true)
150
+ expect(isEnumLike({ type: 'array', options: [{ value: 'a', label: 'A' }] })).toBe(true)
151
+ })
152
+ it('is false for a free-text array and for scalars', () => {
153
+ expect(isEnumLike({ type: 'array' })).toBe(false)
154
+ expect(isEnumLike({ type: 'array', options: [] })).toBe(false)
155
+ expect(isEnumLike({ type: 'string' })).toBe(false)
156
+ })
157
+ })
158
+
104
159
  describe('defaultValueFor', () => {
105
160
  it('returns null for arity 0', () => {
106
161
  expect(defaultValueFor('string', 0)).toBeNull()
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  ComparisonOperator,
3
+ FilterField,
3
4
  FilterFieldType,
4
5
  FilterValue,
5
6
  OperatorArity,
@@ -64,6 +65,7 @@ export const OPERATORS: ReadonlyArray<OperatorDescriptor> = [
64
65
  'user',
65
66
  'principal',
66
67
  'contact-address',
68
+ 'array',
67
69
  ],
68
70
  },
69
71
  {
@@ -80,9 +82,18 @@ export const OPERATORS: ReadonlyArray<OperatorDescriptor> = [
80
82
  'user',
81
83
  'principal',
82
84
  'contact-address',
85
+ 'array',
83
86
  ],
84
87
  },
85
88
  { id: 'contains', label: 'contains', arity: 1, types: ['string', 'array', 'contact-address'] },
89
+ {
90
+ id: 'not_contains',
91
+ label: 'does not contain',
92
+ arity: 1,
93
+ types: ['string', 'array', 'contact-address'],
94
+ },
95
+ // Array-only: "has every one of these". On a scalar the backend rejects it.
96
+ { id: 'contains_all', label: 'has all of', arity: 'n', types: ['array'] },
86
97
  { id: 'starts_with', label: 'starts with', arity: 1, types: ['string', 'contact-address'] },
87
98
  { id: 'ends_with', label: 'ends with', arity: 1, types: ['string', 'contact-address'] },
88
99
  {
@@ -148,15 +159,45 @@ const OP_LABEL_OVERRIDES: Partial<
148
159
  integer: { gt: 'is greater than', lt: 'is less than', eq: 'equals', ne: 'does not equal' },
149
160
  currency: { gt: 'is greater than', lt: 'is less than', eq: 'equals', ne: 'does not equal' },
150
161
  datetime: { eq: 'is on', ne: 'is not on', gt: 'is after', lt: 'is before' },
151
- array: { contains: 'contains', empty: 'is empty', not_empty: 'is not empty' },
162
+ // Arrays filter by set membership, in the words every CRM uses for it.
163
+ array: {
164
+ contains: 'has',
165
+ not_contains: 'does not have',
166
+ in: 'has any of',
167
+ not_in: 'has none of',
168
+ contains_all: 'has all of',
169
+ empty: 'is empty',
170
+ not_empty: 'is not empty',
171
+ },
152
172
  boolean: { eq: 'is', ne: 'is not' },
153
173
  }
154
174
 
175
+ /* Per-type picker order where the catalogue's global order reads wrong.
176
+ Arrays go positive-first, single value before list, so the dropdown reads
177
+ has / does not have / has any of / has all of / has none of. */
178
+ const OP_ORDER_OVERRIDES: Partial<Record<FilterFieldType, ReadonlyArray<ComparisonOperator>>> = {
179
+ array: ['contains', 'not_contains', 'in', 'contains_all', 'not_in', 'empty', 'not_empty'],
180
+ }
181
+
155
182
  export function operatorsForType(type: FilterFieldType): ReadonlyArray<OperatorDescriptor> {
156
- return OPERATORS.filter((op) => op.types.includes(type)).map((op) => {
183
+ const ops = OPERATORS.filter((op) => op.types.includes(type)).map((op) => {
157
184
  const override = OP_LABEL_OVERRIDES[type]?.[op.id]
158
185
  return override ? { ...op, label: override } : op
159
186
  })
187
+ const order = OP_ORDER_OVERRIDES[type]
188
+ if (!order) return ops
189
+ return [...ops].sort((a, b) => order.indexOf(a.id) - order.indexOf(b.id))
190
+ }
191
+
192
+ /**
193
+ * True when the field's values come from a declared option list — an enum,
194
+ * or an array whose ITEMS are an enum. The value editor and the chip render
195
+ * these from `field.options` (Select / BadgeMultiSelect / Badge) rather than
196
+ * as free text.
197
+ */
198
+ export function isEnumLike(field: Pick<FilterField, 'type' | 'options'>): boolean {
199
+ if (field.type === 'enum') return true
200
+ return field.type === 'array' && (field.options?.length ?? 0) > 0
160
201
  }
161
202
 
162
203
  export function operatorById(id: ComparisonOperator): OperatorDescriptor | undefined {
@@ -42,7 +42,10 @@ export type FilterField = {
42
42
  /** Parent label for breadcrumb in chips, e.g. `'Owner'`. Omitted for top-level. */
43
43
  parentLabel?: string
44
44
  type: FilterFieldType
45
- /** Required when `type === 'enum'`; ignored otherwise. */
45
+ /** Required when `type === 'enum'`. For `type === 'array'` these are the
46
+ * ITEM enum's values (an array of enum items), so the membership editors
47
+ * and chips pick from the same list the record editor does. Ignored
48
+ * otherwise. */
46
49
  options?: ReadonlyArray<FilterFieldOption>
47
50
  /** Hint for `<Input type=…>`. Defaults derived from `type`. */
48
51
  inputType?: 'text' | 'number' | 'date' | 'datetime' | 'email'
@@ -111,6 +114,8 @@ export type ComparisonOperator =
111
114
  | 'in'
112
115
  | 'not_in'
113
116
  | 'contains'
117
+ | 'not_contains'
118
+ | 'contains_all'
114
119
  | 'starts_with'
115
120
  | 'ends_with'
116
121
  | 'empty'