@handled-ai/design-system 0.17.0 → 0.17.2
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.
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/button.d.ts +1 -1
- package/dist/components/feedback-primitives.d.ts +66 -0
- package/dist/components/feedback-primitives.js +295 -0
- package/dist/components/feedback-primitives.js.map +1 -0
- package/dist/components/score-why-chips.d.ts +8 -17
- package/dist/components/score-why-chips.js +266 -180
- package/dist/components/score-why-chips.js.map +1 -1
- package/dist/components/signal-priority-popover.d.ts +17 -0
- package/dist/components/signal-priority-popover.js +247 -0
- package/dist/components/signal-priority-popover.js.map +1 -0
- package/dist/components/tabs.d.ts +1 -1
- package/dist/components/user-display.d.ts +22 -0
- package/dist/components/user-display.js +138 -0
- package/dist/components/user-display.js.map +1 -0
- package/dist/components/user-pill.d.ts +3 -0
- package/dist/components/user-pill.js +5 -0
- package/dist/components/user-pill.js.map +1 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/user-display.d.ts +31 -0
- package/dist/lib/user-display.js +57 -0
- package/dist/lib/user-display.js.map +1 -0
- package/dist/prototype/index.d.ts +2 -1
- package/dist/prototype/prototype-accounts-view.d.ts +2 -1
- package/dist/prototype/prototype-admin-view.d.ts +2 -1
- package/dist/prototype/prototype-config.d.ts +15 -328
- package/dist/prototype/prototype-inbox-view.d.ts +8 -3
- package/dist/prototype/prototype-inbox-view.js +24 -13
- package/dist/prototype/prototype-inbox-view.js.map +1 -1
- package/dist/prototype/prototype-insights-view.d.ts +2 -1
- package/dist/prototype/prototype-shell.d.ts +2 -1
- package/dist/signal-priority-popover-DQ_VuHac.d.ts +390 -0
- package/package.json +1 -1
- package/src/components/__tests__/contextual-quick-action-launcher.test.tsx +99 -188
- package/src/components/__tests__/feedback-primitives.test.tsx +509 -0
- package/src/components/__tests__/score-why-chips.test.tsx +540 -0
- package/src/components/__tests__/signal-priority-popover.test.tsx +312 -0
- package/src/components/feedback-primitives.tsx +424 -0
- package/src/components/score-why-chips.tsx +413 -203
- package/src/components/signal-priority-popover.tsx +359 -0
- package/src/components/user-display.tsx +96 -0
- package/src/components/user-pill.tsx +1 -0
- package/src/index.ts +6 -0
- package/src/lib/__tests__/user-display.test.ts +43 -0
- package/src/lib/user-display.ts +88 -0
- package/src/prototype/__tests__/detail-view-score-why.test.tsx +33 -29
- package/src/prototype/__tests__/detail-view-title-slots.test.tsx +65 -0
- package/src/prototype/prototype-config.ts +28 -0
- package/src/prototype/prototype-inbox-view.tsx +25 -11
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { Popover as PopoverPrimitive } from "radix-ui"
|
|
5
|
+
import type { LucideIcon } from "lucide-react"
|
|
6
|
+
import {
|
|
7
|
+
Radar,
|
|
8
|
+
Wallet,
|
|
9
|
+
Link2,
|
|
10
|
+
MessageSquare,
|
|
11
|
+
TrendingDown,
|
|
12
|
+
ArrowUpRight,
|
|
13
|
+
ArrowDownRight,
|
|
14
|
+
Clock,
|
|
15
|
+
Activity,
|
|
16
|
+
Minus,
|
|
17
|
+
ChevronDown,
|
|
18
|
+
ChevronUp,
|
|
19
|
+
Info,
|
|
20
|
+
} from "lucide-react"
|
|
21
|
+
import { cn } from "../lib/utils"
|
|
22
|
+
import { FeedbackFooter } from "./feedback-primitives"
|
|
23
|
+
import type { FeedbackChipTree, FeedbackSubmitData } from "./feedback-primitives"
|
|
24
|
+
import type { SignalScoreUrgencyLabel } from "../prototype/prototype-config"
|
|
25
|
+
import { getSignalScoreUrgencyLabel, scoreRangeForUrgency, SIGNAL_TONE_CLASSES } from "./score-why-chips"
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Types
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A single contributing factor in the priority popover.
|
|
33
|
+
*/
|
|
34
|
+
export interface PriorityFactor {
|
|
35
|
+
key: string
|
|
36
|
+
label: string
|
|
37
|
+
/** Lucide icon name (e.g. "radar", "wallet", "link-2", "message-square"). */
|
|
38
|
+
icon: string
|
|
39
|
+
/** Drives icon background tint. */
|
|
40
|
+
tone: "alert" | "warn" | "info"
|
|
41
|
+
/** Explicit semantic label - NOT inferred from score+weight. */
|
|
42
|
+
direction: "raises" | "lowers" | "neutral"
|
|
43
|
+
/** 0-100 */
|
|
44
|
+
score: number
|
|
45
|
+
/** Evidence text (e.g. "$3.4M moved in 8h - current treasury balance $0.00"). */
|
|
46
|
+
rationale: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface SignalPriorityPopoverProps {
|
|
50
|
+
score: number
|
|
51
|
+
urgencyLabel?: SignalScoreUrgencyLabel
|
|
52
|
+
/** Synthesis sentence displayed in the popover head. */
|
|
53
|
+
urgencyExplanation?: string
|
|
54
|
+
factors: PriorityFactor[]
|
|
55
|
+
/** e.g. "Updated 4m ago - model v3.2" */
|
|
56
|
+
metaText?: string
|
|
57
|
+
/** Negative feedback issue tree. */
|
|
58
|
+
feedbackChips?: FeedbackChipTree[]
|
|
59
|
+
onFeedbackSubmit?: (data: FeedbackSubmitData) => void
|
|
60
|
+
className?: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Static class maps (required for Tailwind v4 source scanning)
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
const URGENCY_TRIGGER_DEFAULT: Record<SignalScoreUrgencyLabel, string> = {
|
|
68
|
+
Urgent: "border-red-200 bg-red-50 text-red-700",
|
|
69
|
+
High: "border-orange-200 bg-orange-50 text-orange-700",
|
|
70
|
+
Medium: "border-amber-200 bg-amber-50 text-amber-700",
|
|
71
|
+
Low: "border-blue-200 bg-blue-50 text-blue-700",
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const URGENCY_TRIGGER_HOVER: Record<SignalScoreUrgencyLabel, string> = {
|
|
75
|
+
Urgent: "hover:bg-red-100",
|
|
76
|
+
High: "hover:bg-orange-100",
|
|
77
|
+
Medium: "hover:bg-amber-100",
|
|
78
|
+
Low: "hover:bg-blue-100",
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const URGENCY_TRIGGER_OPEN: Record<SignalScoreUrgencyLabel, string> = {
|
|
82
|
+
Urgent: "bg-red-100",
|
|
83
|
+
High: "bg-orange-100",
|
|
84
|
+
Medium: "bg-amber-100",
|
|
85
|
+
Low: "bg-blue-100",
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Re-use shared tone classes from score-why-chips. */
|
|
89
|
+
const TONE_ICON_CLASSES: Record<PriorityFactor["tone"], string> = SIGNAL_TONE_CLASSES as Record<PriorityFactor["tone"], string>
|
|
90
|
+
|
|
91
|
+
const DIRECTION_CLASSES: Record<PriorityFactor["direction"], string> = {
|
|
92
|
+
raises: "text-red-600",
|
|
93
|
+
lowers: "text-emerald-600",
|
|
94
|
+
neutral: "text-muted-foreground",
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Icon lookup
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
const FACTOR_ICONS: Record<string, LucideIcon> = {
|
|
102
|
+
radar: Radar,
|
|
103
|
+
wallet: Wallet,
|
|
104
|
+
"link-2": Link2,
|
|
105
|
+
"message-square": MessageSquare,
|
|
106
|
+
"trending-down": TrendingDown,
|
|
107
|
+
"arrow-up-right": ArrowUpRight,
|
|
108
|
+
clock: Clock,
|
|
109
|
+
activity: Activity,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Urgency dot color (static map)
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
const URGENCY_DOT_CLASSES: Record<SignalScoreUrgencyLabel, string> = {
|
|
117
|
+
Urgent: "bg-red-500",
|
|
118
|
+
High: "bg-orange-500",
|
|
119
|
+
Medium: "bg-amber-500",
|
|
120
|
+
Low: "bg-blue-500",
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// Default feedback chips
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
const DEFAULT_PRIORITY_FEEDBACK_CHIPS: FeedbackChipTree[] = [
|
|
128
|
+
{ label: "Wrong factor weighting" },
|
|
129
|
+
{ label: "Missing context" },
|
|
130
|
+
{ label: "Inaccurate data" },
|
|
131
|
+
{ label: "Stale" },
|
|
132
|
+
{ label: "Other" },
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// Direction icon component
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
function DirectionIcon({ direction }: { direction: PriorityFactor["direction"] }) {
|
|
140
|
+
const cls = "h-2.5 w-2.5"
|
|
141
|
+
switch (direction) {
|
|
142
|
+
case "raises":
|
|
143
|
+
return <ArrowUpRight className={cls} />
|
|
144
|
+
case "lowers":
|
|
145
|
+
return <ArrowDownRight className={cls} />
|
|
146
|
+
case "neutral":
|
|
147
|
+
return <Minus className={cls} />
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// PriorityFactorRow
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
function PriorityFactorRow({ factor }: { factor: PriorityFactor }) {
|
|
156
|
+
const IconComponent = FACTOR_ICONS[factor.icon] ?? Activity
|
|
157
|
+
const toneClasses = TONE_ICON_CLASSES[factor.tone]
|
|
158
|
+
const directionClasses = DIRECTION_CLASSES[factor.direction]
|
|
159
|
+
const directionLabel =
|
|
160
|
+
factor.direction === "raises"
|
|
161
|
+
? "Raises"
|
|
162
|
+
: factor.direction === "lowers"
|
|
163
|
+
? "Lowers"
|
|
164
|
+
: "Neutral"
|
|
165
|
+
|
|
166
|
+
return (
|
|
167
|
+
<div
|
|
168
|
+
className="grid grid-cols-[20px_1fr_auto] gap-x-3 gap-y-1 px-4 py-3"
|
|
169
|
+
data-testid={`factor-row-${factor.key}`}
|
|
170
|
+
>
|
|
171
|
+
{/* Icon */}
|
|
172
|
+
<div
|
|
173
|
+
className={cn(
|
|
174
|
+
"flex h-5 w-5 items-center justify-center rounded",
|
|
175
|
+
toneClasses,
|
|
176
|
+
)}
|
|
177
|
+
>
|
|
178
|
+
<IconComponent className="h-3 w-3" />
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
{/* Label + direction tag */}
|
|
182
|
+
<div className="flex items-center gap-2">
|
|
183
|
+
<span className="text-[13px] font-semibold text-foreground">
|
|
184
|
+
{factor.label}
|
|
185
|
+
</span>
|
|
186
|
+
<span
|
|
187
|
+
className={cn(
|
|
188
|
+
"inline-flex items-center gap-0.5 text-[10px] font-medium",
|
|
189
|
+
directionClasses,
|
|
190
|
+
)}
|
|
191
|
+
>
|
|
192
|
+
<DirectionIcon direction={factor.direction} />
|
|
193
|
+
{directionLabel}
|
|
194
|
+
</span>
|
|
195
|
+
</div>
|
|
196
|
+
|
|
197
|
+
{/* Score number */}
|
|
198
|
+
<div className="flex items-center text-right">
|
|
199
|
+
<span className="text-sm font-bold tabular-nums">{factor.score}</span>
|
|
200
|
+
<span className="text-xs font-normal text-muted-foreground">/100</span>
|
|
201
|
+
</div>
|
|
202
|
+
|
|
203
|
+
{/* empty grid cell under icon column */}
|
|
204
|
+
<div />
|
|
205
|
+
|
|
206
|
+
{/* Rationale */}
|
|
207
|
+
<p className="text-xs leading-relaxed text-muted-foreground">
|
|
208
|
+
{factor.rationale}
|
|
209
|
+
</p>
|
|
210
|
+
|
|
211
|
+
{/* empty grid cell under score column */}
|
|
212
|
+
<div />
|
|
213
|
+
|
|
214
|
+
{/* empty grid cell under icon column */}
|
|
215
|
+
<div />
|
|
216
|
+
|
|
217
|
+
{/* Score track */}
|
|
218
|
+
<div className="mt-1 h-0.5 rounded-full bg-muted">
|
|
219
|
+
<div
|
|
220
|
+
className="h-full rounded-full bg-foreground/20"
|
|
221
|
+
style={{ width: `${Math.min(100, Math.max(0, factor.score))}%` }}
|
|
222
|
+
/>
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
{/* empty grid cell under score column */}
|
|
226
|
+
<div />
|
|
227
|
+
</div>
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
// SignalPriorityPopover
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
export function SignalPriorityPopover({
|
|
236
|
+
score,
|
|
237
|
+
urgencyLabel: providedLabel,
|
|
238
|
+
urgencyExplanation,
|
|
239
|
+
factors,
|
|
240
|
+
metaText,
|
|
241
|
+
feedbackChips,
|
|
242
|
+
onFeedbackSubmit,
|
|
243
|
+
className,
|
|
244
|
+
}: SignalPriorityPopoverProps) {
|
|
245
|
+
const urgencyLabel = getSignalScoreUrgencyLabel(score, providedLabel)
|
|
246
|
+
const scoreRange = scoreRangeForUrgency(urgencyLabel)
|
|
247
|
+
|
|
248
|
+
const [open, setOpen] = React.useState(false)
|
|
249
|
+
const [feedback, setFeedback] = React.useState<"positive" | "negative" | null>(null)
|
|
250
|
+
|
|
251
|
+
const triggerDefault = URGENCY_TRIGGER_DEFAULT[urgencyLabel]
|
|
252
|
+
const triggerHover = URGENCY_TRIGGER_HOVER[urgencyLabel]
|
|
253
|
+
const triggerOpen = URGENCY_TRIGGER_OPEN[urgencyLabel]
|
|
254
|
+
|
|
255
|
+
return (
|
|
256
|
+
<PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
|
|
257
|
+
<PopoverPrimitive.Trigger asChild>
|
|
258
|
+
<button
|
|
259
|
+
type="button"
|
|
260
|
+
className={cn(
|
|
261
|
+
"inline-flex items-center gap-1 rounded-md border px-2.5 py-1 text-xs font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
262
|
+
triggerDefault,
|
|
263
|
+
triggerHover,
|
|
264
|
+
open && triggerOpen,
|
|
265
|
+
open && "outline-2 outline-foreground outline-offset-2",
|
|
266
|
+
className,
|
|
267
|
+
)}
|
|
268
|
+
data-testid="priority-popover-trigger"
|
|
269
|
+
>
|
|
270
|
+
{urgencyLabel} Priority
|
|
271
|
+
{open ? (
|
|
272
|
+
<ChevronUp className="h-3 w-3" />
|
|
273
|
+
) : (
|
|
274
|
+
<ChevronDown className="h-3 w-3" />
|
|
275
|
+
)}
|
|
276
|
+
</button>
|
|
277
|
+
</PopoverPrimitive.Trigger>
|
|
278
|
+
|
|
279
|
+
<PopoverPrimitive.Portal>
|
|
280
|
+
<PopoverPrimitive.Content
|
|
281
|
+
side="bottom"
|
|
282
|
+
align="start"
|
|
283
|
+
sideOffset={8}
|
|
284
|
+
onOpenAutoFocus={(e) => e.preventDefault()}
|
|
285
|
+
className="z-50 w-[420px] rounded-lg border border-border bg-background shadow-lg p-0"
|
|
286
|
+
data-testid="priority-popover-content"
|
|
287
|
+
>
|
|
288
|
+
{/* Head section */}
|
|
289
|
+
<div className="p-4 pb-3">
|
|
290
|
+
<div className="flex items-start justify-between gap-3">
|
|
291
|
+
<p className="text-sm font-semibold text-foreground">
|
|
292
|
+
Why this is {urgencyLabel.toLowerCase()} priority
|
|
293
|
+
</p>
|
|
294
|
+
<span className="text-2xl font-bold tabular-nums text-foreground">
|
|
295
|
+
{score}
|
|
296
|
+
<span className="text-sm font-normal text-muted-foreground">/100</span>
|
|
297
|
+
</span>
|
|
298
|
+
</div>
|
|
299
|
+
|
|
300
|
+
{/* Band indicator */}
|
|
301
|
+
<div className="mt-1 flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
|
302
|
+
<span
|
|
303
|
+
className={cn(
|
|
304
|
+
"inline-block h-2 w-2 rounded-full",
|
|
305
|
+
URGENCY_DOT_CLASSES[urgencyLabel],
|
|
306
|
+
)}
|
|
307
|
+
/>
|
|
308
|
+
{urgencyLabel} range: {scoreRange}
|
|
309
|
+
</div>
|
|
310
|
+
|
|
311
|
+
{/* Synthesis sentence */}
|
|
312
|
+
{urgencyExplanation && (
|
|
313
|
+
<p className="mt-2 text-xs leading-relaxed text-muted-foreground">
|
|
314
|
+
{urgencyExplanation}
|
|
315
|
+
</p>
|
|
316
|
+
)}
|
|
317
|
+
</div>
|
|
318
|
+
|
|
319
|
+
{/* Section label */}
|
|
320
|
+
{factors.length > 0 && (
|
|
321
|
+
<>
|
|
322
|
+
<div className="flex items-center justify-between border-t border-border px-4 py-2">
|
|
323
|
+
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
|
|
324
|
+
Contributing factors
|
|
325
|
+
</span>
|
|
326
|
+
<span className="flex items-center gap-1 text-[10px] text-muted-foreground">
|
|
327
|
+
<Info className="h-3 w-3" />
|
|
328
|
+
Score = weighted sum
|
|
329
|
+
</span>
|
|
330
|
+
</div>
|
|
331
|
+
|
|
332
|
+
{/* Factor rows */}
|
|
333
|
+
<div className="divide-y divide-border/40">
|
|
334
|
+
{factors.map((factor) => (
|
|
335
|
+
<PriorityFactorRow key={factor.key} factor={factor} />
|
|
336
|
+
))}
|
|
337
|
+
</div>
|
|
338
|
+
</>
|
|
339
|
+
)}
|
|
340
|
+
|
|
341
|
+
{/* Feedback footer */}
|
|
342
|
+
{onFeedbackSubmit && (
|
|
343
|
+
<div className="border-t border-border">
|
|
344
|
+
<FeedbackFooter
|
|
345
|
+
feedback={feedback}
|
|
346
|
+
onFeedbackChange={setFeedback}
|
|
347
|
+
onSubmit={onFeedbackSubmit}
|
|
348
|
+
metaText={metaText}
|
|
349
|
+
negativeChips={feedbackChips ?? DEFAULT_PRIORITY_FEEDBACK_CHIPS}
|
|
350
|
+
positivePrompt="Thanks. Anything to keep about this score?"
|
|
351
|
+
className="px-4 py-3"
|
|
352
|
+
/>
|
|
353
|
+
</div>
|
|
354
|
+
)}
|
|
355
|
+
</PopoverPrimitive.Content>
|
|
356
|
+
</PopoverPrimitive.Portal>
|
|
357
|
+
</PopoverPrimitive.Root>
|
|
358
|
+
)
|
|
359
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
|
|
5
|
+
import { Avatar, AvatarFallback, AvatarImage } from "./avatar"
|
|
6
|
+
import { cn } from "../lib/utils"
|
|
7
|
+
import { displayName, getInitials, type ProfileLike } from "../lib/user-display"
|
|
8
|
+
|
|
9
|
+
export interface UserPillProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
|
|
10
|
+
profile?: ProfileLike | null
|
|
11
|
+
name?: string | null
|
|
12
|
+
email?: string | null
|
|
13
|
+
avatarUrl?: string | null
|
|
14
|
+
subtitle?: React.ReactNode
|
|
15
|
+
variant?: "default" | "compact" | "large"
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function UserPill({
|
|
19
|
+
profile,
|
|
20
|
+
name,
|
|
21
|
+
email,
|
|
22
|
+
avatarUrl,
|
|
23
|
+
subtitle,
|
|
24
|
+
variant = "default",
|
|
25
|
+
className,
|
|
26
|
+
...props
|
|
27
|
+
}: UserPillProps) {
|
|
28
|
+
const userProfile: ProfileLike = {
|
|
29
|
+
...profile,
|
|
30
|
+
name: name ?? profile?.name,
|
|
31
|
+
email: email ?? profile?.email,
|
|
32
|
+
avatar_url: avatarUrl ?? profile?.avatar_url,
|
|
33
|
+
}
|
|
34
|
+
const resolvedName = displayName(userProfile)
|
|
35
|
+
const resolvedAvatarUrl = avatarUrl ?? profile?.avatar_url
|
|
36
|
+
const avatarSize = variant === "large" ? "default" : "sm"
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div
|
|
40
|
+
data-slot="user-pill"
|
|
41
|
+
data-variant={variant}
|
|
42
|
+
className={cn(
|
|
43
|
+
"inline-flex max-w-full items-center gap-2 rounded-full border border-border bg-background text-sm text-foreground shadow-xs",
|
|
44
|
+
variant === "compact" && "px-2 py-0.5",
|
|
45
|
+
variant === "default" && "px-2.5 py-1",
|
|
46
|
+
variant === "large" && "px-3 py-1.5",
|
|
47
|
+
className
|
|
48
|
+
)}
|
|
49
|
+
title={resolvedName}
|
|
50
|
+
{...props}
|
|
51
|
+
>
|
|
52
|
+
<Avatar size={avatarSize} aria-hidden="true">
|
|
53
|
+
{resolvedAvatarUrl ? <AvatarImage src={resolvedAvatarUrl} alt="" /> : null}
|
|
54
|
+
<AvatarFallback>{getInitials(userProfile)}</AvatarFallback>
|
|
55
|
+
</Avatar>
|
|
56
|
+
<span className="flex min-w-0 flex-col leading-tight">
|
|
57
|
+
<span className="min-w-0 truncate font-medium">{resolvedName}</span>
|
|
58
|
+
{subtitle ? <span className="min-w-0 truncate text-xs text-muted-foreground">{subtitle}</span> : null}
|
|
59
|
+
</span>
|
|
60
|
+
</div>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface ActorBylineProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
|
|
65
|
+
actor?: ProfileLike | null
|
|
66
|
+
verb?: React.ReactNode
|
|
67
|
+
subject?: React.ReactNode
|
|
68
|
+
timestamp?: string | Date | null
|
|
69
|
+
className?: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function ActorByline({
|
|
73
|
+
actor,
|
|
74
|
+
verb,
|
|
75
|
+
subject,
|
|
76
|
+
timestamp,
|
|
77
|
+
className,
|
|
78
|
+
...props
|
|
79
|
+
}: ActorBylineProps) {
|
|
80
|
+
const actorName = displayName(actor)
|
|
81
|
+
const renderedTimestamp = timestamp instanceof Date ? timestamp.toISOString() : timestamp
|
|
82
|
+
return (
|
|
83
|
+
<div
|
|
84
|
+
data-slot="actor-byline"
|
|
85
|
+
className={cn("text-sm text-muted-foreground", className)}
|
|
86
|
+
{...props}
|
|
87
|
+
>
|
|
88
|
+
<span className="text-foreground">{actorName}</span>
|
|
89
|
+
{verb ? <> {verb}</> : null}
|
|
90
|
+
{subject ? <> {subject}</> : null}
|
|
91
|
+
{renderedTimestamp ? <> <span aria-hidden="true">·</span> {renderedTimestamp}</> : null}
|
|
92
|
+
</div>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export { ActorByline, UserPill }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { UserPill, type UserPillProps } from "./user-display"
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// Utilities
|
|
7
7
|
export { cn } from "./lib/utils"
|
|
8
8
|
export { BRAND_ICONS, BRAND_GRAPHICS } from "./lib/icons"
|
|
9
|
+
export { displayName, getInitials, shortName, type ProfileLike } from "./lib/user-display"
|
|
9
10
|
|
|
10
11
|
// Hooks
|
|
11
12
|
export { useIsMobile } from "./hooks/use-mobile"
|
|
@@ -36,6 +37,10 @@ export * from "./components/dialog"
|
|
|
36
37
|
export * from "./components/dropdown-menu"
|
|
37
38
|
export * from "./components/empty-state"
|
|
38
39
|
export * from "./components/entity-panel"
|
|
40
|
+
export { FeedbackFooter, FeedbackChipGroup, FeedbackInput, FeedbackActions } from "./components/feedback-primitives"
|
|
41
|
+
export type { FeedbackFooterProps, FeedbackChipTree, FeedbackChipGroupProps, FeedbackInputProps, FeedbackActionsProps, FeedbackSubmitData } from "./components/feedback-primitives"
|
|
42
|
+
export { SignalPriorityPopover } from "./components/signal-priority-popover"
|
|
43
|
+
export type { SignalPriorityPopoverProps, PriorityFactor } from "./components/signal-priority-popover"
|
|
39
44
|
export * from "./components/filter-chip"
|
|
40
45
|
export * from "./components/inbox-row"
|
|
41
46
|
export * from "./components/inbox-toolbar"
|
|
@@ -92,6 +97,7 @@ export * from "./components/tabs"
|
|
|
92
97
|
export * from "./components/textarea"
|
|
93
98
|
export * from "./components/timeline-activity"
|
|
94
99
|
export * from "./components/tooltip"
|
|
100
|
+
export * from "./components/user-display"
|
|
95
101
|
export * from "./components/variable-autocomplete"
|
|
96
102
|
export * from "./components/view-mode-toggle"
|
|
97
103
|
export * from "./components/virtualized-data-table"
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
|
|
3
|
+
import { displayName, getInitials, shortName } from "../user-display"
|
|
4
|
+
|
|
5
|
+
describe("displayName", () => {
|
|
6
|
+
it("prefers first and last name", () => {
|
|
7
|
+
expect(
|
|
8
|
+
displayName({ first_name: "Sarah", last_name: "Mitchell", name: "S Mitchell", email: "sarah@example.com" }),
|
|
9
|
+
).toBe("Sarah Mitchell")
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it("falls back through first name, name, email local part, then unknown", () => {
|
|
13
|
+
expect(displayName({ first_name: "Sarah", last_name: null, name: null, email: "sarah@example.com" })).toBe("Sarah")
|
|
14
|
+
expect(displayName({ first_name: null, last_name: null, name: "Sarah Mitchell", email: "sarah@example.com" })).toBe("Sarah Mitchell")
|
|
15
|
+
expect(displayName({ first_name: "", last_name: "", name: "", email: "sarah@example.com" })).toBe("sarah")
|
|
16
|
+
expect(displayName({})).toBe("Unknown user")
|
|
17
|
+
expect(displayName()).toBe("Unknown user")
|
|
18
|
+
})
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
describe("getInitials", () => {
|
|
22
|
+
it("uses first and last initials", () => {
|
|
23
|
+
expect(getInitials({ first_name: "joe", last_name: "kim", email: "joe@example.com" })).toBe("JK")
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it("falls back through name, email local part, then question mark", () => {
|
|
27
|
+
expect(getInitials({ first_name: null, last_name: null, name: "Sarah Mitchell", email: "sarah@example.com" })).toBe("SM")
|
|
28
|
+
expect(getInitials({ first_name: null, last_name: null, name: "Sarah", email: "sarah@example.com" })).toBe("SA")
|
|
29
|
+
expect(getInitials({ first_name: null, last_name: null, name: null, email: "zz@example.com" })).toBe("ZZ")
|
|
30
|
+
expect(getInitials({})).toBe("?")
|
|
31
|
+
expect(getInitials()).toBe("?")
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe("shortName", () => {
|
|
36
|
+
it("returns compact first name and last initial", () => {
|
|
37
|
+
expect(shortName({ first_name: "Sarah", last_name: "Mitchell", email: "sarah@example.com" })).toBe("Sarah M.")
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it("falls back to displayName when last name is unavailable", () => {
|
|
41
|
+
expect(shortName({ name: "Sarah Mitchell", email: "sarah@example.com" })).toBe("Sarah Mitchell")
|
|
42
|
+
})
|
|
43
|
+
})
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified display name and initials utilities for user profiles.
|
|
3
|
+
*
|
|
4
|
+
* Keep this behavior aligned with handled-platform's display-name utility so
|
|
5
|
+
* user labels and avatar fallbacks render consistently across products.
|
|
6
|
+
*/
|
|
7
|
+
export interface ProfileLike {
|
|
8
|
+
first_name?: string | null
|
|
9
|
+
last_name?: string | null
|
|
10
|
+
name?: string | null
|
|
11
|
+
email?: string | null
|
|
12
|
+
avatar_url?: string | null
|
|
13
|
+
role?: string | null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function clean(value: string | null | undefined): string | undefined {
|
|
17
|
+
const trimmed = value?.trim()
|
|
18
|
+
return trimmed ? trimmed : undefined
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function emailLocalPart(email: string | null | undefined): string | undefined {
|
|
22
|
+
const normalizedEmail = clean(email)
|
|
23
|
+
return normalizedEmail?.split("@")[0] || undefined
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Returns the best display name for a profile.
|
|
28
|
+
* Priority: first_name + last_name > first_name > name > email local-part > Unknown user.
|
|
29
|
+
*/
|
|
30
|
+
export function displayName(profile?: ProfileLike | null): string {
|
|
31
|
+
const firstName = clean(profile?.first_name)
|
|
32
|
+
const lastName = clean(profile?.last_name)
|
|
33
|
+
|
|
34
|
+
if (firstName && lastName) {
|
|
35
|
+
return `${firstName} ${lastName}`
|
|
36
|
+
}
|
|
37
|
+
if (firstName) return firstName
|
|
38
|
+
|
|
39
|
+
const fullName = clean(profile?.name)
|
|
40
|
+
if (fullName) return fullName
|
|
41
|
+
|
|
42
|
+
return emailLocalPart(profile?.email) ?? "Unknown user"
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Returns up to 2-character initials for avatar display.
|
|
47
|
+
* Priority: first/last initials > split name > first two email local chars > ?.
|
|
48
|
+
*/
|
|
49
|
+
export function getInitials(profile?: ProfileLike | null): string {
|
|
50
|
+
const firstName = clean(profile?.first_name)
|
|
51
|
+
const lastName = clean(profile?.last_name)
|
|
52
|
+
|
|
53
|
+
if (firstName && lastName) {
|
|
54
|
+
return (firstName[0] + lastName[0]).toUpperCase()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const fullName = clean(profile?.name) ?? firstName
|
|
58
|
+
if (fullName) {
|
|
59
|
+
const parts = fullName.split(/\s+/).filter(Boolean)
|
|
60
|
+
if (parts.length >= 2) {
|
|
61
|
+
return (parts[0][0] + parts[1][0]).toUpperCase()
|
|
62
|
+
}
|
|
63
|
+
if (parts.length === 1 && parts[0].length > 0) {
|
|
64
|
+
return parts[0].slice(0, 2).toUpperCase()
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const localPart = emailLocalPart(profile?.email)
|
|
69
|
+
if (localPart) {
|
|
70
|
+
return localPart.slice(0, 2).toUpperCase()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return "?"
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Returns "First L." format for compact display.
|
|
78
|
+
* Falls back to displayName() if last name is unavailable.
|
|
79
|
+
*/
|
|
80
|
+
export function shortName(profile?: ProfileLike | null): string {
|
|
81
|
+
const firstName = clean(profile?.first_name)
|
|
82
|
+
const lastName = clean(profile?.last_name)
|
|
83
|
+
|
|
84
|
+
if (firstName && lastName) {
|
|
85
|
+
return `${firstName} ${lastName[0]}.`
|
|
86
|
+
}
|
|
87
|
+
return displayName(profile)
|
|
88
|
+
}
|