@perspective-ai/sdk-react 0.0.0-pr-21-20260224144030

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,91 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { renderHook, cleanup, act } from "@testing-library/react";
3
+ import { useFloatBubble } from "./useFloatBubble";
4
+
5
+ const mockOpen = vi.fn();
6
+ const mockClose = vi.fn();
7
+ const mockUnmount = vi.fn();
8
+ const mockToggle = vi.fn();
9
+ const mockDestroy = vi.fn();
10
+ const mockUpdate = vi.fn();
11
+
12
+ const stableMockHandle = {
13
+ open: mockOpen,
14
+ close: mockClose,
15
+ unmount: mockUnmount,
16
+ toggle: mockToggle,
17
+ destroy: mockDestroy,
18
+ update: mockUpdate,
19
+ isOpen: false,
20
+ researchId: "test-research-id",
21
+ type: "float" as const,
22
+ iframe: null,
23
+ container: null,
24
+ };
25
+
26
+ vi.mock("@perspective-ai/sdk", () => ({
27
+ createFloatBubble: vi.fn(() => stableMockHandle),
28
+ }));
29
+
30
+ import { createFloatBubble } from "@perspective-ai/sdk";
31
+ const mockCreateFloatBubble = vi.mocked(createFloatBubble);
32
+
33
+ describe("useFloatBubble", () => {
34
+ beforeEach(() => {
35
+ vi.clearAllMocks();
36
+ stableMockHandle.isOpen = false;
37
+ });
38
+
39
+ afterEach(() => {
40
+ cleanup();
41
+ });
42
+
43
+ it("creates bubble on mount and cleans up on unmount", () => {
44
+ const { unmount } = renderHook(() =>
45
+ useFloatBubble({ researchId: "test-research-id" })
46
+ );
47
+
48
+ expect(mockCreateFloatBubble).toHaveBeenCalledTimes(1);
49
+ expect(mockCreateFloatBubble).toHaveBeenCalledWith(
50
+ expect.objectContaining({ researchId: "test-research-id" })
51
+ );
52
+
53
+ unmount();
54
+
55
+ expect(mockUnmount).toHaveBeenCalledTimes(1);
56
+ });
57
+
58
+ it("returns expected interface", () => {
59
+ const { result, unmount } = renderHook(() =>
60
+ useFloatBubble({ researchId: "test-research-id" })
61
+ );
62
+
63
+ expect(result.current.open).toBeInstanceOf(Function);
64
+ expect(result.current.close).toBeInstanceOf(Function);
65
+ expect(result.current.toggle).toBeInstanceOf(Function);
66
+ expect(result.current.unmount).toBeInstanceOf(Function);
67
+ expect(result.current.handle).not.toBeNull();
68
+ expect(typeof result.current.isOpen).toBe("boolean");
69
+
70
+ unmount();
71
+ });
72
+
73
+ it("manual unmount clears handle and prevents double cleanup", () => {
74
+ const { result, unmount } = renderHook(() =>
75
+ useFloatBubble({ researchId: "test-research-id" })
76
+ );
77
+
78
+ expect(result.current.handle).not.toBeNull();
79
+
80
+ act(() => {
81
+ result.current.unmount();
82
+ });
83
+
84
+ expect(mockUnmount).toHaveBeenCalledTimes(1);
85
+ expect(result.current.handle).toBeNull();
86
+
87
+ unmount();
88
+
89
+ expect(mockUnmount).toHaveBeenCalledTimes(1);
90
+ });
91
+ });
@@ -0,0 +1,180 @@
1
+ import { useCallback, useState, useEffect, useRef } from "react";
2
+ import {
3
+ createFloatBubble,
4
+ type EmbedConfig,
5
+ type FloatHandle,
6
+ } from "@perspective-ai/sdk";
7
+ import { useStableCallback } from "./useStableCallback";
8
+
9
+ /** Options for useFloatBubble hook */
10
+ export interface UseFloatBubbleOptions 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 useFloatBubble hook */
18
+ export interface UseFloatBubbleReturn {
19
+ /** Open the float bubble window */
20
+ open: () => void;
21
+ /** Close the float bubble window */
22
+ close: () => void;
23
+ /** Toggle the float bubble window */
24
+ toggle: () => void;
25
+ /** Unmount the float bubble entirely */
26
+ unmount: () => void;
27
+ /** Whether the float bubble window is currently open */
28
+ isOpen: boolean;
29
+ /** The underlying SDK handle (null until mounted) */
30
+ handle: FloatHandle | null;
31
+ }
32
+
33
+ /**
34
+ * Headless hook for float bubble lifecycle management.
35
+ * Creates a floating bubble button that expands into a chat window.
36
+ * The bubble mounts on component mount and unmounts on component unmount.
37
+ *
38
+ * @example
39
+ * ```tsx
40
+ * // Basic usage - bubble mounts on component mount
41
+ * useFloatBubble({ researchId: "abc" });
42
+ *
43
+ * // With programmatic control
44
+ * const { open, close, isOpen } = useFloatBubble({ researchId: "abc" });
45
+ * <button onClick={open}>Open Chat</button>
46
+ * ```
47
+ */
48
+ export function useFloatBubble(
49
+ options: UseFloatBubbleOptions
50
+ ): UseFloatBubbleReturn {
51
+ const {
52
+ researchId,
53
+ params,
54
+ brand,
55
+ theme,
56
+ host,
57
+ onReady,
58
+ onSubmit,
59
+ onNavigate,
60
+ onClose,
61
+ onError,
62
+ open: controlledOpen,
63
+ onOpenChange,
64
+ } = options;
65
+
66
+ const [handle, setHandle] = useState<FloatHandle | null>(null);
67
+ const [internalOpen, setInternalOpen] = useState(false);
68
+ const handleRef = useRef<FloatHandle | null>(null);
69
+
70
+ const isControlled = controlledOpen !== undefined;
71
+
72
+ const stableOnReady = useStableCallback(onReady);
73
+ const stableOnSubmit = useStableCallback(onSubmit);
74
+ const stableOnNavigate = useStableCallback(onNavigate);
75
+ const stableOnError = useStableCallback(onError);
76
+
77
+ const handleClose = useCallback(() => {
78
+ setInternalOpen(false);
79
+ if (isControlled) {
80
+ onOpenChange?.(false);
81
+ }
82
+ onClose?.();
83
+ }, [isControlled, onOpenChange, onClose]);
84
+
85
+ const stableOnClose = useStableCallback(handleClose);
86
+
87
+ useEffect(() => {
88
+ const newHandle = createFloatBubble({
89
+ researchId,
90
+ params,
91
+ brand,
92
+ theme,
93
+ host,
94
+ onReady: stableOnReady,
95
+ onSubmit: stableOnSubmit,
96
+ onNavigate: stableOnNavigate,
97
+ onClose: stableOnClose,
98
+ onError: stableOnError,
99
+ });
100
+
101
+ handleRef.current = newHandle;
102
+ setHandle(newHandle);
103
+
104
+ return () => {
105
+ if (handleRef.current === newHandle) {
106
+ newHandle.unmount();
107
+ handleRef.current = null;
108
+ setHandle(null);
109
+ }
110
+ };
111
+ }, [
112
+ researchId,
113
+ params,
114
+ brand,
115
+ theme,
116
+ host,
117
+ stableOnReady,
118
+ stableOnSubmit,
119
+ stableOnNavigate,
120
+ stableOnClose,
121
+ stableOnError,
122
+ ]);
123
+
124
+ useEffect(() => {
125
+ if (!isControlled || !handle) return;
126
+
127
+ if (controlledOpen && !handle.isOpen) {
128
+ handle.open();
129
+ } else if (!controlledOpen && handle.isOpen) {
130
+ handle.close();
131
+ }
132
+ }, [controlledOpen, isControlled, handle]);
133
+
134
+ const openFn = useCallback(() => {
135
+ if (isControlled) {
136
+ onOpenChange?.(true);
137
+ } else {
138
+ handleRef.current?.open();
139
+ setInternalOpen(true);
140
+ }
141
+ }, [isControlled, onOpenChange]);
142
+
143
+ const closeFn = useCallback(() => {
144
+ if (isControlled) {
145
+ onOpenChange?.(false);
146
+ } else {
147
+ handleRef.current?.close();
148
+ setInternalOpen(false);
149
+ }
150
+ }, [isControlled, onOpenChange]);
151
+
152
+ const toggleFn = useCallback(() => {
153
+ const currentlyOpen = handleRef.current?.isOpen ?? internalOpen;
154
+ if (currentlyOpen) {
155
+ closeFn();
156
+ } else {
157
+ openFn();
158
+ }
159
+ }, [internalOpen, openFn, closeFn]);
160
+
161
+ const unmountFn = useCallback(() => {
162
+ handleRef.current?.unmount();
163
+ handleRef.current = null;
164
+ setHandle(null);
165
+ setInternalOpen(false);
166
+ }, []);
167
+
168
+ const isOpen = isControlled
169
+ ? controlledOpen
170
+ : (handle?.isOpen ?? internalOpen);
171
+
172
+ return {
173
+ open: openFn,
174
+ close: closeFn,
175
+ toggle: toggleFn,
176
+ unmount: unmountFn,
177
+ isOpen,
178
+ handle,
179
+ };
180
+ }
@@ -0,0 +1,219 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { renderHook, act, cleanup } from "@testing-library/react";
3
+ import { usePopup } from "./usePopup";
4
+
5
+ const mockDestroy = vi.fn();
6
+ const mockUnmount = vi.fn();
7
+ const mockUpdate = vi.fn();
8
+
9
+ vi.mock("@perspective-ai/sdk", () => ({
10
+ openPopup: vi.fn(() => ({
11
+ unmount: mockUnmount,
12
+ update: mockUpdate,
13
+ destroy: mockDestroy,
14
+ researchId: "test-research-id",
15
+ type: "popup",
16
+ iframe: null,
17
+ container: null,
18
+ })),
19
+ }));
20
+
21
+ import { openPopup } from "@perspective-ai/sdk";
22
+ const mockOpenPopup = vi.mocked(openPopup);
23
+
24
+ describe("usePopup", () => {
25
+ beforeEach(() => {
26
+ vi.clearAllMocks();
27
+ });
28
+
29
+ afterEach(() => {
30
+ cleanup();
31
+ });
32
+
33
+ it("returns open, close, toggle functions and isOpen state", () => {
34
+ const { result } = renderHook(() =>
35
+ usePopup({ researchId: "test-research-id" })
36
+ );
37
+
38
+ expect(result.current.open).toBeInstanceOf(Function);
39
+ expect(result.current.close).toBeInstanceOf(Function);
40
+ expect(result.current.toggle).toBeInstanceOf(Function);
41
+ expect(result.current.isOpen).toBe(false);
42
+ expect(result.current.handle).toBeNull();
43
+ });
44
+
45
+ it("does not call openPopup on mount", () => {
46
+ renderHook(() => usePopup({ researchId: "test-research-id" }));
47
+
48
+ expect(mockOpenPopup).not.toHaveBeenCalled();
49
+ });
50
+
51
+ it("calls openPopup when open() is called", () => {
52
+ const { result } = renderHook(() =>
53
+ usePopup({ researchId: "test-research-id" })
54
+ );
55
+
56
+ act(() => {
57
+ result.current.open();
58
+ });
59
+
60
+ expect(mockOpenPopup).toHaveBeenCalledTimes(1);
61
+ expect(mockOpenPopup).toHaveBeenCalledWith(
62
+ expect.objectContaining({
63
+ researchId: "test-research-id",
64
+ })
65
+ );
66
+ });
67
+
68
+ it("sets isOpen to true when open() is called", () => {
69
+ const { result } = renderHook(() =>
70
+ usePopup({ researchId: "test-research-id" })
71
+ );
72
+
73
+ expect(result.current.isOpen).toBe(false);
74
+
75
+ act(() => {
76
+ result.current.open();
77
+ });
78
+
79
+ expect(result.current.isOpen).toBe(true);
80
+ });
81
+
82
+ it("calls destroy and sets isOpen to false when close() is called", () => {
83
+ const { result } = renderHook(() =>
84
+ usePopup({ researchId: "test-research-id" })
85
+ );
86
+
87
+ act(() => {
88
+ result.current.open();
89
+ });
90
+
91
+ expect(result.current.isOpen).toBe(true);
92
+
93
+ act(() => {
94
+ result.current.close();
95
+ });
96
+
97
+ expect(mockDestroy).toHaveBeenCalledTimes(1);
98
+ expect(result.current.isOpen).toBe(false);
99
+ });
100
+
101
+ it("toggles open state", () => {
102
+ const { result } = renderHook(() =>
103
+ usePopup({ researchId: "test-research-id" })
104
+ );
105
+
106
+ expect(result.current.isOpen).toBe(false);
107
+
108
+ act(() => {
109
+ result.current.toggle();
110
+ });
111
+
112
+ expect(result.current.isOpen).toBe(true);
113
+ expect(mockOpenPopup).toHaveBeenCalledTimes(1);
114
+
115
+ act(() => {
116
+ result.current.toggle();
117
+ });
118
+
119
+ expect(result.current.isOpen).toBe(false);
120
+ expect(mockDestroy).toHaveBeenCalledTimes(1);
121
+ });
122
+
123
+ it("passes config to openPopup", () => {
124
+ const onReady = vi.fn();
125
+ const onSubmit = vi.fn();
126
+
127
+ const { result } = renderHook(() =>
128
+ usePopup({
129
+ researchId: "test-research-id",
130
+ params: { source: "test" },
131
+ theme: "dark",
132
+ host: "https://custom.example.com",
133
+ onReady,
134
+ onSubmit,
135
+ })
136
+ );
137
+
138
+ act(() => {
139
+ result.current.open();
140
+ });
141
+
142
+ expect(mockOpenPopup).toHaveBeenCalledTimes(1);
143
+ const config = mockOpenPopup.mock.calls[0]![0];
144
+ expect(config.researchId).toBe("test-research-id");
145
+ expect(config.params).toEqual({ source: "test" });
146
+ expect(config.theme).toBe("dark");
147
+ expect(config.host).toBe("https://custom.example.com");
148
+ });
149
+
150
+ it("supports controlled mode with open prop", () => {
151
+ const onOpenChange = vi.fn();
152
+
153
+ const { result, rerender } = renderHook(
154
+ ({ open }) =>
155
+ usePopup({
156
+ researchId: "test-research-id",
157
+ open,
158
+ onOpenChange,
159
+ }),
160
+ { initialProps: { open: false } }
161
+ );
162
+
163
+ expect(result.current.isOpen).toBe(false);
164
+
165
+ rerender({ open: true });
166
+
167
+ expect(mockOpenPopup).toHaveBeenCalledTimes(1);
168
+
169
+ rerender({ open: false });
170
+
171
+ expect(mockDestroy).toHaveBeenCalledTimes(1);
172
+ });
173
+
174
+ it("calls onOpenChange in controlled mode when open() is called", () => {
175
+ const onOpenChange = vi.fn();
176
+
177
+ const { result } = renderHook(() =>
178
+ usePopup({
179
+ researchId: "test-research-id",
180
+ open: false,
181
+ onOpenChange,
182
+ })
183
+ );
184
+
185
+ act(() => {
186
+ result.current.open();
187
+ });
188
+
189
+ expect(onOpenChange).toHaveBeenCalledWith(true);
190
+ });
191
+
192
+ it("cleans up on unmount", () => {
193
+ const { result, unmount } = renderHook(() =>
194
+ usePopup({ researchId: "test-research-id" })
195
+ );
196
+
197
+ act(() => {
198
+ result.current.open();
199
+ });
200
+
201
+ unmount();
202
+
203
+ expect(mockDestroy).toHaveBeenCalled();
204
+ });
205
+
206
+ it("makes handle reactive - handle is available after open", () => {
207
+ const { result } = renderHook(() =>
208
+ usePopup({ researchId: "test-research-id" })
209
+ );
210
+
211
+ expect(result.current.handle).toBeNull();
212
+
213
+ act(() => {
214
+ result.current.open();
215
+ });
216
+
217
+ expect(result.current.handle).not.toBeNull();
218
+ });
219
+ });
@@ -0,0 +1,190 @@
1
+ import { useCallback, useState, useEffect, useRef } from "react";
2
+ import {
3
+ openPopup,
4
+ type EmbedConfig,
5
+ type EmbedHandle,
6
+ } from "@perspective-ai/sdk";
7
+ import { useStableCallback } from "./useStableCallback";
8
+
9
+ /** Options for usePopup hook */
10
+ export interface UsePopupOptions 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 usePopup hook */
18
+ export interface UsePopupReturn {
19
+ /** Open the popup */
20
+ open: () => void;
21
+ /** Close the popup */
22
+ close: () => void;
23
+ /** Toggle the popup */
24
+ toggle: () => void;
25
+ /** Whether the popup 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 popup control.
33
+ * Use this when you need custom trigger elements or programmatic control.
34
+ *
35
+ * @example
36
+ * ```tsx
37
+ * // Basic usage with custom trigger
38
+ * const { open, isOpen } = usePopup({ researchId: "abc" });
39
+ * <MyCustomButton onClick={open}>Open Survey</MyCustomButton>
40
+ *
41
+ * // Controlled mode
42
+ * const [isOpen, setIsOpen] = useState(false);
43
+ * const popup = usePopup({
44
+ * researchId: "abc",
45
+ * open: isOpen,
46
+ * onOpenChange: setIsOpen
47
+ * });
48
+ * ```
49
+ */
50
+ export function usePopup(options: UsePopupOptions): UsePopupReturn {
51
+ const {
52
+ researchId,
53
+ params,
54
+ brand,
55
+ theme,
56
+ host,
57
+ onReady,
58
+ onSubmit,
59
+ onNavigate,
60
+ onClose,
61
+ onError,
62
+ open: controlledOpen,
63
+ onOpenChange,
64
+ } = options;
65
+
66
+ const [handle, setHandle] = useState<EmbedHandle | null>(null);
67
+ const [internalOpen, setInternalOpen] = useState(false);
68
+ const handleRef = useRef<EmbedHandle | null>(null);
69
+
70
+ const isControlled = controlledOpen !== undefined;
71
+ const isOpen = isControlled ? controlledOpen : internalOpen;
72
+
73
+ const stableOnReady = useStableCallback(onReady);
74
+ const stableOnSubmit = useStableCallback(onSubmit);
75
+ const stableOnNavigate = useStableCallback(onNavigate);
76
+ const stableOnError = useStableCallback(onError);
77
+
78
+ const setOpen = useCallback(
79
+ (value: boolean) => {
80
+ if (isControlled) {
81
+ onOpenChange?.(value);
82
+ } else {
83
+ setInternalOpen(value);
84
+ }
85
+ },
86
+ [isControlled, onOpenChange]
87
+ );
88
+
89
+ const handleClose = useCallback(() => {
90
+ handleRef.current = null;
91
+ setHandle(null);
92
+ setOpen(false);
93
+ onClose?.();
94
+ }, [setOpen, onClose]);
95
+
96
+ const stableOnClose = useStableCallback(handleClose);
97
+
98
+ const createPopup = useCallback(() => {
99
+ if (handleRef.current) return handleRef.current;
100
+
101
+ const newHandle = openPopup({
102
+ researchId,
103
+ params,
104
+ brand,
105
+ theme,
106
+ host,
107
+ onReady: stableOnReady,
108
+ onSubmit: stableOnSubmit,
109
+ onNavigate: stableOnNavigate,
110
+ onClose: stableOnClose,
111
+ onError: stableOnError,
112
+ });
113
+
114
+ handleRef.current = newHandle;
115
+ setHandle(newHandle);
116
+ return newHandle;
117
+ }, [
118
+ researchId,
119
+ params,
120
+ brand,
121
+ theme,
122
+ host,
123
+ stableOnReady,
124
+ stableOnSubmit,
125
+ stableOnNavigate,
126
+ stableOnClose,
127
+ stableOnError,
128
+ ]);
129
+
130
+ const destroyPopup = useCallback(() => {
131
+ if (handleRef.current) {
132
+ handleRef.current.destroy();
133
+ handleRef.current = null;
134
+ setHandle(null);
135
+ }
136
+ }, []);
137
+
138
+ const openFn = useCallback(() => {
139
+ if (isControlled) {
140
+ onOpenChange?.(true);
141
+ } else {
142
+ createPopup();
143
+ setInternalOpen(true);
144
+ }
145
+ }, [isControlled, onOpenChange, createPopup]);
146
+
147
+ const closeFn = useCallback(() => {
148
+ if (isControlled) {
149
+ onOpenChange?.(false);
150
+ } else {
151
+ destroyPopup();
152
+ setInternalOpen(false);
153
+ }
154
+ }, [isControlled, onOpenChange, destroyPopup]);
155
+
156
+ const toggleFn = useCallback(() => {
157
+ if (isOpen) {
158
+ closeFn();
159
+ } else {
160
+ openFn();
161
+ }
162
+ }, [isOpen, openFn, closeFn]);
163
+
164
+ useEffect(() => {
165
+ if (!isControlled) return;
166
+
167
+ if (controlledOpen && !handleRef.current) {
168
+ createPopup();
169
+ } else if (!controlledOpen && handleRef.current) {
170
+ destroyPopup();
171
+ }
172
+ }, [controlledOpen, isControlled, createPopup, destroyPopup]);
173
+
174
+ useEffect(() => {
175
+ return () => {
176
+ if (handleRef.current) {
177
+ handleRef.current.destroy();
178
+ handleRef.current = null;
179
+ }
180
+ };
181
+ }, []);
182
+
183
+ return {
184
+ open: openFn,
185
+ close: closeFn,
186
+ toggle: toggleFn,
187
+ isOpen,
188
+ handle,
189
+ };
190
+ }