@handled-ai/design-system 0.18.4 → 0.18.5

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 (76) hide show
  1. package/dist/charts/chart.d.ts +1 -1
  2. package/dist/charts/empty-chart-state.d.ts +11 -0
  3. package/dist/charts/empty-chart-state.js +70 -0
  4. package/dist/charts/empty-chart-state.js.map +1 -0
  5. package/dist/charts/index.d.ts +1 -0
  6. package/dist/charts/index.js +1 -0
  7. package/dist/charts/index.js.map +1 -1
  8. package/dist/charts/pipeline-overview.d.ts +2 -1
  9. package/dist/charts/pipeline-overview.js +32 -1
  10. package/dist/charts/pipeline-overview.js.map +1 -1
  11. package/dist/components/badge.d.ts +1 -1
  12. package/dist/components/button.d.ts +1 -1
  13. package/dist/components/days-open-cell.d.ts +16 -0
  14. package/dist/components/days-open-cell.js +73 -0
  15. package/dist/components/days-open-cell.js.map +1 -0
  16. package/dist/components/detail-drawer.d.ts +16 -0
  17. package/dist/components/detail-drawer.js +45 -0
  18. package/dist/components/detail-drawer.js.map +1 -0
  19. package/dist/components/feedback-primitives.d.ts +2 -41
  20. package/dist/components/feedback-primitives.js +6 -241
  21. package/dist/components/feedback-primitives.js.map +1 -1
  22. package/dist/components/insights-filter-bar.d.ts +2 -1
  23. package/dist/components/insights-filter-bar.js +13 -5
  24. package/dist/components/insights-filter-bar.js.map +1 -1
  25. package/dist/components/linked-entity-cell.d.ts +14 -0
  26. package/dist/components/linked-entity-cell.js +96 -0
  27. package/dist/components/linked-entity-cell.js.map +1 -0
  28. package/dist/components/metric-card.d.ts +14 -1
  29. package/dist/components/metric-card.js +97 -0
  30. package/dist/components/metric-card.js.map +1 -1
  31. package/dist/components/pill.d.ts +26 -0
  32. package/dist/components/pill.js +77 -0
  33. package/dist/components/pill.js.map +1 -0
  34. package/dist/components/quick-segment.d.ts +13 -0
  35. package/dist/components/quick-segment.js +96 -0
  36. package/dist/components/quick-segment.js.map +1 -0
  37. package/dist/components/score-why-chips.d.ts +1 -1
  38. package/dist/components/score-why-chips.js +5 -26
  39. package/dist/components/score-why-chips.js.map +1 -1
  40. package/dist/components/signal-priority-popover.d.ts +1 -1
  41. package/dist/components/signal-priority-popover.js +6 -32
  42. package/dist/components/signal-priority-popover.js.map +1 -1
  43. package/dist/components/tabs.d.ts +1 -1
  44. package/dist/index.d.ts +9 -3
  45. package/dist/index.js +6 -2
  46. package/dist/index.js.map +1 -1
  47. package/dist/prototype/index.d.ts +1 -1
  48. package/dist/prototype/prototype-accounts-view.d.ts +1 -1
  49. package/dist/prototype/prototype-admin-view.d.ts +1 -1
  50. package/dist/prototype/prototype-config.d.ts +1 -1
  51. package/dist/prototype/prototype-inbox-view.d.ts +1 -1
  52. package/dist/prototype/prototype-inbox-view.js +1 -4
  53. package/dist/prototype/prototype-inbox-view.js.map +1 -1
  54. package/dist/prototype/prototype-insights-view.d.ts +1 -1
  55. package/dist/prototype/prototype-shell.d.ts +1 -1
  56. package/dist/{signal-priority-popover-DWaAMhPI.d.ts → signal-priority-popover-DQ_VuHac.d.ts} +2 -26
  57. package/package.json +1 -3
  58. package/src/charts/__tests__/insights-charts.test.tsx +62 -0
  59. package/src/charts/empty-chart-state.tsx +44 -0
  60. package/src/charts/index.ts +1 -0
  61. package/src/charts/pipeline-overview.tsx +41 -1
  62. package/src/components/__tests__/insights-primitives.test.tsx +135 -0
  63. package/src/components/days-open-cell.tsx +50 -0
  64. package/src/components/detail-drawer.tsx +60 -0
  65. package/src/components/feedback-primitives.tsx +26 -333
  66. package/src/components/insights-filter-bar.tsx +13 -4
  67. package/src/components/linked-entity-cell.tsx +74 -0
  68. package/src/components/metric-card.tsx +98 -0
  69. package/src/components/pill.tsx +67 -0
  70. package/src/components/quick-segment.tsx +68 -0
  71. package/src/components/score-why-chips.tsx +2 -28
  72. package/src/components/signal-priority-popover.tsx +4 -44
  73. package/src/index.ts +7 -2
  74. package/src/prototype/prototype-config.ts +1 -11
  75. package/src/prototype/prototype-inbox-view.tsx +0 -3
  76. package/src/components/__tests__/wit-636-feedback-states.test.tsx +0 -546
@@ -0,0 +1,135 @@
1
+ import React from "react"
2
+ import { describe, expect, it, vi } from "vitest"
3
+ import { render, screen, fireEvent } from "@testing-library/react"
4
+
5
+ import { DaysOpenCell, getDaysOpenIntent } from "../days-open-cell"
6
+ import { DetailDrawer } from "../detail-drawer"
7
+ import { InsightsFilterBar } from "../insights-filter-bar"
8
+ import { LinkedEntityCell } from "../linked-entity-cell"
9
+ import { KpiStrip } from "../metric-card"
10
+ import { Pill, StatusPill } from "../pill"
11
+ import { QuickSegment } from "../quick-segment"
12
+
13
+ describe("Insights primitives", () => {
14
+ it("renders compact InsightsFilterBar without changing default API", () => {
15
+ const { container } = render(
16
+ <InsightsFilterBar
17
+ variant="compact"
18
+ filters={[{ id: "status", label: "Status", options: ["All", "Open"], defaultValue: "All" }]}
19
+ values={{ status: "Open" }}
20
+ onChange={() => {}}
21
+ onClearAll={() => {}}
22
+ />
23
+ )
24
+
25
+ const bar = container.querySelector('[data-slot="insights-filter-bar"]')!
26
+ expect(bar.className).toContain("p-2")
27
+ expect(bar.className).toContain("gap-2")
28
+ expect(screen.getByRole("button", { name: /Status: Open/i }).className).toContain("h-7")
29
+ })
30
+
31
+ it("renders KpiStrip items and changes", () => {
32
+ const { container } = render(
33
+ <KpiStrip
34
+ items={[
35
+ { label: "New", value: 42, unit: "leads", change: { value: "8%", direction: "up" } },
36
+ { label: "Aging", value: 12, subtitle: "over SLA", change: { value: "3%", direction: "down", isGood: true } },
37
+ ]}
38
+ />
39
+ )
40
+
41
+ expect(container.querySelectorAll('[data-slot="kpi-strip-item"]')).toHaveLength(2)
42
+ expect(screen.getByText("New")).not.toBeNull()
43
+ expect(screen.getByText("42")).not.toBeNull()
44
+ expect(screen.getByText("8%")).not.toBeNull()
45
+ })
46
+
47
+ it("renders neutral KpiStrip changes without an up/down icon or red/green intent", () => {
48
+ const { container } = render(
49
+ <KpiStrip
50
+ items={[
51
+ { label: "Stable", value: 10, change: { value: "0%", direction: "neutral" } },
52
+ ]}
53
+ />
54
+ )
55
+
56
+ const change = container.querySelector('[data-slot="kpi-strip-change"]')
57
+ expect(change?.className).toContain("text-muted-foreground")
58
+ expect(change?.className).not.toContain("text-red-600")
59
+ expect(change?.className).not.toContain("text-emerald-600")
60
+ expect(change?.querySelector("svg")).toBeNull()
61
+ })
62
+
63
+ it("QuickSegment exposes selected state and invokes onSelect", () => {
64
+ const onSelect = vi.fn()
65
+ render(<QuickSegment label="At risk" value="risk" count={5} selected onSelect={onSelect} />)
66
+
67
+ const button = screen.getByRole("button", { name: /At risk/i })
68
+ expect(button.getAttribute("aria-pressed")).toBe("true")
69
+ expect(screen.getByText("5")).not.toBeNull()
70
+ fireEvent.click(button)
71
+ expect(onSelect).toHaveBeenCalledWith("risk")
72
+ })
73
+
74
+ it("LinkedEntityCell renders entity links and metadata", () => {
75
+ const onNavigate = vi.fn()
76
+ render(
77
+ <LinkedEntityCell
78
+ name="Acme Health"
79
+ href="/accounts/acme"
80
+ subtitle="Account"
81
+ meta="Tier 1"
82
+ onNavigate={onNavigate}
83
+ />
84
+ )
85
+
86
+ const link = screen.getByRole("link", { name: "Acme Health" })
87
+ expect(link.getAttribute("href")).toBe("/accounts/acme")
88
+ expect(screen.getByText(/Tier 1/)).not.toBeNull()
89
+ fireEvent.click(link)
90
+ expect(onNavigate).toHaveBeenCalled()
91
+ })
92
+
93
+ it("DaysOpenCell maps thresholds to status intents", () => {
94
+ expect(getDaysOpenIntent(2, 7, 30)).toBe("success")
95
+ expect(getDaysOpenIntent(10, 7, 30)).toBe("warning")
96
+ expect(getDaysOpenIntent(45, 7, 30)).toBe("error")
97
+
98
+ render(<DaysOpenCell days={45} />)
99
+ expect(screen.getByTestId("days-open-pill").getAttribute("data-variant")).toBe("error")
100
+ })
101
+
102
+ it("Pill and StatusPill render wrapper variants", () => {
103
+ const { container } = render(
104
+ <div>
105
+ <Pill variant="info">Info</Pill>
106
+ <StatusPill status="Blocked" intent="error" />
107
+ </div>
108
+ )
109
+
110
+ expect(container.querySelector('[data-slot="pill"]')?.getAttribute("data-variant")).toBe("info")
111
+ expect(container.querySelector('[data-slot="status-pill"]')?.getAttribute("data-variant")).toBe("error")
112
+ expect(screen.getByText("Blocked")).not.toBeNull()
113
+ })
114
+
115
+ it("DetailDrawer renders title, content, and footer when open", () => {
116
+ render(
117
+ <DetailDrawer
118
+ open
119
+ onOpenChange={() => {}}
120
+ title="Referral details"
121
+ description="A drawer for insights"
122
+ footer={<button type="button">Done</button>}
123
+ >
124
+ <div>Drawer body</div>
125
+ </DetailDrawer>
126
+ )
127
+
128
+ expect(screen.getByText("Referral details")).not.toBeNull()
129
+ expect(screen.getByText("A drawer for insights")).not.toBeNull()
130
+ expect(screen.getByText("Drawer body")).not.toBeNull()
131
+ expect(screen.getByRole("button", { name: "Done" })).not.toBeNull()
132
+ expect(document.querySelector('[data-slot="detail-drawer"]')?.className).toContain("flex")
133
+ expect(document.querySelector('[data-slot="detail-drawer"]')?.className).toContain("flex-col")
134
+ })
135
+ })
@@ -0,0 +1,50 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+
5
+ import { cn } from "../lib/utils"
6
+ import { StatusPill, type PillStatus } from "./pill"
7
+
8
+ export interface DaysOpenCellProps extends React.HTMLAttributes<HTMLDivElement> {
9
+ days: number | null | undefined
10
+ warningAt?: number
11
+ criticalAt?: number
12
+ emptyLabel?: string
13
+ suffix?: string
14
+ }
15
+
16
+ function getDaysOpenIntent(days: number, warningAt: number, criticalAt: number): PillStatus {
17
+ if (days >= criticalAt) return "error"
18
+ if (days >= warningAt) return "warning"
19
+ return "success"
20
+ }
21
+
22
+ export function DaysOpenCell({
23
+ days,
24
+ warningAt = 7,
25
+ criticalAt = 30,
26
+ emptyLabel = "—",
27
+ suffix = "d open",
28
+ className,
29
+ ...props
30
+ }: DaysOpenCellProps) {
31
+ if (days === null || days === undefined) {
32
+ return (
33
+ <div data-slot="days-open-cell" className={cn("text-sm text-muted-foreground", className)} {...props}>
34
+ {emptyLabel}
35
+ </div>
36
+ )
37
+ }
38
+
39
+ const intent = getDaysOpenIntent(days, warningAt, criticalAt)
40
+
41
+ return (
42
+ <div data-slot="days-open-cell" className={cn("inline-flex items-center", className)} {...props}>
43
+ <StatusPill data-testid="days-open-pill" status={`${days} ${suffix}`} intent={intent}>
44
+ {days} {suffix}
45
+ </StatusPill>
46
+ </div>
47
+ )
48
+ }
49
+
50
+ export { getDaysOpenIntent }
@@ -0,0 +1,60 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+
5
+ import { cn } from "../lib/utils"
6
+ import {
7
+ Sheet,
8
+ SheetContent,
9
+ SheetDescription,
10
+ SheetFooter,
11
+ SheetHeader,
12
+ SheetTitle,
13
+ } from "./sheet"
14
+
15
+ export interface DetailDrawerProps {
16
+ open: boolean
17
+ onOpenChange: (open: boolean) => void
18
+ title: React.ReactNode
19
+ description?: React.ReactNode
20
+ children: React.ReactNode
21
+ footer?: React.ReactNode
22
+ side?: "right" | "left"
23
+ className?: string
24
+ contentClassName?: string
25
+ }
26
+
27
+ export function DetailDrawer({
28
+ open,
29
+ onOpenChange,
30
+ title,
31
+ description,
32
+ children,
33
+ footer,
34
+ side = "right",
35
+ className,
36
+ contentClassName,
37
+ }: DetailDrawerProps) {
38
+ return (
39
+ <Sheet open={open} onOpenChange={onOpenChange}>
40
+ <SheetContent
41
+ data-slot="detail-drawer"
42
+ side={side}
43
+ className={cn("flex w-full flex-col gap-0 p-0 sm:max-w-xl", className)}
44
+ >
45
+ <SheetHeader data-slot="detail-drawer-header" className="border-b border-border p-5">
46
+ <SheetTitle>{title}</SheetTitle>
47
+ {description ? <SheetDescription>{description}</SheetDescription> : null}
48
+ </SheetHeader>
49
+ <div data-slot="detail-drawer-content" className={cn("flex-1 overflow-y-auto p-5", contentClassName)}>
50
+ {children}
51
+ </div>
52
+ {footer ? (
53
+ <SheetFooter data-slot="detail-drawer-footer" className="border-t border-border p-5">
54
+ {footer}
55
+ </SheetFooter>
56
+ ) : null}
57
+ </SheetContent>
58
+ </Sheet>
59
+ )
60
+ }
@@ -1,7 +1,7 @@
1
1
  "use client"
2
2
 
3
3
  import * as React from "react"
4
- import { ThumbsUp, ThumbsDown, Check, Pencil } from "lucide-react"
4
+ import { ThumbsUp, ThumbsDown } from "lucide-react"
5
5
  import { cn } from "../lib/utils"
6
6
 
7
7
  // ---------------------------------------------------------------------------
@@ -25,19 +25,6 @@ export interface FeedbackSubmitData {
25
25
  detail: string
26
26
  }
27
27
 
28
- /**
29
- * Persisted feedback data from a previous submission, used to hydrate the
30
- * footer into its "already submitted" visual state.
31
- */
32
- export interface PersistedFeedbackData {
33
- sentiment: "positive" | "negative"
34
- reasonTop?: string
35
- reasonSub?: string
36
- pills?: string[]
37
- detail?: string
38
- ownershipLabel: "Your feedback" | "Team feedback"
39
- }
40
-
41
28
  /**
42
29
  * Defines a tier-1 chip that may have tier-2 sub-chips.
43
30
  */
@@ -198,13 +185,6 @@ export interface FeedbackFooterProps {
198
185
  negativeChips?: FeedbackChipTree[]
199
186
  positiveChips?: string[]
200
187
  className?: string
201
- /** Pre-existing feedback to hydrate from (e.g. after page reload). */
202
- initialFeedback?: PersistedFeedbackData | null
203
- /** Label shown in the transient confirmation pill after submit. */
204
- submittedLabel?: string
205
- /** Stable key for syncing initialFeedback into local state. When this
206
- * changes, the component resets to the new initialFeedback value. */
207
- feedbackKey?: string
208
188
  }
209
189
 
210
190
  const SENTIMENT_BUTTON_ACTIVE: Record<"positive" | "negative", string> = {
@@ -225,9 +205,6 @@ export function FeedbackFooter({
225
205
  negativeChips = [],
226
206
  positiveChips = [],
227
207
  className,
228
- initialFeedback,
229
- submittedLabel = "Saved",
230
- feedbackKey,
231
208
  }: FeedbackFooterProps) {
232
209
  const [expanded, setExpanded] = React.useState(false)
233
210
  const [selectedTier1, setSelectedTier1] = React.useState<string | null>(null)
@@ -237,48 +214,6 @@ export function FeedbackFooter({
237
214
  const [activeTreeIndex, setActiveTreeIndex] = React.useState<number | null>(
238
215
  null,
239
216
  )
240
- /** Transient "Saved" confirmation — shown after successful submit. */
241
- const [submitted, setSubmitted] = React.useState(false)
242
- /** Persisted feedback shown as a clickable indicator (survives reload). */
243
- const [persisted, setPersisted] = React.useState<PersistedFeedbackData | null>(
244
- initialFeedback ?? null,
245
- )
246
- /** Tracks whether the user is actively editing (ref to guard against prop overwrites without triggering re-syncs). */
247
- const isEditingRef = React.useRef(false)
248
- /** Track the last synced feedbackKey to detect key changes. */
249
- const lastKeyRef = React.useRef<string | undefined>(feedbackKey)
250
-
251
- /** Helper to update the editing ref. */
252
- const setIsEditing = React.useCallback((value: boolean) => {
253
- isEditingRef.current = value
254
- }, [])
255
-
256
- // Sync initialFeedback into local state via useEffect keyed on feedbackKey.
257
- // When feedbackKey changes, reset to new target. Preserve active edits
258
- // when feedbackKey stays the same.
259
- React.useEffect(() => {
260
- const keyChanged = feedbackKey !== lastKeyRef.current
261
- lastKeyRef.current = feedbackKey
262
-
263
- if (keyChanged) {
264
- // Key changed — full reset to new target
265
- setPersisted(initialFeedback ?? null)
266
- setSubmitted(false)
267
- setExpanded(false)
268
- isEditingRef.current = false
269
- if (initialFeedback) {
270
- onFeedbackChange(initialFeedback.sentiment)
271
- } else {
272
- onFeedbackChange(null)
273
- }
274
- } else if (!isEditingRef.current) {
275
- // Same key, not actively editing — safe to sync
276
- setPersisted(initialFeedback ?? null)
277
- if (initialFeedback) {
278
- onFeedbackChange(initialFeedback.sentiment)
279
- }
280
- }
281
- }, [initialFeedback, feedbackKey, onFeedbackChange])
282
217
 
283
218
  // Reset state when feedback collapses
284
219
  const resetState = React.useCallback(() => {
@@ -288,8 +223,7 @@ export function FeedbackFooter({
288
223
  setAdditionalPills([])
289
224
  setDetailText("")
290
225
  setActiveTreeIndex(null)
291
- setIsEditing(false)
292
- }, [setIsEditing])
226
+ }, [])
293
227
 
294
228
  const handleSentimentClick = React.useCallback(
295
229
  (sentiment: "positive" | "negative") => {
@@ -297,26 +231,10 @@ export function FeedbackFooter({
297
231
  // Reset chip state when switching sentiment, then expand
298
232
  resetState()
299
233
  setExpanded(true)
300
- setSubmitted(false)
301
- setPersisted(null)
302
- setIsEditing(true)
303
234
  },
304
- [onFeedbackChange, resetState, setIsEditing],
235
+ [onFeedbackChange, resetState],
305
236
  )
306
237
 
307
- /** Open the persisted indicator for editing. */
308
- const handlePersistedClick = React.useCallback(() => {
309
- if (!persisted) return
310
- onFeedbackChange(persisted.sentiment)
311
- setSelectedTier1(persisted.reasonTop ?? null)
312
- setSelectedTier2(persisted.reasonSub ?? null)
313
- setAdditionalPills(persisted.pills ?? [])
314
- setDetailText(persisted.detail ?? "")
315
- setExpanded(true)
316
- setSubmitted(false)
317
- setIsEditing(true)
318
- }, [persisted, onFeedbackChange, setIsEditing])
319
-
320
238
  const handleTier1Toggle = React.useCallback(
321
239
  (chipLabel: string) => {
322
240
  if (selectedTier1 === chipLabel) {
@@ -377,9 +295,6 @@ export function FeedbackFooter({
377
295
  pills: additionalPills,
378
296
  detail: detailText,
379
297
  })
380
- // Show transient "Saved" confirmation
381
- setSubmitted(true)
382
- // Collapse expansion but keep sentiment visible
383
298
  resetState()
384
299
  }, [
385
300
  feedback,
@@ -408,75 +323,38 @@ export function FeedbackFooter({
408
323
  const activeTree =
409
324
  activeTreeIndex !== null ? negativeChips[activeTreeIndex] : null
410
325
 
411
- // Determine if we should show the persisted indicator instead of bare buttons
412
- const showPersistedIndicator = persisted && !expanded && !submitted
413
-
414
326
  return (
415
327
  <div className={cn("space-y-3", className)}>
416
328
  {/* Sentiment buttons + meta text bar */}
417
329
  <div className="flex items-center justify-between">
418
- {showPersistedIndicator ? (
419
- /* Persisted feedback indicator — clickable to reopen editor */
330
+ <div className="flex items-center gap-3">
420
331
  <button
421
332
  type="button"
422
- onClick={handlePersistedClick}
423
- className="group flex items-center gap-1.5 text-[11px] text-muted-foreground hover:text-foreground transition-colors"
424
- data-testid="persisted-feedback-indicator"
425
- >
426
- <span className="font-medium">{persisted.ownershipLabel}:</span>
427
- {persisted.sentiment === "positive" ? (
428
- <ThumbsUp className="h-[11px] w-[11px]" />
429
- ) : (
430
- <ThumbsDown className="h-[11px] w-[11px]" />
431
- )}
432
- {persisted.detail && (
433
- <span className="max-w-[200px] truncate text-muted-foreground/70">
434
- {persisted.detail}
435
- </span>
333
+ onClick={() => handleSentimentClick("positive")}
334
+ className={cn(
335
+ "flex gap-1 items-center text-[11px] font-medium rounded-md px-2 py-1 transition-colors",
336
+ feedback === "positive"
337
+ ? SENTIMENT_BUTTON_ACTIVE.positive
338
+ : SENTIMENT_BUTTON_IDLE,
436
339
  )}
437
- <Pencil className="h-[9px] w-[9px] opacity-0 group-hover:opacity-100 transition-opacity" />
340
+ >
341
+ <ThumbsUp className="h-[11px] w-[11px]" />
342
+ Helpful
438
343
  </button>
439
- ) : (
440
- <div className="flex items-center gap-3">
441
- <button
442
- type="button"
443
- onClick={() => handleSentimentClick("positive")}
444
- className={cn(
445
- "flex gap-1 items-center text-[11px] font-medium rounded-md px-2 py-1 transition-colors",
446
- feedback === "positive"
447
- ? SENTIMENT_BUTTON_ACTIVE.positive
448
- : SENTIMENT_BUTTON_IDLE,
449
- )}
450
- >
451
- <ThumbsUp className="h-[11px] w-[11px]" />
452
- Helpful
453
- </button>
454
- <button
455
- type="button"
456
- onClick={() => handleSentimentClick("negative")}
457
- className={cn(
458
- "flex gap-1 items-center text-[11px] font-medium rounded-md px-2 py-1 transition-colors",
459
- feedback === "negative"
460
- ? SENTIMENT_BUTTON_ACTIVE.negative
461
- : SENTIMENT_BUTTON_IDLE,
462
- )}
463
- >
464
- <ThumbsDown className="h-[11px] w-[11px]" />
465
- Not helpful
466
- </button>
467
- {/* Transient "Saved" confirmation pill */}
468
- {submitted && feedback && (
469
- <span
470
- className="inline-flex items-center gap-1 text-[11px] font-medium text-emerald-600"
471
- role="status"
472
- data-testid="feedback-submitted-pill"
473
- >
474
- <Check className="h-[11px] w-[11px]" />
475
- {submittedLabel}
476
- </span>
344
+ <button
345
+ type="button"
346
+ onClick={() => handleSentimentClick("negative")}
347
+ className={cn(
348
+ "flex gap-1 items-center text-[11px] font-medium rounded-md px-2 py-1 transition-colors",
349
+ feedback === "negative"
350
+ ? SENTIMENT_BUTTON_ACTIVE.negative
351
+ : SENTIMENT_BUTTON_IDLE,
477
352
  )}
478
- </div>
479
- )}
353
+ >
354
+ <ThumbsDown className="h-[11px] w-[11px]" />
355
+ Not helpful
356
+ </button>
357
+ </div>
480
358
  {metaText && (
481
359
  <span className="text-[11px] text-muted-foreground">{metaText}</span>
482
360
  )}
@@ -544,188 +422,3 @@ export function FeedbackFooter({
544
422
  </div>
545
423
  )
546
424
  }
547
-
548
- // ---------------------------------------------------------------------------
549
- // InlineFeedbackControl — shared thumb+detail inline feedback widget
550
- // ---------------------------------------------------------------------------
551
-
552
- export interface InlineFeedbackControlProps {
553
- /** Unique key identifying the feedback target (e.g. factor key). */
554
- feedbackKey: string
555
- /** Persisted/initial feedback to hydrate from. */
556
- initialFeedback?: { type: "up" | "down"; detail: string; ownershipLabel?: string }
557
- /** Called when user submits or clears feedback. */
558
- onFeedback?: (key: string, type: "up" | "down" | null, detail?: string) => void
559
- /** Test ID prefix for all sub-elements. */
560
- testIdPrefix?: string
561
- }
562
-
563
- /**
564
- * Compact inline thumb-up/thumb-down feedback with optional detail text.
565
- * Used by PriorityFactorRow and any other component that needs
566
- * a lightweight feedback control.
567
- */
568
- export function InlineFeedbackControl({
569
- feedbackKey,
570
- initialFeedback,
571
- onFeedback,
572
- testIdPrefix = "inline-feedback",
573
- }: InlineFeedbackControlProps) {
574
- const [thumbState, setThumbState] = React.useState<"up" | "down" | null>(
575
- initialFeedback?.type ?? null,
576
- )
577
- const [showInput, setShowInput] = React.useState(false)
578
- const [detailText, setDetailText] = React.useState(initialFeedback?.detail ?? "")
579
- const [saved, setSaved] = React.useState(!!initialFeedback)
580
- const [savedDetail, setSavedDetail] = React.useState(initialFeedback?.detail ?? "")
581
- const ownershipLabel = initialFeedback?.ownershipLabel ?? "Your feedback"
582
-
583
- // Sync with initialFeedback prop changes
584
- React.useEffect(() => {
585
- if (initialFeedback) {
586
- setThumbState(initialFeedback.type)
587
- setSaved(true)
588
- setSavedDetail(initialFeedback.detail)
589
- }
590
- }, [initialFeedback])
591
-
592
- const handleThumbClick = React.useCallback(
593
- (type: "up" | "down") => {
594
- if (thumbState === type) {
595
- // Toggle off
596
- setThumbState(null)
597
- setShowInput(false)
598
- setSaved(false)
599
- onFeedback?.(feedbackKey, null)
600
- } else {
601
- setThumbState(type)
602
- setShowInput(true)
603
- setSaved(false)
604
- }
605
- },
606
- [thumbState, feedbackKey, onFeedback],
607
- )
608
-
609
- const handleSubmitDetail = React.useCallback(() => {
610
- if (!thumbState) return
611
- const text = detailText.trim()
612
- onFeedback?.(feedbackKey, thumbState, text)
613
- setSaved(true)
614
- setSavedDetail(text)
615
- setShowInput(false)
616
- }, [thumbState, detailText, feedbackKey, onFeedback])
617
-
618
- return (
619
- <div>
620
- {saved && !showInput ? (
621
- /* Persisted / saved indicator */
622
- <button
623
- type="button"
624
- onClick={() => {
625
- setDetailText(savedDetail)
626
- setShowInput(true)
627
- setSaved(false)
628
- }}
629
- className="group flex items-center gap-1.5 text-[11px] text-muted-foreground hover:text-foreground transition-colors"
630
- data-testid={`${testIdPrefix}-feedback-persisted-${feedbackKey}`}
631
- >
632
- <span className="font-medium">{ownershipLabel}:</span>
633
- {thumbState === "up" ? (
634
- <ThumbsUp className="h-[10px] w-[10px]" />
635
- ) : (
636
- <ThumbsDown className="h-[10px] w-[10px]" />
637
- )}
638
- {savedDetail && (
639
- <span className="max-w-[180px] truncate text-muted-foreground/70">
640
- {savedDetail}
641
- </span>
642
- )}
643
- <Pencil className="h-[9px] w-[9px] opacity-0 group-hover:opacity-100 transition-opacity" />
644
- </button>
645
- ) : (
646
- <div className="flex items-center gap-1.5">
647
- {/* Inline thumb buttons */}
648
- <button
649
- type="button"
650
- onClick={() => handleThumbClick("up")}
651
- className={cn(
652
- "p-1 rounded transition-colors",
653
- thumbState === "up"
654
- ? "text-foreground bg-muted"
655
- : "text-muted-foreground/40 hover:text-foreground hover:bg-muted/50",
656
- )}
657
- title="This is accurate"
658
- data-testid={`${testIdPrefix}-thumb-up-${feedbackKey}`}
659
- >
660
- <ThumbsUp className="h-[10px] w-[10px]" />
661
- </button>
662
- <button
663
- type="button"
664
- onClick={() => handleThumbClick("down")}
665
- className={cn(
666
- "p-1 rounded transition-colors",
667
- thumbState === "down"
668
- ? "text-red-600 bg-red-50"
669
- : "text-muted-foreground/40 hover:text-red-600 hover:bg-red-50/50",
670
- )}
671
- title="Report issue"
672
- data-testid={`${testIdPrefix}-thumb-down-${feedbackKey}`}
673
- >
674
- <ThumbsDown className="h-[10px] w-[10px]" />
675
- </button>
676
-
677
- {/* Transient "Saved" pill */}
678
- {saved && (
679
- <span
680
- className="inline-flex items-center gap-1 text-[11px] font-medium text-emerald-600"
681
- role="status"
682
- data-testid={`${testIdPrefix}-saved-${feedbackKey}`}
683
- >
684
- <Check className="h-[10px] w-[10px]" />
685
- Saved
686
- </span>
687
- )}
688
- </div>
689
- )}
690
-
691
- {/* Inline detail input */}
692
- {showInput && thumbState && (
693
- <div className="mt-1.5">
694
- <input
695
- type="text"
696
- value={detailText}
697
- onChange={(e) => setDetailText(e.target.value)}
698
- onKeyDown={(e) => {
699
- if (e.key === "Enter") handleSubmitDetail()
700
- if (e.key === "Escape") setShowInput(false)
701
- }}
702
- placeholder={
703
- thumbState === "up"
704
- ? "What\u2019s accurate? (optional)"
705
- : "What\u2019s wrong? (optional)"
706
- }
707
- className="w-full h-6 rounded border border-border bg-background px-2 text-[11px] text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-1 focus:ring-ring"
708
- data-testid={`${testIdPrefix}-detail-input-${feedbackKey}`}
709
- />
710
- <div className="mt-1 flex items-center gap-1.5">
711
- <button
712
- type="button"
713
- onClick={handleSubmitDetail}
714
- className="bg-foreground text-background rounded px-2 py-0.5 text-[10px] font-semibold"
715
- data-testid={`${testIdPrefix}-submit-${feedbackKey}`}
716
- >
717
- Submit
718
- </button>
719
- <button
720
- type="button"
721
- onClick={() => setShowInput(false)}
722
- className="border border-border rounded px-2 py-0.5 text-[10px] font-medium"
723
- >
724
- Cancel
725
- </button>
726
- </div>
727
- </div>
728
- )}
729
- </div>
730
- )
731
- }