@handled-ai/design-system 0.11.3 → 0.13.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@handled-ai/design-system",
3
- "version": "0.11.3",
3
+ "version": "0.13.0",
4
4
  "description": "Handled UI component library (shadcn-style, New York)",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@9.12.0",
@@ -171,9 +171,11 @@
171
171
  "eslint": "^9.32.0",
172
172
  "eslint-config-next": "15.3.1",
173
173
  "happy-dom": "^20.9.0",
174
+ "lucide-react": "^1.14.0",
174
175
  "next": "15.5.9",
175
176
  "react": "19.1.0",
176
177
  "react-dom": "19.1.0",
178
+ "recharts": "^3.8.1",
177
179
  "shadcn": "^3.0.0",
178
180
  "tailwindcss": "^4.1.11",
179
181
  "three": "^0.183.1",
@@ -85,7 +85,7 @@ describe("ScoreFeedback.Root — initialFeedback prop", () => {
85
85
  expect(screen.queryByText("Noted")).toBeNull();
86
86
  });
87
87
 
88
- it("renders submitted pills from initialFeedback", () => {
88
+ it("does NOT render pills inline in submitted state (compact trigger)", () => {
89
89
  render(
90
90
  <Wrapper
91
91
  initialFeedback={makeInitialScoreFeedback({
@@ -93,28 +93,32 @@ describe("ScoreFeedback.Root — initialFeedback prop", () => {
93
93
  })}
94
94
  />,
95
95
  );
96
- expect(screen.getByText("Right timing")).toBeDefined();
97
- expect(screen.getByText("Accurate data")).toBeDefined();
96
+ // Pills are stored in state but not rendered in the compact trigger
97
+ expect(screen.queryByText("Right timing")).toBeNull();
98
+ expect(screen.queryByText("Accurate data")).toBeNull();
99
+ // Only the "Noted" label is visible
100
+ expect(screen.getByText("Noted")).toBeDefined();
98
101
  });
99
102
 
100
- it("renders submitted detail text from initialFeedback", () => {
103
+ it("does NOT render detail text inline in submitted state (compact trigger)", () => {
101
104
  render(
102
105
  <Wrapper
103
106
  initialFeedback={makeInitialScoreFeedback({ detail: "Score looks correct." })}
104
107
  />,
105
108
  );
106
- expect(screen.getByText("Score looks correct.")).toBeDefined();
109
+ // Detail text is stored in state but not rendered in the compact trigger
110
+ expect(screen.queryByText("Score looks correct.")).toBeNull();
111
+ expect(screen.getByText("Noted")).toBeDefined();
107
112
  });
108
113
 
109
- it("minimal initialFeedback (no pills, no detail) still shows Noted without edit button", () => {
114
+ it("minimal initialFeedback (no pills, no detail) shows Noted as clickable button", () => {
110
115
  const { container } = render(
111
116
  <Wrapper initialFeedback={makeMinimalInitialScoreFeedback("up")} />,
112
117
  );
113
118
  expect(screen.getByText("Noted")).toBeDefined();
114
- // No pill text to find just check no crash
119
+ // The submitted state renders as a single compact button
115
120
  const buttons = container.querySelectorAll("button");
116
- // The edit button requires pills.length > 0 || detail, so no edit button here
117
- expect(buttons.length).toBe(0);
121
+ expect(buttons.length).toBe(1);
118
122
  });
119
123
  });
120
124
 
@@ -226,7 +230,7 @@ describe("ScoreFeedback.Root — onSubmitFeedback callback", () => {
226
230
  });
227
231
 
228
232
  describe("ScoreFeedback.Root — editSubmitted", () => {
229
- it("clicking the pills/detail edit area restores feedback into editing state", async () => {
233
+ it("clicking the compact 'Noted' button restores feedback into editing state", async () => {
230
234
  const { container } = render(
231
235
  <Wrapper
232
236
  initialFeedback={makeInitialScoreFeedback({
@@ -236,9 +240,10 @@ describe("ScoreFeedback.Root — editSubmitted", () => {
236
240
  />,
237
241
  );
238
242
 
239
- // The edit button (wraps pills + detail) should be present
243
+ // The compact submitted button should be present
240
244
  const editButton = container.querySelector("button");
241
245
  expect(editButton).not.toBeNull();
246
+ expect(screen.getByText("Noted")).toBeDefined();
242
247
 
243
248
  await act(async () => {
244
249
  fireEvent.click(editButton!);
@@ -60,7 +60,12 @@ export function AccountContactsPopover({
60
60
  let left = rect.right - popoverWidth
61
61
  if (left < 16) left = 16
62
62
  if (left + popoverWidth > window.innerWidth - 16) left = window.innerWidth - 16 - popoverWidth
63
- setPopoverStyle({ position: "fixed", top: rect.bottom + 4, left })
63
+ const popoverHeight = 320;
64
+ const spaceBelow = window.innerHeight - rect.bottom - 8;
65
+ const spaceAbove = rect.top - 8;
66
+ const placeAbove = spaceBelow < popoverHeight && spaceAbove > spaceBelow;
67
+ const top = placeAbove ? rect.top - popoverHeight - 4 : rect.bottom + 4;
68
+ setPopoverStyle({ position: "fixed", top: Math.max(8, top), left, maxHeight: placeAbove ? spaceAbove : spaceBelow })
64
69
  }
65
70
  }, [open])
66
71
 
@@ -70,7 +75,7 @@ export function AccountContactsPopover({
70
75
  {open && (
71
76
  <>
72
77
  <div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
73
- <div style={popoverStyle} className="fixed bg-background border border-border rounded-lg shadow-xl z-50 w-[28rem] max-w-[calc(100vw-2rem)] py-2 animate-in fade-in slide-in-from-top-1 duration-150">
78
+ <div style={popoverStyle} className="fixed bg-background border border-border rounded-lg shadow-xl z-50 w-[28rem] max-w-[calc(100vw-2rem)] py-2 animate-in fade-in slide-in-from-top-1 duration-150 overflow-y-auto">
74
79
  <div className="px-3 py-1.5 text-[11px] font-medium text-muted-foreground/60 uppercase tracking-wide">
75
80
  Account Contacts
76
81
  </div>
@@ -135,45 +135,23 @@ function Trigger({ className }: { className?: string }) {
135
135
  const { thumbState, notedType, submittedFeedback, handleThumbClick, editSubmitted } = useScoreFeedback()
136
136
 
137
137
  if (notedType || (submittedFeedback && !thumbState)) {
138
+ const label = notedType
139
+ ? notedType === "up" ? "Noted" : "Recorded"
140
+ : submittedFeedback?.type === "up" ? "Noted" : "Recorded"
141
+
138
142
  return (
139
- <div className={cn("shrink-0", className)}>
140
- <div className="flex items-center gap-1">
141
- <Check className="w-3 h-3 text-emerald-500" />
142
- <span className="text-[11px] text-muted-foreground">
143
- {notedType
144
- ? notedType === "up" ? "Noted" : "Recorded"
145
- : submittedFeedback?.type === "up" ? "Noted" : "Recorded"}
146
- </span>
147
- </div>
148
- {submittedFeedback && (submittedFeedback.pills.length > 0 || submittedFeedback.detail) && (
149
- <button
150
- type="button"
151
- onClick={editSubmitted}
152
- className="mt-1.5 w-full text-left space-y-1 group cursor-pointer"
153
- >
154
- {submittedFeedback.pills.length > 0 && (
155
- <div className="flex flex-wrap gap-1">
156
- {submittedFeedback.pills.map((p) => (
157
- <span
158
- key={p}
159
- className={cn(
160
- "rounded-full border px-2 py-0.5 text-[10px] font-medium transition-colors group-hover:opacity-80",
161
- submittedFeedback.type === "up"
162
- ? "border-emerald-200/60 bg-emerald-50/50 text-emerald-700/70"
163
- : "border-red-200/60 bg-red-50/50 text-red-700/70"
164
- )}
165
- >
166
- {p}
167
- </span>
168
- ))}
169
- </div>
170
- )}
171
- {submittedFeedback.detail && (
172
- <p className="text-[11px] text-muted-foreground/70 leading-snug group-hover:text-muted-foreground transition-colors">{submittedFeedback.detail}</p>
173
- )}
174
- </button>
143
+ <button
144
+ type="button"
145
+ onClick={submittedFeedback ? editSubmitted : undefined}
146
+ className={cn(
147
+ "flex items-center gap-1 shrink-0 rounded px-1.5 py-1 transition-colors",
148
+ submittedFeedback ? "cursor-pointer hover:bg-muted/50" : "cursor-default",
149
+ className,
175
150
  )}
176
- </div>
151
+ >
152
+ <Check className="w-3 h-3 text-emerald-500" />
153
+ <span className="text-[11px] text-muted-foreground">{label}</span>
154
+ </button>
177
155
  )
178
156
  }
179
157
 
@@ -1,7 +1,7 @@
1
1
  "use client"
2
2
 
3
3
  import * as React from "react"
4
- import { Check, CirclePlus, ExternalLink, Lock, ThumbsDown } from "lucide-react"
4
+ import { Check, CirclePlus, ExternalLink, Loader2, Lock, ThumbsDown } from "lucide-react"
5
5
 
6
6
  interface DismissReasonNode {
7
7
  label: string
@@ -105,6 +105,9 @@ interface SignalApprovalContextValue {
105
105
  scheduledTime?: string
106
106
  labels: Required<SignalApprovalLabels>
107
107
  hideApproveButton?: boolean
108
+ approveButtonIconUrl?: string
109
+ opportunityPreview?: RootProps['opportunityPreview']
110
+ requestingApproval: boolean
108
111
  approve: () => void
109
112
  submitApproveFeedback: (reasons: string[], detail: string) => void
110
113
  skipApproveFeedback: () => void
@@ -131,6 +134,23 @@ interface RootProps {
131
134
  labels?: SignalApprovalLabels
132
135
  /** When true, the approve/create-opportunity button is hidden but the dismiss button remains. */
133
136
  hideApproveButton?: boolean
137
+ /** Optional icon URL for the approve button. Renders an img instead of CirclePlus when provided. */
138
+ approveButtonIconUrl?: string
139
+ /** Optional structured preview data shown in the confirmation dialog. */
140
+ opportunityPreview?: {
141
+ name: string
142
+ stage: string
143
+ closeDate: string
144
+ amount: string
145
+ accountName: string
146
+ }
147
+ /**
148
+ * Async callback fired when the user clicks the approve button, BEFORE
149
+ * transitioning to the "confirming" state. While the promise is pending,
150
+ * the button shows a loading spinner. On resolve, transitions to "confirming".
151
+ * On reject, stays in "pending".
152
+ */
153
+ onRequestApproval?: () => Promise<void>
134
154
  /**
135
155
  * Called when the user confirms the approval action.
136
156
  *
@@ -145,9 +165,10 @@ interface RootProps {
145
165
  onDismiss?: (reasons: string[], detail: string, subReason?: string) => void
146
166
  }
147
167
 
148
- function Root({ children, companyName, opportunityUrl, scheduledTime, initialApprovalState, labels: labelOverrides, hideApproveButton, onApprove, onApproveFeedback, onDismiss }: RootProps) {
168
+ function Root({ children, companyName, opportunityUrl, scheduledTime, initialApprovalState, labels: labelOverrides, hideApproveButton, approveButtonIconUrl, opportunityPreview, onRequestApproval, onApprove, onApproveFeedback, onDismiss }: RootProps) {
149
169
  const labels = React.useMemo(() => ({ ...DEFAULT_LABELS, ...labelOverrides }), [labelOverrides])
150
170
  const [approvalState, setApprovalState] = React.useState<ApprovalState>(initialApprovalState ?? "pending")
171
+ const [requestingApproval, setRequestingApproval] = React.useState(false)
151
172
 
152
173
  // Guard against state updates after unmount (e.g. user navigates away while
153
174
  // an async onApprove promise is still in flight).
@@ -157,8 +178,24 @@ function Root({ children, companyName, opportunityUrl, scheduledTime, initialApp
157
178
  }, [])
158
179
 
159
180
  const requestApproval = React.useCallback(() => {
160
- setApprovalState("confirming")
161
- }, [])
181
+ if (onRequestApproval) {
182
+ setRequestingApproval(true)
183
+ onRequestApproval()
184
+ .then(() => {
185
+ if (mountedRef.current) {
186
+ setRequestingApproval(false)
187
+ setApprovalState("confirming")
188
+ }
189
+ })
190
+ .catch(() => {
191
+ if (mountedRef.current) {
192
+ setRequestingApproval(false)
193
+ }
194
+ })
195
+ } else {
196
+ setApprovalState("confirming")
197
+ }
198
+ }, [onRequestApproval])
162
199
 
163
200
  const requestDismiss = React.useCallback(() => {
164
201
  setApprovalState("dismissing")
@@ -210,7 +247,7 @@ function Root({ children, companyName, opportunityUrl, scheduledTime, initialApp
210
247
 
211
248
  return (
212
249
  <SignalApprovalCtx.Provider
213
- value={{ approvalState, companyName, opportunityUrl, scheduledTime, labels, hideApproveButton, approve, submitApproveFeedback, skipApproveFeedback, dismiss, requestApproval, requestDismiss, cancel }}
250
+ value={{ approvalState, companyName, opportunityUrl, scheduledTime, labels, hideApproveButton, approveButtonIconUrl, opportunityPreview, requestingApproval, approve, submitApproveFeedback, skipApproveFeedback, dismiss, requestApproval, requestDismiss, cancel }}
214
251
  >
215
252
  {children}
216
253
  </SignalApprovalCtx.Provider>
@@ -381,7 +418,7 @@ function SubmittedFeedback({
381
418
  }
382
419
 
383
420
  function Actions() {
384
- const { approvalState, companyName, opportunityUrl, scheduledTime, labels, hideApproveButton, approve, submitApproveFeedback, skipApproveFeedback, dismiss, requestApproval, requestDismiss, cancel } =
421
+ const { approvalState, companyName, opportunityUrl, scheduledTime, labels, hideApproveButton, approveButtonIconUrl, opportunityPreview, requestingApproval, approve, submitApproveFeedback, skipApproveFeedback, dismiss, requestApproval, requestDismiss, cancel } =
385
422
  useSignalApproval()
386
423
  const [selectedTopReason, setSelectedTopReason] = React.useState<string | null>(null)
387
424
  const [selectedSubReason, setSelectedSubReason] = React.useState<string | null>(null)
@@ -702,6 +739,22 @@ function Actions() {
702
739
  <p className="text-sm text-foreground">
703
740
  {labels.confirmPrompt} <strong>{companyName}</strong>. Confirm?
704
741
  </p>
742
+ {opportunityPreview && (
743
+ <div className="mt-3 space-y-1.5 border-t border-border/50 pt-3">
744
+ {[
745
+ { label: "Opportunity", value: opportunityPreview.name },
746
+ { label: "Account", value: opportunityPreview.accountName },
747
+ { label: "Stage", value: opportunityPreview.stage },
748
+ { label: "Close Date", value: opportunityPreview.closeDate },
749
+ { label: "Amount", value: opportunityPreview.amount },
750
+ ].map(({ label, value }) => (
751
+ <div key={label} className="flex items-center justify-between text-xs">
752
+ <span className="text-muted-foreground">{label}</span>
753
+ <span className="font-medium text-foreground">{value}</span>
754
+ </div>
755
+ ))}
756
+ </div>
757
+ )}
705
758
  </div>
706
759
  <div className="flex items-center gap-2">
707
760
  <button
@@ -752,9 +805,16 @@ function Actions() {
752
805
  <button
753
806
  type="button"
754
807
  onClick={requestApproval}
755
- className="inline-flex h-7 items-center gap-1.5 rounded-md border border-border bg-foreground px-3 text-xs font-semibold text-background shadow-none transition-colors hover:bg-foreground/90"
808
+ disabled={requestingApproval}
809
+ className="inline-flex h-7 items-center gap-1.5 rounded-md border border-border bg-foreground px-3 text-xs font-semibold text-background shadow-none transition-colors hover:bg-foreground/90 disabled:opacity-50"
756
810
  >
757
- <CirclePlus className="h-3.5 w-3.5" />
811
+ {requestingApproval ? (
812
+ <Loader2 className="h-3.5 w-3.5 animate-spin" />
813
+ ) : approveButtonIconUrl ? (
814
+ <img src={approveButtonIconUrl} alt="" className="h-3.5 w-3.5 object-contain" draggable={false} />
815
+ ) : (
816
+ <CirclePlus className="h-3.5 w-3.5" />
817
+ )}
758
818
  {labels.approveButton}
759
819
  </button>
760
820
  )}
@@ -801,4 +861,5 @@ export {
801
861
  Gate as SignalApprovalGate,
802
862
  }
803
863
  export const SignalApproval = { Root, Actions, Gate }
864
+ export type OpportunityPreview = NonNullable<RootProps['opportunityPreview']>
804
865
  export type { ApprovalState, SignalApprovalLabels, SignalApprovalContextValue, RootProps as SignalApprovalRootProps }
@@ -38,7 +38,7 @@ import {
38
38
  type InboxFilterCategory,
39
39
  } from "../components/inbox-toolbar"
40
40
  import { GroupedListView, type GroupedListGroup } from "../components/item-list"
41
- import { SignalApproval, type ApprovalState } from "../components/signal-feedback-inline"
41
+ import { SignalApproval, type ApprovalState, type OpportunityPreview } from "../components/signal-feedback-inline"
42
42
  import { ScoreFeedback } from "../components/score-feedback"
43
43
  import { ScoreBreakdown } from "../components/score-breakdown"
44
44
  import { Citation, type SourceDef } from "../components/detail-view"
@@ -133,6 +133,9 @@ export interface DetailViewProps {
133
133
  /** Render extra metadata chips (e.g. assignee) inside the chips row below the title. */
134
134
  renderMetadataExtra?: (item: QueueItem) => React.ReactNode
135
135
  onScoreFeedback?: (type: "up" | "down", pills: string[], detail: string) => void
136
+ approveButtonIconUrl?: string
137
+ opportunityPreview?: OpportunityPreview
138
+ onRequestApproval?: () => Promise<void>
136
139
  }
137
140
 
138
141
  export function DetailView({
@@ -156,6 +159,9 @@ export function DetailView({
156
159
  renderDetailExtra,
157
160
  renderMetadataExtra,
158
161
  onScoreFeedback,
162
+ approveButtonIconUrl,
163
+ opportunityPreview,
164
+ onRequestApproval,
159
165
  }: DetailViewProps) {
160
166
  const [evidenceExpanded, setEvidenceExpanded] = React.useState(false)
161
167
  const [showTimeline, setShowTimeline] = React.useState(false)
@@ -202,6 +208,9 @@ export function DetailView({
202
208
  companyName={item.company}
203
209
  labels={signalLabels}
204
210
  hideApproveButton={hideApproveButton}
211
+ approveButtonIconUrl={approveButtonIconUrl}
212
+ opportunityPreview={opportunityPreview}
213
+ onRequestApproval={onRequestApproval}
205
214
  initialApprovalState={getSignalApprovalState?.(item)}
206
215
  onApprove={() => onSignalApprove?.(item)}
207
216
  onApproveFeedback={(reasons, detail) => {