@handled-ai/design-system 0.16.0 → 0.16.1

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/components/badge.d.ts +1 -1
  2. package/dist/components/button.d.ts +1 -1
  3. package/dist/components/data-table-condition-filter.d.ts +37 -0
  4. package/dist/components/data-table-condition-filter.js +407 -0
  5. package/dist/components/data-table-condition-filter.js.map +1 -0
  6. package/dist/components/data-table-filter.d.ts +12 -1
  7. package/dist/components/data-table-filter.js +92 -10
  8. package/dist/components/data-table-filter.js.map +1 -1
  9. package/dist/components/data-table-toolbar.d.ts +1 -0
  10. package/dist/components/data-table.d.ts +1 -0
  11. package/dist/components/tabs.d.ts +1 -1
  12. package/dist/index.d.ts +1 -0
  13. package/dist/index.js +1 -0
  14. package/dist/index.js.map +1 -1
  15. package/dist/prototype/index.d.ts +1 -0
  16. package/dist/prototype/prototype-accounts-view.d.ts +1 -0
  17. package/dist/prototype/prototype-admin-view.d.ts +1 -0
  18. package/dist/prototype/prototype-config.d.ts +1 -0
  19. package/dist/prototype/prototype-inbox-view.d.ts +4 -1
  20. package/dist/prototype/prototype-inbox-view.js +6 -1
  21. package/dist/prototype/prototype-inbox-view.js.map +1 -1
  22. package/dist/prototype/prototype-insights-view.d.ts +1 -0
  23. package/dist/prototype/prototype-shell.d.ts +1 -0
  24. package/package.json +1 -1
  25. package/src/components/__tests__/contact-list.test.tsx +1 -1
  26. package/src/components/__tests__/data-table-condition-filter.test.tsx +397 -0
  27. package/src/components/__tests__/data-table-filter-presets.test.tsx +1 -1
  28. package/src/components/__tests__/data-table-filter.test.tsx +270 -3
  29. package/src/components/data-table-condition-filter.tsx +513 -0
  30. package/src/components/data-table-filter.tsx +102 -4
  31. package/src/index.ts +1 -0
  32. package/src/prototype/__tests__/detail-view-attention.test.tsx +101 -0
  33. package/src/prototype/prototype-inbox-view.tsx +8 -0
@@ -0,0 +1,513 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import {
5
+ CalendarDays,
6
+ DollarSign,
7
+ Eye,
8
+ Hash,
9
+ MoreHorizontal,
10
+ Plus,
11
+ Trash2,
12
+ Type,
13
+ } from "lucide-react"
14
+
15
+ import { cn } from "../lib/utils"
16
+ import { Button } from "./button"
17
+ import { Input } from "./input"
18
+ import {
19
+ Select,
20
+ SelectContent,
21
+ SelectItem,
22
+ SelectTrigger,
23
+ SelectValue,
24
+ } from "./select"
25
+
26
+ // ── Types ──────────────────────────────────────────────────────
27
+
28
+ export type ConditionOperator =
29
+ | "eq"
30
+ | "neq"
31
+ | "gt"
32
+ | "gte"
33
+ | "lt"
34
+ | "lte"
35
+ | "in"
36
+ | "is_null"
37
+ | "is_not_null"
38
+
39
+ export interface ConditionFieldDef {
40
+ /** Unique field key (e.g., "Account_Balance__c") */
41
+ id: string
42
+ /** Display label (e.g., "Account Balance") */
43
+ label: string
44
+ /** Field data type — determines which operators are available and how the value input renders */
45
+ type: "text" | "number" | "currency" | "date"
46
+ /** Allowed operators for this field. Defaults based on type if not provided. */
47
+ operators?: ConditionOperator[]
48
+ }
49
+
50
+ export interface ConditionFilterValue {
51
+ /** Stable identity — used as React key to avoid stale-state bugs on removal */
52
+ id: string
53
+ field: string
54
+ operator: ConditionOperator
55
+ value: string | number | null
56
+ }
57
+
58
+ interface DataTableConditionFilterProps {
59
+ /** Available fields the user can filter on */
60
+ fields: ConditionFieldDef[]
61
+ /** Current active conditions */
62
+ conditions: ConditionFilterValue[]
63
+ /** Called when conditions change (add, update, remove) */
64
+ onConditionsChange: (conditions: ConditionFilterValue[]) => void
65
+ className?: string
66
+ }
67
+
68
+ // ── Constants ──────────────────────────────────────────────────
69
+
70
+ const OPERATOR_LABELS: Record<ConditionOperator, string> = {
71
+ eq: "=",
72
+ neq: "≠",
73
+ gt: ">",
74
+ gte: "≥",
75
+ lt: "<",
76
+ lte: "≤",
77
+ in: "contains",
78
+ is_null: "is empty",
79
+ is_not_null: "is not empty",
80
+ }
81
+
82
+ const NUMERIC_OPERATORS: ConditionOperator[] = [
83
+ "eq",
84
+ "neq",
85
+ "gt",
86
+ "gte",
87
+ "lt",
88
+ "lte",
89
+ "is_null",
90
+ "is_not_null",
91
+ ]
92
+
93
+ const DEFAULT_OPERATORS: Record<ConditionFieldDef["type"], ConditionOperator[]> = {
94
+ text: ["eq", "neq", "is_null", "is_not_null"],
95
+ number: NUMERIC_OPERATORS,
96
+ currency: NUMERIC_OPERATORS,
97
+ date: ["gt", "gte", "lt", "lte", "eq", "neq", "is_null", "is_not_null"],
98
+ }
99
+
100
+ /** Generate a stable unique ID for a new condition row. */
101
+ function generateConditionId(): string {
102
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
103
+ return crypto.randomUUID()
104
+ }
105
+ return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
106
+ }
107
+
108
+ // ── Helpers ────────────────────────────────────────────────────
109
+
110
+ function getOperators(field: ConditionFieldDef): ConditionOperator[] {
111
+ return (field.operators ?? DEFAULT_OPERATORS[field.type]).filter((op) => op !== "in")
112
+ }
113
+
114
+ function isUnaryOperator(op: ConditionOperator): boolean {
115
+ return op === "is_null" || op === "is_not_null"
116
+ }
117
+
118
+ function getDefaultOperator(field: ConditionFieldDef): ConditionOperator {
119
+ return getOperators(field)[0] ?? "eq"
120
+ }
121
+
122
+ function createDraftCondition(field: ConditionFieldDef): ConditionFilterValue {
123
+ return {
124
+ id: generateConditionId(),
125
+ field: field.id,
126
+ operator: getDefaultOperator(field),
127
+ value: null,
128
+ }
129
+ }
130
+
131
+ function normalizeCondition(
132
+ condition: ConditionFilterValue,
133
+ fields: ConditionFieldDef[],
134
+ ): ConditionFilterValue {
135
+ const field = fields.find((f) => f.id === condition.field) ?? fields[0]
136
+ if (!field) return condition
137
+
138
+ const operators = getOperators(field)
139
+ const operator = operators.includes(condition.operator)
140
+ ? condition.operator
141
+ : getDefaultOperator(field)
142
+
143
+ return {
144
+ ...condition,
145
+ field: field.id,
146
+ operator,
147
+ value: isUnaryOperator(operator) ? null : condition.value,
148
+ }
149
+ }
150
+
151
+ function parseConditionValue(
152
+ raw: string,
153
+ fieldType: ConditionFieldDef["type"],
154
+ ): string | number | null {
155
+ if (raw === "") return null
156
+ if (fieldType === "number" || fieldType === "currency") {
157
+ const parsed = Number(raw)
158
+ return Number.isNaN(parsed) ? null : parsed
159
+ }
160
+ return raw
161
+ }
162
+
163
+ function isCompleteCondition(
164
+ condition: ConditionFilterValue,
165
+ fields: ConditionFieldDef[],
166
+ ): boolean {
167
+ const field = fields.find((f) => f.id === condition.field)
168
+ if (!field) return false
169
+ if (!getOperators(field).includes(condition.operator)) return false
170
+ if (isUnaryOperator(condition.operator)) return true
171
+ return condition.value !== null && condition.value !== ""
172
+ }
173
+
174
+ function getFieldIcon(type: ConditionFieldDef["type"]) {
175
+ if (type === "currency") return DollarSign
176
+ if (type === "number") return Hash
177
+ if (type === "date") return CalendarDays
178
+ return Type
179
+ }
180
+
181
+ // ── Condition Row ──────────────────────────────────────────────
182
+
183
+ interface ConditionRowProps {
184
+ condition: ConditionFilterValue
185
+ fields: ConditionFieldDef[]
186
+ index: number
187
+ onChange: (updated: ConditionFilterValue) => void
188
+ onRemove: () => void
189
+ onCommit: () => void
190
+ }
191
+
192
+ function ConditionRow({
193
+ condition,
194
+ fields,
195
+ index,
196
+ onChange,
197
+ onRemove,
198
+ onCommit,
199
+ }: ConditionRowProps) {
200
+ const fieldDef = fields.find((f) => f.id === condition.field) ?? fields[0]
201
+ const operators = getOperators(fieldDef)
202
+ const isUnary = isUnaryOperator(condition.operator)
203
+ const FieldIcon = getFieldIcon(fieldDef.type)
204
+
205
+ const handleFieldChange = (newFieldId: string) => {
206
+ const newFieldDef = fields.find((f) => f.id === newFieldId) ?? fields[0]
207
+ if (!newFieldDef) return
208
+ onChange({
209
+ ...condition,
210
+ field: newFieldDef.id,
211
+ operator: getDefaultOperator(newFieldDef),
212
+ value: null,
213
+ })
214
+ }
215
+
216
+ const handleOperatorChange = (newOp: ConditionOperator) => {
217
+ onChange({
218
+ ...condition,
219
+ operator: newOp,
220
+ value: isUnaryOperator(newOp) ? null : condition.value,
221
+ })
222
+ }
223
+
224
+ const handleValueChange = (event: React.ChangeEvent<HTMLInputElement>) => {
225
+ onChange({
226
+ ...condition,
227
+ value: parseConditionValue(event.target.value, fieldDef.type),
228
+ })
229
+ }
230
+
231
+ return (
232
+ <div
233
+ 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"
234
+ data-slot="condition-row"
235
+ >
236
+ <div className="text-xs font-medium text-muted-foreground">
237
+ {index === 0 ? "Where" : "And"}
238
+ </div>
239
+
240
+ <Select value={condition.field} onValueChange={handleFieldChange}>
241
+ <SelectTrigger className="h-8 w-full justify-start gap-2" size="sm">
242
+ <FieldIcon className="h-3.5 w-3.5 text-muted-foreground" />
243
+ <SelectValue placeholder={fieldDef.label} />
244
+ </SelectTrigger>
245
+ <SelectContent>
246
+ {fields.map((field) => {
247
+ const Icon = getFieldIcon(field.type)
248
+ return (
249
+ <SelectItem key={field.id} value={field.id}>
250
+ <span className="inline-flex items-center gap-2">
251
+ <Icon className="h-3.5 w-3.5 text-muted-foreground" />
252
+ {field.label}
253
+ </span>
254
+ </SelectItem>
255
+ )
256
+ })}
257
+ </SelectContent>
258
+ </Select>
259
+
260
+ <Select
261
+ value={condition.operator}
262
+ onValueChange={(value) => handleOperatorChange(value as ConditionOperator)}
263
+ >
264
+ <SelectTrigger className="h-8 w-full" size="sm">
265
+ <SelectValue placeholder="Operator" />
266
+ </SelectTrigger>
267
+ <SelectContent>
268
+ {operators.map((op) => (
269
+ <SelectItem key={op} value={op}>
270
+ {OPERATOR_LABELS[op]}
271
+ </SelectItem>
272
+ ))}
273
+ </SelectContent>
274
+ </Select>
275
+
276
+ {isUnary ? (
277
+ <div className="h-8 rounded-md border border-dashed border-border/70 bg-muted/30 px-3 py-1.5 text-xs text-muted-foreground">
278
+ No value needed
279
+ </div>
280
+ ) : (
281
+ <div className="relative flex items-center">
282
+ {fieldDef.type === "currency" ? (
283
+ <span className="pointer-events-none absolute left-2 text-sm text-muted-foreground">
284
+ $
285
+ </span>
286
+ ) : null}
287
+ <Input
288
+ type={
289
+ fieldDef.type === "number" || fieldDef.type === "currency"
290
+ ? "number"
291
+ : fieldDef.type === "date"
292
+ ? "date"
293
+ : "text"
294
+ }
295
+ value={condition.value != null ? String(condition.value) : ""}
296
+ onChange={handleValueChange}
297
+ onClick={(event) => event.stopPropagation()}
298
+ onKeyDown={(event) => {
299
+ event.stopPropagation()
300
+ if (event.key === "Enter") {
301
+ onCommit()
302
+ }
303
+ }}
304
+ placeholder={
305
+ fieldDef.type === "currency"
306
+ ? "Amount"
307
+ : fieldDef.type === "number"
308
+ ? "Enter number..."
309
+ : fieldDef.type === "date"
310
+ ? ""
311
+ : "Enter value..."
312
+ }
313
+ className={cn("h-8", fieldDef.type === "currency" && "pl-6")}
314
+ />
315
+ </div>
316
+ )}
317
+
318
+ <div className="flex items-center gap-1">
319
+ <Button
320
+ type="button"
321
+ variant="ghost"
322
+ size="icon"
323
+ className="h-8 w-8 text-muted-foreground"
324
+ aria-label="Toggle condition visibility"
325
+ onClick={(event) => event.preventDefault()}
326
+ >
327
+ <Eye className="size-4" />
328
+ </Button>
329
+ <Button
330
+ type="button"
331
+ variant="ghost"
332
+ size="icon"
333
+ className="h-8 w-8 text-muted-foreground"
334
+ aria-label="More condition actions"
335
+ onClick={(event) => event.preventDefault()}
336
+ >
337
+ <MoreHorizontal className="size-4" />
338
+ </Button>
339
+ <Button
340
+ type="button"
341
+ variant="ghost"
342
+ size="icon"
343
+ className="h-8 w-8 text-muted-foreground hover:text-destructive"
344
+ onClick={onRemove}
345
+ aria-label="Remove condition"
346
+ >
347
+ <Trash2 className="size-4" />
348
+ </Button>
349
+ </div>
350
+ </div>
351
+ )
352
+ }
353
+
354
+ // ── Main Component ─────────────────────────────────────────────
355
+
356
+ function DataTableConditionFilter({
357
+ fields,
358
+ conditions,
359
+ onConditionsChange,
360
+ className,
361
+ }: DataTableConditionFilterProps) {
362
+ const [drafts, setDrafts] = React.useState<ConditionFilterValue[]>(() =>
363
+ conditions.map((condition) => normalizeCondition(condition, fields)),
364
+ )
365
+
366
+ React.useEffect(() => {
367
+ setDrafts(conditions.map((condition) => normalizeCondition(condition, fields)))
368
+ }, [conditions, fields])
369
+
370
+ const commitDrafts = React.useCallback(
371
+ (nextDrafts: ConditionFilterValue[] = drafts) => {
372
+ onConditionsChange(
373
+ nextDrafts
374
+ .map((condition) => normalizeCondition(condition, fields))
375
+ .filter((condition) => isCompleteCondition(condition, fields)),
376
+ )
377
+ },
378
+ [drafts, fields, onConditionsChange],
379
+ )
380
+
381
+ const handleAdd = () => {
382
+ const firstField = fields[0]
383
+ if (!firstField) return
384
+ const committedDrafts = drafts.filter((condition) =>
385
+ isCompleteCondition(condition, fields),
386
+ )
387
+ const nextDrafts = [...committedDrafts, createDraftCondition(firstField)]
388
+ setDrafts(nextDrafts)
389
+ commitDrafts(committedDrafts)
390
+ }
391
+
392
+ const handleUpdate = (index: number, updated: ConditionFilterValue) => {
393
+ setDrafts((current) => {
394
+ const next = [...current]
395
+ next[index] = normalizeCondition(updated, fields)
396
+ return next
397
+ })
398
+ }
399
+
400
+ const handleRemove = (index: number) => {
401
+ setDrafts((current) => {
402
+ const next = current.filter((_, currentIndex) => currentIndex !== index)
403
+ commitDrafts(next)
404
+ return next
405
+ })
406
+ }
407
+
408
+ const handleClear = () => {
409
+ setDrafts([])
410
+ onConditionsChange([])
411
+ }
412
+
413
+ const hasAppliedConditions = conditions.length > 0
414
+ const hasDrafts = drafts.length > 0
415
+
416
+ return (
417
+ <div
418
+ className={cn(
419
+ "w-[min(760px,calc(100vw-2rem))] rounded-xl border border-border bg-background p-3 text-foreground shadow-xl",
420
+ className,
421
+ )}
422
+ data-slot="condition-filter"
423
+ onClick={(event) => event.stopPropagation()}
424
+ onKeyDown={(event) => {
425
+ if (event.key !== "Escape") {
426
+ event.stopPropagation()
427
+ }
428
+ }}
429
+ >
430
+ <div className="mb-3 flex items-center justify-between gap-3 border-b border-border/70 pb-3">
431
+ <div>
432
+ <div className="text-sm font-semibold">Filter builder</div>
433
+ <div className="text-xs text-muted-foreground">
434
+ Build field, operator, and value conditions.
435
+ </div>
436
+ </div>
437
+ <Button
438
+ type="button"
439
+ variant="ghost"
440
+ size="sm"
441
+ className="h-8 text-xs text-destructive hover:text-destructive"
442
+ onClick={handleClear}
443
+ disabled={!hasAppliedConditions && !hasDrafts}
444
+ >
445
+ Clear filters
446
+ </Button>
447
+ </div>
448
+
449
+ <div className="flex flex-col gap-2">
450
+ {drafts.map((condition, index) => (
451
+ <ConditionRow
452
+ key={condition.id}
453
+ condition={condition}
454
+ fields={fields}
455
+ index={index}
456
+ onChange={(updated) => handleUpdate(index, updated)}
457
+ onRemove={() => handleRemove(index)}
458
+ onCommit={() => commitDrafts()}
459
+ />
460
+ ))}
461
+ </div>
462
+
463
+ {!hasDrafts ? (
464
+ <div className="rounded-lg border border-dashed border-border/80 bg-muted/20 px-3 py-5 text-center text-xs text-muted-foreground">
465
+ No builder filters yet. Add a filter to start a condition row.
466
+ </div>
467
+ ) : null}
468
+
469
+ <div className="mt-3 flex flex-wrap items-center justify-between gap-2 border-t border-border/70 pt-3">
470
+ <div className="flex items-center gap-2">
471
+ <Button
472
+ type="button"
473
+ variant="ghost"
474
+ size="sm"
475
+ className="h-8 text-xs text-muted-foreground"
476
+ onClick={handleAdd}
477
+ >
478
+ <Plus className="mr-1 size-3.5" />
479
+ Add filter
480
+ </Button>
481
+ <Button
482
+ type="button"
483
+ variant="ghost"
484
+ size="sm"
485
+ className="h-8 text-xs text-muted-foreground opacity-60"
486
+ disabled
487
+ aria-disabled="true"
488
+ >
489
+ <Plus className="mr-1 size-3.5" />
490
+ Add filter group
491
+ </Button>
492
+ </div>
493
+ <Button
494
+ type="button"
495
+ size="sm"
496
+ className="h-8 text-xs"
497
+ onClick={() => commitDrafts()}
498
+ >
499
+ Apply
500
+ </Button>
501
+ </div>
502
+ </div>
503
+ )
504
+ }
505
+
506
+ export {
507
+ DataTableConditionFilter,
508
+ OPERATOR_LABELS,
509
+ DEFAULT_OPERATORS,
510
+ generateConditionId,
511
+ getOperators,
512
+ }
513
+ export type { DataTableConditionFilterProps }
@@ -1,10 +1,17 @@
1
1
  "use client"
2
2
 
3
3
  import * as React from "react"
4
- import { ListFilter, Search } from "lucide-react"
4
+ import { Check, ListFilter, Plus, Search } from "lucide-react"
5
+
6
+ import { Popover as PopoverPrimitive } from "radix-ui"
5
7
 
6
8
  import { cn } from "../lib/utils"
7
9
  import { Button } from "./button"
10
+ import {
11
+ DataTableConditionFilter,
12
+ type ConditionFieldDef,
13
+ type ConditionFilterValue,
14
+ } from "./data-table-condition-filter"
8
15
  import {
9
16
  DropdownMenu,
10
17
  DropdownMenuContent,
@@ -25,6 +32,8 @@ export interface DataTableFilterCategory {
25
32
  label: string
26
33
  icon: React.ComponentType<{ className?: string }>
27
34
  options: (string | FilterOption)[]
35
+ /** Filter behavior. Defaults to "multi" (checkbox multi-select). */
36
+ type?: "multi" | "single" | "boolean"
28
37
  }
29
38
 
30
39
  function getOptionValue(option: string | FilterOption): string {
@@ -47,6 +56,14 @@ export interface DataTableFilterProps {
47
56
  onTogglePreset?: (categoryId: string, option: string) => void
48
57
  /** Label shown on preset chips to distinguish from user-applied filters. Default: "Default". */
49
58
  presetLabel?: string
59
+ /** Fields exposed in the unified condition-builder panel. */
60
+ conditionFields?: ConditionFieldDef[]
61
+ /** Active builder-managed field/operator/value conditions. */
62
+ conditionFilters?: ConditionFilterValue[]
63
+ /** Callback when builder-managed conditions are applied, removed, or cleared. */
64
+ onConditionFiltersChange?: (conditions: ConditionFilterValue[]) => void
65
+ /** Dropdown entry label for the condition-builder panel. Default: "Add filter". */
66
+ conditionBuilderLabel?: string
50
67
  }
51
68
 
52
69
  export function DataTableFilter({
@@ -58,9 +75,15 @@ export function DataTableFilter({
58
75
  presetFilters,
59
76
  onTogglePreset,
60
77
  presetLabel = "Default",
78
+ conditionFields = [],
79
+ conditionFilters = [],
80
+ onConditionFiltersChange,
81
+ conditionBuilderLabel = "Add filter",
61
82
  }: DataTableFilterProps) {
62
83
  const [query, setQuery] = React.useState("")
63
84
  const [subQueries, setSubQueries] = React.useState<Record<string, string>>({})
85
+ const [conditionBuilderOpen, setConditionBuilderOpen] = React.useState(false)
86
+ const hasConditionBuilder = conditionFields.length > 0
64
87
 
65
88
  const visibleCategories = React.useMemo(() => {
66
89
  const normalized = query.trim().toLowerCase()
@@ -95,7 +118,7 @@ export function DataTableFilter({
95
118
  )
96
119
 
97
120
  // Count active preset filters (those that are in presetFilters AND currently active in selectedFilters)
98
- let presetCount = 0
121
+ const presetCount = 0
99
122
  if (presetFilters) {
100
123
  for (const [categoryId, presetValues] of Object.entries(presetFilters)) {
101
124
  for (const value of presetValues) {
@@ -109,8 +132,8 @@ export function DataTableFilter({
109
132
  }
110
133
  }
111
134
 
112
- return userCount + presetCount
113
- }, [selectedFilters, presetFilters])
135
+ return userCount + presetCount + conditionFilters.length
136
+ }, [selectedFilters, presetFilters, conditionFilters.length])
114
137
 
115
138
  /** Collect all preset chips to render */
116
139
  const presetChips = React.useMemo(() => {
@@ -170,6 +193,31 @@ export function DataTableFilter({
170
193
 
171
194
  <div className="max-h-[320px] overflow-y-auto p-1">
172
195
  {visibleCategories.map((category) => {
196
+ const filterType = category.type ?? "multi"
197
+
198
+ /* ── Boolean toggle ─────────────────────────────────── */
199
+ if (filterType === "boolean") {
200
+ const active = selectedFilters[category.id]?.includes("true") ?? false
201
+ return (
202
+ <DropdownMenuItem
203
+ key={category.id}
204
+ className={cn(
205
+ "cursor-pointer py-1.5 text-xs",
206
+ active && "text-brand-purple"
207
+ )}
208
+ onSelect={(event) => {
209
+ event.preventDefault()
210
+ onToggleFilter(category.id, "true")
211
+ }}
212
+ >
213
+ <category.icon className="mr-2 h-3.5 w-3.5" />
214
+ {category.label}
215
+ {active ? <Check className="ml-auto h-4 w-4" /> : null}
216
+ </DropdownMenuItem>
217
+ )
218
+ }
219
+
220
+ /* ── Sub-menu (single / multi) ──────────────────────── */
173
221
  const subQuery = (subQueries[category.id] ?? "").trim().toLowerCase()
174
222
  const filteredOptions = subQuery
175
223
  ? category.options.filter((opt) =>
@@ -241,6 +289,8 @@ export function DataTableFilter({
241
289
  <span className="text-brand-purple text-[10px] font-semibold">
242
290
  {presetLabel}
243
291
  </span>
292
+ ) : filterType === "single" ? (
293
+ <span className="h-1.5 w-1.5 rounded-full bg-current" />
244
294
  ) : (
245
295
  <span className="text-[10px] font-semibold text-brand-purple">
246
296
  Applied
@@ -266,6 +316,54 @@ export function DataTableFilter({
266
316
  </div>
267
317
  ) : null}
268
318
  </div>
319
+
320
+ {hasConditionBuilder ? (
321
+ <div className="border-t border-border p-1">
322
+ <PopoverPrimitive.Root
323
+ open={conditionBuilderOpen}
324
+ onOpenChange={setConditionBuilderOpen}
325
+ >
326
+ <PopoverPrimitive.Trigger asChild>
327
+ <DropdownMenuItem
328
+ className="cursor-pointer py-1.5 text-xs"
329
+ onSelect={(event) => {
330
+ event.preventDefault()
331
+ setConditionBuilderOpen(true)
332
+ }}
333
+ >
334
+ <Plus className="mr-2 h-3.5 w-3.5 text-muted-foreground" />
335
+ {conditionBuilderLabel}
336
+ {conditionFilters.length > 0 ? (
337
+ <span className="ml-auto rounded bg-muted px-1.5 py-0 text-[10px] font-semibold">
338
+ {conditionFilters.length}
339
+ </span>
340
+ ) : null}
341
+ </DropdownMenuItem>
342
+ </PopoverPrimitive.Trigger>
343
+ <PopoverPrimitive.Portal>
344
+ <PopoverPrimitive.Content
345
+ align="start"
346
+ side="right"
347
+ sideOffset={8}
348
+ className="z-60 outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95"
349
+ onEscapeKeyDown={() => setConditionBuilderOpen(false)}
350
+ onInteractOutside={(event) => {
351
+ const target = event.target as HTMLElement | null
352
+ if (target?.closest('[data-slot="dropdown-menu-content"]')) {
353
+ event.preventDefault()
354
+ }
355
+ }}
356
+ >
357
+ <DataTableConditionFilter
358
+ fields={conditionFields}
359
+ conditions={conditionFilters}
360
+ onConditionsChange={onConditionFiltersChange ?? (() => {})}
361
+ />
362
+ </PopoverPrimitive.Content>
363
+ </PopoverPrimitive.Portal>
364
+ </PopoverPrimitive.Root>
365
+ </div>
366
+ ) : null}
269
367
  </DropdownMenuContent>
270
368
  </DropdownMenu>
271
369
  )
package/src/index.ts CHANGED
@@ -25,6 +25,7 @@ export * from "./components/contact-chip"
25
25
  export * from "./components/contact-list"
26
26
  export * from "./components/dashboard-cards"
27
27
  export * from "./components/data-table"
28
+ export * from "./components/data-table-condition-filter"
28
29
  export * from "./components/data-table-display"
29
30
  export * from "./components/data-table-filter"
30
31
  export * from "./components/data-table-quick-views"