@handled-ai/design-system 0.18.53 → 0.19.0-rc.0

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 (39) hide show
  1. package/dist/components/case-panel-activity-timeline.d.ts +2 -0
  2. package/dist/components/case-panel-activity-timeline.js +22 -1
  3. package/dist/components/case-panel-activity-timeline.js.map +1 -1
  4. package/dist/components/comment-composer.d.ts +29 -0
  5. package/dist/components/comment-composer.js +102 -0
  6. package/dist/components/comment-composer.js.map +1 -0
  7. package/dist/components/conversation-panel.d.ts +95 -0
  8. package/dist/components/conversation-panel.js +636 -0
  9. package/dist/components/conversation-panel.js.map +1 -0
  10. package/dist/components/data-table-filter.d.ts +18 -1
  11. package/dist/components/data-table-filter.js +20 -6
  12. package/dist/components/data-table-filter.js.map +1 -1
  13. package/dist/components/owner-chips.d.ts +59 -0
  14. package/dist/components/owner-chips.js +256 -0
  15. package/dist/components/owner-chips.js.map +1 -0
  16. package/dist/components/timeline-activity.d.ts +7 -0
  17. package/dist/components/timeline-activity.js +22 -1
  18. package/dist/components/timeline-activity.js.map +1 -1
  19. package/dist/index.d.ts +3 -0
  20. package/dist/index.js +3 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/internal/safe-html.d.ts +11 -0
  23. package/dist/internal/safe-html.js +222 -0
  24. package/dist/internal/safe-html.js.map +1 -0
  25. package/package.json +1 -1
  26. package/src/components/__tests__/comment-composer.test.tsx +57 -0
  27. package/src/components/__tests__/conversation-panel.test.tsx +157 -0
  28. package/src/components/__tests__/data-table-filter.test.tsx +72 -0
  29. package/src/components/__tests__/owner-chips.test.tsx +100 -0
  30. package/src/components/__tests__/timeline-activity.test.tsx +55 -0
  31. package/src/components/case-panel-activity-timeline.tsx +20 -0
  32. package/src/components/comment-composer.tsx +119 -0
  33. package/src/components/conversation-panel.tsx +790 -0
  34. package/src/components/data-table-filter.tsx +53 -10
  35. package/src/components/owner-chips.tsx +335 -0
  36. package/src/components/timeline-activity.tsx +37 -3
  37. package/src/index.ts +3 -0
  38. package/src/internal/__tests__/safe-html.test.ts +53 -0
  39. package/src/internal/safe-html.ts +284 -0
@@ -149,4 +149,59 @@ describe("TimelineActivity", () => {
149
149
  expect(TONE_CLASSES[tone].icon).toBeTruthy()
150
150
  }
151
151
  })
152
+
153
+ // --- Email body: opt-in HTML render mode ---
154
+
155
+ it("sanitizes formatted HTML when email.bodyHtml is provided", () => {
156
+ const event = minimal({
157
+ isInteractive: true,
158
+ defaultExpanded: true,
159
+ email: {
160
+ from: "Priya Raman",
161
+ to: "Dana Okafor",
162
+ subject: "Re: hi",
163
+ body: "plain fallback",
164
+ bodyHtml:
165
+ '<p onclick="alert(1)">Hello <a href="javascript:alert(1)">bad</a><script>alert(1)</script><img src="https://example.com/pixel.png" onerror="alert(1)"></p>',
166
+ },
167
+ })
168
+ const { container } = render(<TimelineActivity events={[event]} />)
169
+ const html = container.querySelector('[data-slot="timeline-email-html"]')!
170
+ expect(html.innerHTML).not.toContain("script")
171
+ expect(html.innerHTML).not.toContain("onclick")
172
+ expect(html.innerHTML).not.toContain("onerror")
173
+ expect(html.innerHTML).not.toContain("javascript:")
174
+ expect(html.querySelector("img")?.getAttribute("src")).toBe("https://example.com/pixel.png")
175
+ expect(html.querySelector("a")?.getAttribute("href")).toBeNull()
176
+ })
177
+
178
+ it("renders formatted HTML when email.bodyHtml is provided", () => {
179
+ const event = minimal({
180
+ isInteractive: true,
181
+ defaultExpanded: true,
182
+ email: {
183
+ from: "Priya Raman",
184
+ to: "Dana Okafor",
185
+ subject: "Re: hi",
186
+ body: "plain fallback",
187
+ bodyHtml: '<p>Hello <a href="https://example.com">link</a></p>',
188
+ },
189
+ })
190
+ const { container } = render(<TimelineActivity events={[event]} />)
191
+ const html = container.querySelector('[data-slot="timeline-email-html"]')
192
+ expect(html).not.toBeNull()
193
+ // Renders the actual anchor element (not escaped text)
194
+ expect(html!.querySelector("a")?.getAttribute("href")).toBe("https://example.com")
195
+ })
196
+
197
+ it("falls back to the plain-text body when bodyHtml is absent", () => {
198
+ const event = minimal({
199
+ isInteractive: true,
200
+ defaultExpanded: true,
201
+ email: { from: "Priya", to: "Dana", subject: "Re: hi", body: "just text" },
202
+ })
203
+ const { container } = render(<TimelineActivity events={[event]} />)
204
+ expect(container.querySelector('[data-slot="timeline-email-html"]')).toBeNull()
205
+ expect(screen.getByText("just text")).toBeDefined()
206
+ })
152
207
  })
@@ -62,6 +62,8 @@ export type CasePanelActivityPayload =
62
62
  dueLabel: string
63
63
  status?: "upcoming" | "due" | "overdue" | "met"
64
64
  description?: string
65
+ /** 0..1 elapsed toward the deadline; renders a thin progress bar. */
66
+ progress?: number
65
67
  }
66
68
  | {
67
69
  kind: "operatorNote"
@@ -373,6 +375,24 @@ function renderPayloadContent(
373
375
  ) : null}
374
376
  </div>
375
377
  {payload.description ? <p className="text-xs leading-relaxed text-muted-foreground">{payload.description}</p> : null}
378
+ {typeof payload.progress === "number" ? (
379
+ <div
380
+ data-slot="deadline-progress"
381
+ role="progressbar"
382
+ aria-valuenow={Math.round(Math.min(1, Math.max(0, payload.progress)) * 100)}
383
+ aria-valuemin={0}
384
+ aria-valuemax={100}
385
+ className="bg-muted h-1.5 w-full overflow-hidden rounded-full"
386
+ >
387
+ <div
388
+ className={cn(
389
+ "h-full rounded-full",
390
+ payload.status === "overdue" ? "bg-red-500" : payload.status === "due" ? "bg-amber-500" : "bg-foreground/70"
391
+ )}
392
+ style={{ width: `${Math.min(1, Math.max(0, payload.progress)) * 100}%` }}
393
+ />
394
+ </div>
395
+ ) : null}
376
396
  </div>
377
397
  )
378
398
  case "operatorNote":
@@ -0,0 +1,119 @@
1
+ "use client"
2
+
3
+ /**
4
+ * comment-composer.tsx — an internal-note composer for the case activity
5
+ * timeline. Posting a comment prepends an `operatorNote` event to the log
6
+ * (wired by the consumer). Collapses to a single line; expands on focus or
7
+ * when it has text. ⌘↵ / Ctrl↵ posts.
8
+ *
9
+ * Presentational: `onPost` does the work (the consumer persists the note and
10
+ * adds it to the timeline). Reuses Avatar / Button / Textarea primitives.
11
+ */
12
+
13
+ import * as React from "react"
14
+ import { Lock } from "lucide-react"
15
+
16
+ import { cn } from "../lib/utils"
17
+ import { getInitials } from "../lib/user-display"
18
+ import { Avatar, AvatarFallback, AvatarImage } from "./avatar"
19
+ import { Button } from "./button"
20
+ import { Textarea } from "./textarea"
21
+
22
+ export interface CommentComposerProps {
23
+ /** Called with the trimmed note text when the operator posts. */
24
+ onPost: (text: string) => void
25
+ /** Current operator (for the avatar). */
26
+ author?: { name?: string; email?: string; avatarUrl?: string | null }
27
+ placeholder?: string
28
+ /** Hint shown in the footer; defaults to the internal-note reassurance. */
29
+ hint?: string
30
+ className?: string
31
+ }
32
+
33
+ function CommentComposer({
34
+ onPost,
35
+ author,
36
+ placeholder = "Add a comment or internal note…",
37
+ hint = "Internal note: only your team sees this",
38
+ className,
39
+ }: CommentComposerProps) {
40
+ const [text, setText] = React.useState("")
41
+ const [focused, setFocused] = React.useState(false)
42
+ const open = focused || text.length > 0
43
+ const canPost = text.trim().length > 0
44
+
45
+ const post = () => {
46
+ const value = text.trim()
47
+ if (!value) return
48
+ onPost(value)
49
+ setText("")
50
+ setFocused(false)
51
+ }
52
+
53
+ return (
54
+ <div
55
+ data-slot="comment-composer"
56
+ data-open={open ? "true" : undefined}
57
+ className={cn(
58
+ "border-border bg-background flex items-start gap-2 rounded-lg border p-2 transition-colors",
59
+ open && "ring-ring/30 ring-2",
60
+ className
61
+ )}
62
+ >
63
+ <Avatar size="sm" className="mt-0.5">
64
+ {author?.avatarUrl ? <AvatarImage src={author.avatarUrl} alt={author.name ?? "You"} /> : null}
65
+ <AvatarFallback className="bg-muted text-muted-foreground text-[10px] font-medium uppercase">
66
+ {getInitials({ name: author?.name, email: author?.email })}
67
+ </AvatarFallback>
68
+ </Avatar>
69
+
70
+ <div className="min-w-0 flex-1">
71
+ <Textarea
72
+ data-slot="comment-composer-input"
73
+ value={text}
74
+ onChange={(e) => setText(e.target.value)}
75
+ onFocus={() => setFocused(true)}
76
+ placeholder={placeholder}
77
+ rows={open ? 3 : 1}
78
+ className={cn(
79
+ "resize-none border-0 bg-transparent p-1 text-sm shadow-none focus-visible:ring-0",
80
+ !open && "min-h-0"
81
+ )}
82
+ onKeyDown={(e) => {
83
+ if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
84
+ e.preventDefault()
85
+ post()
86
+ }
87
+ }}
88
+ />
89
+
90
+ {open ? (
91
+ <div className="mt-1 flex items-center justify-between gap-2">
92
+ <span className="text-muted-foreground inline-flex items-center gap-1 text-[11px]">
93
+ <Lock size={12} /> {hint}
94
+ </span>
95
+ <span className="flex items-center gap-2">
96
+ <Button
97
+ type="button"
98
+ variant="ghost"
99
+ size="sm"
100
+ onClick={() => {
101
+ setText("")
102
+ setFocused(false)
103
+ }}
104
+ >
105
+ Cancel
106
+ </Button>
107
+ <Button type="button" size="sm" disabled={!canPost} onClick={post}>
108
+ Comment
109
+ <kbd className="bg-primary-foreground/15 ml-1 rounded px-1 text-[10px]">⌘↵</kbd>
110
+ </Button>
111
+ </span>
112
+ </div>
113
+ ) : null}
114
+ </div>
115
+ </div>
116
+ )
117
+ }
118
+
119
+ export { CommentComposer }