@handled-ai/design-system 0.9.27 → 0.10.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/dist/components/badge.d.ts +1 -1
- package/dist/components/button.d.ts +1 -1
- package/dist/components/compliance-badge.d.ts +10 -0
- package/dist/components/compliance-badge.js +95 -0
- package/dist/components/compliance-badge.js.map +1 -0
- package/dist/components/contact-chip.d.ts +12 -0
- package/dist/components/contact-chip.js +98 -0
- package/dist/components/contact-chip.js.map +1 -0
- package/dist/components/empty-state.d.ts +11 -0
- package/dist/components/empty-state.js +46 -0
- package/dist/components/empty-state.js.map +1 -0
- package/dist/components/filter-chip.d.ts +9 -0
- package/dist/components/filter-chip.js +67 -0
- package/dist/components/filter-chip.js.map +1 -0
- package/dist/components/inline-banner.d.ts +10 -0
- package/dist/components/inline-banner.js +97 -0
- package/dist/components/inline-banner.js.map +1 -0
- package/dist/components/kbd-hint.d.ts +5 -0
- package/dist/components/kbd-hint.js +51 -0
- package/dist/components/kbd-hint.js.map +1 -0
- package/dist/components/rich-text-toolbar.d.ts +9 -0
- package/dist/components/rich-text-toolbar.js +103 -0
- package/dist/components/rich-text-toolbar.js.map +1 -0
- package/dist/components/signal-feedback-inline.d.ts +13 -2
- package/dist/components/signal-feedback-inline.js +33 -4
- package/dist/components/signal-feedback-inline.js.map +1 -1
- package/dist/components/step-timeline.d.ts +19 -0
- package/dist/components/step-timeline.js +134 -0
- package/dist/components/step-timeline.js.map +1 -0
- package/dist/components/sticky-action-bar.d.ts +10 -0
- package/dist/components/sticky-action-bar.js +56 -0
- package/dist/components/sticky-action-bar.js.map +1 -0
- package/dist/components/switch.d.ts +6 -0
- package/dist/components/switch.js +66 -0
- package/dist/components/switch.js.map +1 -0
- package/dist/components/tabs.d.ts +1 -1
- package/dist/components/variable-autocomplete.d.ts +21 -0
- package/dist/components/variable-autocomplete.js +171 -0
- package/dist/components/variable-autocomplete.js.map +1 -0
- package/dist/index.d.ts +12 -1
- package/dist/index.js +12 -1
- package/dist/index.js.map +1 -1
- package/dist/prototype/prototype-config.d.ts +2 -1
- package/dist/prototype/prototype-inbox-view.d.ts +1 -1
- package/dist/prototype/prototype-inbox-view.js +1 -1
- package/dist/prototype/prototype-inbox-view.js.map +1 -1
- package/package.json +2 -1
- package/src/components/__tests__/compliance-badge.test.tsx +88 -0
- package/src/components/__tests__/contact-chip.test.tsx +88 -0
- package/src/components/__tests__/empty-state.test.tsx +76 -0
- package/src/components/__tests__/filter-chip.test.tsx +73 -0
- package/src/components/__tests__/inline-banner.test.tsx +110 -0
- package/src/components/__tests__/kbd-hint.test.tsx +29 -0
- package/src/components/__tests__/rich-text-toolbar.test.tsx +92 -0
- package/src/components/__tests__/step-timeline.test.tsx +174 -0
- package/src/components/__tests__/sticky-action-bar.test.tsx +52 -0
- package/src/components/__tests__/switch.test.tsx +39 -0
- package/src/components/__tests__/variable-autocomplete.test.tsx +155 -0
- package/src/components/compliance-badge.tsx +68 -0
- package/src/components/contact-chip.tsx +68 -0
- package/src/components/empty-state.tsx +37 -0
- package/src/components/filter-chip.tsx +37 -0
- package/src/components/inline-banner.tsx +69 -0
- package/src/components/kbd-hint.tsx +21 -0
- package/src/components/rich-text-toolbar.tsx +90 -0
- package/src/components/signal-feedback-inline.tsx +51 -5
- package/src/components/step-timeline.tsx +149 -0
- package/src/components/sticky-action-bar.tsx +36 -0
- package/src/components/switch.tsx +29 -0
- package/src/components/variable-autocomplete.tsx +178 -0
- package/src/index.ts +12 -1
- package/src/prototype/prototype-config.ts +2 -1
- package/src/prototype/prototype-inbox-view.tsx +2 -2
- package/src/styles/globals.css +60 -0
|
@@ -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,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 }
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
|
|
3
|
+
import { cn } from "../lib/utils"
|
|
4
|
+
|
|
5
|
+
interface EmptyStateProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
6
|
+
icon?: React.ReactNode
|
|
7
|
+
title?: string
|
|
8
|
+
description: string
|
|
9
|
+
action?: React.ReactNode
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function EmptyState({ icon, title, description, action, className, ...rest }: EmptyStateProps) {
|
|
13
|
+
return (
|
|
14
|
+
<div data-slot="empty-state" className={cn("flex flex-col items-center justify-center py-24 gap-3", className)} {...rest}>
|
|
15
|
+
{icon && (
|
|
16
|
+
<div data-slot="empty-state-icon" className="text-muted-foreground/30 [&>svg]:w-12 [&>svg]:h-12">
|
|
17
|
+
{icon}
|
|
18
|
+
</div>
|
|
19
|
+
)}
|
|
20
|
+
{title && (
|
|
21
|
+
<p data-slot="empty-state-title" className="text-sm font-medium text-muted-foreground">
|
|
22
|
+
{title}
|
|
23
|
+
</p>
|
|
24
|
+
)}
|
|
25
|
+
<p data-slot="empty-state-description" className="text-xs text-muted-foreground">
|
|
26
|
+
{description}
|
|
27
|
+
</p>
|
|
28
|
+
{action && (
|
|
29
|
+
<div data-slot="empty-state-action" className="mt-3">
|
|
30
|
+
{action}
|
|
31
|
+
</div>
|
|
32
|
+
)}
|
|
33
|
+
</div>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export { EmptyState, type EmptyStateProps }
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { Filter, ChevronDown } from "lucide-react"
|
|
5
|
+
|
|
6
|
+
import { cn } from "../lib/utils"
|
|
7
|
+
|
|
8
|
+
interface FilterChipProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
9
|
+
icon?: React.ReactNode
|
|
10
|
+
active?: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function FilterChip({ icon, children, onClick, active, className, ...rest }: FilterChipProps) {
|
|
14
|
+
return (
|
|
15
|
+
<button
|
|
16
|
+
type="button"
|
|
17
|
+
data-slot="filter-chip"
|
|
18
|
+
data-active={active ? "" : undefined}
|
|
19
|
+
aria-pressed={active}
|
|
20
|
+
onClick={onClick}
|
|
21
|
+
className={cn(
|
|
22
|
+
"text-[11px] px-2.5 py-1 rounded-md border border-border",
|
|
23
|
+
"text-muted-foreground hover:bg-muted/30 cursor-pointer",
|
|
24
|
+
"inline-flex items-center gap-1.5 transition-colors",
|
|
25
|
+
"data-[active]:border-foreground/20 data-[active]:text-foreground",
|
|
26
|
+
className
|
|
27
|
+
)}
|
|
28
|
+
{...rest}
|
|
29
|
+
>
|
|
30
|
+
{icon ?? <Filter size={10} />}
|
|
31
|
+
{children}
|
|
32
|
+
<ChevronDown size={10} />
|
|
33
|
+
</button>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export { FilterChip, type FilterChipProps }
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import { Info, AlertTriangle, ShieldAlert, X } from "lucide-react"
|
|
5
|
+
|
|
6
|
+
import { cn } from "../lib/utils"
|
|
7
|
+
|
|
8
|
+
interface InlineBannerProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
9
|
+
variant?: "info" | "warning" | "destructive"
|
|
10
|
+
icon?: React.ReactNode
|
|
11
|
+
onDismiss?: () => void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const variantConfig = {
|
|
15
|
+
info: {
|
|
16
|
+
icon: Info,
|
|
17
|
+
classes: "bg-status-info-bg border-status-info-border text-status-info-fg",
|
|
18
|
+
},
|
|
19
|
+
warning: {
|
|
20
|
+
icon: AlertTriangle,
|
|
21
|
+
classes: "bg-status-warning-bg border-status-warning-border text-status-warning-fg",
|
|
22
|
+
},
|
|
23
|
+
destructive: {
|
|
24
|
+
icon: ShieldAlert,
|
|
25
|
+
classes: "bg-status-destructive-bg border-status-destructive-border text-status-destructive-fg",
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function InlineBanner({
|
|
30
|
+
variant = "warning",
|
|
31
|
+
icon,
|
|
32
|
+
children,
|
|
33
|
+
onDismiss,
|
|
34
|
+
className,
|
|
35
|
+
...rest
|
|
36
|
+
}: InlineBannerProps) {
|
|
37
|
+
const config = variantConfig[variant]
|
|
38
|
+
const DefaultIcon = config.icon
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div
|
|
42
|
+
data-slot="inline-banner"
|
|
43
|
+
data-variant={variant}
|
|
44
|
+
role={variant === "destructive" || variant === "warning" ? "alert" : "status"}
|
|
45
|
+
className={cn("rounded-lg border px-3 py-2 flex items-center gap-2", config.classes, className)}
|
|
46
|
+
{...rest}
|
|
47
|
+
>
|
|
48
|
+
<span data-slot="inline-banner-icon" className="shrink-0">
|
|
49
|
+
{icon ?? <DefaultIcon size={14} />}
|
|
50
|
+
</span>
|
|
51
|
+
<div data-slot="inline-banner-content" className="flex-1 min-w-0">
|
|
52
|
+
{children}
|
|
53
|
+
</div>
|
|
54
|
+
{onDismiss && (
|
|
55
|
+
<button
|
|
56
|
+
type="button"
|
|
57
|
+
aria-label="Dismiss"
|
|
58
|
+
data-slot="inline-banner-dismiss"
|
|
59
|
+
onClick={onDismiss}
|
|
60
|
+
className="shrink-0 p-0.5 rounded hover:bg-black/5 transition-colors"
|
|
61
|
+
>
|
|
62
|
+
<X size={14} className="opacity-60" />
|
|
63
|
+
</button>
|
|
64
|
+
)}
|
|
65
|
+
</div>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export { InlineBanner, type InlineBannerProps }
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import * as React from "react"
|
|
2
|
+
|
|
3
|
+
import { cn } from "../lib/utils"
|
|
4
|
+
|
|
5
|
+
function KbdHint({
|
|
6
|
+
className,
|
|
7
|
+
...props
|
|
8
|
+
}: React.ComponentProps<"kbd">) {
|
|
9
|
+
return (
|
|
10
|
+
<kbd
|
|
11
|
+
data-slot="kbd-hint"
|
|
12
|
+
className={cn(
|
|
13
|
+
"inline-block text-[10px] leading-none text-muted-foreground/40 font-sans",
|
|
14
|
+
className
|
|
15
|
+
)}
|
|
16
|
+
{...props}
|
|
17
|
+
/>
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export { KbdHint }
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import type { LucideIcon } from "lucide-react"
|
|
5
|
+
import { Undo2, Redo2, Bold, Italic, Underline, AlignLeft, List, Trash2, ChevronDown } from "lucide-react"
|
|
6
|
+
|
|
7
|
+
import { cn } from "../lib/utils"
|
|
8
|
+
|
|
9
|
+
type RichTextAction =
|
|
10
|
+
| "undo" | "redo"
|
|
11
|
+
| "font"
|
|
12
|
+
| "bold" | "italic" | "underline"
|
|
13
|
+
| "align" | "list"
|
|
14
|
+
| "delete"
|
|
15
|
+
|
|
16
|
+
interface RichTextToolbarProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
17
|
+
onAction?: (action: RichTextAction) => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function ToolbarButton({
|
|
21
|
+
action,
|
|
22
|
+
icon: Icon,
|
|
23
|
+
label,
|
|
24
|
+
extraClassName,
|
|
25
|
+
onAction,
|
|
26
|
+
}: {
|
|
27
|
+
action: RichTextAction
|
|
28
|
+
icon: LucideIcon
|
|
29
|
+
label: string
|
|
30
|
+
extraClassName?: string
|
|
31
|
+
onAction?: (action: RichTextAction) => void
|
|
32
|
+
}) {
|
|
33
|
+
return (
|
|
34
|
+
<button
|
|
35
|
+
type="button"
|
|
36
|
+
data-slot="rich-text-toolbar-button"
|
|
37
|
+
onClick={() => onAction?.(action)}
|
|
38
|
+
aria-label={label}
|
|
39
|
+
className={cn("p-1.5 rounded hover:bg-muted/50 cursor-pointer text-muted-foreground", extraClassName)}
|
|
40
|
+
>
|
|
41
|
+
<Icon size={14} />
|
|
42
|
+
</button>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function RichTextToolbar({ onAction, className, ...rest }: RichTextToolbarProps) {
|
|
47
|
+
return (
|
|
48
|
+
<div
|
|
49
|
+
data-slot="rich-text-toolbar"
|
|
50
|
+
role="toolbar"
|
|
51
|
+
aria-label="Rich text formatting"
|
|
52
|
+
className={cn("px-3 py-1.5 flex items-center justify-between", className)}
|
|
53
|
+
{...rest}
|
|
54
|
+
>
|
|
55
|
+
<div className="flex items-center gap-0.5">
|
|
56
|
+
<ToolbarButton action="undo" icon={Undo2} label="Undo" onAction={onAction} />
|
|
57
|
+
<ToolbarButton action="redo" icon={Redo2} label="Redo" onAction={onAction} />
|
|
58
|
+
|
|
59
|
+
<div className="w-px h-4 bg-border mx-1" aria-hidden="true" />
|
|
60
|
+
|
|
61
|
+
<button
|
|
62
|
+
type="button"
|
|
63
|
+
data-slot="rich-text-toolbar-button"
|
|
64
|
+
onClick={() => onAction?.("font")}
|
|
65
|
+
aria-label="Font family"
|
|
66
|
+
aria-haspopup="true"
|
|
67
|
+
className="text-[11px] text-muted-foreground px-1.5 py-0.5 rounded hover:bg-muted/50 cursor-pointer flex items-center gap-1"
|
|
68
|
+
>
|
|
69
|
+
Sans Serif
|
|
70
|
+
<ChevronDown size={10} />
|
|
71
|
+
</button>
|
|
72
|
+
|
|
73
|
+
<div className="w-px h-4 bg-border mx-1" aria-hidden="true" />
|
|
74
|
+
|
|
75
|
+
<ToolbarButton action="bold" icon={Bold} label="Bold" onAction={onAction} />
|
|
76
|
+
<ToolbarButton action="italic" icon={Italic} label="Italic" onAction={onAction} />
|
|
77
|
+
<ToolbarButton action="underline" icon={Underline} label="Underline" onAction={onAction} />
|
|
78
|
+
|
|
79
|
+
<div className="w-px h-4 bg-border mx-1" aria-hidden="true" />
|
|
80
|
+
|
|
81
|
+
<ToolbarButton action="align" icon={AlignLeft} label="Align left" onAction={onAction} />
|
|
82
|
+
<ToolbarButton action="list" icon={List} label="List" onAction={onAction} />
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
<ToolbarButton action="delete" icon={Trash2} label="Delete" extraClassName="hover:text-destructive" onAction={onAction} />
|
|
86
|
+
</div>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export { RichTextToolbar, type RichTextToolbarProps, type RichTextAction }
|
|
@@ -71,7 +71,7 @@ const approveReasons = [
|
|
|
71
71
|
"Actionable",
|
|
72
72
|
]
|
|
73
73
|
|
|
74
|
-
type ApprovalState = "pending" | "confirming" | "approving-feedback" | "dismissing" | "approved" | "dismissed" | "auto-approved"
|
|
74
|
+
type ApprovalState = "pending" | "confirming" | "creating" | "approving-feedback" | "dismissing" | "approved" | "dismissed" | "auto-approved"
|
|
75
75
|
|
|
76
76
|
interface SignalApprovalLabels {
|
|
77
77
|
approveButton?: string
|
|
@@ -82,6 +82,8 @@ interface SignalApprovalLabels {
|
|
|
82
82
|
confirmPrompt?: string
|
|
83
83
|
dismissPrompt?: string
|
|
84
84
|
feedbackPrompt?: string
|
|
85
|
+
/** Label shown while the approve action is in progress (e.g. "Creating Opportunity..."). */
|
|
86
|
+
creatingStatus?: string
|
|
85
87
|
}
|
|
86
88
|
|
|
87
89
|
const DEFAULT_LABELS: Required<SignalApprovalLabels> = {
|
|
@@ -93,6 +95,7 @@ const DEFAULT_LABELS: Required<SignalApprovalLabels> = {
|
|
|
93
95
|
confirmPrompt: "This will approve this action for",
|
|
94
96
|
dismissPrompt: "What\u2019s the issue with this action?",
|
|
95
97
|
feedbackPrompt: "Quick feedback \u2014 what made this action useful?",
|
|
98
|
+
creatingStatus: "Creating\u2026",
|
|
96
99
|
}
|
|
97
100
|
|
|
98
101
|
interface SignalApprovalContextValue {
|
|
@@ -128,7 +131,16 @@ interface RootProps {
|
|
|
128
131
|
labels?: SignalApprovalLabels
|
|
129
132
|
/** When true, the approve/create-opportunity button is hidden but the dismiss button remains. */
|
|
130
133
|
hideApproveButton?: boolean
|
|
131
|
-
|
|
134
|
+
/**
|
|
135
|
+
* Called when the user confirms the approval action.
|
|
136
|
+
*
|
|
137
|
+
* - If the callback returns `void` (or `undefined`), the component transitions
|
|
138
|
+
* directly to the feedback step (backward-compatible behavior).
|
|
139
|
+
* - If the callback returns a `Promise<boolean>`, the component shows a
|
|
140
|
+
* "creating" loading state while the promise is pending. On `true` it
|
|
141
|
+
* transitions to the feedback step; on `false` it reverts to "pending".
|
|
142
|
+
*/
|
|
143
|
+
onApprove?: () => void | Promise<boolean>
|
|
132
144
|
onApproveFeedback?: (reasons: string[], detail: string) => void
|
|
133
145
|
onDismiss?: (reasons: string[], detail: string, subReason?: string) => void
|
|
134
146
|
}
|
|
@@ -137,6 +149,13 @@ function Root({ children, companyName, opportunityUrl, scheduledTime, initialApp
|
|
|
137
149
|
const labels = React.useMemo(() => ({ ...DEFAULT_LABELS, ...labelOverrides }), [labelOverrides])
|
|
138
150
|
const [approvalState, setApprovalState] = React.useState<ApprovalState>(initialApprovalState ?? "pending")
|
|
139
151
|
|
|
152
|
+
// Guard against state updates after unmount (e.g. user navigates away while
|
|
153
|
+
// an async onApprove promise is still in flight).
|
|
154
|
+
const mountedRef = React.useRef(true)
|
|
155
|
+
React.useEffect(() => {
|
|
156
|
+
return () => { mountedRef.current = false }
|
|
157
|
+
}, [])
|
|
158
|
+
|
|
140
159
|
const requestApproval = React.useCallback(() => {
|
|
141
160
|
setApprovalState("confirming")
|
|
142
161
|
}, [])
|
|
@@ -150,8 +169,23 @@ function Root({ children, companyName, opportunityUrl, scheduledTime, initialApp
|
|
|
150
169
|
}, [])
|
|
151
170
|
|
|
152
171
|
const approve = React.useCallback(() => {
|
|
153
|
-
|
|
154
|
-
|
|
172
|
+
const result = onApprove?.()
|
|
173
|
+
// If the callback returns a Promise, show a loading state and wait for it.
|
|
174
|
+
if (result && typeof (result as Promise<boolean>).then === "function") {
|
|
175
|
+
setApprovalState("creating")
|
|
176
|
+
;(result as Promise<boolean>).then((success) => {
|
|
177
|
+
if (mountedRef.current) {
|
|
178
|
+
setApprovalState(success ? "approving-feedback" : "pending")
|
|
179
|
+
}
|
|
180
|
+
}).catch(() => {
|
|
181
|
+
if (mountedRef.current) {
|
|
182
|
+
setApprovalState("pending")
|
|
183
|
+
}
|
|
184
|
+
})
|
|
185
|
+
} else {
|
|
186
|
+
// Synchronous / void — transition immediately (backward-compatible).
|
|
187
|
+
setApprovalState("approving-feedback")
|
|
188
|
+
}
|
|
155
189
|
}, [onApprove])
|
|
156
190
|
|
|
157
191
|
const submitApproveFeedback = React.useCallback(
|
|
@@ -439,6 +473,18 @@ function Actions() {
|
|
|
439
473
|
setDetailText("")
|
|
440
474
|
}
|
|
441
475
|
|
|
476
|
+
if (approvalState === "creating") {
|
|
477
|
+
return (
|
|
478
|
+
<div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
|
|
479
|
+
<svg className="h-3.5 w-3.5 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
480
|
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
481
|
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
|
482
|
+
</svg>
|
|
483
|
+
<span>{labels.creatingStatus}</span>
|
|
484
|
+
</div>
|
|
485
|
+
)
|
|
486
|
+
}
|
|
487
|
+
|
|
442
488
|
if (approvalState === "approved") {
|
|
443
489
|
if (isEditing) {
|
|
444
490
|
return (
|
|
@@ -728,7 +774,7 @@ function Gate({ children }: { children: React.ReactNode }) {
|
|
|
728
774
|
const { approvalState, hideApproveButton } = useSignalApproval()
|
|
729
775
|
// When the approve button is hidden, don't lock content behind approval
|
|
730
776
|
const isLocked = !hideApproveButton &&
|
|
731
|
-
(approvalState === "pending" || approvalState === "confirming" || approvalState === "dismissing")
|
|
777
|
+
(approvalState === "pending" || approvalState === "confirming" || approvalState === "creating" || approvalState === "dismissing")
|
|
732
778
|
|
|
733
779
|
return (
|
|
734
780
|
<div className="relative">
|