@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,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
|
+
}
|