@handled-ai/design-system 0.18.22 → 0.18.24

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 (31) hide show
  1. package/dist/components/case-panel-activity-timeline.d.ts +100 -0
  2. package/dist/components/case-panel-activity-timeline.js +270 -0
  3. package/dist/components/case-panel-activity-timeline.js.map +1 -0
  4. package/dist/components/case-panel-detail.d.ts +60 -0
  5. package/dist/components/case-panel-detail.js +129 -0
  6. package/dist/components/case-panel-detail.js.map +1 -0
  7. package/dist/components/case-panel-email-composer.d.ts +61 -0
  8. package/dist/components/case-panel-email-composer.js +304 -0
  9. package/dist/components/case-panel-email-composer.js.map +1 -0
  10. package/dist/components/case-panel-why.d.ts +35 -0
  11. package/dist/components/case-panel-why.js +149 -0
  12. package/dist/components/case-panel-why.js.map +1 -0
  13. package/dist/components/contextual-quick-action-launcher.d.ts +7 -3
  14. package/dist/components/contextual-quick-action-launcher.js +99 -27
  15. package/dist/components/contextual-quick-action-launcher.js.map +1 -1
  16. package/dist/components/pill.d.ts +1 -1
  17. package/dist/index.d.ts +5 -1
  18. package/dist/index.js +4 -0
  19. package/dist/index.js.map +1 -1
  20. package/package.json +1 -1
  21. package/src/components/__tests__/case-panel-activity-timeline.test.tsx +152 -0
  22. package/src/components/__tests__/case-panel-detail.test.tsx +138 -0
  23. package/src/components/__tests__/case-panel-email-composer.test.tsx +171 -0
  24. package/src/components/__tests__/case-panel-why.test.tsx +152 -0
  25. package/src/components/__tests__/contextual-quick-action-launcher.test.tsx +90 -0
  26. package/src/components/case-panel-activity-timeline.tsx +414 -0
  27. package/src/components/case-panel-detail.tsx +228 -0
  28. package/src/components/case-panel-email-composer.tsx +341 -0
  29. package/src/components/case-panel-why.tsx +214 -0
  30. package/src/components/contextual-quick-action-launcher.tsx +92 -15
  31. package/src/index.ts +4 -0
@@ -0,0 +1,138 @@
1
+ import { describe, it, expect, vi } from "vitest"
2
+ import React from "react"
3
+ import { fireEvent, render, screen, within } from "@testing-library/react"
4
+
5
+ import {
6
+ CasePanelDetail,
7
+ CasePanelDetailSlots,
8
+ CasePanelHeader,
9
+ CasePanelIdentitySubline,
10
+ CasePanelMetadataRow,
11
+ CasePanelSignalBrief,
12
+ } from "../case-panel-detail"
13
+
14
+ function renderBasicPanel() {
15
+ return render(
16
+ <CasePanelDetail>
17
+ <CasePanelHeader title="Treasury Churn Risk: Northwind Systems" action={<button type="button">Quick action</button>}>
18
+ <CasePanelIdentitySubline
19
+ callsign="northwind"
20
+ links={[
21
+ { id: "salesforce", label: "Open in Salesforce", href: "https://example.com/sf", kind: "icon" },
22
+ { id: "admin", label: "Open in Admin", href: "https://example.com/admin", kind: "text" },
23
+ ]}
24
+ />
25
+ </CasePanelHeader>
26
+ <CasePanelSignalBrief>Key account signals indicate elevated churn risk.</CasePanelSignalBrief>
27
+ <CasePanelMetadataRow>
28
+ <span>Northwind Systems</span>
29
+ <span>Medium Priority</span>
30
+ </CasePanelMetadataRow>
31
+ </CasePanelDetail>,
32
+ )
33
+ }
34
+
35
+ describe("CasePanelDetail structure", () => {
36
+ it("keeps the header calm with only title, identity subline, and supplied action", () => {
37
+ renderBasicPanel()
38
+
39
+ expect(screen.getByRole("heading", { level: 1, name: "Treasury Churn Risk: Northwind Systems" })).toBeTruthy()
40
+ expect(screen.getByRole("button", { name: "Quick action" })).toBeTruthy()
41
+ expect(screen.getByText("@northwind")).toBeTruthy()
42
+ expect(screen.queryByText("Medium Priority")).toBeTruthy()
43
+
44
+ const header = screen.getByRole("banner")
45
+ expect(within(header).queryByText("Medium Priority")).toBeNull()
46
+ expect(within(header).queryByText("Northwind Systems", { selector: "span" })).toBeNull()
47
+ })
48
+
49
+ it("renders the identity subline with normalized callsign and icon links", () => {
50
+ render(
51
+ <CasePanelIdentitySubline
52
+ callsign="acme"
53
+ links={[
54
+ { id: "salesforce", label: "Open in Salesforce", href: "https://example.com/sf", kind: "icon" },
55
+ { id: "admin", label: "Open in Admin", href: "https://example.com/admin", kind: "text" },
56
+ ]}
57
+ />,
58
+ )
59
+
60
+ expect(screen.getByText("@acme")).toBeTruthy()
61
+ expect(screen.getByRole("link", { name: "Open in Salesforce" }).getAttribute("href")).toBe("https://example.com/sf")
62
+ expect(screen.getByRole("link", { name: "Open in Admin" }).getAttribute("href")).toBe("https://example.com/admin")
63
+ })
64
+
65
+ it("fires the copy callback with the normalized callsign", () => {
66
+ const onCopy = vi.fn()
67
+ render(<CasePanelIdentitySubline callsign="northwind" onCopyCallsign={onCopy} />)
68
+
69
+ fireEvent.click(screen.getByRole("button", { name: "Copy call sign" }))
70
+
71
+ expect(onCopy).toHaveBeenCalledWith("@northwind")
72
+ expect(screen.getByRole("button", { name: "Call sign copied" })).toBeTruthy()
73
+ })
74
+
75
+ it("renders missing identity links as disabled buttons instead of active links", () => {
76
+ render(
77
+ <CasePanelIdentitySubline
78
+ callsign="northwind"
79
+ links={[
80
+ { id: "salesforce", label: "Open in Salesforce", kind: "icon" },
81
+ { id: "admin", label: "Open in Admin", href: "https://example.com/admin", disabled: true, kind: "text" },
82
+ ]}
83
+ />,
84
+ )
85
+
86
+ expect(screen.queryByRole("link", { name: "Open in Salesforce" })).toBeNull()
87
+ expect(screen.queryByRole("link", { name: "Open in Admin" })).toBeNull()
88
+ expect(screen.getByRole("button", { name: "Open in Salesforce" })).toHaveProperty("disabled", true)
89
+ expect(screen.getByRole("button", { name: "Open in Admin" })).toHaveProperty("disabled", true)
90
+ })
91
+
92
+ it("places metadata below the signal brief", () => {
93
+ const { container } = renderBasicPanel()
94
+
95
+ const brief = screen.getByText("Key account signals indicate elevated churn risk.")
96
+ const metadata = screen.getByLabelText("Case metadata")
97
+ const position = brief.compareDocumentPosition(metadata)
98
+
99
+ expect(position & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
100
+ expect(container.textContent).toContain("Signal brief")
101
+ })
102
+
103
+ it("renders ordered slots for WHY, actions, opportunity, timeline, and play-panel", () => {
104
+ const { container } = render(
105
+ <CasePanelDetailSlots
106
+ timeline={<div>Timeline</div>}
107
+ why={<div>WHY</div>}
108
+ playPanel={<div>Play panel</div>}
109
+ actions={<div>Actions</div>}
110
+ opportunity={<div>Opportunity</div>}
111
+ />,
112
+ )
113
+
114
+ const slots = Array.from(container.querySelectorAll("[data-slot]")).map((node) => node.getAttribute("data-slot"))
115
+ expect(slots).toEqual(["why", "actions", "opportunity", "timeline", "play-panel"])
116
+ })
117
+
118
+ it("supports the modest width variant", () => {
119
+ const { container } = render(
120
+ <CasePanelDetail width="modest">
121
+ <div>Content</div>
122
+ </CasePanelDetail>,
123
+ )
124
+
125
+ const inner = container.querySelector("section > div")
126
+ expect(inner?.className).toContain("max-w-[720px]")
127
+ expect(inner?.className).not.toContain("max-w-[880px]")
128
+ })
129
+
130
+ it("provides accessibility labels for the detail region and copy/link controls", () => {
131
+ renderBasicPanel()
132
+
133
+ expect(screen.getByRole("region", { name: "Case detail" })).toBeTruthy()
134
+ expect(screen.getByRole("button", { name: "Copy call sign" })).toBeTruthy()
135
+ expect(screen.getByRole("link", { name: "Open in Salesforce" })).toBeTruthy()
136
+ expect(screen.getByLabelText("Case metadata")).toBeTruthy()
137
+ })
138
+ })
@@ -0,0 +1,171 @@
1
+ import { describe, it, expect, vi } from "vitest"
2
+ import React from "react"
3
+ import { render, screen, fireEvent } from "@testing-library/react"
4
+
5
+ import {
6
+ CasePanelEmailComposer,
7
+ CasePanelEmailComposerChip,
8
+ CasePanelEmailComposerRow,
9
+ CasePanelEmailComposerSignatureRow,
10
+ } from "../case-panel-email-composer"
11
+ import { BRAND_ICONS } from "../../lib/icons"
12
+
13
+ describe("CasePanelEmailComposer", () => {
14
+ it("renders draft frame, standard rows, labels, and Gmail icon source", () => {
15
+ const { container } = render(
16
+ <CasePanelEmailComposer
17
+ from="rep@handled.ai"
18
+ to="alex@example.com"
19
+ subject="Next steps"
20
+ draftEditor={<p>Draft body</p>}
21
+ />,
22
+ )
23
+
24
+ expect(container.querySelector('[data-slot="case-panel-email-composer"]')).not.toBeNull()
25
+ expect(screen.getByText("Draft")).not.toBeNull()
26
+ expect(screen.getByText("From")).not.toBeNull()
27
+ expect(screen.getByText("To")).not.toBeNull()
28
+ expect(screen.getByText("Subject")).not.toBeNull()
29
+ expect(screen.getByText("rep@handled.ai")).not.toBeNull()
30
+ expect(screen.getByText("alex@example.com")).not.toBeNull()
31
+ expect(screen.getByText("Next steps")).not.toBeNull()
32
+ expect(screen.getByText("Draft body")).not.toBeNull()
33
+
34
+ const gmailIcon = screen.getByAltText("Gmail") as HTMLImageElement
35
+ expect(gmailIcon.tagName).toBe("IMG")
36
+ expect(gmailIcon.getAttribute("src")).toBe(BRAND_ICONS.gmail.icon)
37
+ })
38
+
39
+ it("renders custom slots and render-prop recipient rows", () => {
40
+ render(
41
+ <CasePanelEmailComposer
42
+ recipientRows={({ Row }) => (
43
+ <Row label="To" state="confirmed">
44
+ Rendered recipient
45
+ </Row>
46
+ )}
47
+ draftEditor={<div data-testid="draft-slot">Editor slot</div>}
48
+ toolbar={<div>Formatting toolbar</div>}
49
+ sendBarActions={<button type="button">Schedule send</button>}
50
+ signatureControl={<div>Custom signature slot</div>}
51
+ />,
52
+ )
53
+
54
+ expect(screen.getByText("Rendered recipient")).not.toBeNull()
55
+ expect(screen.getByTestId("draft-slot")).not.toBeNull()
56
+ expect(screen.getByText("Formatting toolbar")).not.toBeNull()
57
+ expect(screen.getByText("Schedule send")).not.toBeNull()
58
+ expect(screen.getByText("Custom signature slot")).not.toBeNull()
59
+ })
60
+
61
+ it("marks unconfirmed and confirmed To rows with visual state attributes", () => {
62
+ const { container, rerender } = render(
63
+ <CasePanelEmailComposer to="needs confirmation" toState="unconfirmed" />,
64
+ )
65
+
66
+ const unconfirmedRow = container.querySelector('[data-slot="case-panel-email-composer-row"][data-state="unconfirmed"]')
67
+ expect(unconfirmedRow).not.toBeNull()
68
+ expect(unconfirmedRow?.className).toContain("bg-amber-50/75")
69
+
70
+ rerender(<CasePanelEmailComposer to="confirmed@example.com" toState="confirmed" />)
71
+ const confirmedRow = container.querySelector('[data-slot="case-panel-email-composer-row"][data-state="confirmed"]')
72
+ expect(confirmedRow).not.toBeNull()
73
+ expect(confirmedRow?.className).toContain("bg-background")
74
+ expect(confirmedRow?.className).not.toContain("bg-amber-50/75")
75
+ })
76
+
77
+ it("renders recipient action chips and invokes their callbacks", () => {
78
+ const onContactsIntent = vi.fn()
79
+ const onAccountDetailsIntent = vi.fn()
80
+ const onAddCcIntent = vi.fn()
81
+ const onAddBccIntent = vi.fn()
82
+
83
+ render(
84
+ <CasePanelEmailComposer
85
+ onContactsIntent={onContactsIntent}
86
+ onAccountDetailsIntent={onAccountDetailsIntent}
87
+ onAddCcIntent={onAddCcIntent}
88
+ onAddBccIntent={onAddBccIntent}
89
+ />,
90
+ )
91
+
92
+ fireEvent.click(screen.getByRole("button", { name: "Contacts" }))
93
+ fireEvent.click(screen.getByRole("button", { name: "Account details" }))
94
+ fireEvent.click(screen.getByRole("button", { name: "Add Cc" }))
95
+ fireEvent.click(screen.getByRole("button", { name: "Add Bcc" }))
96
+
97
+ expect(onContactsIntent).toHaveBeenCalledTimes(1)
98
+ expect(onAccountDetailsIntent).toHaveBeenCalledTimes(1)
99
+ expect(onAddCcIntent).toHaveBeenCalledTimes(1)
100
+ expect(onAddBccIntent).toHaveBeenCalledTimes(1)
101
+ })
102
+
103
+ it("renders signature row visuals", () => {
104
+ const { container } = render(<CasePanelEmailComposerSignatureRow />)
105
+
106
+ expect(screen.getByText("Include signature")).not.toBeNull()
107
+ expect(container.querySelector('[data-slot="case-panel-email-composer-signature-row"]')).not.toBeNull()
108
+ expect(container.querySelector('[data-checked="true"]')).not.toBeNull()
109
+ })
110
+
111
+ it("renders default toolbar and send bar labels", () => {
112
+ render(<CasePanelEmailComposer />)
113
+
114
+ expect(screen.getByText("Toolbar")).not.toBeNull()
115
+ expect(screen.getByRole("button", { name: "Send" })).not.toBeNull()
116
+ })
117
+
118
+ it("disables send when disabledReason is provided", () => {
119
+ render(<CasePanelEmailComposer disabledReason="Confirm recipient before sending" />)
120
+
121
+ const sendButton = screen.getByRole("button", { name: "Send" }) as HTMLButtonElement
122
+ expect(sendButton.disabled).toBe(true)
123
+ expect(screen.getByText("Confirm recipient before sending")).not.toBeNull()
124
+ expect(sendButton.getAttribute("aria-describedby")).toBeTruthy()
125
+ })
126
+
127
+ it("disables shell chips when disabled", () => {
128
+ render(<CasePanelEmailComposer disabled onContactsIntent={vi.fn()} />)
129
+
130
+ const contactsButton = screen.getByRole("button", { name: "Contacts" }) as HTMLButtonElement
131
+ const sendButton = screen.getByRole("button", { name: "Send" }) as HTMLButtonElement
132
+ expect(contactsButton.disabled).toBe(true)
133
+ expect(sendButton.disabled).toBe(true)
134
+ })
135
+
136
+ it("invokes send intent from click and keyboard shortcut only when enabled", () => {
137
+ const onSendIntent = vi.fn()
138
+ const { container, rerender } = render(<CasePanelEmailComposer onSendIntent={onSendIntent} />)
139
+ const shell = container.querySelector('[data-slot="case-panel-email-composer"]')!
140
+
141
+ fireEvent.click(screen.getByRole("button", { name: "Send" }))
142
+ fireEvent.keyDown(shell, { key: "Enter", metaKey: true })
143
+ fireEvent.keyDown(shell, { key: "Enter" })
144
+ fireEvent.keyDown(shell, { key: "Escape" })
145
+
146
+ expect(onSendIntent).toHaveBeenCalledTimes(2)
147
+
148
+ rerender(<CasePanelEmailComposer onSendIntent={onSendIntent} disabledReason="No recipient" />)
149
+ fireEvent.keyDown(shell, { key: "Enter", ctrlKey: true })
150
+ fireEvent.click(screen.getByRole("button", { name: "Send" }))
151
+
152
+ expect(onSendIntent).toHaveBeenCalledTimes(2)
153
+ })
154
+
155
+ it("exposes row and chip primitives for variants", () => {
156
+ const onClick = vi.fn()
157
+ render(
158
+ <>
159
+ <CasePanelEmailComposerRow label="Cc" actions={<span>row action</span>}>
160
+ cc@example.com
161
+ </CasePanelEmailComposerRow>
162
+ <CasePanelEmailComposerChip onClick={onClick}>Custom chip</CasePanelEmailComposerChip>
163
+ </>,
164
+ )
165
+
166
+ expect(screen.getByText("Cc")).not.toBeNull()
167
+ expect(screen.getByText("row action")).not.toBeNull()
168
+ fireEvent.click(screen.getByRole("button", { name: "Custom chip" }))
169
+ expect(onClick).toHaveBeenCalledTimes(1)
170
+ })
171
+ })
@@ -0,0 +1,152 @@
1
+ import "@testing-library/jest-dom/vitest"
2
+
3
+ import { describe, it, expect, vi } from "vitest"
4
+ import React from "react"
5
+ import { fireEvent, render, screen, within } from "@testing-library/react"
6
+
7
+ import { BRAND_ICONS } from "../../lib/icons"
8
+ import { CasePanelSignalApprovalActions, CasePanelWhy } from "../case-panel-why"
9
+ import { SignalApproval } from "../signal-feedback-inline"
10
+
11
+ const whyItems = [
12
+ {
13
+ id: "treasury",
14
+ label: "Treasury movement",
15
+ count: 3,
16
+ summary: "Three payment signals landed inside the risk window.",
17
+ rows: [
18
+ {
19
+ id: "ach-volume",
20
+ label: "ACH volume increased",
21
+ value: "+40%",
22
+ description: "Payment activity is materially above baseline.",
23
+ meta: "Banking data · 2h ago",
24
+ },
25
+ ],
26
+ },
27
+ ]
28
+
29
+ describe("CasePanelWhy", () => {
30
+ it("renders a collapsed fully bordered summary pill with count and no expanded card", () => {
31
+ const { container } = render(<CasePanelWhy items={whyItems} />)
32
+
33
+ const pill = screen.getByRole("button", { name: /treasury movement/i })
34
+ expect(pill).toHaveAttribute("aria-expanded", "false")
35
+ expect(pill.className).toContain("border")
36
+ expect(pill.className).toContain("border-border")
37
+ expect(pill.className).toContain("rounded-full")
38
+ expect(pill.className).not.toContain("border-b-0")
39
+ expect(pill.className).not.toContain("rounded-b-none")
40
+ expect(screen.getByText("×3")).toBeInTheDocument()
41
+ expect(screen.queryByRole("region", { name: /treasury movement/i })).toBeNull()
42
+ expect(container.textContent).not.toContain("Three payment signals landed")
43
+ })
44
+
45
+ it("expands to a separate bordered card with a gap while preserving the pill bottom border", () => {
46
+ render(<CasePanelWhy items={whyItems} />)
47
+
48
+ const pill = screen.getByRole("button", { name: /treasury movement/i })
49
+ fireEvent.click(pill)
50
+
51
+ expect(pill).toHaveAttribute("aria-expanded", "true")
52
+ expect(pill.className).toContain("border")
53
+ expect(pill.className).toContain("border-border")
54
+ expect(pill.className).not.toContain("border-b-0")
55
+ expect(pill.className).not.toContain("rounded-b-none")
56
+
57
+ const panel = screen.getByRole("region", { name: /treasury movement/i })
58
+ expect(panel).toBeInTheDocument()
59
+ expect(panel.className).toContain("mt-2")
60
+ expect(panel.className).toContain("border")
61
+ expect(panel.className).toContain("border-border")
62
+ expect(panel.className).toContain("rounded-xl")
63
+ expect(panel.className).not.toContain("border-t-0")
64
+ expect(within(panel).getByText("Three payment signals landed inside the risk window.")).toBeInTheDocument()
65
+ expect(within(panel).getByText("ACH volume increased")).toBeInTheDocument()
66
+ })
67
+
68
+ it("supports keyboard/aria expansion using the summary pill button", () => {
69
+ render(<CasePanelWhy items={whyItems} />)
70
+
71
+ const pill = screen.getByRole("button", { name: /treasury movement/i })
72
+ const controlsId = pill.getAttribute("aria-controls")
73
+ expect(controlsId).toBeTruthy()
74
+ expect(pill).toHaveAttribute("aria-expanded", "false")
75
+
76
+ fireEvent.keyDown(pill, { key: "Enter", code: "Enter" })
77
+
78
+ expect(pill).toHaveAttribute("aria-expanded", "true")
79
+ expect(document.getElementById(controlsId!)).toBe(screen.getByRole("region", { name: /treasury movement/i }))
80
+
81
+ fireEvent.click(pill)
82
+ expect(pill).toHaveAttribute("aria-expanded", "false")
83
+ expect(document.getElementById(controlsId!)).toBeNull()
84
+ })
85
+ })
86
+
87
+ describe("CasePanelSignalApprovalActions", () => {
88
+ function renderApprovalActions(overrides: Partial<React.ComponentProps<typeof SignalApproval.Root>> = {}) {
89
+ const props: React.ComponentProps<typeof SignalApproval.Root> = {
90
+ companyName: "Northwind Systems",
91
+ labels: {
92
+ approveButton: "Create in Salesforce",
93
+ dismissButton: "Not Helpful",
94
+ },
95
+ ...overrides,
96
+ children: <CasePanelSignalApprovalActions />,
97
+ }
98
+
99
+ return render(<SignalApproval.Root {...props} />)
100
+ }
101
+
102
+ it("renders compact buttons with Salesforce icon support and outline Not Helpful", () => {
103
+ renderApprovalActions({ approveButtonIconUrl: BRAND_ICONS.salesforce })
104
+
105
+ const approve = screen.getByRole("button", { name: /create in salesforce/i })
106
+ const dismiss = screen.getByRole("button", { name: /not helpful/i })
107
+ const icon = approve.querySelector("img")
108
+
109
+ expect(approve.className).toContain("h-8")
110
+ expect(approve.className).toContain("px-3")
111
+ expect(icon).toHaveAttribute("src", BRAND_ICONS.salesforce)
112
+ expect(dismiss.className).toContain("h-8")
113
+ expect(dismiss.className).toContain("border")
114
+ expect(dismiss.className).toContain("bg-background")
115
+ })
116
+
117
+ it("uses existing request approval, confirm, feedback, and dismiss paths", async () => {
118
+ const onRequestApproval = vi.fn().mockResolvedValue(undefined)
119
+ const onApprove = vi.fn()
120
+ const onApproveFeedback = vi.fn()
121
+ const onDismiss = vi.fn()
122
+
123
+ renderApprovalActions({ onRequestApproval, onApprove, onApproveFeedback, onDismiss })
124
+
125
+ fireEvent.click(screen.getByRole("button", { name: /create in salesforce/i }))
126
+ expect(onRequestApproval).toHaveBeenCalledTimes(1)
127
+
128
+ expect(await screen.findByText(/this will approve this action for/i)).toBeInTheDocument()
129
+ fireEvent.click(screen.getByRole("button", { name: /confirm/i }))
130
+ expect(onApprove).toHaveBeenCalledTimes(1)
131
+
132
+ fireEvent.click(screen.getByRole("button", { name: "Right timing" }))
133
+ fireEvent.click(screen.getByRole("button", { name: "Submit" }))
134
+ expect(onApproveFeedback).toHaveBeenCalledWith(["Right timing"], "")
135
+
136
+ renderApprovalActions({ onRequestApproval: vi.fn().mockResolvedValue(undefined), onApprove: vi.fn(), onDismiss })
137
+ fireEvent.click(screen.getByRole("button", { name: /not helpful/i }))
138
+ expect(screen.getByText("What’s the issue with this action?")).toBeInTheDocument()
139
+
140
+ fireEvent.click(screen.getByRole("button", { name: "Already handled" }))
141
+ fireEvent.click(screen.getByRole("button", { name: "Already escalated" }))
142
+ fireEvent.click(screen.getByRole("button", { name: "Submit" }))
143
+ expect(onDismiss).toHaveBeenCalledWith(["Already handled"], "", "Already escalated")
144
+ })
145
+
146
+ it("falls back to the existing SignalApproval actions for confirmation state", () => {
147
+ renderApprovalActions({ initialApprovalState: "confirming" })
148
+
149
+ expect(screen.getByText(/this will approve this action for/i)).toBeInTheDocument()
150
+ expect(screen.getByRole("button", { name: /confirm/i }).className).toContain("h-7")
151
+ })
152
+ })
@@ -14,6 +14,28 @@ const items = [
14
14
  },
15
15
  ]
16
16
 
17
+ const casePanelItems = [
18
+ {
19
+ id: "create-opportunity",
20
+ label: "Create an opportunity",
21
+ description: "Log a new sales or expansion opportunity in Salesforce",
22
+ icon: <svg aria-hidden="true" data-testid="salesforce-icon" />,
23
+ },
24
+ {
25
+ id: "update-opportunity",
26
+ label: "Update an opportunity",
27
+ description: "Update stage, close date, or amount",
28
+ meta: "No opportunity",
29
+ },
30
+ {
31
+ id: "deal-desk",
32
+ label: "Request deal desk approval",
33
+ description: "Route a deal for approval through Slack",
34
+ disabled: true,
35
+ disabledReason: "Coming soon",
36
+ },
37
+ ]
38
+
17
39
  describe("ContextualQuickActionLauncher", () => {
18
40
  it("renders contextual actions with context label when open", () => {
19
41
  render(
@@ -33,6 +55,74 @@ describe("ContextualQuickActionLauncher", () => {
33
55
  expect(screen.getByText("Coming soon")).toBeTruthy()
34
56
  })
35
57
 
58
+ it("preserves default menu sizing and item layout without variant opt-in", () => {
59
+ render(
60
+ <ContextualQuickActionLauncher
61
+ contextLabel="Acme Corp"
62
+ contextSecondary="Account"
63
+ items={casePanelItems}
64
+ onSelect={() => {}}
65
+ open
66
+ />,
67
+ )
68
+
69
+ const menu = screen.getByRole("menu", { name: /quick action/i })
70
+ expect(menu.className).toContain("w-[308px]")
71
+ expect(menu.className).not.toContain("w-[432px]")
72
+
73
+ const item = screen.getByText("Create an opportunity").closest('[role="menuitem"]')
74
+ expect(item?.className).toContain("flex")
75
+ expect(item?.className).not.toContain("grid-cols-[34px_minmax(0,1fr)_auto]")
76
+
77
+ const header = screen.getByText("Acting on").closest('[data-slot="contextual-quick-action-context-label"]')
78
+ expect(header?.getAttribute("data-variant")).toBe("default")
79
+ })
80
+
81
+ it("renders Case Panel variant with wide menu, larger tiles, header, meta, and footer shortcut", () => {
82
+ render(
83
+ <ContextualQuickActionLauncher
84
+ contextLabel="Northwind Systems"
85
+ contextSecondary="Case"
86
+ items={casePanelItems}
87
+ onSelect={() => {}}
88
+ variant="case-panel"
89
+ open
90
+ />,
91
+ )
92
+
93
+ const menu = screen.getByRole("menu", { name: /quick action/i })
94
+ expect(menu.className).toContain("w-[432px]")
95
+ expect(menu.className).toContain("rounded-[13px]")
96
+
97
+ const header = screen.getByText("Acting on").closest('[data-slot="contextual-quick-action-context-label"]')
98
+ expect(header?.getAttribute("data-variant")).toBe("case-panel")
99
+ expect(header?.className).toContain("whitespace-nowrap")
100
+ expect(screen.getByText("Northwind Systems")).toBeTruthy()
101
+ expect(screen.getByText(/· Case/)).toBeTruthy()
102
+
103
+ const enabledItem = screen.getByText("Update an opportunity").closest('[role="menuitem"]')
104
+ expect(enabledItem?.className).toContain("grid-cols-[34px_minmax(0,1fr)_auto]")
105
+ expect(screen.getByText("Update stage, close date, or amount")).toBeTruthy()
106
+
107
+ const iconTile = enabledItem?.querySelector('[data-slot="contextual-quick-action-item-icon"]')
108
+ expect(iconTile?.className).toContain("h-[34px]")
109
+ expect(iconTile?.className).toContain("w-[34px]")
110
+
111
+ const enabledMeta = screen.getByText("No opportunity")
112
+ expect(enabledMeta.getAttribute("data-slot")).toBe("contextual-quick-action-item-meta")
113
+ expect(enabledMeta.className).toContain("italic")
114
+
115
+ const disabledMeta = screen.getByText("Coming soon")
116
+ expect(disabledMeta.getAttribute("data-slot")).toBe("contextual-quick-action-item-disabled-meta")
117
+ expect(disabledMeta.className).toContain("italic")
118
+
119
+ const browseButton = screen.getByRole("button", { name: "Browse all actions" })
120
+ expect(browseButton).toBeTruthy()
121
+ expect(browseButton.textContent).toBe("Browse all actions")
122
+ expect(browseButton.querySelector("svg")).toBeNull()
123
+ expect(screen.getByText("⌘K").className).toContain("font-mono")
124
+ })
125
+
36
126
  it("calls onSelect for enabled items and closes the menu", () => {
37
127
  const onSelect = vi.fn()
38
128
  const onOpenChange = vi.fn()