@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.
@@ -1,7 +1,7 @@
1
1
  "use client"
2
2
 
3
3
  import * as React from "react"
4
- import { createPortal } from "react-dom"
4
+ import { Popover as PopoverPrimitive } from "radix-ui"
5
5
  import {
6
6
  Check,
7
7
  ChevronDown,
@@ -110,82 +110,27 @@ function RecipientChipPill({
110
110
  )
111
111
  }
112
112
 
113
- function ContactPickerPopover({
114
- triggerRef,
113
+ // Contents of the contact picker dropdown. Rendered inside a Radix
114
+ // `Popover.Content` so its focus scope pushes onto the focus-scope stack and
115
+ // PAUSES any parent modal's scope (e.g. the quick-action Dialog). This is what
116
+ // makes the search input typeable: a plain `createPortal(..., document.body)`
117
+ // element renders outside the Dialog's `DialogContent`, so the Dialog's
118
+ // FocusScope kept yanking focus back (input un-typeable) and its modal
119
+ // `pointer-events: none` on <body> left the portal click-dead. A stacked Radix
120
+ // Popover layer gets `pointer-events: auto` and its own (paused-parent) focus
121
+ // scope, fixing both. See WIT-800 / WIT-770.
122
+ function ContactPickerContents({
115
123
  contacts,
116
124
  addedEmails,
117
125
  onSelect,
118
126
  onAddEmail,
119
- onClose,
120
127
  }: {
121
- triggerRef: React.RefObject<HTMLElement | null>
122
128
  contacts: SuggestedContact[]
123
129
  addedEmails: Set<string>
124
130
  onSelect: (contact: SuggestedContact) => void
125
131
  onAddEmail: (email: string) => void
126
- onClose: () => void
127
132
  }) {
128
- const containerRef = React.useRef<HTMLDivElement>(null)
129
- const searchRef = React.useRef<HTMLInputElement>(null)
130
133
  const [query, setQuery] = React.useState("")
131
- const [style, setStyle] = React.useState<React.CSSProperties>({
132
- position: "fixed",
133
- top: -9999,
134
- left: -9999,
135
- })
136
-
137
- React.useEffect(() => {
138
- const trigger = triggerRef.current
139
- if (!trigger) return
140
- const rect = trigger.getBoundingClientRect()
141
- const width = Math.min(448, window.innerWidth - 32)
142
- let left = rect.left
143
- if (left + width > window.innerWidth - 16) {
144
- left = window.innerWidth - 16 - width
145
- }
146
- if (left < 16) left = 16
147
- const popoverHeight = 280
148
- const spaceBelow = window.innerHeight - rect.bottom - 4
149
- const spaceAbove = rect.top - 4
150
- const placeAbove = spaceBelow < popoverHeight && spaceAbove > spaceBelow
151
- let top = placeAbove ? rect.top - popoverHeight - 4 : rect.bottom + 4
152
- if (top < 16) top = 16
153
- setStyle({ position: "fixed", top, left, width })
154
- }, [triggerRef])
155
-
156
- React.useEffect(() => {
157
- searchRef.current?.focus()
158
- const trigger = triggerRef.current
159
- return () => {
160
- if (trigger && typeof trigger.focus === "function") {
161
- trigger.focus()
162
- }
163
- }
164
- }, [triggerRef])
165
-
166
- React.useEffect(() => {
167
- function handleMouseDown(event: MouseEvent) {
168
- if (
169
- containerRef.current &&
170
- !containerRef.current.contains(event.target as Node) &&
171
- !triggerRef.current?.contains(event.target as Node)
172
- ) {
173
- onClose()
174
- }
175
- }
176
- function handleKeyDown(event: KeyboardEvent) {
177
- if (event.key === "Escape") {
178
- event.stopPropagation()
179
- onClose()
180
- }
181
- }
182
- document.addEventListener("mousedown", handleMouseDown)
183
- document.addEventListener("keydown", handleKeyDown)
184
- return () => {
185
- document.removeEventListener("mousedown", handleMouseDown)
186
- document.removeEventListener("keydown", handleKeyDown)
187
- }
188
- }, [onClose, triggerRef])
189
134
 
190
135
  const normalizedQuery = query.trim().toLowerCase()
191
136
  const filtered = normalizedQuery
@@ -209,16 +154,11 @@ function ContactPickerPopover({
209
154
  }
210
155
  }
211
156
 
212
- return createPortal(
213
- <div
214
- ref={containerRef}
215
- style={style}
216
- className="bg-background border rounded-lg shadow-xl z-50 pointer-events-auto"
217
- >
157
+ return (
158
+ <>
218
159
  <div className="flex items-center gap-2 px-3 py-2.5 border-b border-border/50">
219
160
  <Search className="size-4 text-muted-foreground shrink-0" />
220
161
  <input
221
- ref={searchRef}
222
162
  autoFocus
223
163
  value={query}
224
164
  onChange={(event) => setQuery(event.target.value)}
@@ -229,7 +169,16 @@ function ContactPickerPopover({
229
169
  </div>
230
170
 
231
171
  <div role="listbox" className="max-h-[208px] overflow-y-auto p-1">
232
- {filtered.length === 0 ? (
172
+ {contacts.length === 0 ? (
173
+ <div className="px-3 py-5 text-center text-[13px] text-muted-foreground">
174
+ <div className="font-medium text-foreground/80">
175
+ No contacts for this account
176
+ </div>
177
+ <div className="mt-1">
178
+ Type an email address above and press Enter to add a recipient.
179
+ </div>
180
+ </div>
181
+ ) : filtered.length === 0 ? (
233
182
  <div className="px-3 py-4 text-center text-[13px] text-muted-foreground">
234
183
  <div>No contact matches &lsquo;{query}&rsquo;.</div>
235
184
  {queryIsEmail ? (
@@ -296,8 +245,7 @@ function ContactPickerPopover({
296
245
  <CornerDownLeft className="size-3 shrink-0" />
297
246
  <span>Type an address and press Enter to add someone not listed.</span>
298
247
  </div>
299
- </div>,
300
- document.body,
248
+ </>
301
249
  )
302
250
  }
303
251
 
@@ -317,7 +265,6 @@ export function EmailRecipientField({
317
265
  }: EmailRecipientFieldProps) {
318
266
  const [value, setValue] = React.useState("")
319
267
  const [pickerOpen, setPickerOpen] = React.useState(false)
320
- const contactsTriggerRef = React.useRef<HTMLButtonElement>(null)
321
268
 
322
269
  const hasUnconfirmed = recipients.some((r) => !r.confirmed)
323
270
  const state: "default" | "amber" =
@@ -418,16 +365,37 @@ export function EmailRecipientField({
418
365
  {showPicker || showCcBcc ? (
419
366
  <div className="flex gap-1.5 mt-2">
420
367
  {showPicker ? (
421
- <button
422
- ref={contactsTriggerRef}
423
- type="button"
424
- onClick={() => setPickerOpen((open) => !open)}
425
- className="inline-flex items-center gap-1 h-6 px-2.5 rounded-md border border-border bg-background text-[11px] font-medium text-muted-foreground hover:bg-muted/60 hover:text-foreground transition-colors duration-[120ms]"
426
- >
427
- <Users className="size-3" />
428
- Contacts
429
- <ChevronDown className="size-3" />
430
- </button>
368
+ <PopoverPrimitive.Root open={pickerOpen} onOpenChange={setPickerOpen}>
369
+ <PopoverPrimitive.Trigger asChild>
370
+ <button
371
+ type="button"
372
+ className="inline-flex items-center gap-1 h-6 px-2.5 rounded-md border border-border bg-background text-[11px] font-medium text-muted-foreground hover:bg-muted/60 hover:text-foreground transition-colors duration-[120ms]"
373
+ >
374
+ <Users className="size-3" />
375
+ Contacts
376
+ <ChevronDown className="size-3" />
377
+ </button>
378
+ </PopoverPrimitive.Trigger>
379
+ <PopoverPrimitive.Portal>
380
+ <PopoverPrimitive.Content
381
+ side="bottom"
382
+ align="start"
383
+ sideOffset={4}
384
+ collisionPadding={16}
385
+ className="z-50 w-[min(448px,calc(100vw-2rem))] rounded-lg border bg-background shadow-xl p-0"
386
+ >
387
+ <ContactPickerContents
388
+ contacts={contacts}
389
+ addedEmails={added}
390
+ onSelect={selectContact}
391
+ onAddEmail={(email) => {
392
+ addEmail(email)
393
+ setPickerOpen(false)
394
+ }}
395
+ />
396
+ </PopoverPrimitive.Content>
397
+ </PopoverPrimitive.Portal>
398
+ </PopoverPrimitive.Root>
431
399
  ) : null}
432
400
  {showCcBcc ? (
433
401
  <button
@@ -441,20 +409,6 @@ export function EmailRecipientField({
441
409
  ) : null}
442
410
  </div>
443
411
  ) : null}
444
-
445
- {pickerOpen ? (
446
- <ContactPickerPopover
447
- triggerRef={contactsTriggerRef}
448
- contacts={contacts}
449
- addedEmails={added}
450
- onSelect={selectContact}
451
- onAddEmail={(email) => {
452
- addEmail(email)
453
- setPickerOpen(false)
454
- }}
455
- onClose={() => setPickerOpen(false)}
456
- />
457
- ) : null}
458
412
  </div>
459
413
  </div>
460
414
  )
@@ -0,0 +1,169 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { ExternalLink } from "lucide-react"
5
+
6
+ import { BRAND_ICONS } from "../lib/icons"
7
+ import { cn } from "../lib/utils"
8
+
9
+ export type RelatedRecordActionCardKind = "case" | "account" | "opportunity" | "salesforce" | "generic"
10
+
11
+ export type RelatedRecordActionIcon = "salesforce" | React.ReactNode
12
+
13
+ export interface RelatedRecordActionCardProps {
14
+ kind: RelatedRecordActionCardKind
15
+ label: string
16
+ subtitle?: string
17
+ disabledReason?: string
18
+ href?: string
19
+ external?: boolean
20
+ icon?: RelatedRecordActionIcon
21
+ onClick?: React.MouseEventHandler<HTMLButtonElement>
22
+ className?: string
23
+ testId?: string
24
+ }
25
+
26
+ function renderActionIcon(icon: RelatedRecordActionIcon | undefined, kind: RelatedRecordActionCardKind) {
27
+ if (icon === "salesforce" || kind === "salesforce") {
28
+ return (
29
+ <img
30
+ src={BRAND_ICONS.salesforce}
31
+ alt="Salesforce"
32
+ className="h-4 w-4 object-contain"
33
+ draggable={false}
34
+ />
35
+ )
36
+ }
37
+
38
+ if (icon) {
39
+ return icon
40
+ }
41
+
42
+ return <span aria-hidden="true">{kind.slice(0, 1).toUpperCase()}</span>
43
+ }
44
+
45
+ export function RelatedRecordActionCard({
46
+ kind,
47
+ label,
48
+ subtitle,
49
+ disabledReason,
50
+ href,
51
+ external,
52
+ icon,
53
+ onClick,
54
+ className,
55
+ testId,
56
+ }: RelatedRecordActionCardProps) {
57
+ const isDisabled = Boolean(disabledReason) || (!href && !onClick)
58
+
59
+ const content = (
60
+ <>
61
+ <span
62
+ data-slot="related-record-action-card-icon"
63
+ className={cn(
64
+ "flex h-8 w-8 shrink-0 items-center justify-center rounded-md border text-xs font-semibold transition-colors",
65
+ isDisabled
66
+ ? "border-border/60 bg-muted/40 text-muted-foreground"
67
+ : "border-border bg-muted/30 text-muted-foreground group-hover:bg-muted/60 group-active:text-primary"
68
+ )}
69
+ >
70
+ {renderActionIcon(icon, kind)}
71
+ </span>
72
+ <span className="min-w-0 flex-1">
73
+ <span
74
+ data-slot="related-record-action-card-label"
75
+ className={cn(
76
+ "block truncate text-sm font-medium transition-colors",
77
+ isDisabled ? "text-muted-foreground" : "text-foreground group-active:text-primary"
78
+ )}
79
+ >
80
+ {label}
81
+ </span>
82
+ {subtitle && (
83
+ <span
84
+ data-slot="related-record-action-card-subtitle"
85
+ className="mt-0.5 block truncate text-xs text-muted-foreground"
86
+ >
87
+ {subtitle}
88
+ </span>
89
+ )}
90
+ {disabledReason && (
91
+ <span
92
+ data-slot="related-record-action-card-disabled-reason"
93
+ className="mt-1 block text-xs text-muted-foreground"
94
+ >
95
+ {disabledReason}
96
+ </span>
97
+ )}
98
+ </span>
99
+ {external && (
100
+ <>
101
+ <span className="sr-only">opens in a new tab</span>
102
+ <ExternalLink
103
+ aria-hidden="true"
104
+ data-slot="related-record-action-card-external-icon"
105
+ className={cn(
106
+ "h-3.5 w-3.5 shrink-0 text-muted-foreground transition-colors",
107
+ isDisabled ? "" : "group-active:text-primary"
108
+ )}
109
+ />
110
+ </>
111
+ )}
112
+ </>
113
+ )
114
+
115
+ if (isDisabled) {
116
+ return (
117
+ <div
118
+ aria-disabled="true"
119
+ data-kind={kind}
120
+ data-slot="related-record-action-card"
121
+ data-testid={testId}
122
+ className={cn(
123
+ "group flex w-full items-center gap-3 rounded-lg border px-3 py-2 text-left transition-colors",
124
+ "cursor-not-allowed border-border/60 bg-muted/20 text-muted-foreground opacity-70",
125
+ className
126
+ )}
127
+ >
128
+ {content}
129
+ </div>
130
+ )
131
+ }
132
+
133
+ if (href) {
134
+ return (
135
+ <a
136
+ href={href}
137
+ target={external ? "_blank" : undefined}
138
+ rel={external ? "noopener noreferrer" : undefined}
139
+ data-kind={kind}
140
+ data-slot="related-record-action-card"
141
+ data-testid={testId}
142
+ className={cn(
143
+ "group flex w-full items-center gap-3 rounded-lg border border-border bg-background px-3 py-2 text-left transition-colors",
144
+ "cursor-pointer hover:bg-muted/50 hover:border-border/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 active:text-primary",
145
+ className
146
+ )}
147
+ >
148
+ {content}
149
+ </a>
150
+ )
151
+ }
152
+
153
+ return (
154
+ <button
155
+ type="button"
156
+ onClick={onClick}
157
+ data-kind={kind}
158
+ data-slot="related-record-action-card"
159
+ data-testid={testId}
160
+ className={cn(
161
+ "group flex w-full items-center gap-3 rounded-lg border border-border bg-background px-3 py-2 text-left transition-colors",
162
+ "cursor-pointer hover:bg-muted/50 hover:border-border/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 active:text-primary",
163
+ className
164
+ )}
165
+ >
166
+ {content}
167
+ </button>
168
+ )
169
+ }
@@ -149,7 +149,7 @@ function Trigger({ className }: { className?: string }) {
149
149
  className,
150
150
  )}
151
151
  >
152
- <Check className="w-3 h-3 text-emerald-500" />
152
+ <Check className="w-3 h-3 text-foreground" />
153
153
  <span className="text-[11px] text-muted-foreground">{label}</span>
154
154
  </button>
155
155
  )
@@ -163,11 +163,11 @@ function Trigger({ className }: { className?: string }) {
163
163
  className={cn(
164
164
  "p-1.5 rounded transition-colors",
165
165
  thumbState === "up"
166
- ? "bg-emerald-100 text-emerald-600 dark:bg-emerald-900/30 dark:text-emerald-400"
166
+ ? "bg-muted text-foreground"
167
167
  : "hover:bg-muted text-muted-foreground hover:text-foreground"
168
168
  )}
169
169
  >
170
- <ThumbsUp className="w-3.5 h-3.5" fill={thumbState === "up" ? "currentColor" : "none"} />
170
+ <ThumbsUp className="w-3.5 h-3.5" />
171
171
  </button>
172
172
  <button
173
173
  type="button"
@@ -175,11 +175,11 @@ function Trigger({ className }: { className?: string }) {
175
175
  className={cn(
176
176
  "p-1.5 rounded transition-colors",
177
177
  thumbState === "down"
178
- ? "bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400"
178
+ ? "bg-muted text-foreground"
179
179
  : "hover:bg-muted text-muted-foreground hover:text-foreground"
180
180
  )}
181
181
  >
182
- <ThumbsDown className="w-3.5 h-3.5" fill={thumbState === "down" ? "currentColor" : "none"} />
182
+ <ThumbsDown className="w-3.5 h-3.5" />
183
183
  </button>
184
184
  </div>
185
185
  )
@@ -219,8 +219,8 @@ function Panel({ className }: { className?: string }) {
219
219
  "px-2.5 py-1 rounded-full text-[11px] font-medium border transition-colors",
220
220
  selectedPills.includes(pill)
221
221
  ? thumbState === "up"
222
- ? "bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-300 dark:border-emerald-800"
223
- : "bg-red-100 text-red-700 border-red-200 dark:bg-red-900/30 dark:text-red-300 dark:border-red-800"
222
+ ? "bg-muted text-foreground border-border"
223
+ : "bg-red-50 text-red-700 border-red-200 dark:bg-red-950/30 dark:text-red-300 dark:border-red-800"
224
224
  : "bg-background text-muted-foreground border-border hover:bg-muted/50 hover:text-foreground"
225
225
  )}
226
226
  >
@@ -925,6 +925,14 @@ function SuggestedActionCard({
925
925
  )
926
926
  const [showAiEdit, setShowAiEdit] = React.useState(false)
927
927
  const [feedbackOpen, setFeedbackOpen] = React.useState(false)
928
+ const [feedbackDirection, setFeedbackDirection] = React.useState<"up" | "down" | null>(null)
929
+ const handleThumbClick = (dir: "up" | "down") => {
930
+ if (feedbackOpen && feedbackDirection === dir) {
931
+ setFeedbackOpen(false); setFeedbackDirection(null)
932
+ } else {
933
+ setFeedbackDirection(dir); setFeedbackOpen(true)
934
+ }
935
+ }
928
936
  const [followUpEnabled, setFollowUpEnabled] = React.useState(action.followUp?.enabled ?? false)
929
937
  const [threadExpanded, setThreadExpanded] = React.useState(false)
930
938
  const [expandedMessageId, setExpandedMessageId] = React.useState<string | null>(null)
@@ -1016,18 +1024,22 @@ function SuggestedActionCard({
1016
1024
  </div>
1017
1025
  <div className="flex items-center gap-1.5">
1018
1026
  <button
1019
- onClick={() => setFeedbackOpen(!feedbackOpen)}
1027
+ onClick={() => handleThumbClick("up")}
1020
1028
  className={`p-1.5 rounded transition-colors ${
1021
- feedbackOpen
1022
- ? "bg-emerald-100 text-emerald-600 dark:bg-emerald-900/30 dark:text-emerald-400"
1029
+ feedbackOpen && feedbackDirection === "up"
1030
+ ? "bg-muted text-foreground"
1023
1031
  : "hover:bg-muted text-muted-foreground hover:text-foreground"
1024
1032
  }`}
1025
1033
  >
1026
1034
  <ThumbsUp className="w-3.5 h-3.5" />
1027
1035
  </button>
1028
1036
  <button
1029
- onClick={() => setFeedbackOpen(!feedbackOpen)}
1030
- className="p-1.5 rounded transition-colors hover:bg-muted text-muted-foreground hover:text-foreground"
1037
+ onClick={() => handleThumbClick("down")}
1038
+ className={`p-1.5 rounded transition-colors ${
1039
+ feedbackOpen && feedbackDirection === "down"
1040
+ ? "bg-muted text-foreground"
1041
+ : "hover:bg-muted text-muted-foreground hover:text-foreground"
1042
+ }`}
1031
1043
  >
1032
1044
  <ThumbsDown className="w-3.5 h-3.5" />
1033
1045
  </button>
@@ -1047,6 +1059,8 @@ function SuggestedActionCard({
1047
1059
  {feedbackOpen && (
1048
1060
  <div className="px-5 py-3 border-b border-border/40 animate-in fade-in slide-in-from-top-2 duration-200">
1049
1061
  <DraftFeedbackInline
1062
+ key={`feedback-${feedbackDirection}`}
1063
+ initialDirection={feedbackDirection}
1050
1064
  onRegenerateRequest={(pills, detail) => {
1051
1065
  onFeedback?.("down", pills, detail)
1052
1066
  }}
package/src/index.ts CHANGED
@@ -46,6 +46,7 @@ export * from "./components/dialog"
46
46
  export * from "./components/dropdown-menu"
47
47
  export * from "./components/empty-state"
48
48
  export * from "./components/entity-panel"
49
+ export * from "./components/related-record-action-card"
49
50
  export { FeedbackFooter, FeedbackChipGroup, FeedbackInput, FeedbackActions, InlineFeedbackControl } from "./components/feedback-primitives"
50
51
  export type { FeedbackFooterProps, FeedbackChipTree, FeedbackChipGroupProps, FeedbackInputProps, FeedbackActionsProps, FeedbackSubmitData, PersistedFeedbackData, InlineFeedbackControlProps } from "./components/feedback-primitives"
51
52
  export { SignalPriorityPopover } from "./components/signal-priority-popover"