@handled-ai/design-system 0.18.10 → 0.18.11

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.
@@ -3,6 +3,7 @@
3
3
  import * as React from "react"
4
4
  import {
5
5
  CalendarDays,
6
+ Check,
6
7
  DollarSign,
7
8
  Eye,
8
9
  Hash,
@@ -10,6 +11,7 @@ import {
10
11
  Plus,
11
12
  Trash2,
12
13
  Type,
14
+ type LucideIcon,
13
15
  } from "lucide-react"
14
16
 
15
17
  import { cn } from "../lib/utils"
@@ -36,15 +38,26 @@ export type ConditionOperator =
36
38
  | "is_null"
37
39
  | "is_not_null"
38
40
 
41
+ export interface ConditionOptionObject {
42
+ label: string
43
+ value: string
44
+ }
45
+
46
+ export type ConditionFieldOption = string | ConditionOptionObject
47
+
39
48
  export interface ConditionFieldDef {
40
49
  /** Unique field key (e.g., "Account_Balance__c") */
41
50
  id: string
42
51
  /** Display label (e.g., "Account Balance") */
43
52
  label: string
44
53
  /** Field data type — determines which operators are available and how the value input renders */
45
- type: "text" | "number" | "currency" | "date"
54
+ type: "text" | "number" | "currency" | "date" | "select" | "multi_select"
46
55
  /** Allowed operators for this field. Defaults based on type if not provided. */
47
56
  operators?: ConditionOperator[]
57
+ /** Options used by select and multi-select fields. Strings use the same label and value. */
58
+ options?: ConditionFieldOption[]
59
+ /** Show a search box for option-backed value inputs. */
60
+ searchable?: boolean | { threshold?: number }
48
61
  }
49
62
 
50
63
  export interface ConditionFilterValue {
@@ -52,7 +65,7 @@ export interface ConditionFilterValue {
52
65
  id: string
53
66
  field: string
54
67
  operator: ConditionOperator
55
- value: string | number | null
68
+ value: string | number | string[] | null
56
69
  }
57
70
 
58
71
  interface DataTableConditionFilterProps {
@@ -74,7 +87,7 @@ const OPERATOR_LABELS: Record<ConditionOperator, string> = {
74
87
  gte: "≥",
75
88
  lt: "<",
76
89
  lte: "≤",
77
- in: "contains",
90
+ in: "is any of",
78
91
  is_null: "is empty",
79
92
  is_not_null: "is not empty",
80
93
  }
@@ -95,6 +108,8 @@ const DEFAULT_OPERATORS: Record<ConditionFieldDef["type"], ConditionOperator[]>
95
108
  number: NUMERIC_OPERATORS,
96
109
  currency: NUMERIC_OPERATORS,
97
110
  date: ["gt", "gte", "lt", "lte", "eq", "neq", "is_null", "is_not_null"],
111
+ select: ["eq", "neq", "is_null", "is_not_null"],
112
+ multi_select: ["in", "is_null", "is_not_null"],
98
113
  }
99
114
 
100
115
  /** Generate a stable unique ID for a new condition row. */
@@ -108,7 +123,7 @@ function generateConditionId(): string {
108
123
  // ── Helpers ────────────────────────────────────────────────────
109
124
 
110
125
  function getOperators(field: ConditionFieldDef): ConditionOperator[] {
111
- return (field.operators ?? DEFAULT_OPERATORS[field.type]).filter((op) => op !== "in")
126
+ return field.operators ?? DEFAULT_OPERATORS[field.type]
112
127
  }
113
128
 
114
129
  function isUnaryOperator(op: ConditionOperator): boolean {
@@ -128,6 +143,18 @@ function createDraftCondition(field: ConditionFieldDef): ConditionFilterValue {
128
143
  }
129
144
  }
130
145
 
146
+ function normalizeConditionValue(
147
+ value: ConditionFilterValue["value"],
148
+ field: ConditionFieldDef,
149
+ operator: ConditionOperator,
150
+ ): ConditionFilterValue["value"] {
151
+ if (isUnaryOperator(operator)) return null
152
+ if (field.type === "multi_select") {
153
+ return Array.isArray(value) ? value : null
154
+ }
155
+ return Array.isArray(value) ? null : value
156
+ }
157
+
131
158
  function normalizeCondition(
132
159
  condition: ConditionFilterValue,
133
160
  fields: ConditionFieldDef[],
@@ -144,7 +171,7 @@ function normalizeCondition(
144
171
  ...condition,
145
172
  field: field.id,
146
173
  operator,
147
- value: isUnaryOperator(operator) ? null : condition.value,
174
+ value: normalizeConditionValue(condition.value, field, operator),
148
175
  }
149
176
  }
150
177
 
@@ -168,18 +195,40 @@ function isCompleteCondition(
168
195
  if (!field) return false
169
196
  if (!getOperators(field).includes(condition.operator)) return false
170
197
  if (isUnaryOperator(condition.operator)) return true
171
- return condition.value !== null && condition.value !== ""
198
+ if (field.type === "multi_select") {
199
+ return Array.isArray(condition.value) && condition.value.length > 0
200
+ }
201
+ return condition.value !== null && condition.value !== "" && !Array.isArray(condition.value)
202
+ }
203
+
204
+ function getConditionValueSignature(value: ConditionFilterValue["value"]): string {
205
+ return Array.isArray(value) ? JSON.stringify(value) : String(value)
172
206
  }
173
207
 
174
208
  function getConditionsSignature(conditions: ConditionFilterValue[]): string {
175
209
  return conditions
176
- .map((condition) => `${condition.id}:${condition.field}:${condition.operator}:${String(condition.value)}`)
210
+ .map((condition) => `${condition.id}:${condition.field}:${condition.operator}:${getConditionValueSignature(condition.value)}`)
177
211
  .join(";")
178
212
  }
179
213
 
214
+ function normalizeFieldOptions(field: ConditionFieldDef): ConditionOptionObject[] {
215
+ return (field.options ?? []).map((option) =>
216
+ typeof option === "string" ? { label: option, value: option } : option,
217
+ )
218
+ }
219
+
180
220
  function getFieldsSignature(fields: ConditionFieldDef[]): string {
181
221
  return fields
182
- .map((field) => `${field.id}:${field.type}:${field.label}:${(field.operators ?? []).join("|")}`)
222
+ .map((field) => {
223
+ const optionsSignature = normalizeFieldOptions(field)
224
+ .map((option) => `${option.label}:${option.value}`)
225
+ .join("|")
226
+ const searchSignature =
227
+ typeof field.searchable === "object"
228
+ ? `threshold:${field.searchable.threshold ?? ""}`
229
+ : String(field.searchable ?? "")
230
+ return `${field.id}:${field.type}:${field.label}:${(field.operators ?? []).join("|")}:${optionsSignature}:${searchSignature}`
231
+ })
183
232
  .join(";")
184
233
  }
185
234
 
@@ -192,15 +241,198 @@ function getCommittedConditions(
192
241
  .filter((condition) => isCompleteCondition(condition, fields))
193
242
  }
194
243
 
195
- function getFieldIcon(type: ConditionFieldDef["type"]) {
196
- if (type === "currency") return DollarSign
197
- if (type === "number") return Hash
198
- if (type === "date") return CalendarDays
199
- return Type
244
+ const FIELD_ICON_BY_TYPE: Record<ConditionFieldDef["type"], LucideIcon> = {
245
+ text: Type,
246
+ number: Hash,
247
+ currency: DollarSign,
248
+ date: CalendarDays,
249
+ select: MoreHorizontal,
250
+ multi_select: MoreHorizontal,
251
+ }
252
+
253
+ function getFieldIcon(type: ConditionFieldDef["type"]): LucideIcon {
254
+ return FIELD_ICON_BY_TYPE[type]
200
255
  }
201
256
 
202
257
  // ── Condition Row ──────────────────────────────────────────────
203
258
 
259
+ function getInputType(fieldType: ConditionFieldDef["type"]): "text" | "number" | "date" {
260
+ if (fieldType === "number" || fieldType === "currency") return "number"
261
+ if (fieldType === "date") return "date"
262
+ return "text"
263
+ }
264
+
265
+ function getInputPlaceholder(fieldType: ConditionFieldDef["type"]): string {
266
+ if (fieldType === "currency") return "Amount"
267
+ if (fieldType === "number") return "Enter number..."
268
+ if (fieldType === "date") return ""
269
+ return "Enter value..."
270
+ }
271
+
272
+ interface ConditionValueInputProps {
273
+ condition: ConditionFilterValue
274
+ fieldDef: ConditionFieldDef
275
+ onValueChange: (event: React.ChangeEvent<HTMLInputElement>) => void
276
+ onSelectValueChange: (value: string) => void
277
+ onMultiSelectValueToggle: (value: string) => void
278
+ onCommit: () => void
279
+ }
280
+
281
+ function shouldShowOptionSearch(
282
+ fieldDef: ConditionFieldDef,
283
+ optionCount: number,
284
+ ): boolean {
285
+ if (fieldDef.searchable === true) return true
286
+ if (fieldDef.searchable === false) return false
287
+ if (typeof fieldDef.searchable === "object") {
288
+ return optionCount >= (fieldDef.searchable.threshold ?? 8)
289
+ }
290
+ return false
291
+ }
292
+
293
+ function SelectConditionValueInput({
294
+ condition,
295
+ fieldDef,
296
+ onSelectValueChange,
297
+ }: Pick<ConditionValueInputProps, "condition" | "fieldDef" | "onSelectValueChange">) {
298
+ const [query, setQuery] = React.useState("")
299
+ const options = normalizeFieldOptions(fieldDef)
300
+ const normalizedQuery = query.trim().toLowerCase()
301
+ const filteredOptions = normalizedQuery
302
+ ? options.filter((option) => option.label.toLowerCase().includes(normalizedQuery))
303
+ : options
304
+ const showSearch = shouldShowOptionSearch(fieldDef, options.length)
305
+
306
+ return (
307
+ <Select
308
+ value={typeof condition.value === "string" ? condition.value : ""}
309
+ onValueChange={onSelectValueChange}
310
+ >
311
+ <SelectTrigger className="h-8 w-full" size="sm">
312
+ <SelectValue placeholder="Select value..." />
313
+ </SelectTrigger>
314
+ <SelectContent>
315
+ {showSearch ? (
316
+ <div className="sticky top-0 z-10 border-b border-border bg-popover p-1.5">
317
+ <Input
318
+ value={query}
319
+ onChange={(event) => setQuery(event.target.value)}
320
+ onClick={(event) => event.stopPropagation()}
321
+ onKeyDown={(event) => event.stopPropagation()}
322
+ placeholder="Search options..."
323
+ className="h-7 text-xs"
324
+ />
325
+ </div>
326
+ ) : null}
327
+ {filteredOptions.length > 0 ? (
328
+ filteredOptions.map((option) => (
329
+ <SelectItem key={option.value} value={option.value}>
330
+ {option.label}
331
+ </SelectItem>
332
+ ))
333
+ ) : (
334
+ <div className="px-2 py-1.5 text-xs text-muted-foreground">No options</div>
335
+ )}
336
+ </SelectContent>
337
+ </Select>
338
+ )
339
+ }
340
+
341
+ function MultiSelectConditionValueInput({
342
+ condition,
343
+ fieldDef,
344
+ onMultiSelectValueToggle,
345
+ }: Pick<ConditionValueInputProps, "condition" | "fieldDef" | "onMultiSelectValueToggle">) {
346
+ const selectedValues = Array.isArray(condition.value) ? condition.value : []
347
+ const options = normalizeFieldOptions(fieldDef)
348
+
349
+ return (
350
+ <div className="max-h-28 overflow-y-auto rounded-md border border-border bg-background p-1">
351
+ {options.length > 0 ? (
352
+ options.map((option) => {
353
+ const checked = selectedValues.includes(option.value)
354
+ return (
355
+ <button
356
+ key={option.value}
357
+ type="button"
358
+ role="checkbox"
359
+ aria-checked={checked}
360
+ className={cn(
361
+ "flex w-full items-center gap-2 rounded-sm px-2 py-1 text-left text-xs hover:bg-muted",
362
+ checked && "text-brand-purple",
363
+ )}
364
+ onClick={(event) => {
365
+ event.stopPropagation()
366
+ onMultiSelectValueToggle(option.value)
367
+ }}
368
+ >
369
+ <span
370
+ className={cn(
371
+ "flex h-3.5 w-3.5 items-center justify-center rounded-sm border border-border",
372
+ checked && "border-brand-purple bg-brand-purple text-white",
373
+ )}
374
+ aria-hidden="true"
375
+ >
376
+ {checked ? <Check className="h-3 w-3" /> : null}
377
+ </span>
378
+ <span className="min-w-0 flex-1 truncate">{option.label}</span>
379
+ </button>
380
+ )
381
+ })
382
+ ) : (
383
+ <div className="px-2 py-1 text-xs text-muted-foreground">No options</div>
384
+ )}
385
+ </div>
386
+ )
387
+ }
388
+
389
+ function ScalarConditionValueInput({
390
+ condition,
391
+ fieldDef,
392
+ onValueChange,
393
+ onCommit,
394
+ }: Pick<ConditionValueInputProps, "condition" | "fieldDef" | "onValueChange" | "onCommit">) {
395
+ return (
396
+ <div className="relative flex items-center">
397
+ {fieldDef.type === "currency" ? (
398
+ <span className="pointer-events-none absolute left-2 text-sm text-muted-foreground">
399
+ $
400
+ </span>
401
+ ) : null}
402
+ <Input
403
+ type={getInputType(fieldDef.type)}
404
+ value={
405
+ condition.value != null && !Array.isArray(condition.value)
406
+ ? String(condition.value)
407
+ : ""
408
+ }
409
+ onChange={onValueChange}
410
+ onClick={(event) => event.stopPropagation()}
411
+ onKeyDown={(event) => {
412
+ event.stopPropagation()
413
+ if (event.key === "Enter") {
414
+ onCommit()
415
+ }
416
+ }}
417
+ placeholder={getInputPlaceholder(fieldDef.type)}
418
+ className={cn("h-8", fieldDef.type === "currency" && "pl-6")}
419
+ />
420
+ </div>
421
+ )
422
+ }
423
+
424
+ function ConditionValueInput(props: ConditionValueInputProps) {
425
+ if (props.fieldDef.type === "select") {
426
+ return <SelectConditionValueInput {...props} />
427
+ }
428
+
429
+ if (props.fieldDef.type === "multi_select") {
430
+ return <MultiSelectConditionValueInput {...props} />
431
+ }
432
+
433
+ return <ScalarConditionValueInput {...props} />
434
+ }
435
+
204
436
  interface ConditionRowProps {
205
437
  condition: ConditionFilterValue
206
438
  fields: ConditionFieldDef[]
@@ -238,7 +470,7 @@ function ConditionRow({
238
470
  onChange({
239
471
  ...condition,
240
472
  operator: newOp,
241
- value: isUnaryOperator(newOp) ? null : condition.value,
473
+ value: normalizeConditionValue(condition.value, fieldDef, newOp),
242
474
  })
243
475
  }
244
476
 
@@ -249,6 +481,26 @@ function ConditionRow({
249
481
  })
250
482
  }
251
483
 
484
+ const handleSelectValueChange = (value: string) => {
485
+ onChange({
486
+ ...condition,
487
+ value,
488
+ })
489
+ }
490
+
491
+ const handleMultiSelectValueToggle = (value: string) => {
492
+ const currentValues = Array.isArray(condition.value) ? condition.value : []
493
+ const nextValues = currentValues.includes(value)
494
+ ? currentValues.filter((currentValue) => currentValue !== value)
495
+ : [...currentValues, value]
496
+
497
+ onChange({
498
+ ...condition,
499
+ value: nextValues,
500
+ })
501
+ }
502
+
503
+
252
504
  return (
253
505
  <div
254
506
  className="grid grid-cols-[52px_minmax(150px,1fr)_120px_minmax(140px,1fr)_auto] items-center gap-2 rounded-lg border border-border/70 bg-background p-2 shadow-sm"
@@ -299,41 +551,14 @@ function ConditionRow({
299
551
  No value needed
300
552
  </div>
301
553
  ) : (
302
- <div className="relative flex items-center">
303
- {fieldDef.type === "currency" ? (
304
- <span className="pointer-events-none absolute left-2 text-sm text-muted-foreground">
305
- $
306
- </span>
307
- ) : null}
308
- <Input
309
- type={
310
- fieldDef.type === "number" || fieldDef.type === "currency"
311
- ? "number"
312
- : fieldDef.type === "date"
313
- ? "date"
314
- : "text"
315
- }
316
- value={condition.value != null ? String(condition.value) : ""}
317
- onChange={handleValueChange}
318
- onClick={(event) => event.stopPropagation()}
319
- onKeyDown={(event) => {
320
- event.stopPropagation()
321
- if (event.key === "Enter") {
322
- onCommit()
323
- }
324
- }}
325
- placeholder={
326
- fieldDef.type === "currency"
327
- ? "Amount"
328
- : fieldDef.type === "number"
329
- ? "Enter number..."
330
- : fieldDef.type === "date"
331
- ? ""
332
- : "Enter value..."
333
- }
334
- className={cn("h-8", fieldDef.type === "currency" && "pl-6")}
335
- />
336
- </div>
554
+ <ConditionValueInput
555
+ condition={condition}
556
+ fieldDef={fieldDef}
557
+ onValueChange={handleValueChange}
558
+ onSelectValueChange={handleSelectValueChange}
559
+ onMultiSelectValueToggle={handleMultiSelectValueToggle}
560
+ onCommit={onCommit}
561
+ />
337
562
  )}
338
563
 
339
564
  <div className="flex items-center gap-1">
@@ -160,7 +160,7 @@ export function DataTableFilter({
160
160
  <ListFilter className="h-3.5 w-3.5" />
161
161
  Filter
162
162
  {activeCount > 0 ? (
163
- <span className="rounded bg-muted px-1.5 py-0 text-[10px] font-semibold text-foreground">
163
+ <span className="rounded bg-muted px-1.5 py-0 text-[10px] font-semibold">
164
164
  {activeCount}
165
165
  </span>
166
166
  ) : null}