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

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,181 @@
1
+ import { useCallback, useState, useEffect, useRef } from "react";
2
+ import {
3
+ openSlider,
4
+ type EmbedConfig,
5
+ type EmbedHandle,
6
+ } from "@perspective-ai/sdk";
7
+ import { useStableCallback } from "./useStableCallback";
8
+
9
+ /** Options for useSlider hook */
10
+ export interface UseSliderOptions extends Omit<EmbedConfig, "type"> {
11
+ /** Controlled open state */
12
+ open?: boolean;
13
+ /** Callback when open state changes */
14
+ onOpenChange?: (open: boolean) => void;
15
+ }
16
+
17
+ /** Return type for useSlider hook */
18
+ export interface UseSliderReturn {
19
+ /** Open the slider */
20
+ open: () => void;
21
+ /** Close the slider */
22
+ close: () => void;
23
+ /** Toggle the slider */
24
+ toggle: () => void;
25
+ /** Whether the slider is currently open */
26
+ isOpen: boolean;
27
+ /** The underlying SDK handle (null when closed) */
28
+ handle: EmbedHandle | null;
29
+ }
30
+
31
+ /**
32
+ * Headless hook for programmatic slider control.
33
+ * Use this when you need custom trigger elements or programmatic control.
34
+ *
35
+ * @example
36
+ * ```tsx
37
+ * const { open, isOpen } = useSlider({ researchId: "abc" });
38
+ * <MyCustomButton onClick={open}>Give Feedback</MyCustomButton>
39
+ * ```
40
+ */
41
+ export function useSlider(options: UseSliderOptions): UseSliderReturn {
42
+ const {
43
+ researchId,
44
+ params,
45
+ brand,
46
+ theme,
47
+ host,
48
+ onReady,
49
+ onSubmit,
50
+ onNavigate,
51
+ onClose,
52
+ onError,
53
+ open: controlledOpen,
54
+ onOpenChange,
55
+ } = options;
56
+
57
+ const [handle, setHandle] = useState<EmbedHandle | null>(null);
58
+ const [internalOpen, setInternalOpen] = useState(false);
59
+ const handleRef = useRef<EmbedHandle | null>(null);
60
+
61
+ const isControlled = controlledOpen !== undefined;
62
+ const isOpen = isControlled ? controlledOpen : internalOpen;
63
+
64
+ const stableOnReady = useStableCallback(onReady);
65
+ const stableOnSubmit = useStableCallback(onSubmit);
66
+ const stableOnNavigate = useStableCallback(onNavigate);
67
+ const stableOnError = useStableCallback(onError);
68
+
69
+ const setOpen = useCallback(
70
+ (value: boolean) => {
71
+ if (isControlled) {
72
+ onOpenChange?.(value);
73
+ } else {
74
+ setInternalOpen(value);
75
+ }
76
+ },
77
+ [isControlled, onOpenChange]
78
+ );
79
+
80
+ const handleClose = useCallback(() => {
81
+ handleRef.current = null;
82
+ setHandle(null);
83
+ setOpen(false);
84
+ onClose?.();
85
+ }, [setOpen, onClose]);
86
+
87
+ const stableOnClose = useStableCallback(handleClose);
88
+
89
+ const createSlider = useCallback(() => {
90
+ if (handleRef.current) return handleRef.current;
91
+
92
+ const newHandle = openSlider({
93
+ researchId,
94
+ params,
95
+ brand,
96
+ theme,
97
+ host,
98
+ onReady: stableOnReady,
99
+ onSubmit: stableOnSubmit,
100
+ onNavigate: stableOnNavigate,
101
+ onClose: stableOnClose,
102
+ onError: stableOnError,
103
+ });
104
+
105
+ handleRef.current = newHandle;
106
+ setHandle(newHandle);
107
+ return newHandle;
108
+ }, [
109
+ researchId,
110
+ params,
111
+ brand,
112
+ theme,
113
+ host,
114
+ stableOnReady,
115
+ stableOnSubmit,
116
+ stableOnNavigate,
117
+ stableOnClose,
118
+ stableOnError,
119
+ ]);
120
+
121
+ const destroySlider = useCallback(() => {
122
+ if (handleRef.current) {
123
+ handleRef.current.destroy();
124
+ handleRef.current = null;
125
+ setHandle(null);
126
+ }
127
+ }, []);
128
+
129
+ const openFn = useCallback(() => {
130
+ if (isControlled) {
131
+ onOpenChange?.(true);
132
+ } else {
133
+ createSlider();
134
+ setInternalOpen(true);
135
+ }
136
+ }, [isControlled, onOpenChange, createSlider]);
137
+
138
+ const closeFn = useCallback(() => {
139
+ if (isControlled) {
140
+ onOpenChange?.(false);
141
+ } else {
142
+ destroySlider();
143
+ setInternalOpen(false);
144
+ }
145
+ }, [isControlled, onOpenChange, destroySlider]);
146
+
147
+ const toggleFn = useCallback(() => {
148
+ if (isOpen) {
149
+ closeFn();
150
+ } else {
151
+ openFn();
152
+ }
153
+ }, [isOpen, openFn, closeFn]);
154
+
155
+ useEffect(() => {
156
+ if (!isControlled) return;
157
+
158
+ if (controlledOpen && !handleRef.current) {
159
+ createSlider();
160
+ } else if (!controlledOpen && handleRef.current) {
161
+ destroySlider();
162
+ }
163
+ }, [controlledOpen, isControlled, createSlider, destroySlider]);
164
+
165
+ useEffect(() => {
166
+ return () => {
167
+ if (handleRef.current) {
168
+ handleRef.current.destroy();
169
+ handleRef.current = null;
170
+ }
171
+ };
172
+ }, []);
173
+
174
+ return {
175
+ open: openFn,
176
+ close: closeFn,
177
+ toggle: toggleFn,
178
+ isOpen,
179
+ handle,
180
+ };
181
+ }
package/src/index.ts CHANGED
@@ -1,47 +1,44 @@
1
1
  "use client";
2
2
 
3
3
  /**
4
- * Perspective Embed SDK - React Components
4
+ * Perspective Embed SDK - React
5
5
  *
6
- * Usage:
7
- * import { Widget, PopupButton, SliderButton, FloatBubble } from '@perspective-ai/sdk-react';
6
+ * Hooks (for overlays - popup, slider, float bubble):
7
+ * import { usePopup, useSlider, useFloatBubble } from '@perspective-ai/sdk-react';
8
8
  *
9
- * // Inline widget
10
- * <Widget researchId="xxx" onReady={() => {}} />
9
+ * const { open } = usePopup({ researchId: "xxx" });
10
+ * <button onClick={open}>Take Survey</button>
11
11
  *
12
- * // Popup button
13
- * <PopupButton researchId="xxx">Take the interview</PopupButton>
12
+ * Components (for embeds - widget, fullpage):
13
+ * import { Widget, Fullpage, FloatBubble } from '@perspective-ai/sdk-react';
14
14
  *
15
- * // Slider button
16
- * <SliderButton researchId="xxx">Open Interview</SliderButton>
17
- *
18
- * // Floating bubble
19
- * <FloatBubble researchId="xxx" />
20
- *
21
- * // Full page
15
+ * <Widget researchId="xxx" />
22
16
  * <Fullpage researchId="xxx" />
17
+ * <FloatBubble researchId="xxx" />
23
18
  */
24
19
 
25
- // Components
26
- export { Widget, type WidgetProps } from "./Widget";
27
20
  export {
28
- PopupButton,
29
- type PopupButtonProps,
30
- type PopupButtonHandle,
31
- } from "./PopupButton";
21
+ usePopup,
22
+ type UsePopupOptions,
23
+ type UsePopupReturn,
24
+ } from "./hooks/usePopup";
32
25
  export {
33
- SliderButton,
34
- type SliderButtonProps,
35
- type SliderButtonHandle,
36
- } from "./SliderButton";
37
- export { FloatBubble, type FloatBubbleProps } from "./FloatBubble";
38
- export { Fullpage, type FullpageProps } from "./Fullpage";
39
-
40
- // Hooks
26
+ useSlider,
27
+ type UseSliderOptions,
28
+ type UseSliderReturn,
29
+ } from "./hooks/useSlider";
30
+ export {
31
+ useFloatBubble,
32
+ type UseFloatBubbleOptions,
33
+ type UseFloatBubbleReturn,
34
+ } from "./hooks/useFloatBubble";
41
35
  export { useThemeSync } from "./hooks/useThemeSync";
42
36
  export { useStableCallback } from "./hooks/useStableCallback";
43
37
 
44
- // Re-export types from core package for convenience
38
+ export { Widget, type WidgetProps } from "./Widget";
39
+ export { Fullpage, type FullpageProps } from "./Fullpage";
40
+ export { FloatBubble, type FloatBubbleProps } from "./FloatBubble";
41
+
45
42
  export type {
46
43
  EmbedConfig,
47
44
  EmbedHandle,
@@ -1,273 +0,0 @@
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
- });