@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.
- package/README.md +339 -0
- package/dist/index.cjs +607 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +175 -0
- package/dist/index.d.ts +175 -0
- package/dist/index.js +597 -0
- package/dist/index.js.map +1 -0
- package/package.json +77 -0
- package/src/FloatBubble.test.tsx +134 -0
- package/src/FloatBubble.tsx +57 -0
- package/src/Fullpage.test.tsx +164 -0
- package/src/Fullpage.tsx +83 -0
- package/src/Widget.test.tsx +308 -0
- package/src/Widget.tsx +100 -0
- package/src/hooks/useAutoOpen.test.ts +168 -0
- package/src/hooks/useAutoOpen.ts +63 -0
- package/src/hooks/useFloatBubble.test.ts +91 -0
- package/src/hooks/useFloatBubble.ts +180 -0
- package/src/hooks/usePopup.test.ts +219 -0
- package/src/hooks/usePopup.ts +190 -0
- package/src/hooks/useSlider.test.ts +150 -0
- package/src/hooks/useSlider.ts +181 -0
- package/src/hooks/useStableCallback.test.ts +97 -0
- package/src/hooks/useStableCallback.ts +24 -0
- package/src/hooks/useThemeSync.test.ts +127 -0
- package/src/hooks/useThemeSync.ts +36 -0
- package/src/index.ts +57 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { renderHook, act, cleanup } from "@testing-library/react";
|
|
3
|
+
import { useSlider } from "./useSlider";
|
|
4
|
+
|
|
5
|
+
const mockDestroy = vi.fn();
|
|
6
|
+
const mockUnmount = vi.fn();
|
|
7
|
+
const mockUpdate = vi.fn();
|
|
8
|
+
|
|
9
|
+
vi.mock("@perspective-ai/sdk", () => ({
|
|
10
|
+
openSlider: vi.fn(() => ({
|
|
11
|
+
unmount: mockUnmount,
|
|
12
|
+
update: mockUpdate,
|
|
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("useSlider", () => {
|
|
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
|
+
useSlider({ 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 openSlider on mount", () => {
|
|
46
|
+
renderHook(() => useSlider({ researchId: "test-research-id" }));
|
|
47
|
+
|
|
48
|
+
expect(mockOpenSlider).not.toHaveBeenCalled();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("calls openSlider when open() is called", () => {
|
|
52
|
+
const { result } = renderHook(() =>
|
|
53
|
+
useSlider({ researchId: "test-research-id" })
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
act(() => {
|
|
57
|
+
result.current.open();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
expect(mockOpenSlider).toHaveBeenCalledTimes(1);
|
|
61
|
+
expect(mockOpenSlider).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
|
+
useSlider({ 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
|
+
useSlider({ 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
|
+
useSlider({ 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(mockOpenSlider).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("cleans up on unmount", () => {
|
|
124
|
+
const { result, unmount } = renderHook(() =>
|
|
125
|
+
useSlider({ researchId: "test-research-id" })
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
act(() => {
|
|
129
|
+
result.current.open();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
unmount();
|
|
133
|
+
|
|
134
|
+
expect(mockDestroy).toHaveBeenCalled();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("makes handle reactive - handle is available after open", () => {
|
|
138
|
+
const { result } = renderHook(() =>
|
|
139
|
+
useSlider({ researchId: "test-research-id" })
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
expect(result.current.handle).toBeNull();
|
|
143
|
+
|
|
144
|
+
act(() => {
|
|
145
|
+
result.current.open();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
expect(result.current.handle).not.toBeNull();
|
|
149
|
+
});
|
|
150
|
+
});
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { renderHook, act } from "@testing-library/react";
|
|
3
|
+
import { useStableCallback } from "./useStableCallback";
|
|
4
|
+
|
|
5
|
+
describe("useStableCallback", () => {
|
|
6
|
+
it("returns a stable function reference", () => {
|
|
7
|
+
const callback = vi.fn();
|
|
8
|
+
const { result, rerender } = renderHook(({ cb }) => useStableCallback(cb), {
|
|
9
|
+
initialProps: { cb: callback },
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const firstRef = result.current;
|
|
13
|
+
|
|
14
|
+
// Rerender with same callback
|
|
15
|
+
rerender({ cb: callback });
|
|
16
|
+
expect(result.current).toBe(firstRef);
|
|
17
|
+
|
|
18
|
+
// Rerender with new callback
|
|
19
|
+
const newCallback = vi.fn();
|
|
20
|
+
rerender({ cb: newCallback });
|
|
21
|
+
expect(result.current).toBe(firstRef); // Still same reference
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("calls the latest callback", () => {
|
|
25
|
+
const callback1 = vi.fn();
|
|
26
|
+
const callback2 = vi.fn();
|
|
27
|
+
|
|
28
|
+
const { result, rerender } = renderHook(({ cb }) => useStableCallback(cb), {
|
|
29
|
+
initialProps: { cb: callback1 },
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Call with first callback
|
|
33
|
+
act(() => {
|
|
34
|
+
result.current("arg1");
|
|
35
|
+
});
|
|
36
|
+
expect(callback1).toHaveBeenCalledWith("arg1");
|
|
37
|
+
expect(callback2).not.toHaveBeenCalled();
|
|
38
|
+
|
|
39
|
+
// Update to second callback
|
|
40
|
+
rerender({ cb: callback2 });
|
|
41
|
+
|
|
42
|
+
// Call should now use second callback
|
|
43
|
+
act(() => {
|
|
44
|
+
result.current("arg2");
|
|
45
|
+
});
|
|
46
|
+
expect(callback2).toHaveBeenCalledWith("arg2");
|
|
47
|
+
expect(callback1).toHaveBeenCalledTimes(1); // Still only called once
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("returns undefined when callback is undefined", () => {
|
|
51
|
+
const { result } = renderHook(() => useStableCallback(undefined));
|
|
52
|
+
expect(result.current).toBeUndefined();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("transitions between undefined and defined", () => {
|
|
56
|
+
const callback = vi.fn();
|
|
57
|
+
const { result, rerender } = renderHook(({ cb }) => useStableCallback(cb), {
|
|
58
|
+
initialProps: { cb: undefined as (() => void) | undefined },
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
expect(result.current).toBeUndefined();
|
|
62
|
+
|
|
63
|
+
rerender({ cb: callback });
|
|
64
|
+
expect(result.current).toBeDefined();
|
|
65
|
+
const stableRef = result.current;
|
|
66
|
+
|
|
67
|
+
// Remains stable across re-renders with different callback instances
|
|
68
|
+
rerender({ cb: vi.fn() });
|
|
69
|
+
expect(result.current).toBe(stableRef);
|
|
70
|
+
|
|
71
|
+
rerender({ cb: undefined });
|
|
72
|
+
expect(result.current).toBeUndefined();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("passes through all arguments", () => {
|
|
76
|
+
const callback = vi.fn();
|
|
77
|
+
const { result } = renderHook(() => useStableCallback(callback));
|
|
78
|
+
|
|
79
|
+
act(() => {
|
|
80
|
+
result.current("a", "b", 123, { key: "value" });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(callback).toHaveBeenCalledWith("a", "b", 123, { key: "value" });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("returns the callback's return value", () => {
|
|
87
|
+
const callback = vi.fn().mockReturnValue("result");
|
|
88
|
+
const { result } = renderHook(() => useStableCallback(callback));
|
|
89
|
+
|
|
90
|
+
let returnValue: string | undefined;
|
|
91
|
+
act(() => {
|
|
92
|
+
returnValue = result.current();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
expect(returnValue).toBe("result");
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { useRef, useCallback, useLayoutEffect, useEffect } from "react";
|
|
2
|
+
|
|
3
|
+
const useIsomorphicLayoutEffect =
|
|
4
|
+
typeof window !== "undefined" ? useLayoutEffect : useEffect;
|
|
5
|
+
|
|
6
|
+
export function useStableCallback<
|
|
7
|
+
T extends ((...args: any[]) => any) | undefined,
|
|
8
|
+
>(callback: T): T {
|
|
9
|
+
const callbackRef = useRef(callback);
|
|
10
|
+
|
|
11
|
+
useIsomorphicLayoutEffect(() => {
|
|
12
|
+
callbackRef.current = callback;
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// Always create the stable wrapper (hooks can't be conditional),
|
|
16
|
+
// but return undefined when no callback is provided to preserve
|
|
17
|
+
// truthiness semantics for consumers that branch on it.
|
|
18
|
+
const stable = useCallback(
|
|
19
|
+
((...args: any[]) => callbackRef.current?.(...args)) as NonNullable<T>,
|
|
20
|
+
[]
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
return callback ? stable : callback;
|
|
24
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { renderHook, act } from "@testing-library/react";
|
|
3
|
+
import { useThemeSync } from "./useThemeSync";
|
|
4
|
+
|
|
5
|
+
describe("useThemeSync", () => {
|
|
6
|
+
let mediaQueryListeners: Array<(e: MediaQueryListEvent) => void> = [];
|
|
7
|
+
let mockMatches = false;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
mediaQueryListeners = [];
|
|
11
|
+
mockMatches = false;
|
|
12
|
+
|
|
13
|
+
Object.defineProperty(window, "matchMedia", {
|
|
14
|
+
writable: true,
|
|
15
|
+
value: vi.fn().mockImplementation((query: string) => ({
|
|
16
|
+
matches: query === "(prefers-color-scheme: dark)" ? mockMatches : false,
|
|
17
|
+
media: query,
|
|
18
|
+
addEventListener: vi.fn((_, handler) => {
|
|
19
|
+
mediaQueryListeners.push(handler);
|
|
20
|
+
}),
|
|
21
|
+
removeEventListener: vi.fn((_, handler) => {
|
|
22
|
+
mediaQueryListeners = mediaQueryListeners.filter(
|
|
23
|
+
(h) => h !== handler
|
|
24
|
+
);
|
|
25
|
+
}),
|
|
26
|
+
})),
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
vi.restoreAllMocks();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("returns light for explicit light theme", () => {
|
|
35
|
+
const { result } = renderHook(() => useThemeSync("light"));
|
|
36
|
+
expect(result.current).toBe("light");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("returns dark for explicit dark theme", () => {
|
|
40
|
+
const { result } = renderHook(() => useThemeSync("dark"));
|
|
41
|
+
expect(result.current).toBe("dark");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("returns light initially for SSR safety when theme is system", () => {
|
|
45
|
+
// Before useEffect runs, should return "light" for consistent SSR
|
|
46
|
+
const { result } = renderHook(() => useThemeSync("system"));
|
|
47
|
+
// After effect runs, it will update based on system preference
|
|
48
|
+
// But the initial render should be "light" for SSR
|
|
49
|
+
expect(result.current).toBe("light"); // mockMatches is false
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("returns dark when system prefers dark and theme is system", () => {
|
|
53
|
+
mockMatches = true;
|
|
54
|
+
const { result } = renderHook(() => useThemeSync("system"));
|
|
55
|
+
// After useEffect runs, should be dark
|
|
56
|
+
expect(result.current).toBe("dark");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("defaults to system when no theme provided", () => {
|
|
60
|
+
mockMatches = true;
|
|
61
|
+
const { result } = renderHook(() => useThemeSync());
|
|
62
|
+
expect(result.current).toBe("dark");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("updates when theme prop changes", () => {
|
|
66
|
+
type ThemeValue = "light" | "dark" | "system";
|
|
67
|
+
const { result, rerender } = renderHook(
|
|
68
|
+
({ theme }: { theme: ThemeValue }) => useThemeSync(theme),
|
|
69
|
+
{ initialProps: { theme: "light" as ThemeValue } }
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
expect(result.current).toBe("light");
|
|
73
|
+
|
|
74
|
+
rerender({ theme: "dark" as ThemeValue });
|
|
75
|
+
expect(result.current).toBe("dark");
|
|
76
|
+
|
|
77
|
+
rerender({ theme: "light" as ThemeValue });
|
|
78
|
+
expect(result.current).toBe("light");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("responds to system theme changes when theme is system", () => {
|
|
82
|
+
mockMatches = false;
|
|
83
|
+
const { result } = renderHook(() => useThemeSync("system"));
|
|
84
|
+
|
|
85
|
+
expect(result.current).toBe("light");
|
|
86
|
+
|
|
87
|
+
// Simulate system theme change
|
|
88
|
+
act(() => {
|
|
89
|
+
mediaQueryListeners.forEach((handler) => {
|
|
90
|
+
handler({ matches: true } as MediaQueryListEvent);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
expect(result.current).toBe("dark");
|
|
95
|
+
|
|
96
|
+
// Change back
|
|
97
|
+
act(() => {
|
|
98
|
+
mediaQueryListeners.forEach((handler) => {
|
|
99
|
+
handler({ matches: false } as MediaQueryListEvent);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
expect(result.current).toBe("light");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("cleans up listener on unmount", () => {
|
|
107
|
+
const { unmount } = renderHook(() => useThemeSync("system"));
|
|
108
|
+
|
|
109
|
+
expect(mediaQueryListeners.length).toBe(1);
|
|
110
|
+
|
|
111
|
+
unmount();
|
|
112
|
+
|
|
113
|
+
expect(mediaQueryListeners.length).toBe(0);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("cleans up listener when theme changes from system to explicit", () => {
|
|
117
|
+
const { rerender } = renderHook(({ theme }) => useThemeSync(theme), {
|
|
118
|
+
initialProps: { theme: "system" as "light" | "dark" | "system" },
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
expect(mediaQueryListeners.length).toBe(1);
|
|
122
|
+
|
|
123
|
+
rerender({ theme: "dark" });
|
|
124
|
+
|
|
125
|
+
expect(mediaQueryListeners.length).toBe(0);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
|
|
3
|
+
type Theme = "light" | "dark";
|
|
4
|
+
type ThemeInput = "light" | "dark" | "system";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Hook to resolve theme based on override and system preference.
|
|
8
|
+
* Listens for system preference changes when theme is "system".
|
|
9
|
+
*/
|
|
10
|
+
export function useThemeSync(theme: ThemeInput = "system"): Theme {
|
|
11
|
+
// Always start with a deterministic value for SSR hydration safety.
|
|
12
|
+
// The actual system preference is synced in useEffect.
|
|
13
|
+
const [resolved, setResolved] = useState<Theme>(
|
|
14
|
+
theme !== "system" ? theme : "light"
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (theme !== "system") {
|
|
19
|
+
setResolved(theme);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
|
24
|
+
|
|
25
|
+
// Set initial value
|
|
26
|
+
setResolved(mq.matches ? "dark" : "light");
|
|
27
|
+
|
|
28
|
+
const handler = (e: MediaQueryListEvent) =>
|
|
29
|
+
setResolved(e.matches ? "dark" : "light");
|
|
30
|
+
|
|
31
|
+
mq.addEventListener("change", handler);
|
|
32
|
+
return () => mq.removeEventListener("change", handler);
|
|
33
|
+
}, [theme]);
|
|
34
|
+
|
|
35
|
+
return resolved;
|
|
36
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Perspective Embed SDK - React
|
|
5
|
+
*
|
|
6
|
+
* Hooks (for overlays - popup, slider, float bubble):
|
|
7
|
+
* import { usePopup, useSlider, useFloatBubble } from '@perspective-ai/sdk-react';
|
|
8
|
+
*
|
|
9
|
+
* const { open } = usePopup({ researchId: "xxx" });
|
|
10
|
+
* <button onClick={open}>Take Survey</button>
|
|
11
|
+
*
|
|
12
|
+
* Components (for embeds - widget, fullpage):
|
|
13
|
+
* import { Widget, Fullpage, FloatBubble } from '@perspective-ai/sdk-react';
|
|
14
|
+
*
|
|
15
|
+
* <Widget researchId="xxx" />
|
|
16
|
+
* <Fullpage researchId="xxx" />
|
|
17
|
+
* <FloatBubble researchId="xxx" />
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export {
|
|
21
|
+
usePopup,
|
|
22
|
+
type UsePopupOptions,
|
|
23
|
+
type UsePopupReturn,
|
|
24
|
+
} from "./hooks/usePopup";
|
|
25
|
+
export {
|
|
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";
|
|
35
|
+
export {
|
|
36
|
+
useAutoOpen,
|
|
37
|
+
type UseAutoOpenOptions,
|
|
38
|
+
type UseAutoOpenReturn,
|
|
39
|
+
} from "./hooks/useAutoOpen";
|
|
40
|
+
export { useThemeSync } from "./hooks/useThemeSync";
|
|
41
|
+
export { useStableCallback } from "./hooks/useStableCallback";
|
|
42
|
+
|
|
43
|
+
export { Widget, type WidgetProps } from "./Widget";
|
|
44
|
+
export { Fullpage, type FullpageProps } from "./Fullpage";
|
|
45
|
+
export { FloatBubble, type FloatBubbleProps } from "./FloatBubble";
|
|
46
|
+
|
|
47
|
+
export type {
|
|
48
|
+
EmbedConfig,
|
|
49
|
+
EmbedHandle,
|
|
50
|
+
FloatHandle,
|
|
51
|
+
BrandColors,
|
|
52
|
+
ThemeValue,
|
|
53
|
+
EmbedError,
|
|
54
|
+
TriggerConfig,
|
|
55
|
+
ShowOnce,
|
|
56
|
+
AutoOpenConfig,
|
|
57
|
+
} from "@perspective-ai/sdk";
|