@prosopo/procaptcha-common 2.12.5 → 2.13.0

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.
@@ -0,0 +1,285 @@
1
+ // Copyright 2021-2026 Prosopo (UK) Ltd.
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+
15
+ import { PlacementEnum, type PlacementType } from "@prosopo/types";
16
+ import React, {
17
+ type CSSProperties,
18
+ type ReactNode,
19
+ useCallback,
20
+ useEffect,
21
+ useLayoutEffect,
22
+ useRef,
23
+ useState,
24
+ } from "react";
25
+ import { createPortal } from "react-dom";
26
+
27
+ /**
28
+ * The layer every challenge is presented on. Portals to `document.body` so a
29
+ * host page's `overflow: hidden` or transformed ancestor cannot clip it.
30
+ * `popup` centres the content over the page; `float` anchors it to the widget.
31
+ */
32
+
33
+ /** Scrim behind the content. Image has never dimmed the page; puzzle always has. */
34
+ export type SurfaceScrim = "none" | "dim";
35
+
36
+ interface ChallengeSurfaceProps {
37
+ show: boolean;
38
+ children: ReactNode;
39
+ placement?: PlacementType;
40
+ /** Element a floating challenge is positioned against. Without one, float falls back to popup. */
41
+ anchor?: HTMLElement | null;
42
+ scrim?: SurfaceScrim;
43
+ /** Called on Escape, and on an outside click when floating. */
44
+ onDismiss?: () => void;
45
+ /** Lifts the popup content on iOS, where Safari's bottom bar overlaps a centred dialog. */
46
+ popupIosLift?: boolean;
47
+ className?: string;
48
+ }
49
+
50
+ const SURFACE_Z_INDEX = 2147483646;
51
+ const CONTENT_Z_INDEX = 2147483647;
52
+
53
+ // `@supports` cannot be expressed inline, so the iOS lift is a stylesheet rule.
54
+ const IOS_LIFT_STYLE_ID = "prosopo-challenge-surface-ios-lift";
55
+
56
+ const IOS_LIFT_CSS = `
57
+ .prosopo-challenge-content--ios-lift {
58
+ transform: translate(-50%, -50%);
59
+ }
60
+ @supports (-webkit-touch-callout: none) {
61
+ .prosopo-challenge-content--ios-lift {
62
+ transform: translate(-50%, -100%);
63
+ }
64
+ }
65
+ `;
66
+
67
+ const ensureIosLiftStyles = (): void => {
68
+ if (typeof document === "undefined") return;
69
+ if (document.getElementById(IOS_LIFT_STYLE_ID)) return;
70
+
71
+ const style = document.createElement("style");
72
+ style.id = IOS_LIFT_STYLE_ID;
73
+ style.textContent = IOS_LIFT_CSS;
74
+ document.head.appendChild(style);
75
+ };
76
+
77
+ const FLOAT_GAP_PX = 8;
78
+
79
+ const useIsomorphicLayoutEffect =
80
+ typeof window === "undefined" ? useEffect : useLayoutEffect;
81
+
82
+ interface FloatPosition {
83
+ top: number;
84
+ left: number;
85
+ }
86
+
87
+ /**
88
+ * Places the panel directly above the anchor, in document coordinates.
89
+ *
90
+ * Document coordinates rather than viewport ones, because the panel is
91
+ * `position: absolute`: the page carries it while scrolling instead of the
92
+ * panel being recomputed against a moving viewport, which is what made it
93
+ * drift. Always above, never flipped, so the challenge does not jump to the
94
+ * other side of the widget as the page moves.
95
+ */
96
+ const computeFloatPosition = (
97
+ anchorRect: DOMRect,
98
+ panelHeight: number,
99
+ scrollX: number,
100
+ scrollY: number,
101
+ ): FloatPosition => ({
102
+ // Clamped at the top of the document so a widget near the top of the page
103
+ // cannot push the panel out of reach.
104
+ top: Math.max(0, anchorRect.top + scrollY - panelHeight - FLOAT_GAP_PX),
105
+ left: anchorRect.left + scrollX,
106
+ });
107
+
108
+ const ChallengeSurface = React.memo((props: ChallengeSurfaceProps) => {
109
+ const {
110
+ show,
111
+ children,
112
+ placement = PlacementEnum.popup,
113
+ anchor,
114
+ scrim = "none",
115
+ onDismiss,
116
+ popupIosLift = false,
117
+ className,
118
+ } = props;
119
+
120
+ const contentRef = useRef<HTMLDivElement>(null);
121
+ const [floatPosition, setFloatPosition] = useState<FloatPosition | null>(
122
+ null,
123
+ );
124
+
125
+ const isFloating = placement === PlacementEnum.float && !!anchor;
126
+
127
+ const reposition = useCallback(() => {
128
+ if (!isFloating || !anchor || !contentRef.current) return;
129
+ const panel = contentRef.current.getBoundingClientRect();
130
+ const next = computeFloatPosition(
131
+ anchor.getBoundingClientRect(),
132
+ panel.height,
133
+ window.scrollX,
134
+ window.scrollY,
135
+ );
136
+ // Reflow and panel-size changes fire more often than the panel actually
137
+ // moves; keeping the previous object when nothing changed avoids a
138
+ // needless re-render.
139
+ setFloatPosition((current) =>
140
+ current && current.top === next.top && current.left === next.left
141
+ ? current
142
+ : next,
143
+ );
144
+ }, [isFloating, anchor]);
145
+
146
+ // Layout effect so the first paint already has the panel in place.
147
+ useIsomorphicLayoutEffect(() => {
148
+ if (!show || !isFloating) {
149
+ setFloatPosition(null);
150
+ return;
151
+ }
152
+ reposition();
153
+ }, [show, isFloating, reposition]);
154
+
155
+ useEffect(() => {
156
+ if (!show || !isFloating) return;
157
+
158
+ // No scroll listener: the coordinates are document-relative, so the page
159
+ // scrolls the panel along with the widget on its own. Resize still
160
+ // matters because it can reflow the anchor to a new place in the page.
161
+ window.addEventListener("resize", reposition);
162
+
163
+ const observer =
164
+ typeof ResizeObserver === "function"
165
+ ? new ResizeObserver(reposition)
166
+ : null;
167
+ if (observer && contentRef.current) observer.observe(contentRef.current);
168
+ if (observer && anchor) observer.observe(anchor);
169
+
170
+ return () => {
171
+ window.removeEventListener("resize", reposition);
172
+ observer?.disconnect();
173
+ };
174
+ }, [show, isFloating, anchor, reposition]);
175
+
176
+ useEffect(() => {
177
+ if (!show || !onDismiss) return;
178
+
179
+ const onKeyDown = (event: KeyboardEvent) => {
180
+ if (event.key === "Escape") onDismiss();
181
+ };
182
+ document.addEventListener("keydown", onKeyDown);
183
+ return () => document.removeEventListener("keydown", onKeyDown);
184
+ }, [show, onDismiss]);
185
+
186
+ useEffect(() => {
187
+ if (!show || !isFloating || !onDismiss) return;
188
+
189
+ const onPointerDown = (event: PointerEvent) => {
190
+ const target = event.target;
191
+ if (!(target instanceof Node)) return;
192
+ if (contentRef.current?.contains(target)) return;
193
+ // The anchor's own click is what opens the panel.
194
+ if (anchor?.contains(target)) return;
195
+ onDismiss();
196
+ };
197
+ document.addEventListener("pointerdown", onPointerDown);
198
+ return () => document.removeEventListener("pointerdown", onPointerDown);
199
+ }, [show, isFloating, anchor, onDismiss]);
200
+
201
+ useEffect(() => {
202
+ if (popupIosLift && !isFloating) ensureIosLiftStyles();
203
+ }, [popupIosLift, isFloating]);
204
+
205
+ if (typeof document === "undefined") return null;
206
+
207
+ const layerStyle: CSSProperties = isFloating
208
+ ? {
209
+ // A zero-sized box at the document origin: it must not cover the
210
+ // page, and only its content takes pointer events. Absolute with
211
+ // no positioned ancestor resolves against the initial containing
212
+ // block, which is what makes the child's coordinates document
213
+ // coordinates.
214
+ position: "absolute",
215
+ top: 0,
216
+ left: 0,
217
+ width: 0,
218
+ height: 0,
219
+ zIndex: SURFACE_Z_INDEX,
220
+ display: show ? "block" : "none",
221
+ pointerEvents: "none",
222
+ }
223
+ : {
224
+ position: "fixed",
225
+ inset: 0,
226
+ zIndex: SURFACE_Z_INDEX,
227
+ display: show ? "flex" : "none",
228
+ alignItems: "center",
229
+ justifyContent: "center",
230
+ minHeight: "100vh",
231
+ backgroundColor:
232
+ scrim === "dim" && show ? "rgba(0, 0, 0, 0.4)" : "transparent",
233
+ transition: "background-color 0.3s ease",
234
+ };
235
+
236
+ const contentStyle: CSSProperties = isFloating
237
+ ? {
238
+ position: "absolute",
239
+ zIndex: CONTENT_Z_INDEX,
240
+ pointerEvents: "auto",
241
+ top: `${floatPosition?.top ?? 0}px`,
242
+ left: `${floatPosition?.left ?? 0}px`,
243
+ // Hidden until the first measurement so it does not flash at 0,0.
244
+ visibility: floatPosition ? "visible" : "hidden",
245
+ }
246
+ : {
247
+ position: "absolute",
248
+ top: "50%",
249
+ left: "50%",
250
+ // When lifting, the stylesheet rule owns the transform.
251
+ transform: popupIosLift ? undefined : "translate(-50%, -50%)",
252
+ zIndex: CONTENT_Z_INDEX,
253
+ boxSizing: "border-box",
254
+ };
255
+
256
+ return createPortal(
257
+ <div
258
+ className={[
259
+ "prosopo-challenge-surface",
260
+ `prosopo-challenge-surface--${isFloating ? "float" : "popup"}`,
261
+ className,
262
+ ]
263
+ .filter(Boolean)
264
+ .join(" ")}
265
+ style={layerStyle}
266
+ >
267
+ <div
268
+ ref={contentRef}
269
+ className={
270
+ popupIosLift && !isFloating
271
+ ? "prosopo-challenge-content prosopo-challenge-content--ios-lift"
272
+ : "prosopo-challenge-content"
273
+ }
274
+ style={contentStyle}
275
+ >
276
+ {children}
277
+ </div>
278
+ </div>,
279
+ document.body,
280
+ );
281
+ });
282
+
283
+ ChallengeSurface.displayName = "ChallengeSurface";
284
+
285
+ export { ChallengeSurface, computeFloatPosition };
@@ -0,0 +1,104 @@
1
+ // Copyright 2021-2026 Prosopo (UK) Ltd.
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+
15
+ import { describe, expect, it } from "vitest";
16
+ import { computeFloatPosition } from "../reactComponents/ChallengeSurface.js";
17
+
18
+ const VIEWPORT_WIDTH = 1000;
19
+ const VIEWPORT_HEIGHT = 800;
20
+
21
+ // jsdom gives every element a zero rect, so rects are built by hand.
22
+ const rect = (
23
+ left: number,
24
+ top: number,
25
+ width: number,
26
+ height: number,
27
+ ): DOMRect =>
28
+ ({
29
+ left,
30
+ top,
31
+ width,
32
+ height,
33
+ right: left + width,
34
+ bottom: top + height,
35
+ x: left,
36
+ y: top,
37
+ toJSON: () => ({}),
38
+ }) as DOMRect;
39
+
40
+ const PANEL_HEIGHT = 250;
41
+
42
+ const place = (
43
+ anchor: DOMRect,
44
+ panelHeight = PANEL_HEIGHT,
45
+ scrollX = 0,
46
+ scrollY = 0,
47
+ ) => computeFloatPosition(anchor, panelHeight, scrollX, scrollY);
48
+
49
+ describe("computeFloatPosition", () => {
50
+ it("sits directly above the anchor", () => {
51
+ const anchor = rect(100, 300, 300, 78);
52
+
53
+ const { top, left } = place(anchor);
54
+
55
+ expect(top).toBe(anchor.top - PANEL_HEIGHT - 8);
56
+ expect(left).toBe(anchor.left);
57
+ });
58
+
59
+ it("stays above the anchor even when the space below is larger", () => {
60
+ // Plenty of room below, none of which should tempt it downwards.
61
+ const anchor = rect(100, 400, 300, 50);
62
+
63
+ const { top } = place(anchor);
64
+
65
+ expect(top).toBe(anchor.top - PANEL_HEIGHT - 8);
66
+ expect(top + PANEL_HEIGHT).toBeLessThan(anchor.top);
67
+ });
68
+
69
+ it("converts the viewport rect into document coordinates", () => {
70
+ const anchor = rect(100, 300, 300, 78);
71
+
72
+ const { top, left } = place(anchor, PANEL_HEIGHT, 40, 500);
73
+
74
+ expect(top).toBe(anchor.top + 500 - PANEL_HEIGHT - 8);
75
+ expect(left).toBe(anchor.left + 40);
76
+ });
77
+
78
+ it("does not move when only the scroll offset changes", () => {
79
+ // The same widget, seen after scrolling 200px: its viewport rect moves
80
+ // up by exactly what the scroll offset gains, so the document position
81
+ // is unchanged and the panel does not drift.
82
+ const unscrolled = place(rect(100, 300, 300, 78), PANEL_HEIGHT, 0, 0);
83
+ const scrolled = place(rect(100, 100, 300, 78), PANEL_HEIGHT, 0, 200);
84
+
85
+ expect(scrolled).toEqual(unscrolled);
86
+ });
87
+
88
+ it("tracks a taller panel so its bottom edge stays on the anchor", () => {
89
+ const anchor = rect(100, 600, 300, 78);
90
+
91
+ const short = place(anchor, 100);
92
+ const tall = place(anchor, 400);
93
+
94
+ expect(short.top + 100).toBe(tall.top + 400);
95
+ });
96
+
97
+ it("clamps to the top of the document rather than going out of reach", () => {
98
+ const anchor = rect(100, 20, 300, 78);
99
+
100
+ const { top } = place(anchor, PANEL_HEIGHT);
101
+
102
+ expect(top).toBe(0);
103
+ });
104
+ });
@@ -0,0 +1,207 @@
1
+ // Copyright 2021-2026 Prosopo (UK) Ltd.
2
+ //
3
+ // Licensed under the Apache License, Version 2.0 (the "License");
4
+ // you may not use this file except in compliance with the License.
5
+ // You may obtain a copy of the License at
6
+ //
7
+ // http://www.apache.org/licenses/LICENSE-2.0
8
+ //
9
+ // Unless required by applicable law or agreed to in writing, software
10
+ // distributed under the License is distributed on an "AS IS" BASIS,
11
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ // See the License for the specific language governing permissions and
13
+ // limitations under the License.
14
+
15
+ // jsdom has no PointerEvent; the dismiss handler only reads event.target, so
16
+ // a plain Event of the same type is dispatched instead.
17
+ import { PlacementEnum, type PlacementType } from "@prosopo/types";
18
+ import { type Root, createRoot } from "react-dom/client";
19
+ import { act } from "react-dom/test-utils";
20
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
21
+ import { ChallengeSurface } from "../reactComponents/ChallengeSurface.js";
22
+
23
+ let container: HTMLDivElement;
24
+ let root: Root;
25
+ let anchor: HTMLDivElement;
26
+
27
+ const layer = (): HTMLElement | null =>
28
+ document.querySelector<HTMLElement>(".prosopo-challenge-surface");
29
+
30
+ const content = (): HTMLElement | null =>
31
+ document.querySelector<HTMLElement>(".prosopo-challenge-content");
32
+
33
+ interface RenderArgs {
34
+ placement?: PlacementType;
35
+ withAnchor?: boolean;
36
+ onDismiss?: () => void;
37
+ show?: boolean;
38
+ }
39
+
40
+ const render = ({
41
+ placement,
42
+ withAnchor = true,
43
+ onDismiss,
44
+ show = true,
45
+ }: RenderArgs): void => {
46
+ act(() => {
47
+ root.render(
48
+ <ChallengeSurface
49
+ show={show}
50
+ placement={placement}
51
+ anchor={withAnchor ? anchor : null}
52
+ onDismiss={onDismiss}
53
+ >
54
+ <div data-testid="challenge">challenge</div>
55
+ </ChallengeSurface>,
56
+ );
57
+ });
58
+ };
59
+
60
+ beforeEach(() => {
61
+ container = document.createElement("div");
62
+ anchor = document.createElement("div");
63
+ document.body.append(container, anchor);
64
+ root = createRoot(container);
65
+ });
66
+
67
+ afterEach(() => {
68
+ act(() => root.unmount());
69
+ container.remove();
70
+ anchor.remove();
71
+ });
72
+
73
+ describe("where the surface renders", () => {
74
+ it("portals out of the mount container to the body", () => {
75
+ render({});
76
+
77
+ expect(container.querySelector(".prosopo-challenge-surface")).toBeNull();
78
+ expect(layer()?.parentElement).toBe(document.body);
79
+ });
80
+
81
+ it("hides rather than unmounts when not shown", () => {
82
+ render({ show: false });
83
+
84
+ expect(layer()?.style.display).toBe("none");
85
+ expect(layer()?.textContent).toContain("challenge");
86
+ });
87
+ });
88
+
89
+ describe("popup", () => {
90
+ it("is the default placement", () => {
91
+ render({});
92
+
93
+ expect(layer()?.className).toContain("prosopo-challenge-surface--popup");
94
+ });
95
+
96
+ it("covers the page, so nothing behind it is reachable", () => {
97
+ render({ placement: PlacementEnum.popup });
98
+
99
+ expect(layer()?.style.display).toBe("flex");
100
+ expect(layer()?.style.pointerEvents).toBe("");
101
+ });
102
+
103
+ it("ignores an outside click", () => {
104
+ const onDismiss = vi.fn();
105
+ render({ placement: PlacementEnum.popup, onDismiss });
106
+
107
+ act(() => {
108
+ document.body.dispatchEvent(new Event("pointerdown", { bubbles: true }));
109
+ });
110
+
111
+ expect(onDismiss).not.toHaveBeenCalled();
112
+ });
113
+ });
114
+
115
+ describe("float", () => {
116
+ it("leaves the page usable behind it", () => {
117
+ render({ placement: PlacementEnum.float });
118
+
119
+ expect(layer()?.className).toContain("prosopo-challenge-surface--float");
120
+ expect(layer()?.style.pointerEvents).toBe("none");
121
+ expect(content()?.style.pointerEvents).toBe("auto");
122
+ });
123
+
124
+ it("positions the panel absolutely so the page scrolls it", () => {
125
+ render({ placement: PlacementEnum.float });
126
+
127
+ // Viewport-relative positioning is what made the panel drift while
128
+ // scrolling; document-relative is what keeps it still.
129
+ expect(content()?.style.position).toBe("absolute");
130
+ expect(layer()?.style.position).toBe("absolute");
131
+ expect(layer()?.style.width).toBe("0px");
132
+ expect(layer()?.style.height).toBe("0px");
133
+ });
134
+
135
+ it("dismisses on a click outside the panel", () => {
136
+ const onDismiss = vi.fn();
137
+ render({ placement: PlacementEnum.float, onDismiss });
138
+
139
+ act(() => {
140
+ document.body.dispatchEvent(new Event("pointerdown", { bubbles: true }));
141
+ });
142
+
143
+ expect(onDismiss).toHaveBeenCalledTimes(1);
144
+ });
145
+
146
+ it("does not dismiss on a click inside the panel", () => {
147
+ const onDismiss = vi.fn();
148
+ render({ placement: PlacementEnum.float, onDismiss });
149
+
150
+ act(() => {
151
+ content()?.dispatchEvent(new Event("pointerdown", { bubbles: true }));
152
+ });
153
+
154
+ expect(onDismiss).not.toHaveBeenCalled();
155
+ });
156
+
157
+ it("does not dismiss on a click on the anchor", () => {
158
+ const onDismiss = vi.fn();
159
+ render({ placement: PlacementEnum.float, onDismiss });
160
+
161
+ act(() => {
162
+ anchor.dispatchEvent(new Event("pointerdown", { bubbles: true }));
163
+ });
164
+
165
+ expect(onDismiss).not.toHaveBeenCalled();
166
+ });
167
+
168
+ it("falls back to popup with no anchor to attach to", () => {
169
+ render({ placement: PlacementEnum.float, withAnchor: false });
170
+
171
+ expect(layer()?.className).toContain("prosopo-challenge-surface--popup");
172
+ expect(layer()?.style.pointerEvents).toBe("");
173
+ });
174
+ });
175
+
176
+ describe("dismissing with the keyboard", () => {
177
+ it("closes on Escape in either placement", () => {
178
+ for (const placement of [PlacementEnum.popup, PlacementEnum.float]) {
179
+ const onDismiss = vi.fn();
180
+ render({ placement, onDismiss });
181
+
182
+ act(() => {
183
+ document.dispatchEvent(
184
+ new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
185
+ );
186
+ });
187
+
188
+ expect(
189
+ onDismiss,
190
+ `${placement} should close on Escape`,
191
+ ).toHaveBeenCalled();
192
+ }
193
+ });
194
+
195
+ it("ignores other keys", () => {
196
+ const onDismiss = vi.fn();
197
+ render({ placement: PlacementEnum.float, onDismiss });
198
+
199
+ act(() => {
200
+ document.dispatchEvent(
201
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
202
+ );
203
+ });
204
+
205
+ expect(onDismiss).not.toHaveBeenCalled();
206
+ });
207
+ });