@handled-ai/design-system 0.18.6 → 0.18.8

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 (32) 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-filter.d.ts +8 -0
  4. package/dist/components/data-table-filter.js +8 -1
  5. package/dist/components/data-table-filter.js.map +1 -1
  6. package/dist/components/pill.d.ts +1 -1
  7. package/dist/components/score-why-chips.d.ts +1 -1
  8. package/dist/components/signal-priority-popover.d.ts +1 -1
  9. package/dist/components/tabs.d.ts +1 -1
  10. package/dist/components/timeline-activity.d.ts +16 -1
  11. package/dist/components/timeline-activity.js +69 -1
  12. package/dist/components/timeline-activity.js.map +1 -1
  13. package/dist/index.d.ts +2 -2
  14. package/dist/prototype/index.d.ts +1 -1
  15. package/dist/prototype/prototype-accounts-view.d.ts +1 -1
  16. package/dist/prototype/prototype-admin-view.d.ts +1 -1
  17. package/dist/prototype/prototype-config.d.ts +1 -1
  18. package/dist/prototype/prototype-inbox-view.d.ts +15 -3
  19. package/dist/prototype/prototype-inbox-view.js +156 -36
  20. package/dist/prototype/prototype-inbox-view.js.map +1 -1
  21. package/dist/prototype/prototype-insights-view.d.ts +1 -1
  22. package/dist/prototype/prototype-shell.d.ts +1 -1
  23. package/dist/{signal-priority-popover-DWaAMhPI.d.ts → signal-priority-popover-BT6CPYNs.d.ts} +17 -1
  24. package/package.json +2 -1
  25. package/src/components/__tests__/data-table-filter.test.tsx +41 -0
  26. package/src/components/__tests__/timeline-activity.test.tsx +152 -0
  27. package/src/components/data-table-filter.tsx +17 -2
  28. package/src/components/timeline-activity.tsx +112 -1
  29. package/src/prototype/__tests__/detail-view-attention.test.tsx +2 -2
  30. package/src/prototype/__tests__/detail-view-timeline-system-events.test.tsx +409 -0
  31. package/src/prototype/prototype-config.ts +21 -0
  32. package/src/prototype/prototype-inbox-view.tsx +227 -30
@@ -271,6 +271,47 @@ describe("DataTableFilter", () => {
271
271
  expect(document.querySelector('[data-slot="condition-filter"]')).toBeNull();
272
272
  });
273
273
 
274
+ it("shows submenu search for categories that opt in even below the global threshold", () => {
275
+ const category: DataTableFilterCategory = {
276
+ id: "owner",
277
+ label: "Owner",
278
+ icon: ListFilter,
279
+ options: ["Avery", "Jordan"],
280
+ searchable: true,
281
+ };
282
+
283
+ render(
284
+ <DataTableFilter
285
+ categories={[category]}
286
+ selectedFilters={{}}
287
+ onToggleFilter={() => {}}
288
+ />
289
+ );
290
+
291
+ expect(screen.getByPlaceholderText("Search...")).toBeDefined();
292
+ });
293
+
294
+ it("uses per-category search thresholds when provided", () => {
295
+ const category: DataTableFilterCategory = {
296
+ id: "stage",
297
+ label: "Stage",
298
+ icon: ListFilter,
299
+ options: ["Open", "Closed"],
300
+ searchable: { threshold: 1 },
301
+ };
302
+
303
+ render(
304
+ <DataTableFilter
305
+ categories={[category]}
306
+ selectedFilters={{}}
307
+ onToggleFilter={() => {}}
308
+ optionSearchThreshold={8}
309
+ />
310
+ );
311
+
312
+ expect(screen.getByPlaceholderText("Search...")).toBeDefined();
313
+ });
314
+
274
315
  it("exposes a condition builder popover entry point when condition fields are provided", () => {
275
316
  const conditionFields: ConditionFieldDef[] = [
276
317
  { id: "balance", label: "Account Balance", type: "currency" },
@@ -0,0 +1,152 @@
1
+ import { describe, it, expect } from "vitest"
2
+ import React from "react"
3
+ import { render, screen } from "@testing-library/react"
4
+ import {
5
+ TimelineActivity,
6
+ TONE_CLASSES,
7
+ type TimelineEvent,
8
+ } from "../timeline-activity"
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Helpers
12
+ // ---------------------------------------------------------------------------
13
+
14
+ function minimal(overrides: Partial<TimelineEvent> = {}): TimelineEvent {
15
+ return {
16
+ id: "e1",
17
+ icon: React.createElement("span", { "data-testid": "icon" }, "⚡"),
18
+ title: "Test event",
19
+ time: "2h ago",
20
+ ...overrides,
21
+ }
22
+ }
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Tests
26
+ // ---------------------------------------------------------------------------
27
+
28
+ describe("TimelineActivity", () => {
29
+ // --- Tone rendering ---
30
+
31
+ it("renders red dot classes when tone is 'red'", () => {
32
+ const event = minimal({ tone: "red" })
33
+ const { container } = render(<TimelineActivity events={[event]} />)
34
+ const dot = container.querySelector('[data-testid="timeline-dot"]')!
35
+ expect(dot).not.toBeNull()
36
+ const cls = dot.className
37
+ // Should contain all the red tone dot classes
38
+ expect(cls).toContain("bg-red-50")
39
+ expect(cls).toContain("border-red-200")
40
+ // Should contain the red icon classes
41
+ expect(cls).toContain("text-red-600")
42
+ // Should NOT contain neutral classes
43
+ expect(cls).not.toContain("border-border/60")
44
+ expect(cls).not.toContain("text-muted-foreground")
45
+ })
46
+
47
+ it("renders neutral dot classes when tone is absent", () => {
48
+ const event = minimal()
49
+ const { container } = render(<TimelineActivity events={[event]} />)
50
+ const dot = container.querySelector('[data-testid="timeline-dot"]')!
51
+ expect(dot).not.toBeNull()
52
+ const cls = dot.className
53
+ expect(cls).toContain("border-border/60")
54
+ expect(cls).toContain("bg-background")
55
+ expect(cls).toContain("text-muted-foreground")
56
+ })
57
+
58
+ // --- Actor byline ---
59
+
60
+ it("renders actor byline with name when actor.kind is 'user'", () => {
61
+ const event = minimal({
62
+ actor: { kind: "user", name: "Alice" },
63
+ })
64
+ render(<TimelineActivity events={[event]} />)
65
+ const byline = screen.getByTestId("actor-byline")
66
+ expect(byline).not.toBeNull()
67
+ expect(byline.textContent).toContain("Alice")
68
+ expect(byline.textContent).toContain("performed this action")
69
+ })
70
+
71
+ it("renders no byline when actor.kind is 'system'", () => {
72
+ const event = minimal({
73
+ actor: { kind: "system" },
74
+ })
75
+ const { container } = render(<TimelineActivity events={[event]} />)
76
+ const byline = container.querySelector('[data-testid="actor-byline"]')
77
+ expect(byline).toBeNull()
78
+ })
79
+
80
+ it("renders 'Integration' text when actor.kind is 'integration'", () => {
81
+ const event = minimal({
82
+ actor: { kind: "integration" },
83
+ })
84
+ render(<TimelineActivity events={[event]} />)
85
+ const byline = screen.getByTestId("actor-byline")
86
+ expect(byline).not.toBeNull()
87
+ expect(byline.textContent).toContain("Integration")
88
+ })
89
+
90
+ it("renders custom verb for user actor", () => {
91
+ const event = minimal({
92
+ actor: { kind: "user", name: "Bob", verb: "approved this case" },
93
+ })
94
+ render(<TimelineActivity events={[event]} />)
95
+ const byline = screen.getByTestId("actor-byline")
96
+ expect(byline.textContent).toContain("approved this case")
97
+ })
98
+
99
+ it("does not render name span when actor.name is undefined", () => {
100
+ const event = minimal({
101
+ actor: { kind: "user" },
102
+ })
103
+ render(<TimelineActivity events={[event]} />)
104
+ const byline = screen.getByTestId("actor-byline")
105
+ expect(byline).not.toBeNull()
106
+ // Should show "?" initials and the default verb, but no empty name span
107
+ expect(byline.textContent).toContain("?")
108
+ expect(byline.textContent).toContain("performed this action")
109
+ // No font-medium span should be present (that's the name span)
110
+ const nameSpans = byline.querySelectorAll("span.text-foreground.font-medium")
111
+ expect(nameSpans.length).toBe(0)
112
+ })
113
+
114
+ it("renders no byline when actor is absent", () => {
115
+ const event = minimal()
116
+ const { container } = render(<TimelineActivity events={[event]} />)
117
+ const byline = container.querySelector('[data-testid="actor-byline"]')
118
+ expect(byline).toBeNull()
119
+ })
120
+
121
+ // --- Backwards compatibility ---
122
+
123
+ it("renders correctly with minimal TimelineEvent (only id, icon, title, time)", () => {
124
+ const event: TimelineEvent = {
125
+ id: "min-1",
126
+ icon: React.createElement("span", null, "📌"),
127
+ title: "Minimal event",
128
+ time: "5m ago",
129
+ }
130
+ const { container } = render(<TimelineActivity events={[event]} />)
131
+ // Should render without errors
132
+ expect(container.textContent).toContain("Minimal event")
133
+ expect(container.textContent).toContain("5m ago")
134
+ // Dot should have neutral classes
135
+ const dot = container.querySelector('[data-testid="timeline-dot"]')!
136
+ expect(dot.className).toContain("border-border/60")
137
+ // No byline
138
+ const byline = container.querySelector('[data-testid="actor-byline"]')
139
+ expect(byline).toBeNull()
140
+ })
141
+
142
+ // --- TONE_CLASSES export ---
143
+
144
+ it("exports TONE_CLASSES with all expected tones", () => {
145
+ const tones = ["red", "amber", "emerald", "violet", "blue", "slate", "salesforce", "gmail"] as const
146
+ for (const tone of tones) {
147
+ expect(TONE_CLASSES[tone]).toBeDefined()
148
+ expect(TONE_CLASSES[tone].dot).toBeTruthy()
149
+ expect(TONE_CLASSES[tone].icon).toBeTruthy()
150
+ }
151
+ })
152
+ })
@@ -34,6 +34,12 @@ export interface DataTableFilterCategory {
34
34
  options: (string | FilterOption)[]
35
35
  /** Filter behavior. Defaults to "multi" (checkbox multi-select). */
36
36
  type?: "multi" | "single" | "boolean"
37
+ /**
38
+ * Submenu search behavior. Defaults to the DataTableFilter
39
+ * optionSearchThreshold prop. Use true to always show search or false to
40
+ * hide it for a specific category.
41
+ */
42
+ searchable?: boolean | { threshold?: number }
37
43
  }
38
44
 
39
45
  function getOptionValue(option: string | FilterOption): string {
@@ -208,6 +214,15 @@ export function DataTableFilter({
208
214
  getOptionLabel(opt).toLowerCase().includes(subQuery)
209
215
  )
210
216
  : category.options
217
+ const shouldShowSubmenuSearch = (() => {
218
+ if (category.searchable === true) return true
219
+ if (category.searchable === false) return false
220
+ const threshold =
221
+ typeof category.searchable === "object"
222
+ ? (category.searchable.threshold ?? optionSearchThreshold)
223
+ : optionSearchThreshold
224
+ return category.options.length > threshold
225
+ })()
211
226
 
212
227
  return (
213
228
  <DropdownMenuSub
@@ -227,8 +242,8 @@ export function DataTableFilter({
227
242
  {category.label}
228
243
  </DropdownMenuSubTrigger>
229
244
  <DropdownMenuSubContent className="max-h-[320px] w-52 overflow-y-auto p-1">
230
- {/* Submenu search — only for categories with many options */}
231
- {category.options.length > optionSearchThreshold && (
245
+ {/* Submenu search — shown for long lists or categories that opt in. */}
246
+ {shouldShowSubmenuSearch && (
232
247
  <div className="sticky top-0 z-10 border-b border-border bg-popover p-1.5">
233
248
  <div className="relative">
234
249
  <Search className="absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" />
@@ -4,6 +4,24 @@ import * as React from "react"
4
4
  import { cn } from "../lib/utils"
5
5
  import { ChevronDown, ChevronUp, ExternalLink } from "lucide-react"
6
6
 
7
+ export type TimelineEventTone =
8
+ | "red"
9
+ | "amber"
10
+ | "emerald"
11
+ | "violet"
12
+ | "blue"
13
+ | "slate"
14
+ | "salesforce"
15
+ | "gmail"
16
+
17
+ export interface TimelineEventActor {
18
+ kind: "user" | "integration" | "system"
19
+ name?: string
20
+ initials?: string
21
+ avatarUrl?: string
22
+ verb?: string
23
+ }
24
+
7
25
  export interface TimelineEvent {
8
26
  id: string
9
27
  icon: React.ReactNode
@@ -28,8 +46,57 @@ export interface TimelineEvent {
28
46
  defaultExpanded?: boolean
29
47
  isInteractive?: boolean
30
48
  onSourceClick?: () => void
49
+ tone?: TimelineEventTone
50
+ actor?: TimelineEventActor
51
+ isSystemNoise?: boolean
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Tone class map — every class is a complete static string literal so
56
+ // Tailwind's JIT scanner can detect them. NO interpolation.
57
+ // ---------------------------------------------------------------------------
58
+
59
+ export const TONE_CLASSES: Record<
60
+ TimelineEventTone,
61
+ { dot: string; icon: string }
62
+ > = {
63
+ red: {
64
+ dot: "bg-red-50 border-red-200 dark:bg-red-950/30 dark:border-red-900/40",
65
+ icon: "text-red-600 dark:text-red-300",
66
+ },
67
+ amber: {
68
+ dot: "bg-amber-50 border-amber-200 dark:bg-amber-950/30 dark:border-amber-900/40",
69
+ icon: "text-amber-600 dark:text-amber-300",
70
+ },
71
+ emerald: {
72
+ dot: "bg-emerald-50 border-emerald-200 dark:bg-emerald-950/30 dark:border-emerald-900/40",
73
+ icon: "text-emerald-600 dark:text-emerald-300",
74
+ },
75
+ violet: {
76
+ dot: "bg-violet-50 border-violet-200 dark:bg-violet-950/30 dark:border-violet-900/40",
77
+ icon: "text-violet-600 dark:text-violet-300",
78
+ },
79
+ blue: {
80
+ dot: "bg-blue-50 border-blue-200 dark:bg-blue-950/30 dark:border-blue-900/40",
81
+ icon: "text-blue-600 dark:text-blue-300",
82
+ },
83
+ slate: {
84
+ dot: "bg-slate-100 border-slate-200 dark:bg-slate-800/50 dark:border-slate-700",
85
+ icon: "text-slate-500 dark:text-slate-300",
86
+ },
87
+ salesforce: {
88
+ dot: "bg-white border-[#00A1E0]/25 dark:bg-background dark:border-[#00A1E0]/25",
89
+ icon: "text-[#00A1E0]",
90
+ },
91
+ gmail: {
92
+ dot: "bg-white border-red-200 dark:bg-background dark:border-red-900/40",
93
+ icon: "text-red-500 dark:text-red-300",
94
+ },
31
95
  }
32
96
 
97
+ const NEUTRAL_DOT_CLASSES = "border-border/60 bg-background"
98
+ const NEUTRAL_ICON_CLASSES = "text-muted-foreground"
99
+
33
100
  export interface TimelineActivityProps {
34
101
  events: TimelineEvent[]
35
102
  className?: string
@@ -49,12 +116,54 @@ export function TimelineActivity({ events, className }: TimelineActivityProps) {
49
116
  )
50
117
  }
51
118
 
119
+ function ActorByline({ actor, time }: { actor: TimelineEventActor; time: string }) {
120
+ if (actor.kind === "system") return null
121
+
122
+ if (actor.kind === "integration") {
123
+ return (
124
+ <div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground" data-testid="actor-byline">
125
+ <span>Integration</span>
126
+ <span className="text-muted-foreground/40">&middot;</span>
127
+ <span>{time}</span>
128
+ </div>
129
+ )
130
+ }
131
+
132
+ // actor.kind === "user"
133
+ const verb = actor.verb ?? "performed this action"
134
+ const displayInitials = actor.initials ?? (actor.name ? actor.name.charAt(0).toUpperCase() : "?")
135
+
136
+ return (
137
+ <div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground" data-testid="actor-byline">
138
+ {actor.avatarUrl ? (
139
+ <img
140
+ src={actor.avatarUrl}
141
+ alt={actor.name ?? "User"}
142
+ className="h-4 w-4 rounded-full object-cover"
143
+ />
144
+ ) : (
145
+ <span className="flex h-4 w-4 items-center justify-center rounded-full bg-muted-foreground/10 text-[8px] font-semibold text-muted-foreground">
146
+ {displayInitials}
147
+ </span>
148
+ )}
149
+ {actor.name && <span className="text-foreground font-medium">{actor.name}</span>}
150
+ <span>{verb}</span>
151
+ <span className="text-muted-foreground/40">&middot;</span>
152
+ <span>{time}</span>
153
+ </div>
154
+ )
155
+ }
156
+
52
157
  function TimelineItem({ event, isLast }: { event: TimelineEvent; isLast: boolean }) {
53
158
  const [expanded, setExpanded] = React.useState(event.defaultExpanded ?? false)
54
159
  const [showAllRecipients, setShowAllRecipients] = React.useState(false)
55
160
  const hasContent = !!event.content
56
161
  const hasEmail = !!event.email
57
162
 
163
+ const toneStyle = event.tone ? TONE_CLASSES[event.tone] : null
164
+ const dotClasses = toneStyle ? toneStyle.dot : NEUTRAL_DOT_CLASSES
165
+ const iconClasses = toneStyle ? toneStyle.icon : NEUTRAL_ICON_CLASSES
166
+
58
167
  return (
59
168
  <div className="group relative flex gap-3.5">
60
169
  {!isLast && (
@@ -62,7 +171,7 @@ function TimelineItem({ event, isLast }: { event: TimelineEvent; isLast: boolean
62
171
  )}
63
172
 
64
173
  <div className="relative z-10 mt-1 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-background">
65
- <div className="flex h-4.5 w-4.5 items-center justify-center rounded-full border border-border/60 bg-background text-muted-foreground ring-4 ring-background">
174
+ <div className={cn("flex h-4.5 w-4.5 items-center justify-center rounded-full border ring-4 ring-background", dotClasses, iconClasses)} data-testid="timeline-dot">
66
175
  {event.icon}
67
176
  </div>
68
177
  </div>
@@ -77,6 +186,8 @@ function TimelineItem({ event, isLast }: { event: TimelineEvent; isLast: boolean
77
186
  </span>
78
187
  </div>
79
188
 
189
+ {event.actor && <ActorByline actor={event.actor} time={event.time} />}
190
+
80
191
  {(hasContent || hasEmail) && (
81
192
  <div className="mt-2">
82
193
  {event.isInteractive ? (
@@ -89,8 +89,8 @@ describe("DetailView attentionCount", () => {
89
89
  expect(pill).not.toBeNull();
90
90
  expect(pill!.textContent).toContain("5");
91
91
 
92
- // Click the timeline header button to expand
93
- const timelineButton = container.querySelector("button.group\\/timeline") as HTMLElement;
92
+ // Click the timeline collapse button to expand
93
+ const timelineButton = container.querySelector('[data-testid="timeline-collapse-btn"]') as HTMLElement;
94
94
  expect(timelineButton).not.toBeNull();
95
95
  fireEvent.click(timelineButton);
96
96