@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,279 @@
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 { SliderButton, type SliderButtonHandle } from "./SliderButton";
5
+
6
+ const mockDestroy = vi.fn();
7
+ const mockUnmount = vi.fn();
8
+
9
+ vi.mock("@perspective-ai/sdk", () => ({
10
+ openSlider: vi.fn(() => ({
11
+ unmount: mockUnmount,
12
+ update: vi.fn(),
13
+ destroy: mockDestroy,
14
+ researchId: "test-research-id",
15
+ type: "slider",
16
+ iframe: null,
17
+ container: null,
18
+ })),
19
+ }));
20
+
21
+ import { openSlider } from "@perspective-ai/sdk";
22
+ const mockOpenSlider = vi.mocked(openSlider);
23
+
24
+ describe("SliderButton", () => {
25
+ beforeEach(() => {
26
+ vi.clearAllMocks();
27
+ });
28
+
29
+ afterEach(() => {
30
+ cleanup();
31
+ });
32
+
33
+ it("renders a button with children", () => {
34
+ render(
35
+ <SliderButton researchId="test-research-id">Open Interview</SliderButton>
36
+ );
37
+
38
+ const button = screen.getByRole("button");
39
+ expect(button).toBeDefined();
40
+ expect(button.textContent).toBe("Open Interview");
41
+ });
42
+
43
+ it("has correct test id", () => {
44
+ render(<SliderButton researchId="test-research-id">Open</SliderButton>);
45
+
46
+ const button = screen.getByTestId("perspective-slider-button");
47
+ expect(button).toBeDefined();
48
+ });
49
+
50
+ it("opens slider on click", () => {
51
+ render(<SliderButton researchId="test-research-id">Open</SliderButton>);
52
+
53
+ const button = screen.getByRole("button");
54
+ fireEvent.click(button);
55
+
56
+ expect(mockOpenSlider).toHaveBeenCalledTimes(1);
57
+ expect(mockOpenSlider).toHaveBeenCalledWith(
58
+ expect.objectContaining({
59
+ researchId: "test-research-id",
60
+ })
61
+ );
62
+ });
63
+
64
+ it("closes slider on second click", () => {
65
+ render(<SliderButton researchId="test-research-id">Open</SliderButton>);
66
+
67
+ const button = screen.getByRole("button");
68
+
69
+ fireEvent.click(button);
70
+ expect(mockOpenSlider).toHaveBeenCalledTimes(1);
71
+
72
+ fireEvent.click(button);
73
+ expect(mockDestroy).toHaveBeenCalledTimes(1);
74
+ });
75
+
76
+ it("passes config to openSlider", () => {
77
+ const onReady = vi.fn();
78
+ const onSubmit = vi.fn();
79
+
80
+ render(
81
+ <SliderButton
82
+ researchId="test-research-id"
83
+ params={{ source: "test" }}
84
+ theme="dark"
85
+ host="https://custom.example.com"
86
+ onReady={onReady}
87
+ onSubmit={onSubmit}
88
+ >
89
+ Open
90
+ </SliderButton>
91
+ );
92
+
93
+ fireEvent.click(screen.getByRole("button"));
94
+
95
+ const config = mockOpenSlider.mock.calls[0]![0];
96
+ expect(config.researchId).toBe("test-research-id");
97
+ expect(config.params).toEqual({ source: "test" });
98
+ expect(config.theme).toBe("dark");
99
+ expect(config.host).toBe("https://custom.example.com");
100
+ });
101
+
102
+ it("calls custom onClick handler", () => {
103
+ const onClick = vi.fn();
104
+
105
+ render(
106
+ <SliderButton researchId="test-research-id" onClick={onClick}>
107
+ Open
108
+ </SliderButton>
109
+ );
110
+
111
+ fireEvent.click(screen.getByRole("button"));
112
+
113
+ expect(onClick).toHaveBeenCalledTimes(1);
114
+ expect(mockOpenSlider).toHaveBeenCalledTimes(1);
115
+ });
116
+
117
+ it("does not open slider if onClick prevents default", () => {
118
+ const onClick = vi.fn((e: React.MouseEvent) => e.preventDefault());
119
+
120
+ render(
121
+ <SliderButton researchId="test-research-id" onClick={onClick}>
122
+ Open
123
+ </SliderButton>
124
+ );
125
+
126
+ fireEvent.click(screen.getByRole("button"));
127
+
128
+ expect(onClick).toHaveBeenCalledTimes(1);
129
+ expect(mockOpenSlider).not.toHaveBeenCalled();
130
+ });
131
+
132
+ it("supports controlled mode via open prop", () => {
133
+ const onOpenChange = vi.fn();
134
+
135
+ const { rerender } = render(
136
+ <SliderButton
137
+ researchId="test-research-id"
138
+ open={false}
139
+ onOpenChange={onOpenChange}
140
+ >
141
+ Open
142
+ </SliderButton>
143
+ );
144
+
145
+ expect(mockOpenSlider).not.toHaveBeenCalled();
146
+
147
+ rerender(
148
+ <SliderButton
149
+ researchId="test-research-id"
150
+ open={true}
151
+ onOpenChange={onOpenChange}
152
+ >
153
+ Open
154
+ </SliderButton>
155
+ );
156
+
157
+ expect(mockOpenSlider).toHaveBeenCalledTimes(1);
158
+
159
+ rerender(
160
+ <SliderButton
161
+ researchId="test-research-id"
162
+ open={false}
163
+ onOpenChange={onOpenChange}
164
+ >
165
+ Open
166
+ </SliderButton>
167
+ );
168
+
169
+ expect(mockDestroy).toHaveBeenCalledTimes(1);
170
+ });
171
+
172
+ it("exposes handle via embedRef", () => {
173
+ const embedRef = createRef<SliderButtonHandle | null>();
174
+
175
+ render(
176
+ <SliderButton researchId="test-research-id" embedRef={embedRef}>
177
+ Open
178
+ </SliderButton>
179
+ );
180
+
181
+ expect(embedRef.current).not.toBeNull();
182
+ expect(typeof embedRef.current?.open).toBe("function");
183
+ expect(typeof embedRef.current?.close).toBe("function");
184
+ expect(typeof embedRef.current?.toggle).toBe("function");
185
+ expect(embedRef.current?.researchId).toBe("test-research-id");
186
+ });
187
+
188
+ it("embedRef.open() opens slider", () => {
189
+ const embedRef = createRef<SliderButtonHandle | null>();
190
+
191
+ render(
192
+ <SliderButton researchId="test-research-id" embedRef={embedRef}>
193
+ Open
194
+ </SliderButton>
195
+ );
196
+
197
+ embedRef.current?.open();
198
+
199
+ expect(mockOpenSlider).toHaveBeenCalledTimes(1);
200
+ });
201
+
202
+ it("embedRef.close() closes slider", () => {
203
+ const embedRef = createRef<SliderButtonHandle | null>();
204
+
205
+ render(
206
+ <SliderButton researchId="test-research-id" embedRef={embedRef}>
207
+ Open
208
+ </SliderButton>
209
+ );
210
+
211
+ embedRef.current?.open();
212
+ expect(mockOpenSlider).toHaveBeenCalledTimes(1);
213
+
214
+ embedRef.current?.close();
215
+ expect(mockDestroy).toHaveBeenCalledTimes(1);
216
+ });
217
+
218
+ it("embedRef.toggle() toggles slider state", () => {
219
+ const embedRef = createRef<SliderButtonHandle | null>();
220
+ const onOpenChange = vi.fn();
221
+
222
+ render(
223
+ <SliderButton
224
+ researchId="test-research-id"
225
+ embedRef={embedRef}
226
+ onOpenChange={onOpenChange}
227
+ >
228
+ Open
229
+ </SliderButton>
230
+ );
231
+
232
+ embedRef.current?.toggle();
233
+ expect(mockOpenSlider).toHaveBeenCalledTimes(1);
234
+
235
+ embedRef.current?.toggle();
236
+ expect(mockDestroy).toHaveBeenCalledTimes(1);
237
+ });
238
+
239
+ it("passes button props", () => {
240
+ render(
241
+ <SliderButton
242
+ researchId="test-research-id"
243
+ className="custom-button"
244
+ disabled
245
+ aria-label="Open interview slider"
246
+ >
247
+ Open
248
+ </SliderButton>
249
+ );
250
+
251
+ const button = screen.getByRole("button");
252
+ expect(button.classList.contains("custom-button")).toBe(true);
253
+ expect(button.hasAttribute("disabled")).toBe(true);
254
+ expect(button.getAttribute("aria-label")).toBe("Open interview slider");
255
+ });
256
+
257
+ it("has type button", () => {
258
+ render(<SliderButton researchId="test-research-id">Open</SliderButton>);
259
+
260
+ const button = screen.getByRole("button");
261
+ expect(button.getAttribute("type")).toBe("button");
262
+ });
263
+
264
+ it("clears embedRef on unmount", () => {
265
+ const embedRef = createRef<SliderButtonHandle | null>();
266
+
267
+ const { unmount } = render(
268
+ <SliderButton researchId="test-research-id" embedRef={embedRef}>
269
+ Open
270
+ </SliderButton>
271
+ );
272
+
273
+ expect(embedRef.current).not.toBeNull();
274
+
275
+ unmount();
276
+
277
+ expect(embedRef.current).toBeNull();
278
+ });
279
+ });
@@ -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
+ openSlider,
13
+ type EmbedConfig,
14
+ type EmbedHandle,
15
+ } from "@perspective-ai/sdk";
16
+ import { useStableCallback } from "./hooks/useStableCallback";
17
+
18
+ /** Handle for programmatic control of slider button */
19
+ export interface SliderButtonHandle {
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 SliderButtonProps
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<SliderButtonHandle | null>;
40
+ }
41
+
42
+ /**
43
+ * Button that opens a slider panel when clicked.
44
+ * Supports both controlled and uncontrolled modes.
45
+ */
46
+ export function SliderButton({
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
+ }: SliderButtonProps) {
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 createSlider = useCallback(() => {
95
+ if (handleRef.current) return handleRef.current;
96
+
97
+ const handle = openSlider({
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<SliderButtonHandle>(
126
+ () => ({
127
+ open: () => {
128
+ createSlider();
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
+ createSlider();
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
+ [createSlider, 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
+ createSlider();
175
+ } else if (!open && handleRef.current) {
176
+ handleRef.current.destroy();
177
+ handleRef.current = null;
178
+ }
179
+ }, [open, isControlled, createSlider]);
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
+ createSlider();
192
+ setOpen(true);
193
+ }
194
+ },
195
+ [onClick, isOpen, createSlider, setOpen]
196
+ );
197
+
198
+ return (
199
+ <button
200
+ type="button"
201
+ onClick={handleClick}
202
+ data-testid="perspective-slider-button"
203
+ {...buttonProps}
204
+ >
205
+ {children}
206
+ </button>
207
+ );
208
+ }