@handled-ai/design-system 0.20.28 → 0.20.29

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.
@@ -44,6 +44,22 @@ describe("TimelineActivity", () => {
44
44
  expect(container.firstElementChild).toHaveAttribute("data-variant", "default")
45
45
  })
46
46
 
47
+ it("renders each timeline item wrapper with scroll/focus anchors and test ids", () => {
48
+ const events = [minimal({ id: "evt-a" }), minimal({ id: "evt-b" })]
49
+ const { container } = render(<TimelineActivity events={events} />)
50
+
51
+ for (const id of ["evt-a", "evt-b"]) {
52
+ const wrapper = container.querySelector(`#timeline-event-${id}`)
53
+ expect(wrapper).not.toBeNull()
54
+ expect(wrapper).toHaveAttribute("data-testid", `timeline-event-${id}`)
55
+ expect(wrapper).toHaveAttribute("data-activity-id", id)
56
+ expect(wrapper).toHaveAttribute("tabindex", "-1")
57
+ }
58
+
59
+ // The test id is also reachable via screen for click-through targeting.
60
+ expect(screen.getByTestId("timeline-event-evt-a")).toBeTruthy()
61
+ })
62
+
47
63
  it("marks the root and interactive cards with the case-panel variant", () => {
48
64
  const event = minimal({
49
65
  isInteractive: true,
@@ -5,6 +5,7 @@ import {
5
5
  Clock,
6
6
  ExternalLink,
7
7
  } from "lucide-react"
8
+ import { cn } from "../lib/utils"
8
9
  import type { SuggestedContact, SuggestedActionsIconMap } from "./suggested-actions"
9
10
 
10
11
  // ---------------------------------------------------------------------------
@@ -37,7 +38,7 @@ export interface AccountContactsPopoverProps {
37
38
  /** Optional replacement-selection callback. When provided, row clicks call this instead of additive onSelect/onSelectTo. */
38
39
  onSelectSwitch?: (contact: SuggestedContact) => void
39
40
  onViewAll?: () => void
40
- onOpenRecentActivity?: () => void
41
+ onOpenRecentActivity?: (contact: SuggestedContact) => void
41
42
  trigger: React.ReactNode
42
43
  iconMap?: SuggestedActionsIconMap
43
44
  }
@@ -117,24 +118,37 @@ export function AccountContactsPopover({
117
118
  <div className="truncate text-xs text-muted-foreground leading-tight">
118
119
  {c.role} · {c.email ?? c.emails?.[0] ?? c.phone ?? c.phones?.[0] ?? ""}
119
120
  </div>
120
- {c.lastActivity && (
121
- <button
122
- type="button"
123
- onClick={(e) => {
124
- e.stopPropagation()
125
- onOpenRecentActivity?.()
126
- setOpen(false)
127
- }}
128
- className="mt-1.5 flex max-w-full items-center gap-1.5 overflow-hidden rounded-md border border-border/70 bg-muted/30 px-2 py-1 text-[11px] text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
129
- >
130
- <Clock className="w-3 h-3 shrink-0" />
131
- <span className="shrink-0 font-medium">Last activity</span>
132
- <span className="shrink-0 text-muted-foreground/60">·</span>
133
- <span className="shrink-0">{c.lastActivity.date}</span>
134
- <span className="shrink-0 text-muted-foreground/60">·</span>
135
- <span className="truncate capitalize">{c.lastActivity.type}</span>
136
- </button>
137
- )}
121
+ {c.lastActivity && (() => {
122
+ const activityContent = (
123
+ <>
124
+ <Clock className="w-3 h-3 shrink-0" />
125
+ <span className="shrink-0 font-medium">Last activity</span>
126
+ <span className="shrink-0 text-muted-foreground/60">·</span>
127
+ <span className="shrink-0">{c.lastActivity.date}</span>
128
+ <span className="shrink-0 text-muted-foreground/60">·</span>
129
+ <span className="truncate capitalize">{c.lastActivity.type}</span>
130
+ </>
131
+ )
132
+ const chipBaseClass =
133
+ "mt-1.5 flex max-w-full items-center gap-1.5 overflow-hidden rounded-md border border-border/70 bg-muted/30 px-2 py-1 text-[11px] text-muted-foreground"
134
+ return onOpenRecentActivity && c.lastActivity.timelineEventId ? (
135
+ <button
136
+ type="button"
137
+ onClick={(e) => {
138
+ e.stopPropagation()
139
+ onOpenRecentActivity(c)
140
+ setOpen(false)
141
+ }}
142
+ className={cn(chipBaseClass, "hover:text-foreground hover:bg-muted/50 transition-colors")}
143
+ >
144
+ {activityContent}
145
+ </button>
146
+ ) : (
147
+ <div className={chipBaseClass}>
148
+ {activityContent}
149
+ </div>
150
+ )
151
+ })()}
138
152
  </div>
139
153
  <div className="ml-2 flex items-center gap-1.5 shrink-0">
140
154
  {resolvedDefaultSelectLabel && (
@@ -4,6 +4,7 @@ import * as React from "react"
4
4
  import { Popover as PopoverPrimitive } from "radix-ui"
5
5
  import {
6
6
  ChevronDown,
7
+ Clock,
7
8
  CornerDownLeft,
8
9
  Plus,
9
10
  Search,
@@ -63,6 +64,14 @@ export interface EmailRecipientFieldProps {
63
64
  onSearch?: (query: string) => void
64
65
  /** Shows a "Searching contacts..." indicator while async results load. */
65
66
  searchLoading?: boolean
67
+ /**
68
+ * Opens the recent activity for a contact in the Account Panel timeline.
69
+ * When provided AND the contact's `lastActivity.timelineEventId` exists, the
70
+ * last-activity line renders as a button that calls this callback (and stops
71
+ * propagation so it never adds/selects the recipient). Otherwise the line
72
+ * renders as non-clickable text.
73
+ */
74
+ onOpenRecentActivity?: (contact: SuggestedContact) => void
66
75
  }
67
76
 
68
77
  function RecipientChipPill({
@@ -146,6 +155,7 @@ function ContactPickerContents({
146
155
  onAddEmail,
147
156
  onSearch,
148
157
  searchLoading = false,
158
+ onOpenRecentActivity,
149
159
  }: {
150
160
  contacts: SuggestedContact[]
151
161
  addedEmails: Set<string>
@@ -153,6 +163,7 @@ function ContactPickerContents({
153
163
  onAddEmail: (email: string) => void
154
164
  onSearch?: (query: string) => void
155
165
  searchLoading?: boolean
166
+ onOpenRecentActivity?: (contact: SuggestedContact) => void
156
167
  }) {
157
168
  const [query, setQuery] = React.useState("")
158
169
 
@@ -279,6 +290,38 @@ function ContactPickerContents({
279
290
  {email}
280
291
  </div>
281
292
  ) : null}
293
+ {contact.lastActivity ? (() => {
294
+ const activityContent = (
295
+ <>
296
+ <Clock className="size-3 shrink-0" />
297
+ <span className="shrink-0">Last activity</span>
298
+ <span className="shrink-0 text-muted-foreground/60">·</span>
299
+ <span className="shrink-0">{contact.lastActivity.date}</span>
300
+ <span className="shrink-0 text-muted-foreground/60">·</span>
301
+ <span className="truncate capitalize">{contact.lastActivity.type}</span>
302
+ </>
303
+ )
304
+ return onOpenRecentActivity && contact.lastActivity.timelineEventId ? (
305
+ <button
306
+ type="button"
307
+ onClick={(event) => {
308
+ event.stopPropagation()
309
+ onOpenRecentActivity(contact)
310
+ }}
311
+ // `pointer-events-auto` keeps the activity button clickable even
312
+ // when the parent row is disabled (no email / already added) and
313
+ // gets `pointer-events-none` — the timeline activity is still
314
+ // openable for those contacts.
315
+ className="mt-1 flex max-w-full items-center gap-1 overflow-hidden text-[11px] text-muted-foreground hover:text-foreground transition-colors pointer-events-auto"
316
+ >
317
+ {activityContent}
318
+ </button>
319
+ ) : (
320
+ <div className="mt-1 flex max-w-full items-center gap-1 overflow-hidden text-[11px] text-muted-foreground">
321
+ {activityContent}
322
+ </div>
323
+ )
324
+ })() : null}
282
325
  </div>
283
326
  {alreadyAdded ? (
284
327
  <span className="shrink-0 text-[10.5px] font-medium text-muted-foreground">
@@ -318,6 +361,7 @@ export function EmailRecipientField({
318
361
  contactToRecipient,
319
362
  onSearch,
320
363
  searchLoading,
364
+ onOpenRecentActivity,
321
365
  }: EmailRecipientFieldProps) {
322
366
  const [value, setValue] = React.useState("")
323
367
  const [pickerOpen, setPickerOpen] = React.useState(false)
@@ -465,6 +509,7 @@ export function EmailRecipientField({
465
509
  }}
466
510
  onSearch={onSearch}
467
511
  searchLoading={searchLoading}
512
+ onOpenRecentActivity={onOpenRecentActivity}
468
513
  />
469
514
  </PopoverPrimitive.Content>
470
515
  </PopoverPrimitive.Portal>
@@ -84,6 +84,7 @@ export interface SuggestedContact {
84
84
  lastActivity?: {
85
85
  date: string
86
86
  type: string
87
+ timelineEventId?: string
87
88
  }
88
89
  }
89
90
 
@@ -547,7 +548,7 @@ function EmailHeader({
547
548
  onBccAdd?: (contact: SuggestedContact) => void
548
549
  onBccRemove?: (index: number) => void
549
550
  onOpenAccountDetails?: () => void
550
- onOpenRecentActivity?: () => void
551
+ onOpenRecentActivity?: (contact: SuggestedContact) => void
551
552
  iconMap?: SuggestedActionsIconMap
552
553
  showSubject?: boolean
553
554
  accountDetailsLabel?: string
@@ -910,7 +911,7 @@ function SuggestedActionCard({
910
911
  signature?: string | React.ReactNode
911
912
  onDuplicate?: (id: number | string) => void
912
913
  onOpenAccountDetails?: () => void
913
- onOpenRecentActivity?: () => void
914
+ onOpenRecentActivity?: (contact: SuggestedContact) => void
914
915
  onMarkComplete?: (id: number | string) => void
915
916
  onDispatchAgent?: (id: number | string, editedContent?: string, settings?: { aiDisclosureEnabled?: boolean; maxDurationMinutes?: string; callRecordingEnabled?: boolean; recordingNoticeEnabled?: boolean }) => void
916
917
  onFeedback?: (type: "up" | "down", pills: string[], detail: string) => void
@@ -1571,7 +1572,7 @@ export interface SuggestedActionsProps {
1571
1572
  signature?: string | React.ReactNode
1572
1573
  onDuplicate?: (id: number | string) => void
1573
1574
  onOpenAccountDetails?: () => void
1574
- onOpenRecentActivity?: () => void
1575
+ onOpenRecentActivity?: (contact: SuggestedContact) => void
1575
1576
  onMarkComplete?: (id: number | string) => void
1576
1577
  onDispatchAgent?: (id: number | string, editedContent?: string, settings?: { aiDisclosureEnabled?: boolean; maxDurationMinutes?: string; callRecordingEnabled?: boolean; recordingNoticeEnabled?: boolean }) => void
1577
1578
  iconMap?: SuggestedActionsIconMap
@@ -298,7 +298,13 @@ function TimelineItem({
298
298
  const iconClasses = toneStyle ? toneStyle.icon : NEUTRAL_ICON_CLASSES
299
299
 
300
300
  return (
301
- <div className={classes.outerRowGap}>
301
+ <div
302
+ id={`timeline-event-${event.id}`}
303
+ data-testid={`timeline-event-${event.id}`}
304
+ data-activity-id={event.id}
305
+ tabIndex={-1}
306
+ className={classes.outerRowGap}
307
+ >
302
308
  {!isLast && (
303
309
  <div className={classes.connector} />
304
310
  )}