@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,308 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { render, screen, cleanup } from "@testing-library/react";
|
|
3
|
+
import { createRef, StrictMode } from "react";
|
|
4
|
+
import { Widget } from "./Widget";
|
|
5
|
+
import type { EmbedHandle } from "@perspective-ai/sdk";
|
|
6
|
+
|
|
7
|
+
// Mock the core embed package
|
|
8
|
+
vi.mock("@perspective-ai/sdk", () => ({
|
|
9
|
+
createWidget: vi.fn(() => ({
|
|
10
|
+
unmount: vi.fn(),
|
|
11
|
+
update: vi.fn(),
|
|
12
|
+
destroy: vi.fn(),
|
|
13
|
+
researchId: "test-research-id",
|
|
14
|
+
type: "widget",
|
|
15
|
+
iframe: null,
|
|
16
|
+
container: null,
|
|
17
|
+
})),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
import { createWidget } from "@perspective-ai/sdk";
|
|
21
|
+
const mockCreateWidget = vi.mocked(createWidget);
|
|
22
|
+
|
|
23
|
+
describe("Widget", () => {
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
vi.clearAllMocks();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
cleanup();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("renders a div container", () => {
|
|
33
|
+
render(<Widget researchId="test-research-id" />);
|
|
34
|
+
|
|
35
|
+
const container = screen.getByTestId("perspective-widget");
|
|
36
|
+
expect(container).toBeDefined();
|
|
37
|
+
expect(container.tagName).toBe("DIV");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("has default minHeight of 500px", () => {
|
|
41
|
+
render(<Widget researchId="test-research-id" />);
|
|
42
|
+
|
|
43
|
+
const container = screen.getByTestId("perspective-widget");
|
|
44
|
+
expect(container.style.minHeight).toBe("500px");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("accepts custom className", () => {
|
|
48
|
+
render(<Widget researchId="test-research-id" className="custom-class" />);
|
|
49
|
+
|
|
50
|
+
const container = screen.getByTestId("perspective-widget");
|
|
51
|
+
expect(container.classList.contains("custom-class")).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("accepts custom style", () => {
|
|
55
|
+
render(
|
|
56
|
+
<Widget
|
|
57
|
+
researchId="test-research-id"
|
|
58
|
+
style={{ backgroundColor: "red", minHeight: 600 }}
|
|
59
|
+
/>
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const container = screen.getByTestId("perspective-widget");
|
|
63
|
+
expect(container.style.backgroundColor).toBe("red");
|
|
64
|
+
expect(container.style.minHeight).toBe("600px"); // Custom overrides default
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("calls createWidget with correct config", () => {
|
|
68
|
+
const onReady = vi.fn();
|
|
69
|
+
const onSubmit = vi.fn();
|
|
70
|
+
|
|
71
|
+
render(
|
|
72
|
+
<Widget
|
|
73
|
+
researchId="test-research-id"
|
|
74
|
+
params={{ source: "test" }}
|
|
75
|
+
theme="dark"
|
|
76
|
+
host="https://custom.example.com"
|
|
77
|
+
onReady={onReady}
|
|
78
|
+
onSubmit={onSubmit}
|
|
79
|
+
/>
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
expect(mockCreateWidget).toHaveBeenCalledTimes(1);
|
|
83
|
+
const [container, config] = mockCreateWidget.mock.calls[0]!;
|
|
84
|
+
expect(container).toBeInstanceOf(HTMLDivElement);
|
|
85
|
+
expect(config.researchId).toBe("test-research-id");
|
|
86
|
+
expect(config.params).toEqual({ source: "test" });
|
|
87
|
+
expect(config.theme).toBe("dark");
|
|
88
|
+
expect(config.host).toBe("https://custom.example.com");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("calls unmount on cleanup", () => {
|
|
92
|
+
const mockUnmount = vi.fn();
|
|
93
|
+
mockCreateWidget.mockReturnValueOnce({
|
|
94
|
+
unmount: mockUnmount,
|
|
95
|
+
update: vi.fn(),
|
|
96
|
+
destroy: vi.fn(),
|
|
97
|
+
researchId: "test-research-id",
|
|
98
|
+
type: "widget",
|
|
99
|
+
iframe: null,
|
|
100
|
+
container: null,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const { unmount } = render(<Widget researchId="test-research-id" />);
|
|
104
|
+
|
|
105
|
+
expect(mockUnmount).not.toHaveBeenCalled();
|
|
106
|
+
|
|
107
|
+
unmount();
|
|
108
|
+
|
|
109
|
+
expect(mockUnmount).toHaveBeenCalled();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("exposes handle via embedRef", () => {
|
|
113
|
+
const mockHandle: EmbedHandle = {
|
|
114
|
+
unmount: vi.fn(),
|
|
115
|
+
update: vi.fn(),
|
|
116
|
+
destroy: vi.fn(),
|
|
117
|
+
researchId: "test-research-id",
|
|
118
|
+
type: "widget",
|
|
119
|
+
iframe: null,
|
|
120
|
+
container: null,
|
|
121
|
+
};
|
|
122
|
+
mockCreateWidget.mockReturnValueOnce(mockHandle);
|
|
123
|
+
|
|
124
|
+
const embedRef = createRef<EmbedHandle | null>();
|
|
125
|
+
|
|
126
|
+
render(<Widget researchId="test-research-id" embedRef={embedRef} />);
|
|
127
|
+
|
|
128
|
+
expect(embedRef.current).toBe(mockHandle);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("clears embedRef on unmount", () => {
|
|
132
|
+
const embedRef = createRef<EmbedHandle | null>();
|
|
133
|
+
|
|
134
|
+
const { unmount } = render(
|
|
135
|
+
<Widget researchId="test-research-id" embedRef={embedRef} />
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
expect(embedRef.current).not.toBeNull();
|
|
139
|
+
|
|
140
|
+
unmount();
|
|
141
|
+
|
|
142
|
+
expect(embedRef.current).toBeNull();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("re-creates widget when researchId changes", () => {
|
|
146
|
+
const { rerender } = render(<Widget researchId="research-1" />);
|
|
147
|
+
|
|
148
|
+
expect(mockCreateWidget).toHaveBeenCalledTimes(1);
|
|
149
|
+
|
|
150
|
+
rerender(<Widget researchId="research-2" />);
|
|
151
|
+
|
|
152
|
+
expect(mockCreateWidget).toHaveBeenCalledTimes(2);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("passes additional div props", () => {
|
|
156
|
+
render(
|
|
157
|
+
<Widget
|
|
158
|
+
researchId="test-research-id"
|
|
159
|
+
aria-label="Interview widget"
|
|
160
|
+
role="region"
|
|
161
|
+
/>
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const container = screen.getByTestId("perspective-widget");
|
|
165
|
+
expect(container.getAttribute("aria-label")).toBe("Interview widget");
|
|
166
|
+
expect(container.getAttribute("role")).toBe("region");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("StrictMode behavior", () => {
|
|
170
|
+
it("creates only one iframe in StrictMode (mock inserts real DOM)", () => {
|
|
171
|
+
const mockUnmount = vi.fn();
|
|
172
|
+
mockCreateWidget.mockImplementation((containerEl: HTMLElement | null) => {
|
|
173
|
+
if (!containerEl) {
|
|
174
|
+
return {
|
|
175
|
+
unmount: vi.fn(),
|
|
176
|
+
update: vi.fn(),
|
|
177
|
+
destroy: vi.fn(),
|
|
178
|
+
researchId: "test-research-id",
|
|
179
|
+
type: "widget" as const,
|
|
180
|
+
iframe: null,
|
|
181
|
+
container: null,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
const iframe = document.createElement("iframe");
|
|
185
|
+
iframe.setAttribute("data-perspective", "true");
|
|
186
|
+
containerEl.appendChild(iframe);
|
|
187
|
+
return {
|
|
188
|
+
unmount: () => {
|
|
189
|
+
mockUnmount();
|
|
190
|
+
iframe.remove();
|
|
191
|
+
},
|
|
192
|
+
update: vi.fn(),
|
|
193
|
+
destroy: vi.fn(),
|
|
194
|
+
researchId: "test-research-id",
|
|
195
|
+
type: "widget" as const,
|
|
196
|
+
iframe,
|
|
197
|
+
container: containerEl,
|
|
198
|
+
};
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
render(
|
|
202
|
+
<StrictMode>
|
|
203
|
+
<Widget researchId="test-research-id" />
|
|
204
|
+
</StrictMode>
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
const container = screen.getByTestId("perspective-widget");
|
|
208
|
+
const iframes = container.querySelectorAll("iframe[data-perspective]");
|
|
209
|
+
expect(iframes.length).toBe(1);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("properly cleans up in StrictMode double-mount cycle", () => {
|
|
213
|
+
const mockUnmount = vi.fn();
|
|
214
|
+
mockCreateWidget.mockImplementation((containerEl: HTMLElement | null) => {
|
|
215
|
+
if (!containerEl) {
|
|
216
|
+
return {
|
|
217
|
+
unmount: vi.fn(),
|
|
218
|
+
update: vi.fn(),
|
|
219
|
+
destroy: vi.fn(),
|
|
220
|
+
researchId: "test-research-id",
|
|
221
|
+
type: "widget" as const,
|
|
222
|
+
iframe: null,
|
|
223
|
+
container: null,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
const iframe = document.createElement("iframe");
|
|
227
|
+
iframe.setAttribute("data-perspective", "true");
|
|
228
|
+
containerEl.appendChild(iframe);
|
|
229
|
+
return {
|
|
230
|
+
unmount: () => {
|
|
231
|
+
mockUnmount();
|
|
232
|
+
iframe.remove();
|
|
233
|
+
},
|
|
234
|
+
update: vi.fn(),
|
|
235
|
+
destroy: vi.fn(),
|
|
236
|
+
researchId: "test-research-id",
|
|
237
|
+
type: "widget" as const,
|
|
238
|
+
iframe,
|
|
239
|
+
container: containerEl,
|
|
240
|
+
};
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const { unmount } = render(
|
|
244
|
+
<StrictMode>
|
|
245
|
+
<Widget researchId="test-research-id" />
|
|
246
|
+
</StrictMode>
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
unmount();
|
|
250
|
+
|
|
251
|
+
expect(mockUnmount).toHaveBeenCalled();
|
|
252
|
+
const container = screen.queryByTestId("perspective-widget");
|
|
253
|
+
if (container) {
|
|
254
|
+
const iframes = container.querySelectorAll("iframe[data-perspective]");
|
|
255
|
+
expect(iframes.length).toBe(0);
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("StrictMode double-mount calls createWidget twice but cleanup prevents duplicates", () => {
|
|
260
|
+
const createCalls: number[] = [];
|
|
261
|
+
const unmountCalls: number[] = [];
|
|
262
|
+
let callCount = 0;
|
|
263
|
+
|
|
264
|
+
mockCreateWidget.mockImplementation((containerEl: HTMLElement | null) => {
|
|
265
|
+
if (!containerEl) {
|
|
266
|
+
return {
|
|
267
|
+
unmount: vi.fn(),
|
|
268
|
+
update: vi.fn(),
|
|
269
|
+
destroy: vi.fn(),
|
|
270
|
+
researchId: "test-research-id",
|
|
271
|
+
type: "widget" as const,
|
|
272
|
+
iframe: null,
|
|
273
|
+
container: null,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
const thisCall = ++callCount;
|
|
277
|
+
createCalls.push(thisCall);
|
|
278
|
+
const iframe = document.createElement("iframe");
|
|
279
|
+
iframe.setAttribute("data-perspective", "true");
|
|
280
|
+
iframe.setAttribute("data-call", String(thisCall));
|
|
281
|
+
containerEl.appendChild(iframe);
|
|
282
|
+
return {
|
|
283
|
+
unmount: () => {
|
|
284
|
+
unmountCalls.push(thisCall);
|
|
285
|
+
iframe.remove();
|
|
286
|
+
},
|
|
287
|
+
update: vi.fn(),
|
|
288
|
+
destroy: vi.fn(),
|
|
289
|
+
researchId: "test-research-id",
|
|
290
|
+
type: "widget" as const,
|
|
291
|
+
iframe,
|
|
292
|
+
container: containerEl,
|
|
293
|
+
};
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
render(
|
|
297
|
+
<StrictMode>
|
|
298
|
+
<Widget researchId="test-research-id" />
|
|
299
|
+
</StrictMode>
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
const container = screen.getByTestId("perspective-widget");
|
|
303
|
+
const iframes = container.querySelectorAll("iframe[data-perspective]");
|
|
304
|
+
expect(iframes.length).toBe(1);
|
|
305
|
+
expect(createCalls.length).toBeGreaterThanOrEqual(1);
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
});
|
package/src/Widget.tsx
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { useRef, useEffect, type HTMLAttributes, type RefObject } from "react";
|
|
2
|
+
import {
|
|
3
|
+
createWidget,
|
|
4
|
+
type EmbedConfig,
|
|
5
|
+
type EmbedHandle,
|
|
6
|
+
} from "@perspective-ai/sdk";
|
|
7
|
+
import { useStableCallback } from "./hooks/useStableCallback";
|
|
8
|
+
|
|
9
|
+
export interface WidgetProps
|
|
10
|
+
extends
|
|
11
|
+
Omit<EmbedConfig, "type">,
|
|
12
|
+
Omit<HTMLAttributes<HTMLDivElement>, "onError" | "onSubmit"> {
|
|
13
|
+
/** Ref to access the embed handle for programmatic control */
|
|
14
|
+
embedRef?: RefObject<EmbedHandle | null>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Inline widget embed component.
|
|
19
|
+
* Renders the interview directly in a container.
|
|
20
|
+
*/
|
|
21
|
+
export function Widget({
|
|
22
|
+
researchId,
|
|
23
|
+
params,
|
|
24
|
+
brand,
|
|
25
|
+
theme,
|
|
26
|
+
host,
|
|
27
|
+
onReady,
|
|
28
|
+
onSubmit,
|
|
29
|
+
onNavigate,
|
|
30
|
+
onClose,
|
|
31
|
+
onError,
|
|
32
|
+
embedRef,
|
|
33
|
+
className,
|
|
34
|
+
style,
|
|
35
|
+
...divProps
|
|
36
|
+
}: WidgetProps) {
|
|
37
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
38
|
+
const handleRef = useRef<EmbedHandle | null>(null);
|
|
39
|
+
|
|
40
|
+
// Stable callbacks to avoid re-mounting on callback changes
|
|
41
|
+
const stableOnReady = useStableCallback(onReady);
|
|
42
|
+
const stableOnSubmit = useStableCallback(onSubmit);
|
|
43
|
+
const stableOnNavigate = useStableCallback(onNavigate);
|
|
44
|
+
const stableOnClose = useStableCallback(onClose);
|
|
45
|
+
const stableOnError = useStableCallback(onError);
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
const container = containerRef.current;
|
|
49
|
+
if (!container) return;
|
|
50
|
+
|
|
51
|
+
const handle = createWidget(container, {
|
|
52
|
+
researchId,
|
|
53
|
+
params,
|
|
54
|
+
brand,
|
|
55
|
+
theme,
|
|
56
|
+
host,
|
|
57
|
+
onReady: stableOnReady,
|
|
58
|
+
onSubmit: stableOnSubmit,
|
|
59
|
+
onNavigate: stableOnNavigate,
|
|
60
|
+
onClose: stableOnClose,
|
|
61
|
+
onError: stableOnError,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
handleRef.current = handle;
|
|
65
|
+
|
|
66
|
+
if (embedRef) {
|
|
67
|
+
embedRef.current = handle;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return () => {
|
|
71
|
+
handle.unmount();
|
|
72
|
+
handleRef.current = null;
|
|
73
|
+
if (embedRef) {
|
|
74
|
+
embedRef.current = null;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
}, [
|
|
78
|
+
researchId,
|
|
79
|
+
params,
|
|
80
|
+
brand,
|
|
81
|
+
theme,
|
|
82
|
+
host,
|
|
83
|
+
stableOnReady,
|
|
84
|
+
stableOnSubmit,
|
|
85
|
+
stableOnNavigate,
|
|
86
|
+
stableOnClose,
|
|
87
|
+
stableOnError,
|
|
88
|
+
embedRef,
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<div
|
|
93
|
+
ref={containerRef}
|
|
94
|
+
className={className}
|
|
95
|
+
style={{ minHeight: 500, ...style }}
|
|
96
|
+
data-testid="perspective-widget"
|
|
97
|
+
{...divProps}
|
|
98
|
+
/>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
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("handles undefined callback", () => {
|
|
51
|
+
const { result } = renderHook(() => useStableCallback(undefined));
|
|
52
|
+
|
|
53
|
+
// Should not throw when called
|
|
54
|
+
expect(() => {
|
|
55
|
+
act(() => {
|
|
56
|
+
result.current("test");
|
|
57
|
+
});
|
|
58
|
+
}).not.toThrow();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("passes through all arguments", () => {
|
|
62
|
+
const callback = vi.fn();
|
|
63
|
+
const { result } = renderHook(() => useStableCallback(callback));
|
|
64
|
+
|
|
65
|
+
act(() => {
|
|
66
|
+
result.current("a", "b", 123, { key: "value" });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(callback).toHaveBeenCalledWith("a", "b", 123, { key: "value" });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("returns the callback's return value", () => {
|
|
73
|
+
const callback = vi.fn().mockReturnValue("result");
|
|
74
|
+
const { result } = renderHook(() => useStableCallback(callback));
|
|
75
|
+
|
|
76
|
+
let returnValue: string | undefined;
|
|
77
|
+
act(() => {
|
|
78
|
+
returnValue = result.current();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
expect(returnValue).toBe("result");
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { useRef, useCallback, useLayoutEffect, useEffect } from "react";
|
|
2
|
+
|
|
3
|
+
const useIsomorphicLayoutEffect =
|
|
4
|
+
typeof window !== "undefined" ? useLayoutEffect : useEffect;
|
|
5
|
+
|
|
6
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
7
|
+
export function useStableCallback<T extends (...args: any[]) => any>(
|
|
8
|
+
callback: T | undefined
|
|
9
|
+
): T {
|
|
10
|
+
const callbackRef = useRef(callback);
|
|
11
|
+
|
|
12
|
+
useIsomorphicLayoutEffect(() => {
|
|
13
|
+
callbackRef.current = callback;
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
return useCallback(
|
|
17
|
+
((...args: Parameters<T>) => callbackRef.current?.(...args)) as T,
|
|
18
|
+
[]
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -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
|
+
}
|