@handled-ai/design-system 0.18.38 → 0.18.40

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.
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Tests for DraftFeedbackInline.
3
+ *
4
+ * Covers:
5
+ * - Clicking the up thumb activates the positive state (monochrome bg-muted)
6
+ * - Clicking the down thumb activates the negative state (restrained red)
7
+ * - Thumb icons render outline-only (never fill="currentColor")
8
+ * - initialDirection="down" renders the down state pre-selected
9
+ * - initialDirection="up" renders the up state pre-selected
10
+ */
11
+
12
+ import { describe, it, expect } from "vitest"
13
+ import React from "react"
14
+ import { render, screen, fireEvent } from "@testing-library/react"
15
+ import { DraftFeedbackInline } from "../draft-feedback-inline"
16
+
17
+ describe("DraftFeedbackInline", () => {
18
+ it("renders idle with no expanded feedback area", () => {
19
+ render(<DraftFeedbackInline />)
20
+ expect(screen.getByText("How's this draft?")).toBeTruthy()
21
+ expect(screen.queryByText("What worked well?")).toBeNull()
22
+ expect(screen.queryByText("What needs improvement?")).toBeNull()
23
+ })
24
+
25
+ it("activates the positive state with monochrome tokens when up is clicked", () => {
26
+ const { container } = render(<DraftFeedbackInline />)
27
+ const buttons = container.querySelectorAll("button")
28
+ const upButton = buttons[0] as HTMLButtonElement
29
+ fireEvent.click(upButton)
30
+
31
+ expect(screen.getByText("What worked well?")).toBeTruthy()
32
+ expect(upButton.className).toContain("bg-muted")
33
+ expect(upButton.className).toContain("text-foreground")
34
+ expect(upButton.className).not.toContain("emerald")
35
+ })
36
+
37
+ it("activates the negative state with restrained red when down is clicked", () => {
38
+ const { container } = render(<DraftFeedbackInline />)
39
+ const buttons = container.querySelectorAll("button")
40
+ const downButton = buttons[1] as HTMLButtonElement
41
+ fireEvent.click(downButton)
42
+
43
+ expect(screen.getByText("What needs improvement?")).toBeTruthy()
44
+ expect(downButton.className).toContain("bg-muted")
45
+ expect(downButton.className).toContain("text-foreground")
46
+ expect(downButton.className).not.toContain("bg-red")
47
+ })
48
+
49
+ it("renders thumb icons outline-only (never fill=currentColor)", () => {
50
+ const { container } = render(<DraftFeedbackInline />)
51
+ const buttons = container.querySelectorAll("button")
52
+ // Activate up so the icon would be "filled" under the old behavior.
53
+ fireEvent.click(buttons[0] as HTMLButtonElement)
54
+
55
+ const svgs = container.querySelectorAll("svg")
56
+ svgs.forEach((svg) => {
57
+ expect(svg.getAttribute("fill")).not.toBe("currentColor")
58
+ })
59
+ })
60
+
61
+ it("renders the down state pre-selected with initialDirection='down'", () => {
62
+ render(<DraftFeedbackInline initialDirection="down" />)
63
+ expect(screen.getByText("What needs improvement?")).toBeTruthy()
64
+ expect(screen.queryByText("What worked well?")).toBeNull()
65
+ })
66
+
67
+ it("renders the up state pre-selected with initialDirection='up'", () => {
68
+ render(<DraftFeedbackInline initialDirection="up" />)
69
+ expect(screen.getByText("What worked well?")).toBeTruthy()
70
+ expect(screen.queryByText("What needs improvement?")).toBeNull()
71
+ })
72
+ })
@@ -1,11 +1,16 @@
1
1
  import { describe, it, expect, vi, afterEach } from "vitest"
2
2
  import React from "react"
3
- import { render, screen, fireEvent, cleanup } from "@testing-library/react"
3
+ import { render, screen, fireEvent, cleanup, within } from "@testing-library/react"
4
4
 
5
5
  import {
6
6
  EmailRecipientField,
7
7
  type RecipientChip,
8
8
  } from "../email-recipient-field"
9
+ import {
10
+ Dialog,
11
+ DialogContent,
12
+ DialogTitle,
13
+ } from "../dialog"
9
14
  import type { SuggestedContact } from "../suggested-actions"
10
15
 
11
16
  afterEach(() => {
@@ -253,4 +258,114 @@ describe("EmailRecipientField", () => {
253
258
  expect(noEmailRow.className).toContain("pointer-events-none")
254
259
  expect(noEmailRow.textContent).toContain("No email")
255
260
  })
261
+
262
+ it("autofocuses the search input when the picker opens", () => {
263
+ render(
264
+ <EmailRecipientField
265
+ label="To"
266
+ recipients={[]}
267
+ onRecipientsChange={vi.fn()}
268
+ showPicker
269
+ contacts={contacts}
270
+ />,
271
+ )
272
+
273
+ fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
274
+ const search = screen.getByPlaceholderText("Search contacts...")
275
+ expect(document.activeElement).toBe(search)
276
+ })
277
+
278
+ it("accepts typing in the search box and filters the list", () => {
279
+ render(
280
+ <EmailRecipientField
281
+ label="To"
282
+ recipients={[]}
283
+ onRecipientsChange={vi.fn()}
284
+ showPicker
285
+ contacts={contacts}
286
+ />,
287
+ )
288
+
289
+ fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
290
+ const search = screen.getByPlaceholderText("Search contacts...") as HTMLInputElement
291
+
292
+ fireEvent.change(search, { target: { value: "Bea" } })
293
+ expect(search.value).toBe("Bea")
294
+ expect(screen.getByText("Bea Buyer")).toBeTruthy()
295
+ expect(screen.queryByText("Alex Admin")).toBeNull()
296
+ })
297
+
298
+ it("adds a typed email from the picker search box on Enter", () => {
299
+ const onChange = vi.fn()
300
+ render(
301
+ <EmailRecipientField
302
+ label="To"
303
+ recipients={[]}
304
+ onRecipientsChange={onChange}
305
+ showPicker
306
+ contacts={contacts}
307
+ />,
308
+ )
309
+
310
+ fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
311
+ const search = screen.getByPlaceholderText("Search contacts...")
312
+
313
+ fireEvent.change(search, { target: { value: "typed@example.com" } })
314
+ fireEvent.keyDown(search, { key: "Enter" })
315
+
316
+ expect(onChange).toHaveBeenCalledWith([
317
+ { id: "typed@example.com", email: "typed@example.com", name: "", confirmed: false },
318
+ ])
319
+ })
320
+
321
+ it("shows a clear empty state when there are no contacts", () => {
322
+ render(
323
+ <EmailRecipientField
324
+ label="To"
325
+ recipients={[]}
326
+ onRecipientsChange={vi.fn()}
327
+ showPicker
328
+ contacts={[]}
329
+ />,
330
+ )
331
+
332
+ fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
333
+ expect(screen.getByText("No contacts for this account")).toBeTruthy()
334
+ // The empty state guides the user to type an address instead of looking broken.
335
+ expect(
336
+ screen.getByText(/Type an email address above and press Enter/),
337
+ ).toBeTruthy()
338
+ })
339
+
340
+ it("remains typeable when rendered inside a modal Dialog (WIT-800 focus trap)", () => {
341
+ // Reproduces the staging bug: the picker used to render via
342
+ // createPortal(document.body), outside the Dialog's FocusScope, so the
343
+ // search input could not hold focus. The Radix Popover rebuild keeps the
344
+ // input inside its own (parent-pausing) focus scope.
345
+ render(
346
+ <Dialog open>
347
+ <DialogContent aria-describedby={undefined}>
348
+ <DialogTitle>Compose</DialogTitle>
349
+ <EmailRecipientField
350
+ label="To"
351
+ recipients={[]}
352
+ onRecipientsChange={vi.fn()}
353
+ showPicker
354
+ contacts={contacts}
355
+ />
356
+ </DialogContent>
357
+ </Dialog>,
358
+ )
359
+
360
+ fireEvent.click(screen.getByRole("button", { name: /Contacts/ }))
361
+ const search = screen.getByPlaceholderText("Search contacts...") as HTMLInputElement
362
+
363
+ search.focus()
364
+ expect(document.activeElement).toBe(search)
365
+
366
+ fireEvent.change(search, { target: { value: "Alex" } })
367
+ expect(search.value).toBe("Alex")
368
+ const listbox = screen.getByRole("listbox")
369
+ expect(within(listbox).getByText("Alex Admin")).toBeTruthy()
370
+ })
256
371
  })
@@ -0,0 +1,123 @@
1
+ import "@testing-library/jest-dom/vitest"
2
+
3
+ import React from "react"
4
+ import { fireEvent, render, screen } from "@testing-library/react"
5
+ import { describe, expect, it, vi } from "vitest"
6
+
7
+ import { BRAND_ICONS } from "../../lib/icons"
8
+ import { RelatedRecordActionCard } from "../related-record-action-card"
9
+
10
+ describe("RelatedRecordActionCard", () => {
11
+ it("renders an enabled href as a link with pointer affordance", () => {
12
+ render(
13
+ <RelatedRecordActionCard
14
+ kind="account"
15
+ label="Open account"
16
+ subtitle="Acme Corp"
17
+ href="/accounts/acme"
18
+ testId="record-card"
19
+ />
20
+ )
21
+
22
+ const card = screen.getByTestId("record-card")
23
+
24
+ expect(card.tagName).toBe("A")
25
+ expect(card).toHaveAttribute("href", "/accounts/acme")
26
+ expect(card.className).toContain("cursor-pointer")
27
+ expect(card.className).toContain("hover:bg-muted/50")
28
+ expect(card.className).toContain("hover:border-border/80")
29
+ expect(card.className).toContain("focus-visible:ring-2")
30
+ expect(card.className).toContain("active:text-primary")
31
+ expect(screen.getByText("Acme Corp")).toBeInTheDocument()
32
+ })
33
+
34
+ it("renders an enabled onClick action as a button and invokes the callback", () => {
35
+ const onClick = vi.fn()
36
+
37
+ render(
38
+ <RelatedRecordActionCard
39
+ kind="case"
40
+ label="Open case"
41
+ onClick={onClick}
42
+ testId="record-card"
43
+ />
44
+ )
45
+
46
+ const card = screen.getByRole("button", { name: /open case/i })
47
+
48
+ expect(card.tagName).toBe("BUTTON")
49
+ expect(card).toHaveAttribute("type", "button")
50
+ expect(card.className).toContain("cursor-pointer")
51
+
52
+ fireEvent.click(card)
53
+
54
+ expect(onClick).toHaveBeenCalledTimes(1)
55
+ })
56
+
57
+ it("renders a disabled card when disabledReason is present", () => {
58
+ render(
59
+ <RelatedRecordActionCard
60
+ kind="opportunity"
61
+ label="Open opportunity"
62
+ disabledReason="Connect Salesforce to view this opportunity."
63
+ href="/opportunities/1"
64
+ testId="record-card"
65
+ />
66
+ )
67
+
68
+ const card = screen.getByTestId("record-card")
69
+
70
+ expect(card.tagName).toBe("DIV")
71
+ expect(card).toHaveAttribute("aria-disabled", "true")
72
+ expect(card.className).toContain("cursor-not-allowed")
73
+ expect(card.className).toContain("text-muted-foreground")
74
+ expect(card.className).not.toContain("cursor-pointer")
75
+ expect(card.className).not.toContain("hover:bg-muted/50")
76
+ expect(screen.getByText("Connect Salesforce to view this opportunity.")).toBeInTheDocument()
77
+ })
78
+
79
+ it("renders a disabled card when no action is available", () => {
80
+ render(<RelatedRecordActionCard kind="generic" label="Unavailable record" testId="record-card" />)
81
+
82
+ const card = screen.getByTestId("record-card")
83
+
84
+ expect(card.tagName).toBe("DIV")
85
+ expect(card).toHaveAttribute("aria-disabled", "true")
86
+ expect(card.className).toContain("cursor-not-allowed")
87
+ })
88
+
89
+ it("sets external link attributes and exposes an accessible external-link cue", () => {
90
+ const { container } = render(
91
+ <RelatedRecordActionCard
92
+ kind="salesforce"
93
+ label="Open in Salesforce"
94
+ href="https://example.salesforce.com/001"
95
+ external
96
+ testId="record-card"
97
+ />
98
+ )
99
+
100
+ const card = screen.getByTestId("record-card")
101
+
102
+ expect(card).toHaveAttribute("target", "_blank")
103
+ expect(card).toHaveAttribute("rel", "noopener noreferrer")
104
+ expect(screen.getByText("opens in a new tab")).toHaveClass("sr-only")
105
+ expect(container.querySelector('[data-slot="related-record-action-card-external-icon"]')).not.toBeNull()
106
+ })
107
+
108
+ it("renders the Salesforce brand icon when icon is salesforce", () => {
109
+ const { container } = render(
110
+ <RelatedRecordActionCard
111
+ kind="generic"
112
+ label="Salesforce record"
113
+ href="https://example.salesforce.com/001"
114
+ icon="salesforce"
115
+ />
116
+ )
117
+
118
+ const icon = container.querySelector('[data-slot="related-record-action-card-icon"] img')
119
+
120
+ expect(icon).toHaveAttribute("src", BRAND_ICONS.salesforce)
121
+ expect(icon).toHaveAttribute("alt", "Salesforce")
122
+ })
123
+ })
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Tests for the SuggestedActions card header thumbs (feedback direction).
3
+ *
4
+ * Covers:
5
+ * - Clicking up opens the feedback panel with direction "up"
6
+ * - Clicking down opens the feedback panel with direction "down"
7
+ * - Clicking the same direction again closes the panel
8
+ * - Switching direction remounts DraftFeedbackInline (panel stays open, content switches)
9
+ */
10
+
11
+ import { describe, it, expect } from "vitest"
12
+ import React from "react"
13
+ import { render, screen, fireEvent } from "@testing-library/react"
14
+ import { SuggestedActions } from "../suggested-actions"
15
+ import type { SuggestedAction } from "../suggested-actions"
16
+
17
+ const action: SuggestedAction = {
18
+ id: 1,
19
+ type: "email",
20
+ label: "Send follow-up email",
21
+ status: "pending",
22
+ content: "<p>Hello there.</p>",
23
+ }
24
+
25
+ // The header thumbs are the only buttons that wrap a thumbs-up/down SVG and
26
+ // live in the card header. We resolve them by locating the lucide icon class.
27
+ function getHeaderThumbs(container: HTMLElement) {
28
+ const up = container.querySelector("button .lucide-thumbs-up")?.closest("button")
29
+ const down = container.querySelector("button .lucide-thumbs-down")?.closest("button")
30
+ return { up: up as HTMLButtonElement, down: down as HTMLButtonElement }
31
+ }
32
+
33
+ describe("SuggestedActions header thumbs", () => {
34
+ it("does not show the feedback panel initially", () => {
35
+ render(<SuggestedActions actions={[action]} />)
36
+ expect(screen.queryByText("How's this draft?")).toBeNull()
37
+ })
38
+
39
+ it("opens the feedback panel with direction up when up is clicked", () => {
40
+ const { container } = render(<SuggestedActions actions={[action]} />)
41
+ const { up } = getHeaderThumbs(container)
42
+ fireEvent.click(up)
43
+
44
+ expect(screen.getByText("How's this draft?")).toBeTruthy()
45
+ expect(screen.getByText("What worked well?")).toBeTruthy()
46
+ expect(screen.queryByText("What needs improvement?")).toBeNull()
47
+ expect(up.className).toContain("bg-muted")
48
+ expect(up.className).toContain("text-foreground")
49
+ })
50
+
51
+ it("opens the feedback panel with direction down when down is clicked", () => {
52
+ const { container } = render(<SuggestedActions actions={[action]} />)
53
+ const { down } = getHeaderThumbs(container)
54
+ fireEvent.click(down)
55
+
56
+ expect(screen.getByText("How's this draft?")).toBeTruthy()
57
+ expect(screen.getByText("What needs improvement?")).toBeTruthy()
58
+ expect(screen.queryByText("What worked well?")).toBeNull()
59
+ expect(down.className).toContain("bg-muted")
60
+ expect(down.className).toContain("text-foreground")
61
+ })
62
+
63
+ it("closes the panel when the same direction is clicked again", () => {
64
+ const { container } = render(<SuggestedActions actions={[action]} />)
65
+ const { up } = getHeaderThumbs(container)
66
+ fireEvent.click(up)
67
+ expect(screen.getByText("How's this draft?")).toBeTruthy()
68
+
69
+ fireEvent.click(up)
70
+ expect(screen.queryByText("How's this draft?")).toBeNull()
71
+ })
72
+
73
+ it("switches direction (remounts DraftFeedbackInline) while keeping the panel open", () => {
74
+ const { container } = render(<SuggestedActions actions={[action]} />)
75
+ const { up, down } = getHeaderThumbs(container)
76
+
77
+ fireEvent.click(up)
78
+ expect(screen.getByText("What worked well?")).toBeTruthy()
79
+
80
+ fireEvent.click(down)
81
+ // Panel still open, but now showing the negative (down) variant.
82
+ expect(screen.getByText("How's this draft?")).toBeTruthy()
83
+ expect(screen.getByText("What needs improvement?")).toBeTruthy()
84
+ expect(screen.queryByText("What worked well?")).toBeNull()
85
+ })
86
+ })
@@ -16,7 +16,7 @@ const positivePills = ["Tone", "Personalization", "Length", "CTA", "Other"]
16
16
  const negativePills = ["Too formal", "Too casual", "Too long", "Missing context", "Wrong angle", "Factual error", "Other"]
17
17
 
18
18
  export interface DraftFeedbackInlineProps {
19
- initialDirection?: 'up' | 'down' | null
19
+ initialDirection?: "up" | "down" | null
20
20
  onRegenerateRequest?: (pills: string[], detail: string) => void
21
21
  onSubmitFeedback?: (type: "up" | "down", pills: string[], detail: string) => void
22
22
  onDiscardRequest?: (pills: string[], detail: string) => void
@@ -70,7 +70,7 @@ export function DraftFeedbackInline({
70
70
  if (noted) {
71
71
  return (
72
72
  <div className="flex items-center gap-1.5 py-1 animate-in fade-in slide-in-from-top-1 duration-200">
73
- <Check className="w-3.5 h-3.5 text-emerald-500" />
73
+ <Check className="w-3.5 h-3.5 text-foreground" />
74
74
  <span className="text-xs text-muted-foreground">Feedback recorded</span>
75
75
  </div>
76
76
  )
@@ -88,7 +88,7 @@ export function DraftFeedbackInline({
88
88
  }
89
89
 
90
90
  return (
91
- <div className="space-y-0">
91
+ <div className="space-y-2">
92
92
  <div className="flex items-center justify-between">
93
93
  <span className="text-sm text-foreground font-medium">How&apos;s this draft?</span>
94
94
  <div className="flex gap-1">
@@ -100,11 +100,11 @@ export function DraftFeedbackInline({
100
100
  }}
101
101
  className={`p-1.5 rounded transition-colors ${
102
102
  thumbState === "up"
103
- ? "bg-emerald-100 text-emerald-600 dark:bg-emerald-900/30 dark:text-emerald-400"
103
+ ? "bg-muted text-foreground"
104
104
  : "hover:bg-muted text-muted-foreground hover:text-foreground"
105
105
  }`}
106
106
  >
107
- <ThumbsUp className="w-4 h-4" fill={thumbState === "up" ? "currentColor" : "none"} />
107
+ <ThumbsUp className="w-4 h-4" />
108
108
  </button>
109
109
  <button
110
110
  onClick={() => {
@@ -114,31 +114,31 @@ export function DraftFeedbackInline({
114
114
  }}
115
115
  className={`p-1.5 rounded transition-colors ${
116
116
  thumbState === "down"
117
- ? "bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400"
117
+ ? "bg-muted text-foreground"
118
118
  : "hover:bg-muted text-muted-foreground hover:text-foreground"
119
119
  }`}
120
120
  >
121
- <ThumbsDown className="w-4 h-4" fill={thumbState === "down" ? "currentColor" : "none"} />
121
+ <ThumbsDown className="w-4 h-4" />
122
122
  </button>
123
123
  </div>
124
124
  </div>
125
125
 
126
126
  {thumbState && (
127
- <div className="pt-3 space-y-3 animate-in fade-in slide-in-from-top-2 duration-200">
127
+ <div className="pt-2 space-y-2.5 animate-in fade-in slide-in-from-top-2 duration-200">
128
128
  <div>
129
129
  <span className="text-xs text-muted-foreground mb-2 block font-medium">
130
130
  {thumbState === "up" ? "What worked well?" : "What needs improvement?"}
131
131
  </span>
132
- <div className="flex flex-wrap gap-1.5">
132
+ <div className="flex flex-wrap gap-2">
133
133
  {(thumbState === "up" ? positivePills : negativePills).map((pill) => (
134
134
  <button
135
135
  key={pill}
136
136
  onClick={() => togglePill(pill)}
137
- className={`px-2.5 py-1 rounded-full text-[11px] font-medium border transition-colors ${
137
+ className={`px-3 py-1.5 rounded-full text-[11px] font-medium border transition-colors ${
138
138
  selectedPills.includes(pill)
139
139
  ? thumbState === "up"
140
- ? "bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-300 dark:border-emerald-800"
141
- : "bg-red-100 text-red-700 border-red-200 dark:bg-red-900/30 dark:text-red-300 dark:border-red-800"
140
+ ? "bg-muted text-foreground border-border"
141
+ : "bg-red-50 text-red-700 border-red-200 dark:bg-red-950/30 dark:text-red-300 dark:border-red-800"
142
142
  : "bg-background text-muted-foreground border-border hover:bg-muted/50 hover:text-foreground"
143
143
  }`}
144
144
  >
@@ -155,7 +155,7 @@ export function DraftFeedbackInline({
155
155
  className="w-full text-xs bg-background border border-border rounded-md px-2.5 py-2 text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-indigo-500/50 focus:border-indigo-500/50 resize-none min-h-[60px]"
156
156
  />
157
157
 
158
- <div className="flex items-center gap-2 pt-1">
158
+ <div className="flex items-center gap-2.5 pt-2">
159
159
  {thumbState === "down" ? (
160
160
  <>
161
161
  <button