@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.
- package/README.md +316 -0
- package/dist/index.cjs +529 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +96 -0
- package/dist/index.d.ts +96 -0
- package/dist/index.js +521 -0
- package/dist/index.js.map +1 -0
- package/package.json +70 -0
- package/src/FloatBubble.test.tsx +173 -0
- package/src/FloatBubble.tsx +83 -0
- package/src/Fullpage.test.tsx +164 -0
- package/src/Fullpage.tsx +83 -0
- package/src/PopupButton.test.tsx +273 -0
- package/src/PopupButton.tsx +208 -0
- package/src/SliderButton.test.tsx +279 -0
- package/src/SliderButton.tsx +208 -0
- package/src/Widget.test.tsx +308 -0
- package/src/Widget.tsx +100 -0
- package/src/hooks/useStableCallback.test.ts +83 -0
- package/src/hooks/useStableCallback.ts +20 -0
- package/src/hooks/useThemeSync.test.ts +127 -0
- package/src/hooks/useThemeSync.ts +36 -0
- package/src/index.ts +52 -0
|
@@ -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
|
+
}
|