@perspective-ai/sdk 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 +333 -0
- package/dist/browser.cjs +1939 -0
- package/dist/browser.cjs.map +1 -0
- package/dist/browser.d.cts +213 -0
- package/dist/browser.d.ts +213 -0
- package/dist/browser.js +1900 -0
- package/dist/browser.js.map +1 -0
- package/dist/cdn/perspective.global.js +406 -0
- package/dist/cdn/perspective.global.js.map +1 -0
- package/dist/constants.cjs +142 -0
- package/dist/constants.cjs.map +1 -0
- package/dist/constants.d.cts +104 -0
- package/dist/constants.d.ts +104 -0
- package/dist/constants.js +127 -0
- package/dist/constants.js.map +1 -0
- package/dist/index.cjs +1596 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +155 -0
- package/dist/index.d.ts +155 -0
- package/dist/index.js +1579 -0
- package/dist/index.js.map +1 -0
- package/package.json +83 -0
- package/src/browser.test.ts +388 -0
- package/src/browser.ts +509 -0
- package/src/config.test.ts +81 -0
- package/src/config.ts +95 -0
- package/src/constants.ts +214 -0
- package/src/float.test.ts +332 -0
- package/src/float.ts +231 -0
- package/src/fullpage.test.ts +224 -0
- package/src/fullpage.ts +126 -0
- package/src/iframe.test.ts +1037 -0
- package/src/iframe.ts +421 -0
- package/src/index.ts +61 -0
- package/src/loading.ts +90 -0
- package/src/popup.test.ts +344 -0
- package/src/popup.ts +157 -0
- package/src/slider.test.ts +277 -0
- package/src/slider.ts +158 -0
- package/src/styles.ts +395 -0
- package/src/types.ts +148 -0
- package/src/utils.test.ts +162 -0
- package/src/utils.ts +86 -0
- package/src/widget.test.ts +375 -0
- package/src/widget.ts +195 -0
package/src/utils.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared utilities for the Perspective Embed SDK
|
|
3
|
+
* SSR-safe - DOM access is guarded
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { THEME_VALUES, type ThemeValue } from "./constants";
|
|
7
|
+
import { hasDom } from "./config";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Join class names, filtering out falsy values
|
|
11
|
+
*/
|
|
12
|
+
export function cn(...classes: (string | false | null | undefined)[]): string {
|
|
13
|
+
return classes
|
|
14
|
+
.map((c) => (c || "").split(" "))
|
|
15
|
+
.flat()
|
|
16
|
+
.filter(Boolean)
|
|
17
|
+
.join(" ");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Get the perspective theme class based on the theme value
|
|
22
|
+
* Returns "perspective-{theme}-theme" when theme is available, undefined otherwise
|
|
23
|
+
*/
|
|
24
|
+
export function getThemeClass(theme: string | undefined): string | undefined {
|
|
25
|
+
return theme && theme !== THEME_VALUES.system
|
|
26
|
+
? `perspective-${theme}-theme`
|
|
27
|
+
: undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolve whether dark mode should be used.
|
|
32
|
+
* Priority: 1) explicit theme override, 2) system preference
|
|
33
|
+
* SSR-safe: defaults to light theme on server
|
|
34
|
+
*/
|
|
35
|
+
export function resolveIsDark(theme?: ThemeValue | string): boolean {
|
|
36
|
+
if (theme === THEME_VALUES.dark) return true;
|
|
37
|
+
if (theme === THEME_VALUES.light) return false;
|
|
38
|
+
// system or undefined → use system preference (or light on server)
|
|
39
|
+
if (!hasDom()) return false;
|
|
40
|
+
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolve effective theme based on override and system preference
|
|
45
|
+
* Returns the theme string ('light' or 'dark')
|
|
46
|
+
*/
|
|
47
|
+
export function resolveTheme(themeOverride?: ThemeValue): "light" | "dark" {
|
|
48
|
+
return resolveIsDark(themeOverride) ? "dark" : "light";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Normalize and validate hex color. Returns undefined for invalid colors.
|
|
53
|
+
*/
|
|
54
|
+
export function normalizeHex(color: string): string | undefined {
|
|
55
|
+
const trimmed = color.trim();
|
|
56
|
+
if (!trimmed) return undefined;
|
|
57
|
+
|
|
58
|
+
const normalized = trimmed.startsWith("#") ? trimmed : `#${trimmed}`;
|
|
59
|
+
|
|
60
|
+
// Validate hex format (#RGB, #RRGGBB, or #RRGGBBAA)
|
|
61
|
+
if (/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(normalized)) {
|
|
62
|
+
return normalized;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Try to extract valid hex chars
|
|
66
|
+
const hexChars = normalized.slice(1).replace(/[^0-9a-fA-F]/g, "");
|
|
67
|
+
if (hexChars.length >= 6) return `#${hexChars.slice(0, 6)}`;
|
|
68
|
+
if (hexChars.length >= 3) return `#${hexChars.slice(0, 3)}`;
|
|
69
|
+
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Convert hex to rgba for spinner track
|
|
75
|
+
*/
|
|
76
|
+
export function hexToRgba(hex: string, alpha: number): string {
|
|
77
|
+
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
|
78
|
+
if (!result || !result[1] || !result[2] || !result[3]) {
|
|
79
|
+
return `rgba(118, 41, 200, ${alpha})`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const r = parseInt(result[1], 16);
|
|
83
|
+
const g = parseInt(result[2], 16);
|
|
84
|
+
const b = parseInt(result[3], 16);
|
|
85
|
+
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
|
86
|
+
}
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import { createWidget } from "./widget";
|
|
3
|
+
import * as config from "./config";
|
|
4
|
+
import * as iframe from "./iframe";
|
|
5
|
+
|
|
6
|
+
describe("createWidget", () => {
|
|
7
|
+
let container: HTMLDivElement;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
container = document.createElement("div");
|
|
11
|
+
document.body.appendChild(container);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
container.remove();
|
|
16
|
+
vi.restoreAllMocks();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("creates widget in container", () => {
|
|
20
|
+
const handle = createWidget(container, {
|
|
21
|
+
researchId: "test-research-id",
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
expect(handle.researchId).toBe("test-research-id");
|
|
25
|
+
expect(handle.type).toBe("widget");
|
|
26
|
+
expect(handle.container).toBe(container);
|
|
27
|
+
expect(handle.iframe).toBeInstanceOf(HTMLIFrameElement);
|
|
28
|
+
expect(container.querySelector("iframe[data-perspective]")).toBeTruthy();
|
|
29
|
+
|
|
30
|
+
handle.unmount();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("creates loading indicator", () => {
|
|
34
|
+
const handle = createWidget(container, {
|
|
35
|
+
researchId: "test-research-id",
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const loading = container.querySelector(".perspective-loading");
|
|
39
|
+
expect(loading).toBeTruthy();
|
|
40
|
+
|
|
41
|
+
handle.unmount();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("returns no-op handle when no DOM", () => {
|
|
45
|
+
vi.spyOn(config, "hasDom").mockReturnValue(false);
|
|
46
|
+
|
|
47
|
+
const handle = createWidget(container, {
|
|
48
|
+
researchId: "test-research-id",
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
expect(handle.iframe).toBeNull();
|
|
52
|
+
expect(handle.container).toBeNull();
|
|
53
|
+
expect(handle.unmount).toBeInstanceOf(Function);
|
|
54
|
+
expect(() => handle.unmount()).not.toThrow();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("returns no-op handle when container is null", () => {
|
|
58
|
+
const handle = createWidget(null, {
|
|
59
|
+
researchId: "test-research-id",
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
expect(handle.iframe).toBeNull();
|
|
63
|
+
expect(handle.container).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("is idempotent (React Strict Mode safe)", () => {
|
|
67
|
+
const handle1 = createWidget(container, {
|
|
68
|
+
researchId: "test-research-id",
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const handle2 = createWidget(container, {
|
|
72
|
+
researchId: "test-research-id",
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
expect(handle1.iframe).toBeInstanceOf(HTMLIFrameElement);
|
|
76
|
+
expect(handle2.iframe).toBeInstanceOf(HTMLIFrameElement);
|
|
77
|
+
expect(handle2.iframe).toBe(handle1.iframe);
|
|
78
|
+
|
|
79
|
+
const iframes = container.querySelectorAll("iframe[data-perspective]");
|
|
80
|
+
expect(iframes.length).toBe(1);
|
|
81
|
+
|
|
82
|
+
handle1.unmount();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("idempotent handle can clean up existing widget", () => {
|
|
86
|
+
createWidget(container, {
|
|
87
|
+
researchId: "test-research-id",
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
expect(container.querySelector("iframe[data-perspective]")).toBeTruthy();
|
|
91
|
+
|
|
92
|
+
const handle2 = createWidget(container, {
|
|
93
|
+
researchId: "test-research-id",
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
handle2.unmount();
|
|
97
|
+
|
|
98
|
+
expect(container.querySelector("iframe[data-perspective]")).toBeFalsy();
|
|
99
|
+
expect(container.querySelector(".perspective-embed-root")).toBeFalsy();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("idempotent handle cleans up message listeners and iframe registry", () => {
|
|
103
|
+
const cleanupSpy = vi.fn();
|
|
104
|
+
const unregisterSpy = vi.fn();
|
|
105
|
+
|
|
106
|
+
vi.spyOn(iframe, "setupMessageListener").mockReturnValue(cleanupSpy);
|
|
107
|
+
vi.spyOn(iframe, "registerIframe").mockReturnValue(unregisterSpy);
|
|
108
|
+
|
|
109
|
+
createWidget(container, {
|
|
110
|
+
researchId: "test-research-id",
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const handle2 = createWidget(container, {
|
|
114
|
+
researchId: "test-research-id",
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
expect(cleanupSpy).not.toHaveBeenCalled();
|
|
118
|
+
expect(unregisterSpy).not.toHaveBeenCalled();
|
|
119
|
+
|
|
120
|
+
handle2.unmount();
|
|
121
|
+
|
|
122
|
+
expect(cleanupSpy).toHaveBeenCalledTimes(1);
|
|
123
|
+
expect(unregisterSpy).toHaveBeenCalledTimes(1);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("unmount removes elements", () => {
|
|
127
|
+
const handle = createWidget(container, {
|
|
128
|
+
researchId: "test-research-id",
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
expect(container.querySelector("iframe[data-perspective]")).toBeTruthy();
|
|
132
|
+
|
|
133
|
+
handle.unmount();
|
|
134
|
+
|
|
135
|
+
expect(container.querySelector("iframe[data-perspective]")).toBeFalsy();
|
|
136
|
+
expect(container.querySelector(".perspective-embed-root")).toBeFalsy();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("destroy is alias for unmount", () => {
|
|
140
|
+
const handle = createWidget(container, {
|
|
141
|
+
researchId: "test-research-id",
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
expect(container.querySelector("iframe[data-perspective]")).toBeTruthy();
|
|
145
|
+
|
|
146
|
+
handle.destroy();
|
|
147
|
+
|
|
148
|
+
expect(container.querySelector("iframe[data-perspective]")).toBeFalsy();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("update modifies config", () => {
|
|
152
|
+
const onSubmit1 = vi.fn();
|
|
153
|
+
const onSubmit2 = vi.fn();
|
|
154
|
+
|
|
155
|
+
const handle = createWidget(container, {
|
|
156
|
+
researchId: "test-research-id",
|
|
157
|
+
onSubmit: onSubmit1,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
handle.update({ onSubmit: onSubmit2 });
|
|
161
|
+
|
|
162
|
+
// The update should not throw
|
|
163
|
+
expect(() => handle.update({ onSubmit: onSubmit2 })).not.toThrow();
|
|
164
|
+
|
|
165
|
+
handle.unmount();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("applies theme class", () => {
|
|
169
|
+
const handle = createWidget(container, {
|
|
170
|
+
researchId: "test-research-id",
|
|
171
|
+
theme: "dark",
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const wrapper = container.querySelector(".perspective-embed-root");
|
|
175
|
+
expect(wrapper?.classList.contains("perspective-dark-theme")).toBe(true);
|
|
176
|
+
|
|
177
|
+
handle.unmount();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("uses custom host", () => {
|
|
181
|
+
const handle = createWidget(container, {
|
|
182
|
+
researchId: "test-research-id",
|
|
183
|
+
host: "https://custom.example.com",
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const iframe = handle.iframe as HTMLIFrameElement;
|
|
187
|
+
expect(iframe.src).toContain("https://custom.example.com");
|
|
188
|
+
|
|
189
|
+
handle.unmount();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("passes params to iframe", () => {
|
|
193
|
+
const handle = createWidget(container, {
|
|
194
|
+
researchId: "test-research-id",
|
|
195
|
+
params: { source: "test", campaign: "demo" },
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const iframe = handle.iframe as HTMLIFrameElement;
|
|
199
|
+
const url = new URL(iframe.src);
|
|
200
|
+
expect(url.searchParams.get("source")).toBe("test");
|
|
201
|
+
expect(url.searchParams.get("campaign")).toBe("demo");
|
|
202
|
+
|
|
203
|
+
handle.unmount();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("unmount is idempotent - calling twice does not throw", () => {
|
|
207
|
+
const cleanupSpy = vi.fn();
|
|
208
|
+
const unregisterSpy = vi.fn();
|
|
209
|
+
|
|
210
|
+
vi.spyOn(iframe, "setupMessageListener").mockReturnValue(cleanupSpy);
|
|
211
|
+
vi.spyOn(iframe, "registerIframe").mockReturnValue(unregisterSpy);
|
|
212
|
+
|
|
213
|
+
const handle = createWidget(container, {
|
|
214
|
+
researchId: "test-research-id",
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
expect(() => {
|
|
218
|
+
handle.unmount();
|
|
219
|
+
handle.unmount();
|
|
220
|
+
}).not.toThrow();
|
|
221
|
+
|
|
222
|
+
// Cleanup functions should only be called once
|
|
223
|
+
expect(cleanupSpy).toHaveBeenCalledTimes(1);
|
|
224
|
+
expect(unregisterSpy).toHaveBeenCalledTimes(1);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("destroy is idempotent - calling twice does not throw", () => {
|
|
228
|
+
const cleanupSpy = vi.fn();
|
|
229
|
+
const unregisterSpy = vi.fn();
|
|
230
|
+
|
|
231
|
+
vi.spyOn(iframe, "setupMessageListener").mockReturnValue(cleanupSpy);
|
|
232
|
+
vi.spyOn(iframe, "registerIframe").mockReturnValue(unregisterSpy);
|
|
233
|
+
|
|
234
|
+
const handle = createWidget(container, {
|
|
235
|
+
researchId: "test-research-id",
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
expect(() => {
|
|
239
|
+
handle.destroy();
|
|
240
|
+
handle.destroy();
|
|
241
|
+
}).not.toThrow();
|
|
242
|
+
|
|
243
|
+
// Cleanup functions should only be called once
|
|
244
|
+
expect(cleanupSpy).toHaveBeenCalledTimes(1);
|
|
245
|
+
expect(unregisterSpy).toHaveBeenCalledTimes(1);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("mixed unmount then destroy does not double-cleanup", () => {
|
|
249
|
+
const cleanupSpy = vi.fn();
|
|
250
|
+
const unregisterSpy = vi.fn();
|
|
251
|
+
|
|
252
|
+
vi.spyOn(iframe, "setupMessageListener").mockReturnValue(cleanupSpy);
|
|
253
|
+
vi.spyOn(iframe, "registerIframe").mockReturnValue(unregisterSpy);
|
|
254
|
+
|
|
255
|
+
const handle = createWidget(container, {
|
|
256
|
+
researchId: "test-research-id",
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
expect(() => {
|
|
260
|
+
handle.unmount();
|
|
261
|
+
handle.destroy();
|
|
262
|
+
}).not.toThrow();
|
|
263
|
+
|
|
264
|
+
// Cleanup functions should only be called once
|
|
265
|
+
expect(cleanupSpy).toHaveBeenCalledTimes(1);
|
|
266
|
+
expect(unregisterSpy).toHaveBeenCalledTimes(1);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("idempotent handle unmount is also idempotent", () => {
|
|
270
|
+
const cleanupSpy = vi.fn();
|
|
271
|
+
const unregisterSpy = vi.fn();
|
|
272
|
+
|
|
273
|
+
vi.spyOn(iframe, "setupMessageListener").mockReturnValue(cleanupSpy);
|
|
274
|
+
vi.spyOn(iframe, "registerIframe").mockReturnValue(unregisterSpy);
|
|
275
|
+
|
|
276
|
+
// Create first handle
|
|
277
|
+
createWidget(container, {
|
|
278
|
+
researchId: "test-research-id",
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// Get idempotent handle
|
|
282
|
+
const handle2 = createWidget(container, {
|
|
283
|
+
researchId: "test-research-id",
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
expect(() => {
|
|
287
|
+
handle2.unmount();
|
|
288
|
+
handle2.unmount();
|
|
289
|
+
}).not.toThrow();
|
|
290
|
+
|
|
291
|
+
// Cleanup functions should only be called once
|
|
292
|
+
expect(cleanupSpy).toHaveBeenCalledTimes(1);
|
|
293
|
+
expect(unregisterSpy).toHaveBeenCalledTimes(1);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
describe("update() behavior", () => {
|
|
297
|
+
const host = "https://getperspective.ai";
|
|
298
|
+
const researchId = "test-research-id";
|
|
299
|
+
|
|
300
|
+
const sendMessage = (
|
|
301
|
+
iframeEl: HTMLIFrameElement,
|
|
302
|
+
type: string,
|
|
303
|
+
extra?: Record<string, unknown>
|
|
304
|
+
) => {
|
|
305
|
+
window.dispatchEvent(
|
|
306
|
+
new MessageEvent("message", {
|
|
307
|
+
data: { type, researchId, ...extra },
|
|
308
|
+
origin: host,
|
|
309
|
+
source: iframeEl.contentWindow,
|
|
310
|
+
})
|
|
311
|
+
);
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
it("update changes which callback is invoked", () => {
|
|
315
|
+
const onSubmit1 = vi.fn();
|
|
316
|
+
const onSubmit2 = vi.fn();
|
|
317
|
+
|
|
318
|
+
const handle = createWidget(container, {
|
|
319
|
+
researchId,
|
|
320
|
+
host,
|
|
321
|
+
onSubmit: onSubmit1,
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
handle.update({ onSubmit: onSubmit2 });
|
|
325
|
+
|
|
326
|
+
sendMessage(handle.iframe!, "perspective:submit");
|
|
327
|
+
|
|
328
|
+
expect(onSubmit2).toHaveBeenCalledTimes(1);
|
|
329
|
+
expect(onSubmit1).not.toHaveBeenCalled();
|
|
330
|
+
|
|
331
|
+
handle.unmount();
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("sequential updates only use latest callback", () => {
|
|
335
|
+
const fn1 = vi.fn();
|
|
336
|
+
const fn2 = vi.fn();
|
|
337
|
+
const fn3 = vi.fn();
|
|
338
|
+
|
|
339
|
+
const handle = createWidget(container, {
|
|
340
|
+
researchId,
|
|
341
|
+
host,
|
|
342
|
+
onSubmit: fn1,
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
handle.update({ onSubmit: fn2 });
|
|
346
|
+
handle.update({ onSubmit: fn3 });
|
|
347
|
+
|
|
348
|
+
sendMessage(handle.iframe!, "perspective:submit");
|
|
349
|
+
|
|
350
|
+
expect(fn3).toHaveBeenCalledTimes(1);
|
|
351
|
+
expect(fn2).not.toHaveBeenCalled();
|
|
352
|
+
expect(fn1).not.toHaveBeenCalled();
|
|
353
|
+
|
|
354
|
+
handle.unmount();
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("destroy prevents further callback invocations", () => {
|
|
358
|
+
const onSubmit = vi.fn();
|
|
359
|
+
|
|
360
|
+
const handle = createWidget(container, {
|
|
361
|
+
researchId,
|
|
362
|
+
host,
|
|
363
|
+
onSubmit,
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
const iframeEl = handle.iframe!;
|
|
367
|
+
|
|
368
|
+
handle.unmount();
|
|
369
|
+
|
|
370
|
+
sendMessage(iframeEl, "perspective:submit");
|
|
371
|
+
|
|
372
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
});
|
package/src/widget.ts
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inline widget embed - renders directly in a container element
|
|
3
|
+
* SSR-safe - returns no-op handle on server
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { EmbedConfig, EmbedHandle } from "./types";
|
|
7
|
+
import { hasDom, getHost } from "./config";
|
|
8
|
+
import {
|
|
9
|
+
createIframe,
|
|
10
|
+
setupMessageListener,
|
|
11
|
+
registerIframe,
|
|
12
|
+
ensureGlobalListeners,
|
|
13
|
+
} from "./iframe";
|
|
14
|
+
import { createLoadingIndicator } from "./loading";
|
|
15
|
+
import { injectStyles } from "./styles";
|
|
16
|
+
import { cn, getThemeClass } from "./utils";
|
|
17
|
+
|
|
18
|
+
type WidgetResources = {
|
|
19
|
+
cleanup: () => void;
|
|
20
|
+
unregister: () => void;
|
|
21
|
+
wrapper: HTMLElement;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const widgetResources = new WeakMap<HTMLIFrameElement, WidgetResources>();
|
|
25
|
+
|
|
26
|
+
function createNoOpHandle(researchId: string, type: "widget"): EmbedHandle {
|
|
27
|
+
return {
|
|
28
|
+
unmount: () => {},
|
|
29
|
+
update: () => {},
|
|
30
|
+
destroy: () => {},
|
|
31
|
+
researchId,
|
|
32
|
+
type,
|
|
33
|
+
iframe: null,
|
|
34
|
+
container: null,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function createExistingWidgetHandle(
|
|
39
|
+
container: HTMLElement,
|
|
40
|
+
researchId: string
|
|
41
|
+
): EmbedHandle {
|
|
42
|
+
const existingWrapper = container.querySelector<HTMLElement>(
|
|
43
|
+
".perspective-embed-root"
|
|
44
|
+
);
|
|
45
|
+
const existingIframe = container.querySelector<HTMLIFrameElement>(
|
|
46
|
+
"iframe[data-perspective]"
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
let destroyed = false;
|
|
50
|
+
|
|
51
|
+
const unmount = () => {
|
|
52
|
+
if (destroyed) return;
|
|
53
|
+
destroyed = true;
|
|
54
|
+
|
|
55
|
+
if (existingIframe) {
|
|
56
|
+
const resources = widgetResources.get(existingIframe);
|
|
57
|
+
if (resources) {
|
|
58
|
+
resources.cleanup();
|
|
59
|
+
resources.unregister();
|
|
60
|
+
widgetResources.delete(existingIframe);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
existingWrapper?.remove();
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
unmount,
|
|
68
|
+
update: () => {},
|
|
69
|
+
destroy: unmount,
|
|
70
|
+
researchId,
|
|
71
|
+
type: "widget" as const,
|
|
72
|
+
iframe: existingIframe,
|
|
73
|
+
container,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function createWidget(
|
|
78
|
+
container: HTMLElement | null,
|
|
79
|
+
config: EmbedConfig
|
|
80
|
+
): EmbedHandle {
|
|
81
|
+
const { researchId } = config;
|
|
82
|
+
|
|
83
|
+
// SSR safety: return no-op handle
|
|
84
|
+
if (!hasDom() || !container) {
|
|
85
|
+
return createNoOpHandle(researchId, "widget");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Idempotency check for React Strict Mode
|
|
89
|
+
if (container.querySelector("iframe[data-perspective]")) {
|
|
90
|
+
return createExistingWidgetHandle(container, researchId);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const host = getHost(config.host);
|
|
94
|
+
|
|
95
|
+
injectStyles();
|
|
96
|
+
ensureGlobalListeners();
|
|
97
|
+
|
|
98
|
+
// Create wrapper for positioning
|
|
99
|
+
const wrapper = document.createElement("div");
|
|
100
|
+
wrapper.className = cn("perspective-embed-root", getThemeClass(config.theme));
|
|
101
|
+
wrapper.style.cssText =
|
|
102
|
+
"position:relative;width:100%;height:100%;min-height:500px;";
|
|
103
|
+
|
|
104
|
+
// Create loading indicator with theme and brand colors
|
|
105
|
+
const loading = createLoadingIndicator({
|
|
106
|
+
theme: config.theme,
|
|
107
|
+
brand: config.brand,
|
|
108
|
+
});
|
|
109
|
+
wrapper.appendChild(loading);
|
|
110
|
+
|
|
111
|
+
// Create iframe (hidden initially)
|
|
112
|
+
const iframe = createIframe(
|
|
113
|
+
researchId,
|
|
114
|
+
"widget",
|
|
115
|
+
host,
|
|
116
|
+
config.params,
|
|
117
|
+
config.brand,
|
|
118
|
+
config.theme
|
|
119
|
+
);
|
|
120
|
+
iframe.style.width = "100%";
|
|
121
|
+
iframe.style.height = "100%";
|
|
122
|
+
iframe.style.minHeight = "500px";
|
|
123
|
+
iframe.style.opacity = "0";
|
|
124
|
+
iframe.style.transition = "opacity 0.3s ease";
|
|
125
|
+
|
|
126
|
+
wrapper.appendChild(iframe);
|
|
127
|
+
container.appendChild(wrapper);
|
|
128
|
+
|
|
129
|
+
// Mutable config reference for updates
|
|
130
|
+
let currentConfig = { ...config };
|
|
131
|
+
|
|
132
|
+
// Set up message listener with loading state handling
|
|
133
|
+
const cleanup = setupMessageListener(
|
|
134
|
+
researchId,
|
|
135
|
+
{
|
|
136
|
+
get onReady() {
|
|
137
|
+
return () => {
|
|
138
|
+
// Hide loading, show iframe
|
|
139
|
+
loading.style.opacity = "0";
|
|
140
|
+
iframe.style.opacity = "1";
|
|
141
|
+
setTimeout(() => loading.remove(), 300);
|
|
142
|
+
currentConfig.onReady?.();
|
|
143
|
+
};
|
|
144
|
+
},
|
|
145
|
+
get onSubmit() {
|
|
146
|
+
return currentConfig.onSubmit;
|
|
147
|
+
},
|
|
148
|
+
get onNavigate() {
|
|
149
|
+
return currentConfig.onNavigate;
|
|
150
|
+
},
|
|
151
|
+
get onClose() {
|
|
152
|
+
return currentConfig.onClose;
|
|
153
|
+
},
|
|
154
|
+
get onError() {
|
|
155
|
+
return currentConfig.onError;
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
iframe,
|
|
159
|
+
host,
|
|
160
|
+
{ skipResize: true }
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
// Register iframe for theme change notifications
|
|
164
|
+
const unregisterIframe = registerIframe(iframe, host);
|
|
165
|
+
|
|
166
|
+
widgetResources.set(iframe, {
|
|
167
|
+
cleanup,
|
|
168
|
+
unregister: unregisterIframe,
|
|
169
|
+
wrapper,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
let destroyed = false;
|
|
173
|
+
|
|
174
|
+
const unmount = () => {
|
|
175
|
+
if (destroyed) return;
|
|
176
|
+
destroyed = true;
|
|
177
|
+
|
|
178
|
+
cleanup();
|
|
179
|
+
unregisterIframe();
|
|
180
|
+
widgetResources.delete(iframe);
|
|
181
|
+
wrapper.remove();
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
unmount,
|
|
186
|
+
update: (options) => {
|
|
187
|
+
currentConfig = { ...currentConfig, ...options };
|
|
188
|
+
},
|
|
189
|
+
destroy: unmount,
|
|
190
|
+
researchId,
|
|
191
|
+
type: "widget" as const,
|
|
192
|
+
iframe,
|
|
193
|
+
container,
|
|
194
|
+
};
|
|
195
|
+
}
|