@handled-ai/design-system 0.15.1 → 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 (47) hide show
  1. package/dist/components/badge.d.ts +1 -1
  2. package/dist/components/button.d.ts +1 -1
  3. package/dist/components/collapsible-section.d.ts +20 -0
  4. package/dist/components/collapsible-section.js +48 -0
  5. package/dist/components/collapsible-section.js.map +1 -0
  6. package/dist/components/contact-list.d.ts +3 -1
  7. package/dist/components/contact-list.js +20 -3
  8. package/dist/components/contact-list.js.map +1 -1
  9. package/dist/components/data-table-condition-filter.d.ts +37 -0
  10. package/dist/components/data-table-condition-filter.js +407 -0
  11. package/dist/components/data-table-condition-filter.js.map +1 -0
  12. package/dist/components/data-table-filter.d.ts +19 -2
  13. package/dist/components/data-table-filter.js +160 -13
  14. package/dist/components/data-table-filter.js.map +1 -1
  15. package/dist/components/data-table-toolbar.d.ts +1 -0
  16. package/dist/components/data-table.d.ts +1 -0
  17. package/dist/components/entity-panel.js +1 -1
  18. package/dist/components/entity-panel.js.map +1 -1
  19. package/dist/components/tabs.d.ts +1 -1
  20. package/dist/index.d.ts +3 -1
  21. package/dist/index.js +3 -0
  22. package/dist/index.js.map +1 -1
  23. package/dist/prototype/index.d.ts +1 -0
  24. package/dist/prototype/prototype-accounts-view.d.ts +1 -0
  25. package/dist/prototype/prototype-admin-view.d.ts +1 -0
  26. package/dist/prototype/prototype-config.d.ts +1 -0
  27. package/dist/prototype/prototype-inbox-view.d.ts +4 -1
  28. package/dist/prototype/prototype-inbox-view.js +6 -1
  29. package/dist/prototype/prototype-inbox-view.js.map +1 -1
  30. package/dist/prototype/prototype-insights-view.d.ts +1 -0
  31. package/dist/prototype/prototype-shell.d.ts +1 -0
  32. package/package.json +1 -1
  33. package/src/components/__tests__/collapsible-section.test.tsx +143 -0
  34. package/src/components/__tests__/contact-list.test.tsx +116 -0
  35. package/src/components/__tests__/data-table-condition-filter.test.tsx +397 -0
  36. package/src/components/__tests__/data-table-filter-presets.test.tsx +209 -0
  37. package/src/components/__tests__/data-table-filter.test.tsx +270 -3
  38. package/src/components/__tests__/entity-metadata-grid.test.tsx +25 -0
  39. package/src/components/__tests__/virtualized-data-table.test.tsx +0 -1
  40. package/src/components/collapsible-section.tsx +62 -0
  41. package/src/components/contact-list.tsx +22 -3
  42. package/src/components/data-table-condition-filter.tsx +513 -0
  43. package/src/components/data-table-filter.tsx +201 -13
  44. package/src/components/entity-panel.tsx +1 -1
  45. package/src/index.ts +2 -0
  46. package/src/prototype/__tests__/detail-view-attention.test.tsx +101 -0
  47. package/src/prototype/prototype-inbox-view.tsx +8 -0
@@ -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 {
@@ -34,13 +43,27 @@ function getOptionLabel(option: string | FilterOption): string {
34
43
  return typeof option === "string" ? option : option.label
35
44
  }
36
45
 
37
- interface DataTableFilterProps {
46
+ export interface DataTableFilterProps {
38
47
  categories: DataTableFilterCategory[]
39
48
  selectedFilters: Record<string, string[]>
40
49
  onToggleFilter: (categoryId: string, option: string) => void
41
50
  className?: string
42
51
  /** Minimum number of options before showing the sub-menu search input. Defaults to 8. */
43
52
  optionSearchThreshold?: number
53
+ /** Filters applied by default. Shown as distinct chips that can be toggled off but not dismissed. */
54
+ presetFilters?: Record<string, string[]>
55
+ /** Callback when a preset filter is toggled on/off. */
56
+ onTogglePreset?: (categoryId: string, option: string) => void
57
+ /** Label shown on preset chips to distinguish from user-applied filters. Default: "Default". */
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
44
67
  }
45
68
 
46
69
  export function DataTableFilter({
@@ -49,9 +72,18 @@ export function DataTableFilter({
49
72
  onToggleFilter,
50
73
  className,
51
74
  optionSearchThreshold = 8,
75
+ presetFilters,
76
+ onTogglePreset,
77
+ presetLabel = "Default",
78
+ conditionFields = [],
79
+ conditionFilters = [],
80
+ onConditionFiltersChange,
81
+ conditionBuilderLabel = "Add filter",
52
82
  }: DataTableFilterProps) {
53
83
  const [query, setQuery] = React.useState("")
54
84
  const [subQueries, setSubQueries] = React.useState<Record<string, string>>({})
85
+ const [conditionBuilderOpen, setConditionBuilderOpen] = React.useState(false)
86
+ const hasConditionBuilder = conditionFields.length > 0
55
87
 
56
88
  const visibleCategories = React.useMemo(() => {
57
89
  const normalized = query.trim().toLowerCase()
@@ -70,16 +102,61 @@ export function DataTableFilter({
70
102
  })
71
103
  }, [categories, query])
72
104
 
73
- const activeCount = React.useMemo(
74
- () =>
75
- Object.values(selectedFilters).reduce(
76
- (count, selected) => count + selected.length,
77
- 0
78
- ),
79
- [selectedFilters]
105
+ /** Check if a specific option is a preset filter */
106
+ const isPresetOption = React.useCallback(
107
+ (categoryId: string, value: string): boolean => {
108
+ return presetFilters?.[categoryId]?.includes(value) ?? false
109
+ },
110
+ [presetFilters]
80
111
  )
81
112
 
82
- return (
113
+ const activeCount = React.useMemo(() => {
114
+ // Count user-selected filters
115
+ const userCount = Object.values(selectedFilters).reduce(
116
+ (count, selected) => count + selected.length,
117
+ 0
118
+ )
119
+
120
+ // Count active preset filters (those that are in presetFilters AND currently active in selectedFilters)
121
+ const presetCount = 0
122
+ if (presetFilters) {
123
+ for (const [categoryId, presetValues] of Object.entries(presetFilters)) {
124
+ for (const value of presetValues) {
125
+ // Only count if the preset is active (in selectedFilters) but NOT already counted as a user filter
126
+ if (selectedFilters[categoryId]?.includes(value)) {
127
+ // Already counted in userCount, skip
128
+ } else {
129
+ // Not in selectedFilters — it's an inactive preset, don't count
130
+ }
131
+ }
132
+ }
133
+ }
134
+
135
+ return userCount + presetCount + conditionFilters.length
136
+ }, [selectedFilters, presetFilters, conditionFilters.length])
137
+
138
+ /** Collect all preset chips to render */
139
+ const presetChips = React.useMemo(() => {
140
+ if (!presetFilters) return []
141
+
142
+ const chips: { categoryId: string; value: string; label: string; active: boolean }[] = []
143
+
144
+ for (const [categoryId, values] of Object.entries(presetFilters)) {
145
+ const category = categories.find((c) => c.id === categoryId)
146
+ for (const value of values) {
147
+ const option = category?.options.find(
148
+ (opt) => getOptionValue(opt) === value
149
+ )
150
+ const label = option ? getOptionLabel(option) : value
151
+ const active = selectedFilters[categoryId]?.includes(value) ?? false
152
+ chips.push({ categoryId, value, label, active })
153
+ }
154
+ }
155
+
156
+ return chips
157
+ }, [presetFilters, categories, selectedFilters])
158
+
159
+ const triggerButton = (
83
160
  <DropdownMenu>
84
161
  <DropdownMenuTrigger asChild>
85
162
  <Button
@@ -116,6 +193,31 @@ export function DataTableFilter({
116
193
 
117
194
  <div className="max-h-[320px] overflow-y-auto p-1">
118
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) ──────────────────────── */
119
221
  const subQuery = (subQueries[category.id] ?? "").trim().toLowerCase()
120
222
  const filteredOptions = subQuery
121
223
  ? category.options.filter((opt) =>
@@ -171,6 +273,7 @@ export function DataTableFilter({
171
273
  const value = getOptionValue(option)
172
274
  const label = getOptionLabel(option)
173
275
  const selected = selectedFilters[category.id]?.includes(value) ?? false
276
+ const isPreset = isPresetOption(category.id, value)
174
277
  return (
175
278
  <DropdownMenuItem
176
279
  key={value}
@@ -182,9 +285,17 @@ export function DataTableFilter({
182
285
  >
183
286
  {label}
184
287
  {selected ? (
185
- <span className="text-[10px] font-semibold text-brand-purple">
186
- Applied
187
- </span>
288
+ isPreset ? (
289
+ <span className="text-brand-purple text-[10px] font-semibold">
290
+ {presetLabel}
291
+ </span>
292
+ ) : filterType === "single" ? (
293
+ <span className="h-1.5 w-1.5 rounded-full bg-current" />
294
+ ) : (
295
+ <span className="text-[10px] font-semibold text-brand-purple">
296
+ Applied
297
+ </span>
298
+ )
188
299
  ) : null}
189
300
  </DropdownMenuItem>
190
301
  )
@@ -205,7 +316,84 @@ export function DataTableFilter({
205
316
  </div>
206
317
  ) : null}
207
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}
208
367
  </DropdownMenuContent>
209
368
  </DropdownMenu>
210
369
  )
370
+
371
+ // If there are preset chips, wrap trigger + chips together
372
+ if (presetChips.length > 0) {
373
+ return (
374
+ <div className="flex flex-wrap items-center gap-1.5">
375
+ {triggerButton}
376
+ {presetChips.map((chip) => (
377
+ <button
378
+ key={`${chip.categoryId}-${chip.value}`}
379
+ type="button"
380
+ onClick={() => onTogglePreset?.(chip.categoryId, chip.value)}
381
+ className={cn(
382
+ "inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-[11px] font-medium transition-colors",
383
+ chip.active
384
+ ? "border-dashed border-brand-purple/30 bg-brand-purple/5 text-brand-purple/80"
385
+ : "border-border/40 bg-transparent text-muted-foreground/60 line-through"
386
+ )}
387
+ >
388
+ <span className="text-brand-purple/50 text-[10px]">
389
+ {presetLabel}:{" "}
390
+ </span>
391
+ {chip.label}
392
+ </button>
393
+ ))}
394
+ </div>
395
+ )
396
+ }
397
+
398
+ return triggerButton
211
399
  }
@@ -242,7 +242,7 @@ export interface EntityMetadataField {
242
242
 
243
243
  export function EntityMetadataGrid({ fields }: { fields: EntityMetadataField[] }) {
244
244
  return (
245
- <div className="grid grid-cols-1 md:grid-cols-[140px_1fr] gap-y-3 gap-x-4 mb-7 text-[13px]">
245
+ <div className="grid grid-cols-1 md:grid-cols-[140px_1fr] gap-y-3 gap-x-4 mb-7 text-[13px] overflow-hidden">
246
246
  {fields.map((field, idx) => (
247
247
  <React.Fragment key={idx}>
248
248
  <div className="flex items-center gap-1.5 text-muted-foreground text-[13px] font-normal">
package/src/index.ts CHANGED
@@ -19,11 +19,13 @@ export * from "./components/avatar"
19
19
  export * from "./components/badge"
20
20
  export * from "./components/button"
21
21
  export * from "./components/card"
22
+ export { CollapsibleSection, type CollapsibleSectionProps } from "./components/collapsible-section"
22
23
  export * from "./components/compliance-badge"
23
24
  export * from "./components/contact-chip"
24
25
  export * from "./components/contact-list"
25
26
  export * from "./components/dashboard-cards"
26
27
  export * from "./components/data-table"
28
+ export * from "./components/data-table-condition-filter"
27
29
  export * from "./components/data-table-display"
28
30
  export * from "./components/data-table-filter"
29
31
  export * from "./components/data-table-quick-views"
@@ -0,0 +1,101 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import React from "react";
3
+ import { render, fireEvent } from "@testing-library/react";
4
+ import { DetailView, type DetailViewProps } from "../prototype-inbox-view";
5
+ import type { QueueItem, SignalScoreData } from "../prototype-config";
6
+ import type { TimelineEvent } from "../../components/timeline-activity";
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Helpers
10
+ // ---------------------------------------------------------------------------
11
+
12
+ const baseItem: QueueItem = {
13
+ id: "1",
14
+ title: "Test Signal",
15
+ details: "Some details",
16
+ statusColor: "green",
17
+ time: "2h ago",
18
+ company: "Acme Inc",
19
+ tag1: "renewal",
20
+ };
21
+
22
+ function makeSignalScore(): SignalScoreData {
23
+ return {
24
+ score: 75,
25
+ factors: [
26
+ { key: "trigger", label: "Trigger strength", score: 70, why: "Strong signal" },
27
+ ],
28
+ whyNow: "Strong signals detected.",
29
+ evidence: ["Evidence line 1"],
30
+ confidence: 80,
31
+ };
32
+ }
33
+
34
+ const sampleTimelineEvents: TimelineEvent[] = [
35
+ { id: "t1", icon: React.createElement("span", null, "📧"), title: "Email sent", time: "1h ago" },
36
+ { id: "t2", icon: React.createElement("span", null, "📞"), title: "Call logged", time: "3h ago" },
37
+ ];
38
+
39
+ function baseProps(overrides: Partial<DetailViewProps> = {}): DetailViewProps {
40
+ return {
41
+ item: baseItem,
42
+ sections: { signalBrief: true, suggestedActions: false, timeline: true },
43
+ getSignalScore: () => makeSignalScore(),
44
+ buildSuggestedActions: () => [],
45
+ buildSourceItems: () => [],
46
+ getTimelineEvents: () => sampleTimelineEvents,
47
+ accountContacts: [],
48
+ emailSignature: "",
49
+ iconMap: {},
50
+ ...overrides,
51
+ };
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Tests
56
+ // ---------------------------------------------------------------------------
57
+
58
+ describe("DetailView attentionCount", () => {
59
+ it("renders attention pill with count when attentionCount > 0 and timeline collapsed", () => {
60
+ const props = baseProps({ attentionCount: 3 });
61
+ const { container } = render(<DetailView {...props} />);
62
+ const pill = container.querySelector("span.inline-flex");
63
+ expect(pill).not.toBeNull();
64
+ expect(pill!.textContent).toContain("3");
65
+ expect(pill!.textContent).toContain("new");
66
+ expect(pill!.className).toContain("bg-destructive/10");
67
+ });
68
+
69
+ it("does NOT render pill when attentionCount is 0", () => {
70
+ const props = baseProps({ attentionCount: 0 });
71
+ const { container } = render(<DetailView {...props} />);
72
+ const pill = container.querySelector("span.inline-flex");
73
+ expect(pill).toBeNull();
74
+ });
75
+
76
+ it("does NOT render pill when attentionCount is undefined", () => {
77
+ const props = baseProps({ attentionCount: undefined });
78
+ const { container } = render(<DetailView {...props} />);
79
+ const pill = container.querySelector("span.inline-flex");
80
+ expect(pill).toBeNull();
81
+ });
82
+
83
+ it("does NOT render pill when timeline is expanded", () => {
84
+ const props = baseProps({ attentionCount: 5 });
85
+ const { container } = render(<DetailView {...props} />);
86
+
87
+ // Pill should be visible while collapsed
88
+ let pill = container.querySelector("span.inline-flex");
89
+ expect(pill).not.toBeNull();
90
+ expect(pill!.textContent).toContain("5");
91
+
92
+ // Click the timeline header button to expand
93
+ const timelineButton = container.querySelector("button.group\\/timeline") as HTMLElement;
94
+ expect(timelineButton).not.toBeNull();
95
+ fireEvent.click(timelineButton);
96
+
97
+ // After expanding, pill should be gone
98
+ pill = container.querySelector("span.inline-flex");
99
+ expect(pill).toBeNull();
100
+ });
101
+ });
@@ -142,6 +142,8 @@ export interface DetailViewProps {
142
142
  approveButtonIconUrl?: string
143
143
  opportunityPreview?: OpportunityPreview
144
144
  onRequestApproval?: () => Promise<void>
145
+ /** Number of important/attention-worthy events to highlight on the collapsed timeline header. */
146
+ attentionCount?: number
145
147
  }
146
148
 
147
149
  export function DetailView({
@@ -172,6 +174,7 @@ export function DetailView({
172
174
  approveButtonIconUrl,
173
175
  opportunityPreview,
174
176
  onRequestApproval,
177
+ attentionCount,
175
178
  }: DetailViewProps) {
176
179
  const [evidenceExpanded, setEvidenceExpanded] = React.useState(false)
177
180
  const [showTimeline, setShowTimeline] = React.useState(false)
@@ -422,6 +425,11 @@ export function DetailView({
422
425
  >
423
426
  <div className="flex items-center gap-2">
424
427
  <h3 className="text-xs font-bold text-muted-foreground uppercase tracking-wider group-hover/timeline:text-foreground transition-colors">Activity timeline</h3>
428
+ {!showTimeline && attentionCount != null && attentionCount > 0 && (
429
+ <span className="inline-flex items-center gap-1 rounded-full bg-destructive/10 px-1.5 py-0.5 text-[10px] font-semibold text-destructive border border-destructive/20">
430
+ {attentionCount} new
431
+ </span>
432
+ )}
425
433
  {!showTimeline && (lastActivityTime || (timelineEvents.length > 0 && timelineEvents[0].time)) && (
426
434
  <span className="text-[11px] text-muted-foreground/60">
427
435
  &middot; Last activity {lastActivityTime ?? timelineEvents[0]?.time ?? ''}