@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.
Files changed (45) hide show
  1. package/README.md +333 -0
  2. package/dist/browser.cjs +1939 -0
  3. package/dist/browser.cjs.map +1 -0
  4. package/dist/browser.d.cts +213 -0
  5. package/dist/browser.d.ts +213 -0
  6. package/dist/browser.js +1900 -0
  7. package/dist/browser.js.map +1 -0
  8. package/dist/cdn/perspective.global.js +406 -0
  9. package/dist/cdn/perspective.global.js.map +1 -0
  10. package/dist/constants.cjs +142 -0
  11. package/dist/constants.cjs.map +1 -0
  12. package/dist/constants.d.cts +104 -0
  13. package/dist/constants.d.ts +104 -0
  14. package/dist/constants.js +127 -0
  15. package/dist/constants.js.map +1 -0
  16. package/dist/index.cjs +1596 -0
  17. package/dist/index.cjs.map +1 -0
  18. package/dist/index.d.cts +155 -0
  19. package/dist/index.d.ts +155 -0
  20. package/dist/index.js +1579 -0
  21. package/dist/index.js.map +1 -0
  22. package/package.json +83 -0
  23. package/src/browser.test.ts +388 -0
  24. package/src/browser.ts +509 -0
  25. package/src/config.test.ts +81 -0
  26. package/src/config.ts +95 -0
  27. package/src/constants.ts +214 -0
  28. package/src/float.test.ts +332 -0
  29. package/src/float.ts +231 -0
  30. package/src/fullpage.test.ts +224 -0
  31. package/src/fullpage.ts +126 -0
  32. package/src/iframe.test.ts +1037 -0
  33. package/src/iframe.ts +421 -0
  34. package/src/index.ts +61 -0
  35. package/src/loading.ts +90 -0
  36. package/src/popup.test.ts +344 -0
  37. package/src/popup.ts +157 -0
  38. package/src/slider.test.ts +277 -0
  39. package/src/slider.ts +158 -0
  40. package/src/styles.ts +395 -0
  41. package/src/types.ts +148 -0
  42. package/src/utils.test.ts +162 -0
  43. package/src/utils.ts +86 -0
  44. package/src/widget.test.ts +375 -0
  45. package/src/widget.ts +195 -0
@@ -0,0 +1,277 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import { openSlider } from "./slider";
3
+ import * as config from "./config";
4
+
5
+ describe("openSlider", () => {
6
+ beforeEach(() => {
7
+ document.body.innerHTML = "";
8
+ });
9
+
10
+ afterEach(() => {
11
+ document.body.innerHTML = "";
12
+ vi.restoreAllMocks();
13
+ });
14
+
15
+ it("creates slider with backdrop", () => {
16
+ const handle = openSlider({
17
+ researchId: "test-research-id",
18
+ });
19
+
20
+ expect(handle.researchId).toBe("test-research-id");
21
+ expect(handle.type).toBe("slider");
22
+ expect(handle.iframe).toBeInstanceOf(HTMLIFrameElement);
23
+ expect(document.querySelector(".perspective-slider")).toBeTruthy();
24
+ expect(document.querySelector(".perspective-slider-backdrop")).toBeTruthy();
25
+
26
+ handle.unmount();
27
+ });
28
+
29
+ it("creates loading indicator", () => {
30
+ const handle = openSlider({
31
+ researchId: "test-research-id",
32
+ });
33
+
34
+ const loading = document.querySelector(".perspective-loading");
35
+ expect(loading).toBeTruthy();
36
+
37
+ handle.unmount();
38
+ });
39
+
40
+ it("returns no-op handle when no DOM", () => {
41
+ vi.spyOn(config, "hasDom").mockReturnValue(false);
42
+
43
+ const handle = openSlider({
44
+ researchId: "test-research-id",
45
+ });
46
+
47
+ expect(handle.iframe).toBeNull();
48
+ expect(handle.container).toBeNull();
49
+ expect(handle.unmount).toBeInstanceOf(Function);
50
+ expect(() => handle.unmount()).not.toThrow();
51
+ });
52
+
53
+ it("unmount removes slider and backdrop", () => {
54
+ const handle = openSlider({
55
+ researchId: "test-research-id",
56
+ });
57
+
58
+ expect(document.querySelector(".perspective-slider")).toBeTruthy();
59
+ expect(document.querySelector(".perspective-slider-backdrop")).toBeTruthy();
60
+
61
+ handle.unmount();
62
+
63
+ expect(document.querySelector(".perspective-slider")).toBeFalsy();
64
+ expect(document.querySelector(".perspective-slider-backdrop")).toBeFalsy();
65
+ });
66
+
67
+ it("destroy is alias for unmount", () => {
68
+ const handle = openSlider({
69
+ researchId: "test-research-id",
70
+ });
71
+
72
+ expect(document.querySelector(".perspective-slider")).toBeTruthy();
73
+
74
+ handle.destroy();
75
+
76
+ expect(document.querySelector(".perspective-slider")).toBeFalsy();
77
+ });
78
+
79
+ it("closes on close button click", () => {
80
+ const onClose = vi.fn();
81
+ openSlider({
82
+ researchId: "test-research-id",
83
+ onClose,
84
+ });
85
+
86
+ const closeBtn = document.querySelector(
87
+ ".perspective-slider .perspective-close"
88
+ ) as HTMLButtonElement;
89
+ expect(closeBtn).toBeTruthy();
90
+
91
+ closeBtn.click();
92
+
93
+ expect(document.querySelector(".perspective-slider")).toBeFalsy();
94
+ expect(onClose).toHaveBeenCalled();
95
+ });
96
+
97
+ it("closes on backdrop click", () => {
98
+ const onClose = vi.fn();
99
+ openSlider({
100
+ researchId: "test-research-id",
101
+ onClose,
102
+ });
103
+
104
+ const backdrop = document.querySelector(
105
+ ".perspective-slider-backdrop"
106
+ ) as HTMLElement;
107
+ expect(backdrop).toBeTruthy();
108
+
109
+ backdrop.click();
110
+
111
+ expect(document.querySelector(".perspective-slider")).toBeFalsy();
112
+ expect(onClose).toHaveBeenCalled();
113
+ });
114
+
115
+ it("closes on Escape key", () => {
116
+ const onClose = vi.fn();
117
+ openSlider({
118
+ researchId: "test-research-id",
119
+ onClose,
120
+ });
121
+
122
+ expect(document.querySelector(".perspective-slider")).toBeTruthy();
123
+
124
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
125
+
126
+ expect(document.querySelector(".perspective-slider")).toBeFalsy();
127
+ expect(onClose).toHaveBeenCalled();
128
+ });
129
+
130
+ it("only closes once on multiple triggers", () => {
131
+ const onClose = vi.fn();
132
+ const handle = openSlider({
133
+ researchId: "test-research-id",
134
+ onClose,
135
+ });
136
+
137
+ handle.unmount();
138
+ handle.unmount();
139
+ handle.unmount();
140
+
141
+ expect(onClose).toHaveBeenCalledTimes(1);
142
+ });
143
+
144
+ it("applies theme class", () => {
145
+ const handle = openSlider({
146
+ researchId: "test-research-id",
147
+ theme: "dark",
148
+ });
149
+
150
+ const slider = document.querySelector(".perspective-slider");
151
+ expect(slider?.classList.contains("perspective-dark-theme")).toBe(true);
152
+
153
+ handle.unmount();
154
+ });
155
+
156
+ it("uses custom host", () => {
157
+ const handle = openSlider({
158
+ researchId: "test-research-id",
159
+ host: "https://custom.example.com",
160
+ });
161
+
162
+ const iframe = handle.iframe as HTMLIFrameElement;
163
+ expect(iframe.src).toContain("https://custom.example.com");
164
+
165
+ handle.unmount();
166
+ });
167
+
168
+ it("update modifies config", () => {
169
+ const onSubmit1 = vi.fn();
170
+ const onSubmit2 = vi.fn();
171
+
172
+ const handle = openSlider({
173
+ researchId: "test-research-id",
174
+ onSubmit: onSubmit1,
175
+ });
176
+
177
+ expect(() => handle.update({ onSubmit: onSubmit2 })).not.toThrow();
178
+
179
+ handle.unmount();
180
+ });
181
+
182
+ describe("update() behavior", () => {
183
+ const host = "https://getperspective.ai";
184
+ const researchId = "test-research-id";
185
+
186
+ const sendMessage = (
187
+ iframe: HTMLIFrameElement,
188
+ type: string,
189
+ extra?: Record<string, unknown>
190
+ ) => {
191
+ window.dispatchEvent(
192
+ new MessageEvent("message", {
193
+ data: { type, researchId, ...extra },
194
+ origin: host,
195
+ source: iframe.contentWindow,
196
+ })
197
+ );
198
+ };
199
+
200
+ it("update changes which callback is invoked", () => {
201
+ const onSubmit1 = vi.fn();
202
+ const onSubmit2 = vi.fn();
203
+
204
+ const handle = openSlider({
205
+ researchId,
206
+ host,
207
+ onSubmit: onSubmit1,
208
+ });
209
+
210
+ handle.update({ onSubmit: onSubmit2 });
211
+
212
+ sendMessage(handle.iframe!, "perspective:submit");
213
+
214
+ expect(onSubmit2).toHaveBeenCalledTimes(1);
215
+ expect(onSubmit1).not.toHaveBeenCalled();
216
+
217
+ handle.unmount();
218
+ });
219
+
220
+ it("sequential updates only use latest callback", () => {
221
+ const fn1 = vi.fn();
222
+ const fn2 = vi.fn();
223
+ const fn3 = vi.fn();
224
+
225
+ const handle = openSlider({
226
+ researchId,
227
+ host,
228
+ onSubmit: fn1,
229
+ });
230
+
231
+ handle.update({ onSubmit: fn2 });
232
+ handle.update({ onSubmit: fn3 });
233
+
234
+ sendMessage(handle.iframe!, "perspective:submit");
235
+
236
+ expect(fn3).toHaveBeenCalledTimes(1);
237
+ expect(fn2).not.toHaveBeenCalled();
238
+ expect(fn1).not.toHaveBeenCalled();
239
+
240
+ handle.unmount();
241
+ });
242
+
243
+ it("destroy prevents further callback invocations", () => {
244
+ const onSubmit = vi.fn();
245
+ const onClose = vi.fn();
246
+
247
+ const handle = openSlider({
248
+ researchId,
249
+ host,
250
+ onSubmit,
251
+ onClose,
252
+ });
253
+
254
+ const iframe = handle.iframe!;
255
+
256
+ handle.unmount();
257
+ expect(onClose).toHaveBeenCalledTimes(1);
258
+
259
+ sendMessage(iframe, "perspective:submit");
260
+ sendMessage(iframe, "perspective:close");
261
+
262
+ expect(onSubmit).not.toHaveBeenCalled();
263
+ expect(onClose).toHaveBeenCalledTimes(1);
264
+ });
265
+
266
+ it("update after destroy is safe (no-op)", () => {
267
+ const handle = openSlider({
268
+ researchId,
269
+ host,
270
+ });
271
+
272
+ handle.unmount();
273
+
274
+ expect(() => handle.update({ onSubmit: vi.fn() })).not.toThrow();
275
+ });
276
+ });
277
+ });
package/src/slider.ts ADDED
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Slider/drawer embed - slides in from the right
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, CLOSE_ICON } from "./styles";
16
+ import { cn, getThemeClass } from "./utils";
17
+
18
+ function createNoOpHandle(researchId: string): EmbedHandle {
19
+ return {
20
+ unmount: () => {},
21
+ update: () => {},
22
+ destroy: () => {},
23
+ researchId,
24
+ type: "slider",
25
+ iframe: null,
26
+ container: null,
27
+ };
28
+ }
29
+
30
+ export function openSlider(config: EmbedConfig): EmbedHandle {
31
+ const { researchId } = config;
32
+
33
+ // SSR safety: return no-op handle
34
+ if (!hasDom()) {
35
+ return createNoOpHandle(researchId);
36
+ }
37
+ const host = getHost(config.host);
38
+
39
+ injectStyles();
40
+ ensureGlobalListeners();
41
+
42
+ // Create backdrop
43
+ const backdrop = document.createElement("div");
44
+ backdrop.className = cn(
45
+ "perspective-slider-backdrop perspective-embed-root",
46
+ getThemeClass(config.theme)
47
+ );
48
+
49
+ // Create slider container
50
+ const slider = document.createElement("div");
51
+ slider.className = cn(
52
+ "perspective-slider perspective-embed-root",
53
+ getThemeClass(config.theme)
54
+ );
55
+
56
+ // Create close button
57
+ const closeBtn = document.createElement("button");
58
+ closeBtn.className = "perspective-close";
59
+ closeBtn.innerHTML = CLOSE_ICON;
60
+ closeBtn.setAttribute("aria-label", "Close");
61
+
62
+ // Create loading indicator with theme and brand colors
63
+ const loading = createLoadingIndicator({
64
+ theme: config.theme,
65
+ brand: config.brand,
66
+ });
67
+
68
+ // Create iframe (hidden initially)
69
+ const iframe = createIframe(
70
+ researchId,
71
+ "slider",
72
+ host,
73
+ config.params,
74
+ config.brand,
75
+ config.theme
76
+ );
77
+ iframe.style.opacity = "0";
78
+ iframe.style.transition = "opacity 0.3s ease";
79
+
80
+ slider.appendChild(closeBtn);
81
+ slider.appendChild(loading);
82
+ slider.appendChild(iframe);
83
+ document.body.appendChild(backdrop);
84
+ document.body.appendChild(slider);
85
+
86
+ // Mutable config reference for updates
87
+ let currentConfig = { ...config };
88
+ let isOpen = true;
89
+ let messageCleanup: (() => void) | null = null;
90
+
91
+ // Register iframe for theme change notifications
92
+ const unregisterIframe = registerIframe(iframe, host);
93
+
94
+ const destroy = () => {
95
+ if (!isOpen) return;
96
+ isOpen = false;
97
+ messageCleanup?.();
98
+ unregisterIframe();
99
+ slider.remove();
100
+ backdrop.remove();
101
+ document.removeEventListener("keydown", escHandler);
102
+ currentConfig.onClose?.();
103
+ };
104
+
105
+ // Set up message listener with loading state handling
106
+ messageCleanup = setupMessageListener(
107
+ researchId,
108
+ {
109
+ get onReady() {
110
+ return () => {
111
+ loading.style.opacity = "0";
112
+ iframe.style.opacity = "1";
113
+ setTimeout(() => loading.remove(), 300);
114
+ currentConfig.onReady?.();
115
+ };
116
+ },
117
+ get onSubmit() {
118
+ return currentConfig.onSubmit;
119
+ },
120
+ get onNavigate() {
121
+ return currentConfig.onNavigate;
122
+ },
123
+ get onClose() {
124
+ return destroy;
125
+ },
126
+ get onError() {
127
+ return currentConfig.onError;
128
+ },
129
+ },
130
+ iframe,
131
+ host,
132
+ { skipResize: true }
133
+ );
134
+
135
+ // Close handlers
136
+ closeBtn.addEventListener("click", destroy);
137
+ backdrop.addEventListener("click", destroy);
138
+
139
+ // ESC key closes
140
+ const escHandler = (e: KeyboardEvent) => {
141
+ if (e.key === "Escape") {
142
+ destroy();
143
+ }
144
+ };
145
+ document.addEventListener("keydown", escHandler);
146
+
147
+ return {
148
+ unmount: destroy,
149
+ update: (options: Parameters<EmbedHandle["update"]>[0]) => {
150
+ currentConfig = { ...currentConfig, ...options };
151
+ },
152
+ destroy,
153
+ researchId,
154
+ type: "slider" as const,
155
+ iframe,
156
+ container: slider,
157
+ };
158
+ }