@handled-ai/design-system 0.9.28 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/dist/components/account-contacts-popover.d.ts +22 -0
  2. package/dist/components/account-contacts-popover.js +180 -0
  3. package/dist/components/account-contacts-popover.js.map +1 -0
  4. package/dist/components/badge.d.ts +1 -1
  5. package/dist/components/button.d.ts +2 -2
  6. package/dist/components/compliance-badge.d.ts +10 -0
  7. package/dist/components/compliance-badge.js +95 -0
  8. package/dist/components/compliance-badge.js.map +1 -0
  9. package/dist/components/contact-chip.d.ts +12 -0
  10. package/dist/components/contact-chip.js +98 -0
  11. package/dist/components/contact-chip.js.map +1 -0
  12. package/dist/components/draft-feedback-inline.d.ts +11 -0
  13. package/dist/components/draft-feedback-inline.js +153 -0
  14. package/dist/components/draft-feedback-inline.js.map +1 -0
  15. package/dist/components/empty-state.d.ts +11 -0
  16. package/dist/components/empty-state.js +46 -0
  17. package/dist/components/empty-state.js.map +1 -0
  18. package/dist/components/filter-chip.d.ts +9 -0
  19. package/dist/components/filter-chip.js +67 -0
  20. package/dist/components/filter-chip.js.map +1 -0
  21. package/dist/components/inline-banner.d.ts +10 -0
  22. package/dist/components/inline-banner.js +97 -0
  23. package/dist/components/inline-banner.js.map +1 -0
  24. package/dist/components/kbd-hint.d.ts +5 -0
  25. package/dist/components/kbd-hint.js +51 -0
  26. package/dist/components/kbd-hint.js.map +1 -0
  27. package/dist/components/rich-text-toolbar.d.ts +9 -0
  28. package/dist/components/rich-text-toolbar.js +103 -0
  29. package/dist/components/rich-text-toolbar.js.map +1 -0
  30. package/dist/components/step-timeline.d.ts +19 -0
  31. package/dist/components/step-timeline.js +134 -0
  32. package/dist/components/step-timeline.js.map +1 -0
  33. package/dist/components/sticky-action-bar.d.ts +10 -0
  34. package/dist/components/sticky-action-bar.js +56 -0
  35. package/dist/components/sticky-action-bar.js.map +1 -0
  36. package/dist/components/suggested-actions.js +2 -304
  37. package/dist/components/suggested-actions.js.map +1 -1
  38. package/dist/components/switch.d.ts +6 -0
  39. package/dist/components/switch.js +66 -0
  40. package/dist/components/switch.js.map +1 -0
  41. package/dist/components/variable-autocomplete.d.ts +21 -0
  42. package/dist/components/variable-autocomplete.js +171 -0
  43. package/dist/components/variable-autocomplete.js.map +1 -0
  44. package/dist/index.d.ts +14 -1
  45. package/dist/index.js +17 -1
  46. package/dist/index.js.map +1 -1
  47. package/package.json +2 -1
  48. package/src/components/__tests__/compliance-badge.test.tsx +88 -0
  49. package/src/components/__tests__/contact-chip.test.tsx +88 -0
  50. package/src/components/__tests__/empty-state.test.tsx +76 -0
  51. package/src/components/__tests__/filter-chip.test.tsx +73 -0
  52. package/src/components/__tests__/inline-banner.test.tsx +110 -0
  53. package/src/components/__tests__/kbd-hint.test.tsx +29 -0
  54. package/src/components/__tests__/rich-text-toolbar.test.tsx +92 -0
  55. package/src/components/__tests__/step-timeline.test.tsx +174 -0
  56. package/src/components/__tests__/sticky-action-bar.test.tsx +52 -0
  57. package/src/components/__tests__/switch.test.tsx +39 -0
  58. package/src/components/__tests__/variable-autocomplete.test.tsx +155 -0
  59. package/src/components/account-contacts-popover.tsx +192 -0
  60. package/src/components/compliance-badge.tsx +68 -0
  61. package/src/components/contact-chip.tsx +68 -0
  62. package/src/components/draft-feedback-inline.tsx +193 -0
  63. package/src/components/empty-state.tsx +37 -0
  64. package/src/components/filter-chip.tsx +37 -0
  65. package/src/components/inline-banner.tsx +69 -0
  66. package/src/components/kbd-hint.tsx +21 -0
  67. package/src/components/rich-text-toolbar.tsx +90 -0
  68. package/src/components/step-timeline.tsx +149 -0
  69. package/src/components/sticky-action-bar.tsx +36 -0
  70. package/src/components/suggested-actions.tsx +2 -363
  71. package/src/components/switch.tsx +29 -0
  72. package/src/components/variable-autocomplete.tsx +178 -0
  73. package/src/index.ts +16 -1
  74. package/src/styles/globals.css +60 -0
@@ -0,0 +1,52 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import React from "react";
3
+ import { render, screen } from "@testing-library/react";
4
+ import { StickyActionBar } from "../sticky-action-bar";
5
+
6
+ describe("StickyActionBar", () => {
7
+ it("renders with data-slot='sticky-action-bar'", () => {
8
+ const { container } = render(<StickyActionBar />);
9
+ const el = container.querySelector('[data-slot="sticky-action-bar"]');
10
+ expect(el).not.toBeNull();
11
+ });
12
+
13
+ it("renders left slot content", () => {
14
+ render(<StickyActionBar left={<span>Left Content</span>} />);
15
+ expect(screen.getByText("Left Content")).toBeDefined();
16
+ });
17
+
18
+ it("renders right slot content", () => {
19
+ render(<StickyActionBar right={<span>Right Content</span>} />);
20
+ expect(screen.getByText("Right Content")).toBeDefined();
21
+ });
22
+
23
+ it("applies border-t class when bordered=true (default)", () => {
24
+ const { container } = render(<StickyActionBar />);
25
+ const el = container.querySelector('[data-slot="sticky-action-bar"]')!;
26
+ expect(el.classList.contains("border-t")).toBe(true);
27
+ });
28
+
29
+ it("does not apply border-t class when bordered=false", () => {
30
+ const { container } = render(<StickyActionBar bordered={false} />);
31
+ const el = container.querySelector('[data-slot="sticky-action-bar"]')!;
32
+ expect(el.classList.contains("border-t")).toBe(false);
33
+ });
34
+
35
+ it("does not render left wrapper when left not provided", () => {
36
+ const { container } = render(<StickyActionBar />);
37
+ const leftSlot = container.querySelector('[data-slot="sticky-action-bar-left"]');
38
+ expect(leftSlot).toBeNull();
39
+ });
40
+
41
+ it("does not render right wrapper when right not provided", () => {
42
+ const { container } = render(<StickyActionBar />);
43
+ const rightSlot = container.querySelector('[data-slot="sticky-action-bar-right"]');
44
+ expect(rightSlot).toBeNull();
45
+ });
46
+
47
+ it("merges custom className", () => {
48
+ const { container } = render(<StickyActionBar className="my-custom" />);
49
+ const el = container.querySelector('[data-slot="sticky-action-bar"]')!;
50
+ expect(el.classList.contains("my-custom")).toBe(true);
51
+ });
52
+ });
@@ -0,0 +1,39 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import React from "react";
3
+ import { render, fireEvent } from "@testing-library/react";
4
+ import { Switch } from "../switch";
5
+
6
+ describe("Switch", () => {
7
+ it("renders with data-slot='switch'", () => {
8
+ const { container } = render(<Switch />);
9
+ const el = container.querySelector('[data-slot="switch"]');
10
+ expect(el).not.toBeNull();
11
+ });
12
+
13
+ it("renders thumb with data-slot='switch-thumb'", () => {
14
+ const { container } = render(<Switch />);
15
+ const thumb = container.querySelector('[data-slot="switch-thumb"]');
16
+ expect(thumb).not.toBeNull();
17
+ });
18
+
19
+ it("toggles checked state on click", () => {
20
+ const { container } = render(<Switch defaultChecked={false} />);
21
+ const switchEl = container.querySelector('[data-slot="switch"]')!;
22
+ expect(switchEl.getAttribute("data-state")).toBe("unchecked");
23
+
24
+ fireEvent.click(switchEl);
25
+ expect(switchEl.getAttribute("data-state")).toBe("checked");
26
+ });
27
+
28
+ it("respects disabled prop", () => {
29
+ const { container } = render(<Switch disabled />);
30
+ const switchEl = container.querySelector('[data-slot="switch"]')!;
31
+ expect(switchEl).toHaveProperty("disabled", true);
32
+ });
33
+
34
+ it("merges custom className", () => {
35
+ const { container } = render(<Switch className="my-custom-class" />);
36
+ const switchEl = container.querySelector('[data-slot="switch"]')!;
37
+ expect(switchEl.classList.contains("my-custom-class")).toBe(true);
38
+ });
39
+ });
@@ -0,0 +1,155 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import React from "react";
3
+ import { render, screen, fireEvent } from "@testing-library/react";
4
+ import { VariableAutocomplete, type VariableGroup } from "../variable-autocomplete";
5
+
6
+ const sampleGroups: VariableGroup[] = [
7
+ {
8
+ label: "Contact",
9
+ variables: [
10
+ { name: "first_name", description: "Contact first name", source: "crm" },
11
+ { name: "last_name", description: "Contact last name", source: "crm" },
12
+ { name: "email", description: "Contact email" },
13
+ ],
14
+ },
15
+ {
16
+ label: "Company",
17
+ variables: [
18
+ { name: "company_name", source: "enrichment" },
19
+ { name: "company_size" },
20
+ ],
21
+ },
22
+ ];
23
+
24
+ const defaultProps = {
25
+ groups: sampleGroups,
26
+ onSelect: vi.fn(),
27
+ onClose: vi.fn(),
28
+ };
29
+
30
+ describe("VariableAutocomplete", () => {
31
+ it("renders with data-slot='variable-autocomplete'", () => {
32
+ const { container } = render(<VariableAutocomplete {...defaultProps} />);
33
+ const el = container.querySelector('[data-slot="variable-autocomplete"]');
34
+ expect(el).not.toBeNull();
35
+ });
36
+
37
+ it("renders header with 'Insert variable' text", () => {
38
+ render(<VariableAutocomplete {...defaultProps} />);
39
+ expect(screen.getByText("Insert variable")).toBeDefined();
40
+ });
41
+
42
+ it("renders variable items with monospace names wrapped in {{ }}", () => {
43
+ render(<VariableAutocomplete {...defaultProps} />);
44
+ expect(screen.getByText("{{first_name}}")).toBeDefined();
45
+ expect(screen.getByText("{{last_name}}")).toBeDefined();
46
+ expect(screen.getByText("{{email}}")).toBeDefined();
47
+ });
48
+
49
+ it("renders variable description when provided", () => {
50
+ render(<VariableAutocomplete {...defaultProps} />);
51
+ expect(screen.getByText("Contact first name")).toBeDefined();
52
+ expect(screen.getByText("Contact last name")).toBeDefined();
53
+ });
54
+
55
+ it("renders variable source badge when provided", () => {
56
+ render(<VariableAutocomplete {...defaultProps} />);
57
+ const crmBadges = screen.getAllByText("crm");
58
+ expect(crmBadges.length).toBe(2);
59
+ expect(screen.getByText("enrichment")).toBeDefined();
60
+ });
61
+
62
+ it("renders group labels", () => {
63
+ render(<VariableAutocomplete {...defaultProps} />);
64
+ expect(screen.getByText("Contact")).toBeDefined();
65
+ expect(screen.getByText("Company")).toBeDefined();
66
+ });
67
+
68
+ it("filters variables by name (case-insensitive startsWith)", () => {
69
+ const { container } = render(
70
+ <VariableAutocomplete {...defaultProps} filter="FIRST" />
71
+ );
72
+ const items = container.querySelectorAll('[data-slot="variable-autocomplete-item"]');
73
+ expect(items.length).toBe(1);
74
+ expect(screen.getByText("{{first_name}}")).toBeDefined();
75
+ });
76
+
77
+ it("returns null when no matches", () => {
78
+ const { container } = render(
79
+ <VariableAutocomplete {...defaultProps} filter="zzz_no_match" />
80
+ );
81
+ expect(container.firstChild).toBeNull();
82
+ });
83
+
84
+ it("fires onSelect on item click", () => {
85
+ const onSelect = vi.fn();
86
+ const { container } = render(
87
+ <VariableAutocomplete {...defaultProps} onSelect={onSelect} />
88
+ );
89
+ const item = container.querySelector('[data-slot="variable-autocomplete-item"]')!;
90
+ fireEvent.click(item);
91
+ expect(onSelect).toHaveBeenCalledTimes(1);
92
+ expect(onSelect).toHaveBeenCalledWith({
93
+ name: "first_name",
94
+ description: "Contact first name",
95
+ source: "crm",
96
+ });
97
+ });
98
+
99
+ it("items use preventDefault on mousedown", () => {
100
+ const { container } = render(<VariableAutocomplete {...defaultProps} />);
101
+ const item = container.querySelector('[data-slot="variable-autocomplete-item"]')!;
102
+ const event = new MouseEvent("mousedown", { bubbles: true, cancelable: true });
103
+ const preventDefaultSpy = vi.spyOn(event, "preventDefault");
104
+ item.dispatchEvent(event);
105
+ expect(preventDefaultSpy).toHaveBeenCalled();
106
+ });
107
+
108
+ it("fires onClose on Escape key", () => {
109
+ const onClose = vi.fn();
110
+ render(<VariableAutocomplete {...defaultProps} onClose={onClose} />);
111
+ const event = new KeyboardEvent("keydown", { key: "Escape", bubbles: true });
112
+ document.dispatchEvent(event);
113
+ expect(onClose).toHaveBeenCalledTimes(1);
114
+ });
115
+
116
+ it("fires onClose on outside mousedown", () => {
117
+ const onClose = vi.fn();
118
+ render(<VariableAutocomplete {...defaultProps} onClose={onClose} />);
119
+ const event = new MouseEvent("mousedown", { bubbles: true });
120
+ document.dispatchEvent(event);
121
+ expect(onClose).toHaveBeenCalledTimes(1);
122
+ });
123
+
124
+ it("respects maxResults prop", () => {
125
+ const { container } = render(
126
+ <VariableAutocomplete {...defaultProps} maxResults={2} />
127
+ );
128
+ const items = container.querySelectorAll('[data-slot="variable-autocomplete-item"]');
129
+ expect(items.length).toBe(2);
130
+ });
131
+
132
+ it("has listbox role on the scrollable list", () => {
133
+ const { container } = render(<VariableAutocomplete {...defaultProps} />);
134
+ const listbox = container.querySelector('[role="listbox"]');
135
+ expect(listbox).not.toBeNull();
136
+ expect(listbox!.getAttribute("aria-label")).toBe("Variables");
137
+ });
138
+
139
+ it("items are buttons with role='option'", () => {
140
+ const { container } = render(<VariableAutocomplete {...defaultProps} />);
141
+ const items = container.querySelectorAll('[data-slot="variable-autocomplete-item"]');
142
+ items.forEach((item) => {
143
+ expect(item.tagName).toBe("BUTTON");
144
+ expect(item.getAttribute("role")).toBe("option");
145
+ });
146
+ });
147
+
148
+ it("renders group containers with role='group'", () => {
149
+ const { container } = render(<VariableAutocomplete {...defaultProps} />);
150
+ const groups = container.querySelectorAll('[role="group"]');
151
+ expect(groups.length).toBe(2);
152
+ expect(groups[0].getAttribute("aria-label")).toBe("Contact");
153
+ expect(groups[1].getAttribute("aria-label")).toBe("Company");
154
+ });
155
+ });
@@ -0,0 +1,192 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import {
5
+ Clock,
6
+ ExternalLink,
7
+ } from "lucide-react"
8
+ import type { SuggestedContact, SuggestedActionsIconMap } from "./suggested-actions"
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // BrandIcon
12
+ // ---------------------------------------------------------------------------
13
+
14
+ export function BrandIcon({ src, alt, className }: { src: string; alt: string; className?: string }) {
15
+ return (
16
+ <img
17
+ src={src}
18
+ alt={alt}
19
+ className={`${className ?? ""} object-contain`}
20
+ draggable={false}
21
+ />
22
+ )
23
+ }
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // AccountContactsPopover
27
+ // ---------------------------------------------------------------------------
28
+
29
+ export interface AccountContactsPopoverProps {
30
+ contacts: SuggestedContact[]
31
+ onSelect: (contact: SuggestedContact) => void
32
+ onSelectTo?: (contact: SuggestedContact) => void
33
+ onSelectCc?: (contact: SuggestedContact) => void
34
+ onSelectBcc?: (contact: SuggestedContact) => void
35
+ onViewAll?: () => void
36
+ onOpenRecentActivity?: () => void
37
+ trigger: React.ReactNode
38
+ iconMap?: SuggestedActionsIconMap
39
+ }
40
+
41
+ export function AccountContactsPopover({
42
+ contacts,
43
+ onSelect,
44
+ onSelectTo,
45
+ onSelectCc,
46
+ onSelectBcc,
47
+ onViewAll,
48
+ onOpenRecentActivity,
49
+ trigger,
50
+ iconMap,
51
+ }: AccountContactsPopoverProps) {
52
+ const [open, setOpen] = React.useState(false)
53
+ const triggerRef = React.useRef<HTMLDivElement>(null)
54
+ const [popoverStyle, setPopoverStyle] = React.useState<React.CSSProperties>({})
55
+
56
+ React.useEffect(() => {
57
+ if (open && triggerRef.current) {
58
+ const rect = triggerRef.current.getBoundingClientRect()
59
+ const popoverWidth = Math.min(448, window.innerWidth - 32)
60
+ let left = rect.right - popoverWidth
61
+ if (left < 16) left = 16
62
+ if (left + popoverWidth > window.innerWidth - 16) left = window.innerWidth - 16 - popoverWidth
63
+ setPopoverStyle({ position: "fixed", top: rect.bottom + 4, left })
64
+ }
65
+ }, [open])
66
+
67
+ return (
68
+ <div>
69
+ <div ref={triggerRef} onClick={() => setOpen(!open)}>{trigger}</div>
70
+ {open && (
71
+ <>
72
+ <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">
74
+ <div className="px-3 py-1.5 text-[11px] font-medium text-muted-foreground/60 uppercase tracking-wide">
75
+ Account Contacts
76
+ </div>
77
+ <div className="max-h-48 overflow-y-auto">
78
+ {contacts.map((c, i) => (
79
+ <div
80
+ key={i}
81
+ role="button"
82
+ onClick={() => { (onSelectTo ?? onSelect)(c); setOpen(false) }}
83
+ className="flex items-center gap-3 w-full px-3 py-2 text-left hover:bg-muted/50 transition-colors cursor-pointer"
84
+ >
85
+ <div className="w-7 h-7 rounded-full bg-muted flex items-center justify-center text-[10px] font-medium text-muted-foreground shrink-0">
86
+ {c.name.split(" ").map((n) => n[0]).join("")}
87
+ </div>
88
+ <div className="flex-1 min-w-0 overflow-hidden">
89
+ <div className="truncate text-sm font-medium text-foreground">{c.name}</div>
90
+ <div className="truncate text-xs text-muted-foreground leading-tight">
91
+ {c.role} · {c.email ?? c.emails?.[0] ?? c.phone ?? c.phones?.[0] ?? ""}
92
+ </div>
93
+ {c.lastActivity && (
94
+ <button
95
+ type="button"
96
+ onClick={(e) => {
97
+ e.stopPropagation()
98
+ onOpenRecentActivity?.()
99
+ setOpen(false)
100
+ }}
101
+ className="mt-1.5 flex max-w-full items-center gap-1.5 overflow-hidden rounded-md border border-border/70 bg-muted/30 px-2 py-1 text-[11px] text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
102
+ >
103
+ <Clock className="w-3 h-3 shrink-0" />
104
+ <span className="shrink-0 font-medium">Last activity</span>
105
+ <span className="shrink-0 text-muted-foreground/60">·</span>
106
+ <span className="shrink-0">{c.lastActivity.date}</span>
107
+ <span className="shrink-0 text-muted-foreground/60">·</span>
108
+ <span className="truncate capitalize">{c.lastActivity.type}</span>
109
+ </button>
110
+ )}
111
+ </div>
112
+ <div className="ml-2 flex items-center gap-1.5 shrink-0">
113
+ {onSelectTo && (
114
+ <button
115
+ type="button"
116
+ onClick={(e) => {
117
+ e.stopPropagation()
118
+ onSelectTo(c)
119
+ setOpen(false)
120
+ }}
121
+ className="h-6 rounded border border-border bg-background px-1.5 text-[10px] text-muted-foreground hover:text-foreground hover:bg-muted/40"
122
+ >
123
+ To
124
+ </button>
125
+ )}
126
+ {onSelectCc && (
127
+ <button
128
+ type="button"
129
+ onClick={(e) => {
130
+ e.stopPropagation()
131
+ onSelectCc(c)
132
+ setOpen(false)
133
+ }}
134
+ className="h-6 rounded border border-border bg-background px-1.5 text-[10px] text-muted-foreground hover:text-foreground hover:bg-muted/40"
135
+ >
136
+ Cc
137
+ </button>
138
+ )}
139
+ {onSelectBcc && (
140
+ <button
141
+ type="button"
142
+ onClick={(e) => {
143
+ e.stopPropagation()
144
+ onSelectBcc(c)
145
+ setOpen(false)
146
+ }}
147
+ className="h-6 rounded border border-border bg-background px-1.5 text-[10px] text-muted-foreground hover:text-foreground hover:bg-muted/40"
148
+ >
149
+ Bcc
150
+ </button>
151
+ )}
152
+ <button
153
+ type="button"
154
+ onClick={(e) => {
155
+ e.stopPropagation()
156
+ if (c.salesforceUrl) {
157
+ window.open(c.salesforceUrl, "_blank", "noopener,noreferrer")
158
+ } else {
159
+ onViewAll?.()
160
+ }
161
+ }}
162
+ className="h-7 w-7 inline-flex items-center justify-center rounded-md border border-border bg-background hover:bg-muted/40 transition-colors shrink-0"
163
+ aria-label={`Open ${c.name} in Salesforce`}
164
+ >
165
+ {iconMap?.salesforce ? (
166
+ <BrandIcon src={iconMap.salesforce} alt="Salesforce" className="w-3.5 h-3.5" />
167
+ ) : (
168
+ <ExternalLink className="w-3.5 h-3.5 text-muted-foreground" />
169
+ )}
170
+ </button>
171
+ </div>
172
+ </div>
173
+ ))}
174
+ </div>
175
+ {onViewAll && (
176
+ <>
177
+ <div className="h-px bg-border mx-3 my-1" />
178
+ <button
179
+ onClick={() => { onViewAll(); setOpen(false) }}
180
+ className="flex items-center gap-2 w-full px-3 py-2 text-left text-xs text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
181
+ >
182
+ <ExternalLink className="w-3 h-3" />
183
+ View all contacts
184
+ </button>
185
+ </>
186
+ )}
187
+ </div>
188
+ </>
189
+ )}
190
+ </div>
191
+ )
192
+ }
@@ -0,0 +1,68 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { Check, Clock, AlertTriangle, Minus } from "lucide-react"
5
+
6
+ import { cn } from "../lib/utils"
7
+
8
+ type ComplianceStatus = "verified" | "pending" | "changed_since_verified" | "never_verified"
9
+
10
+ interface ComplianceBadgeProps extends React.HTMLAttributes<HTMLElement> {
11
+ status: ComplianceStatus
12
+ variant?: "line" | "pill"
13
+ }
14
+
15
+ const statusConfig = {
16
+ verified: {
17
+ line: { icon: Check, label: "Compliance verified", classes: "text-status-active-fg" },
18
+ pill: { icon: Check, label: "Verified", classes: "bg-status-active-bg text-status-active-fg" },
19
+ },
20
+ pending: {
21
+ line: { icon: Clock, label: "Pending compliance review", classes: "text-status-pending-fg" },
22
+ pill: { icon: AlertTriangle, label: "Pending review", classes: "bg-status-pending-bg text-status-pending-fg" },
23
+ },
24
+ changed_since_verified: {
25
+ line: { icon: AlertTriangle, label: "Changed since last verification", classes: "text-status-warning-fg" },
26
+ pill: { icon: AlertTriangle, label: "Changed", classes: "bg-status-warning-bg text-status-warning-fg" },
27
+ },
28
+ never_verified: {
29
+ line: { icon: Minus, label: "Never verified", classes: "text-muted-foreground" },
30
+ pill: { icon: Minus, label: "Never verified", classes: "bg-muted text-muted-foreground" },
31
+ },
32
+ }
33
+
34
+ function ComplianceBadge({ status, variant = "line", className, ...rest }: ComplianceBadgeProps) {
35
+ const config = statusConfig[status][variant]
36
+ const Icon = config.icon
37
+ const iconSize = variant === "line" ? 12 : 10
38
+
39
+ if (variant === "pill") {
40
+ return (
41
+ <span
42
+ data-slot="compliance-badge"
43
+ data-status={status}
44
+ data-variant="pill"
45
+ className={cn("text-[10px] px-2 py-0.5 rounded-full inline-flex w-fit items-center gap-1", config.classes, className)}
46
+ {...rest}
47
+ >
48
+ <Icon size={iconSize} />
49
+ <span>{config.label}</span>
50
+ </span>
51
+ )
52
+ }
53
+
54
+ return (
55
+ <div
56
+ data-slot="compliance-badge"
57
+ data-status={status}
58
+ data-variant="line"
59
+ className={cn("text-xs inline-flex w-fit items-center gap-1.5", config.classes, className)}
60
+ {...rest}
61
+ >
62
+ <Icon size={iconSize} />
63
+ <span>{config.label}</span>
64
+ </div>
65
+ )
66
+ }
67
+
68
+ export { ComplianceBadge, type ComplianceBadgeProps, type ComplianceStatus }
@@ -0,0 +1,68 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { X } from "lucide-react"
5
+
6
+ import { cn } from "../lib/utils"
7
+
8
+ interface ContactChipProps extends React.HTMLAttributes<HTMLSpanElement> {
9
+ name: string
10
+ email?: string
11
+ verified?: boolean
12
+ onConfirm?: () => void
13
+ onRemove?: () => void
14
+ }
15
+
16
+ function ContactChip({
17
+ name,
18
+ email,
19
+ verified = true,
20
+ onConfirm,
21
+ onRemove,
22
+ className,
23
+ ...rest
24
+ }: ContactChipProps) {
25
+ const isVerified = verified !== false
26
+
27
+ return (
28
+ <span
29
+ data-slot="contact-chip"
30
+ data-verified={isVerified ? "true" : "false"}
31
+ className={cn(
32
+ "inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs",
33
+ isVerified
34
+ ? "border border-border bg-background"
35
+ : "border border-status-warning-border bg-status-warning-bg",
36
+ className
37
+ )}
38
+ {...rest}
39
+ >
40
+ <span className="font-medium">{name}</span>
41
+ {email && <span className="text-muted-foreground">{email}</span>}
42
+ {!isVerified && onConfirm && (
43
+ <button
44
+ type="button"
45
+ data-slot="contact-chip-confirm"
46
+ onClick={onConfirm}
47
+ aria-label={`Confirm ${name}`}
48
+ className="text-[10px] px-1.5 py-0 rounded bg-status-warning-border/50 text-status-warning-fg hover:bg-status-warning-border cursor-pointer font-medium ml-1"
49
+ >
50
+ Confirm
51
+ </button>
52
+ )}
53
+ {onRemove && (
54
+ <button
55
+ type="button"
56
+ data-slot="contact-chip-remove"
57
+ onClick={onRemove}
58
+ aria-label={`Remove ${name}`}
59
+ className="inline-flex size-4 items-center justify-center rounded text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
60
+ >
61
+ <X size={12} />
62
+ </button>
63
+ )}
64
+ </span>
65
+ )
66
+ }
67
+
68
+ export { ContactChip, type ContactChipProps }