@perspective-ai/sdk-react 1.0.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,273 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { render, screen, cleanup, fireEvent } from "@testing-library/react";
3
+ import { createRef } from "react";
4
+ import { PopupButton, type PopupButtonHandle } from "./PopupButton";
5
+
6
+ // Mock the core embed package
7
+ const mockDestroy = vi.fn();
8
+ const mockUnmount = vi.fn();
9
+
10
+ vi.mock("@perspective-ai/sdk", () => ({
11
+ openPopup: vi.fn(() => ({
12
+ unmount: mockUnmount,
13
+ update: vi.fn(),
14
+ destroy: mockDestroy,
15
+ researchId: "test-research-id",
16
+ type: "popup",
17
+ iframe: null,
18
+ container: null,
19
+ })),
20
+ }));
21
+
22
+ import { openPopup } from "@perspective-ai/sdk";
23
+ const mockOpenPopup = vi.mocked(openPopup);
24
+
25
+ describe("PopupButton", () => {
26
+ beforeEach(() => {
27
+ vi.clearAllMocks();
28
+ });
29
+
30
+ afterEach(() => {
31
+ cleanup();
32
+ });
33
+
34
+ it("renders a button with children", () => {
35
+ render(
36
+ <PopupButton researchId="test-research-id">Open Interview</PopupButton>
37
+ );
38
+
39
+ const button = screen.getByRole("button");
40
+ expect(button).toBeDefined();
41
+ expect(button.textContent).toBe("Open Interview");
42
+ });
43
+
44
+ it("has correct test id", () => {
45
+ render(<PopupButton researchId="test-research-id">Open</PopupButton>);
46
+
47
+ const button = screen.getByTestId("perspective-popup-button");
48
+ expect(button).toBeDefined();
49
+ });
50
+
51
+ it("opens popup on click", () => {
52
+ render(<PopupButton researchId="test-research-id">Open</PopupButton>);
53
+
54
+ const button = screen.getByRole("button");
55
+ fireEvent.click(button);
56
+
57
+ expect(mockOpenPopup).toHaveBeenCalledTimes(1);
58
+ expect(mockOpenPopup).toHaveBeenCalledWith(
59
+ expect.objectContaining({
60
+ researchId: "test-research-id",
61
+ })
62
+ );
63
+ });
64
+
65
+ it("closes popup on second click", () => {
66
+ render(<PopupButton researchId="test-research-id">Open</PopupButton>);
67
+
68
+ const button = screen.getByRole("button");
69
+
70
+ // First click opens
71
+ fireEvent.click(button);
72
+ expect(mockOpenPopup).toHaveBeenCalledTimes(1);
73
+
74
+ // Second click closes
75
+ fireEvent.click(button);
76
+ expect(mockDestroy).toHaveBeenCalledTimes(1);
77
+ });
78
+
79
+ it("passes config to openPopup", () => {
80
+ const onReady = vi.fn();
81
+ const onSubmit = vi.fn();
82
+
83
+ render(
84
+ <PopupButton
85
+ researchId="test-research-id"
86
+ params={{ source: "test" }}
87
+ theme="dark"
88
+ host="https://custom.example.com"
89
+ onReady={onReady}
90
+ onSubmit={onSubmit}
91
+ >
92
+ Open
93
+ </PopupButton>
94
+ );
95
+
96
+ fireEvent.click(screen.getByRole("button"));
97
+
98
+ const config = mockOpenPopup.mock.calls[0]![0];
99
+ expect(config.researchId).toBe("test-research-id");
100
+ expect(config.params).toEqual({ source: "test" });
101
+ expect(config.theme).toBe("dark");
102
+ expect(config.host).toBe("https://custom.example.com");
103
+ });
104
+
105
+ it("calls custom onClick handler", () => {
106
+ const onClick = vi.fn();
107
+
108
+ render(
109
+ <PopupButton researchId="test-research-id" onClick={onClick}>
110
+ Open
111
+ </PopupButton>
112
+ );
113
+
114
+ fireEvent.click(screen.getByRole("button"));
115
+
116
+ expect(onClick).toHaveBeenCalledTimes(1);
117
+ expect(mockOpenPopup).toHaveBeenCalledTimes(1);
118
+ });
119
+
120
+ it("does not open popup if onClick prevents default", () => {
121
+ const onClick = vi.fn((e: React.MouseEvent) => e.preventDefault());
122
+
123
+ render(
124
+ <PopupButton researchId="test-research-id" onClick={onClick}>
125
+ Open
126
+ </PopupButton>
127
+ );
128
+
129
+ fireEvent.click(screen.getByRole("button"));
130
+
131
+ expect(onClick).toHaveBeenCalledTimes(1);
132
+ expect(mockOpenPopup).not.toHaveBeenCalled();
133
+ });
134
+
135
+ it("supports controlled mode via open prop", () => {
136
+ const onOpenChange = vi.fn();
137
+
138
+ const { rerender } = render(
139
+ <PopupButton
140
+ researchId="test-research-id"
141
+ open={false}
142
+ onOpenChange={onOpenChange}
143
+ >
144
+ Open
145
+ </PopupButton>
146
+ );
147
+
148
+ // Should not auto-open
149
+ expect(mockOpenPopup).not.toHaveBeenCalled();
150
+
151
+ // When open becomes true, popup should open
152
+ rerender(
153
+ <PopupButton
154
+ researchId="test-research-id"
155
+ open={true}
156
+ onOpenChange={onOpenChange}
157
+ >
158
+ Open
159
+ </PopupButton>
160
+ );
161
+
162
+ expect(mockOpenPopup).toHaveBeenCalledTimes(1);
163
+
164
+ // When open becomes false, popup should close
165
+ rerender(
166
+ <PopupButton
167
+ researchId="test-research-id"
168
+ open={false}
169
+ onOpenChange={onOpenChange}
170
+ >
171
+ Open
172
+ </PopupButton>
173
+ );
174
+
175
+ expect(mockDestroy).toHaveBeenCalledTimes(1);
176
+ });
177
+
178
+ it("exposes handle via embedRef", () => {
179
+ const embedRef = createRef<PopupButtonHandle | null>();
180
+
181
+ render(
182
+ <PopupButton researchId="test-research-id" embedRef={embedRef}>
183
+ Open
184
+ </PopupButton>
185
+ );
186
+
187
+ expect(embedRef.current).not.toBeNull();
188
+ expect(typeof embedRef.current?.open).toBe("function");
189
+ expect(typeof embedRef.current?.close).toBe("function");
190
+ expect(typeof embedRef.current?.toggle).toBe("function");
191
+ expect(embedRef.current?.researchId).toBe("test-research-id");
192
+ });
193
+
194
+ it("embedRef.open() opens popup", () => {
195
+ const embedRef = createRef<PopupButtonHandle | null>();
196
+
197
+ render(
198
+ <PopupButton researchId="test-research-id" embedRef={embedRef}>
199
+ Open
200
+ </PopupButton>
201
+ );
202
+
203
+ embedRef.current?.open();
204
+
205
+ expect(mockOpenPopup).toHaveBeenCalledTimes(1);
206
+ });
207
+
208
+ it("embedRef.close() closes popup", () => {
209
+ const embedRef = createRef<PopupButtonHandle | null>();
210
+
211
+ render(
212
+ <PopupButton researchId="test-research-id" embedRef={embedRef}>
213
+ Open
214
+ </PopupButton>
215
+ );
216
+
217
+ // First open
218
+ embedRef.current?.open();
219
+ expect(mockOpenPopup).toHaveBeenCalledTimes(1);
220
+
221
+ // Then close
222
+ embedRef.current?.close();
223
+ expect(mockDestroy).toHaveBeenCalledTimes(1);
224
+ });
225
+
226
+ it("embedRef.toggle() toggles popup state", async () => {
227
+ const embedRef = createRef<PopupButtonHandle | null>();
228
+ const onOpenChange = vi.fn();
229
+
230
+ render(
231
+ <PopupButton
232
+ researchId="test-research-id"
233
+ embedRef={embedRef}
234
+ onOpenChange={onOpenChange}
235
+ >
236
+ Open
237
+ </PopupButton>
238
+ );
239
+
240
+ // Toggle on
241
+ embedRef.current?.toggle();
242
+ expect(mockOpenPopup).toHaveBeenCalledTimes(1);
243
+
244
+ // Toggle off
245
+ embedRef.current?.toggle();
246
+ expect(mockDestroy).toHaveBeenCalledTimes(1);
247
+ });
248
+
249
+ it("passes button props", () => {
250
+ render(
251
+ <PopupButton
252
+ researchId="test-research-id"
253
+ className="custom-button"
254
+ disabled
255
+ aria-label="Open interview popup"
256
+ >
257
+ Open
258
+ </PopupButton>
259
+ );
260
+
261
+ const button = screen.getByRole("button");
262
+ expect(button.classList.contains("custom-button")).toBe(true);
263
+ expect(button.hasAttribute("disabled")).toBe(true);
264
+ expect(button.getAttribute("aria-label")).toBe("Open interview popup");
265
+ });
266
+
267
+ it("has type button", () => {
268
+ render(<PopupButton researchId="test-research-id">Open</PopupButton>);
269
+
270
+ const button = screen.getByRole("button");
271
+ expect(button.getAttribute("type")).toBe("button");
272
+ });
273
+ });
@@ -0,0 +1,208 @@
1
+ import {
2
+ useRef,
3
+ useCallback,
4
+ useState,
5
+ useEffect,
6
+ useMemo,
7
+ type ReactNode,
8
+ type ButtonHTMLAttributes,
9
+ type RefObject,
10
+ } from "react";
11
+ import {
12
+ openPopup,
13
+ type EmbedConfig,
14
+ type EmbedHandle,
15
+ } from "@perspective-ai/sdk";
16
+ import { useStableCallback } from "./hooks/useStableCallback";
17
+
18
+ /** Handle for programmatic control of popup button */
19
+ export interface PopupButtonHandle {
20
+ open: () => void;
21
+ close: () => void;
22
+ toggle: () => void;
23
+ unmount: () => void;
24
+ readonly isOpen: boolean;
25
+ readonly researchId: string;
26
+ }
27
+
28
+ export interface PopupButtonProps
29
+ extends
30
+ Omit<EmbedConfig, "type">,
31
+ Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onError" | "onSubmit"> {
32
+ /** Button content */
33
+ children: ReactNode;
34
+ /** Controlled open state */
35
+ open?: boolean;
36
+ /** Callback when open state changes */
37
+ onOpenChange?: (open: boolean) => void;
38
+ /** Ref to access the handle for programmatic control */
39
+ embedRef?: RefObject<PopupButtonHandle | null>;
40
+ }
41
+
42
+ /**
43
+ * Button that opens a popup modal when clicked.
44
+ * Supports both controlled and uncontrolled modes.
45
+ */
46
+ export function PopupButton({
47
+ researchId,
48
+ params,
49
+ brand,
50
+ theme,
51
+ host,
52
+ onReady,
53
+ onSubmit,
54
+ onNavigate,
55
+ onClose,
56
+ onError,
57
+ children,
58
+ open,
59
+ onOpenChange,
60
+ embedRef,
61
+ onClick,
62
+ ...buttonProps
63
+ }: PopupButtonProps) {
64
+ const handleRef = useRef<EmbedHandle | null>(null);
65
+ const [internalOpen, setInternalOpen] = useState(false);
66
+
67
+ const isControlled = open !== undefined;
68
+ const isOpen = isControlled ? open : internalOpen;
69
+
70
+ const stableOnReady = useStableCallback(onReady);
71
+ const stableOnSubmit = useStableCallback(onSubmit);
72
+ const stableOnNavigate = useStableCallback(onNavigate);
73
+ const stableOnError = useStableCallback(onError);
74
+
75
+ const setOpen = useCallback(
76
+ (value: boolean) => {
77
+ if (isControlled) {
78
+ onOpenChange?.(value);
79
+ } else {
80
+ setInternalOpen(value);
81
+ }
82
+ },
83
+ [isControlled, onOpenChange]
84
+ );
85
+
86
+ const handleClose = useCallback(() => {
87
+ handleRef.current = null;
88
+ setOpen(false);
89
+ onClose?.();
90
+ }, [setOpen, onClose]);
91
+
92
+ const stableOnClose = useStableCallback(handleClose);
93
+
94
+ const createPopup = useCallback(() => {
95
+ if (handleRef.current) return handleRef.current;
96
+
97
+ const handle = openPopup({
98
+ researchId,
99
+ params,
100
+ brand,
101
+ theme,
102
+ host,
103
+ onReady: stableOnReady,
104
+ onSubmit: stableOnSubmit,
105
+ onNavigate: stableOnNavigate,
106
+ onClose: stableOnClose,
107
+ onError: stableOnError,
108
+ });
109
+
110
+ handleRef.current = handle;
111
+ return handle;
112
+ }, [
113
+ researchId,
114
+ params,
115
+ brand,
116
+ theme,
117
+ host,
118
+ stableOnReady,
119
+ stableOnSubmit,
120
+ stableOnNavigate,
121
+ stableOnClose,
122
+ stableOnError,
123
+ ]);
124
+
125
+ const proxyHandle = useMemo<PopupButtonHandle>(
126
+ () => ({
127
+ open: () => {
128
+ createPopup();
129
+ setOpen(true);
130
+ },
131
+ close: () => {
132
+ handleRef.current?.destroy();
133
+ handleRef.current = null;
134
+ setOpen(false);
135
+ },
136
+ toggle: () => {
137
+ if (handleRef.current) {
138
+ handleRef.current.destroy();
139
+ handleRef.current = null;
140
+ setOpen(false);
141
+ } else {
142
+ createPopup();
143
+ setOpen(true);
144
+ }
145
+ },
146
+ unmount: () => {
147
+ handleRef.current?.unmount();
148
+ handleRef.current = null;
149
+ setOpen(false);
150
+ },
151
+ get isOpen() {
152
+ return isOpen;
153
+ },
154
+ researchId,
155
+ }),
156
+ [createPopup, setOpen, researchId, isOpen]
157
+ );
158
+
159
+ useEffect(() => {
160
+ if (embedRef) {
161
+ embedRef.current = proxyHandle;
162
+ }
163
+ return () => {
164
+ if (embedRef) {
165
+ embedRef.current = null;
166
+ }
167
+ };
168
+ }, [embedRef, proxyHandle]);
169
+
170
+ useEffect(() => {
171
+ if (!isControlled) return;
172
+
173
+ if (open && !handleRef.current) {
174
+ createPopup();
175
+ } else if (!open && handleRef.current) {
176
+ handleRef.current.destroy();
177
+ handleRef.current = null;
178
+ }
179
+ }, [open, isControlled, createPopup]);
180
+
181
+ const handleClick = useCallback(
182
+ (e: React.MouseEvent<HTMLButtonElement>) => {
183
+ onClick?.(e);
184
+ if (e.defaultPrevented) return;
185
+
186
+ if (isOpen && handleRef.current) {
187
+ handleRef.current.destroy();
188
+ handleRef.current = null;
189
+ setOpen(false);
190
+ } else {
191
+ createPopup();
192
+ setOpen(true);
193
+ }
194
+ },
195
+ [onClick, isOpen, createPopup, setOpen]
196
+ );
197
+
198
+ return (
199
+ <button
200
+ type="button"
201
+ onClick={handleClick}
202
+ data-testid="perspective-popup-button"
203
+ {...buttonProps}
204
+ >
205
+ {children}
206
+ </button>
207
+ );
208
+ }