@prosopo/procaptcha-puzzle 2.12.3 → 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.
- package/.turbo/turbo-build$colon$cjs.log +5 -5
- package/.turbo/turbo-build$colon$tsc.log +19 -19
- package/.turbo/turbo-build.log +9 -9
- package/CHANGELOG.md +49 -0
- package/dist/cjs/components/ProcaptchaWidget.cjs +1 -0
- package/dist/cjs/components/PuzzleCanvas.cjs +301 -148
- package/dist/components/ProcaptchaWidget.d.ts.map +1 -1
- package/dist/components/ProcaptchaWidget.js +1 -0
- package/dist/components/ProcaptchaWidget.js.map +1 -1
- package/dist/components/PuzzleCanvas.d.ts.map +1 -1
- package/dist/components/PuzzleCanvas.js +303 -150
- package/dist/components/PuzzleCanvas.js.map +1 -1
- package/dist/tests/puzzleCanvas.unit.test.js +193 -0
- package/dist/tests/puzzleCanvas.unit.test.js.map +1 -1
- package/package.json +6 -6
- package/src/components/ProcaptchaWidget.tsx +3 -0
- package/src/components/PuzzleCanvas.tsx +222 -20
- package/src/tests/puzzleCanvas.unit.test.ts +260 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -12,10 +12,18 @@
|
|
|
12
12
|
// See the License for the specific language governing permissions and
|
|
13
13
|
// limitations under the License.
|
|
14
14
|
|
|
15
|
-
import {
|
|
15
|
+
import { useTranslation } from "@prosopo/locale";
|
|
16
|
+
import { ChallengeSurface, isEventTrusted } from "@prosopo/procaptcha-common";
|
|
16
17
|
import type { PlacementType, PuzzleEvent } from "@prosopo/types";
|
|
17
18
|
import type { Theme } from "@prosopo/widget-skeleton";
|
|
18
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
type CSSProperties,
|
|
21
|
+
useCallback,
|
|
22
|
+
useEffect,
|
|
23
|
+
useId,
|
|
24
|
+
useRef,
|
|
25
|
+
useState,
|
|
26
|
+
} from "react";
|
|
19
27
|
|
|
20
28
|
interface PuzzleCanvasProps {
|
|
21
29
|
originX: number;
|
|
@@ -42,12 +50,43 @@ interface PuzzleCanvasProps {
|
|
|
42
50
|
const CONTAINER_WIDTH = 300;
|
|
43
51
|
const CONTAINER_HEIGHT = 200;
|
|
44
52
|
|
|
45
|
-
const
|
|
53
|
+
const PIECE_CSS_CLASS = "prosopo-puzzle-piece";
|
|
54
|
+
|
|
55
|
+
// An arrow press moves a tenth of the board's width. The provider accepts a
|
|
56
|
+
// solution within 15px of the target, so a 10px lattice always contains a
|
|
57
|
+
// winning cell (worst case is half a diagonal, ~7.1px) — a keyboard user can
|
|
58
|
+
// land the piece without ever needing the finer step.
|
|
59
|
+
const STEP_PX = 10;
|
|
60
|
+
const FINE_STEP_PX = 2;
|
|
61
|
+
|
|
62
|
+
// Arrow keys repeat far faster than a screen reader speaks. Coalescing to the
|
|
63
|
+
// last position after a pause keeps the running commentary from queueing up
|
|
64
|
+
// behind the user and reporting somewhere they left several seconds ago.
|
|
65
|
+
const ANNOUNCE_DEBOUNCE_MS = 400;
|
|
66
|
+
|
|
67
|
+
const VISUALLY_HIDDEN: CSSProperties = {
|
|
68
|
+
position: "absolute",
|
|
69
|
+
width: "1px",
|
|
70
|
+
height: "1px",
|
|
71
|
+
padding: 0,
|
|
72
|
+
margin: "-1px",
|
|
73
|
+
overflow: "hidden",
|
|
74
|
+
clip: "rect(0, 0, 0, 0)",
|
|
75
|
+
clipPath: "inset(50%)",
|
|
76
|
+
whiteSpace: "nowrap",
|
|
77
|
+
border: 0,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const stylesheet = (focusRingColor: string): string => `
|
|
46
81
|
@keyframes prosopo-puzzle-shake {
|
|
47
82
|
0%, 100% { transform: translateX(0); }
|
|
48
83
|
10%, 30%, 50%, 70%, 90% { transform: translateX(-4px); }
|
|
49
84
|
20%, 40%, 60%, 80% { transform: translateX(4px); }
|
|
50
85
|
}
|
|
86
|
+
.${PIECE_CSS_CLASS}:focus-visible {
|
|
87
|
+
outline: 3px solid ${focusRingColor};
|
|
88
|
+
outline-offset: 2px;
|
|
89
|
+
}
|
|
51
90
|
`;
|
|
52
91
|
|
|
53
92
|
export const PuzzleCanvas = ({
|
|
@@ -77,11 +116,21 @@ export const PuzzleCanvas = ({
|
|
|
77
116
|
const offsetRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
|
|
78
117
|
const [visible, setVisible] = useState(false);
|
|
79
118
|
const [shaking, setShaking] = useState(false);
|
|
119
|
+
const { t } = useTranslation();
|
|
120
|
+
const baseId = useId();
|
|
121
|
+
const instructionId = `${baseId}-instruction`;
|
|
122
|
+
const keyboardHintId = `${baseId}-keyboard-hint`;
|
|
123
|
+
// Set to true by the first arrow press of a keyboard run, so the run starts
|
|
124
|
+
// from a clean trail exactly as a fresh mouse grab does.
|
|
125
|
+
const keyboardDragging = useRef<boolean>(false);
|
|
126
|
+
const [announcement, setAnnouncement] = useState<string>("");
|
|
127
|
+
const announceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
80
128
|
|
|
81
129
|
// Reset piece position when challenge data changes (new puzzle on retry)
|
|
82
130
|
useEffect(() => {
|
|
83
131
|
setPosX(originX);
|
|
84
132
|
setPosY(originY);
|
|
133
|
+
keyboardDragging.current = false;
|
|
85
134
|
}, [originX, originY]);
|
|
86
135
|
|
|
87
136
|
// Trigger entrance animation after mount
|
|
@@ -107,6 +156,38 @@ export const PuzzleCanvas = ({
|
|
|
107
156
|
[],
|
|
108
157
|
);
|
|
109
158
|
|
|
159
|
+
const announce = useCallback((message: string, delayMs = 0): void => {
|
|
160
|
+
if (announceTimer.current) {
|
|
161
|
+
clearTimeout(announceTimer.current);
|
|
162
|
+
announceTimer.current = null;
|
|
163
|
+
}
|
|
164
|
+
if (delayMs === 0) {
|
|
165
|
+
setAnnouncement(message);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
announceTimer.current = setTimeout(() => setAnnouncement(message), delayMs);
|
|
169
|
+
}, []);
|
|
170
|
+
|
|
171
|
+
useEffect(
|
|
172
|
+
() => () => {
|
|
173
|
+
if (announceTimer.current) clearTimeout(announceTimer.current);
|
|
174
|
+
},
|
|
175
|
+
[],
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
// Pixel coordinates mean nothing to someone who cannot see the board, and
|
|
179
|
+
// the widget is never told where the target is, so proportions are the only
|
|
180
|
+
// bearing it can honestly offer.
|
|
181
|
+
const describePosition = useCallback(
|
|
182
|
+
(x: number, y: number): string =>
|
|
183
|
+
t("WIDGET.PUZZLE.POSITION", {
|
|
184
|
+
defaultValue: "{{x}} percent across, {{y}} percent down",
|
|
185
|
+
x: Math.round((x / CONTAINER_WIDTH) * 100),
|
|
186
|
+
y: Math.round((y / CONTAINER_HEIGHT) * 100),
|
|
187
|
+
}),
|
|
188
|
+
[t],
|
|
189
|
+
);
|
|
190
|
+
|
|
110
191
|
const getContainerOffset = useCallback((): { x: number; y: number } => {
|
|
111
192
|
if (containerRef.current) {
|
|
112
193
|
const rect = containerRef.current.getBoundingClientRect();
|
|
@@ -141,6 +222,13 @@ export const PuzzleCanvas = ({
|
|
|
141
222
|
[clamp, getContainerOffset],
|
|
142
223
|
);
|
|
143
224
|
|
|
225
|
+
const complete = useCallback(
|
|
226
|
+
(finalX: number, finalY: number): void => {
|
|
227
|
+
onComplete(finalX, finalY, [...puzzleEvents.current]);
|
|
228
|
+
},
|
|
229
|
+
[onComplete],
|
|
230
|
+
);
|
|
231
|
+
|
|
144
232
|
const handleEndEvent = useCallback(() => {
|
|
145
233
|
if (!isDragging.current) {
|
|
146
234
|
return;
|
|
@@ -149,13 +237,12 @@ export const PuzzleCanvas = ({
|
|
|
149
237
|
isDragging.current = false;
|
|
150
238
|
setDragging(false);
|
|
151
239
|
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
}, [onComplete, originX, originY]);
|
|
240
|
+
const lastEvent = puzzleEvents.current[puzzleEvents.current.length - 1];
|
|
241
|
+
complete(
|
|
242
|
+
lastEvent ? lastEvent.x : originX,
|
|
243
|
+
lastEvent ? lastEvent.y : originY,
|
|
244
|
+
);
|
|
245
|
+
}, [complete, originX, originY]);
|
|
159
246
|
|
|
160
247
|
const handleMouseMove = useCallback(
|
|
161
248
|
(event: MouseEvent) => {
|
|
@@ -200,6 +287,7 @@ export const PuzzleCanvas = ({
|
|
|
200
287
|
(event: React.MouseEvent<HTMLDivElement>) => {
|
|
201
288
|
if (submitting) return;
|
|
202
289
|
isDragging.current = true;
|
|
290
|
+
keyboardDragging.current = false;
|
|
203
291
|
setDragging(true);
|
|
204
292
|
puzzleEvents.current = [];
|
|
205
293
|
const containerOffset = getContainerOffset();
|
|
@@ -217,6 +305,7 @@ export const PuzzleCanvas = ({
|
|
|
217
305
|
const touch = event.touches[0];
|
|
218
306
|
if (touch) {
|
|
219
307
|
isDragging.current = true;
|
|
308
|
+
keyboardDragging.current = false;
|
|
220
309
|
setDragging(true);
|
|
221
310
|
puzzleEvents.current = [];
|
|
222
311
|
const containerOffset = getContainerOffset();
|
|
@@ -229,9 +318,87 @@ export const PuzzleCanvas = ({
|
|
|
229
318
|
[getContainerOffset, posX, posY, submitting],
|
|
230
319
|
);
|
|
231
320
|
|
|
321
|
+
const moveByKeyboard = useCallback(
|
|
322
|
+
(deltaX: number, deltaY: number): void => {
|
|
323
|
+
if (!keyboardDragging.current) {
|
|
324
|
+
keyboardDragging.current = true;
|
|
325
|
+
puzzleEvents.current = [];
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const nextX = clamp(posX + deltaX, 0, CONTAINER_WIDTH);
|
|
329
|
+
const nextY = clamp(posY + deltaY, 0, CONTAINER_HEIGHT);
|
|
330
|
+
|
|
331
|
+
setPosX(nextX);
|
|
332
|
+
setPosY(nextY);
|
|
333
|
+
puzzleEvents.current.push({ x: nextX, y: nextY, t: Date.now() });
|
|
334
|
+
announce(describePosition(nextX, nextY), ANNOUNCE_DEBOUNCE_MS);
|
|
335
|
+
},
|
|
336
|
+
[announce, clamp, describePosition, posX, posY],
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
const handlePieceKeyDown = useCallback(
|
|
340
|
+
(event: React.KeyboardEvent<HTMLDivElement>): void => {
|
|
341
|
+
if (submitting) return;
|
|
342
|
+
if (!isEventTrusted(event)) return;
|
|
343
|
+
|
|
344
|
+
const step = event.shiftKey ? FINE_STEP_PX : STEP_PX;
|
|
345
|
+
const moves: Record<string, [number, number] | undefined> = {
|
|
346
|
+
ArrowLeft: [-step, 0],
|
|
347
|
+
ArrowRight: [step, 0],
|
|
348
|
+
ArrowUp: [0, -step],
|
|
349
|
+
ArrowDown: [0, step],
|
|
350
|
+
Home: [originX - posX, originY - posY],
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
const move = moves[event.key];
|
|
354
|
+
if (move) {
|
|
355
|
+
event.preventDefault();
|
|
356
|
+
moveByKeyboard(move[0], move[1]);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
361
|
+
event.preventDefault();
|
|
362
|
+
// Ends the run, so the next arrow press opens a clean trail just
|
|
363
|
+
// as the next mouse grab would.
|
|
364
|
+
keyboardDragging.current = false;
|
|
365
|
+
complete(posX, posY);
|
|
366
|
+
}
|
|
367
|
+
},
|
|
368
|
+
[complete, moveByKeyboard, originX, originY, posX, posY, submitting],
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
const handlePieceFocus = useCallback((): void => {
|
|
372
|
+
announce(describePosition(posX, posY));
|
|
373
|
+
}, [announce, describePosition, posX, posY]);
|
|
374
|
+
|
|
232
375
|
const instructionText = showRetry
|
|
233
|
-
? "Not quite \u2014 try again"
|
|
234
|
-
: "
|
|
376
|
+
? t("WIDGET.PUZZLE.RETRY", { defaultValue: "Not quite \u2014 try again" })
|
|
377
|
+
: t("WIDGET.PUZZLE.DRAG", {
|
|
378
|
+
defaultValue: "Drag the piece to the target",
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
const keyboardHintText = t("WIDGET.PUZZLE.KEYBOARD_HINT", {
|
|
382
|
+
defaultValue:
|
|
383
|
+
"Use the arrow keys to move the piece, holding shift for smaller steps. Press Enter to submit it, Home to put it back at the start, or Escape to cancel.",
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
useEffect(() => {
|
|
387
|
+
if (!submitting) return;
|
|
388
|
+
announce(
|
|
389
|
+
t("WIDGET.PUZZLE.CHECKING", { defaultValue: "Checking your answer" }),
|
|
390
|
+
);
|
|
391
|
+
}, [submitting, announce, t]);
|
|
392
|
+
|
|
393
|
+
useEffect(() => {
|
|
394
|
+
if (!showRetry) return;
|
|
395
|
+
announce(
|
|
396
|
+
t("WIDGET.PUZZLE.RETRY_ANNOUNCEMENT", {
|
|
397
|
+
defaultValue:
|
|
398
|
+
"Not quite. A new puzzle has loaded and the piece is back at the start.",
|
|
399
|
+
}),
|
|
400
|
+
);
|
|
401
|
+
}, [showRetry, announce, t]);
|
|
235
402
|
|
|
236
403
|
const headerBorderColor = showRetry
|
|
237
404
|
? theme.palette.error.main
|
|
@@ -273,9 +440,22 @@ export const PuzzleCanvas = ({
|
|
|
273
440
|
anchor={anchor}
|
|
274
441
|
onDismiss={onDismiss}
|
|
275
442
|
scrim={visible ? "dim" : "none"}
|
|
443
|
+
dialogLabel={t("WIDGET.PUZZLE.DIALOG_LABEL", {
|
|
444
|
+
defaultValue: "Puzzle challenge",
|
|
445
|
+
})}
|
|
276
446
|
>
|
|
277
|
-
{
|
|
278
|
-
|
|
447
|
+
<style>{stylesheet(theme.palette.primary.main)}</style>
|
|
448
|
+
|
|
449
|
+
{/* Progress the piece cannot show a screen reader: where it has got
|
|
450
|
+
to, that a solution is being checked, and that a failed go has
|
|
451
|
+
been replaced by a fresh puzzle. */}
|
|
452
|
+
<output aria-live="polite" aria-atomic="true" style={VISUALLY_HIDDEN}>
|
|
453
|
+
{announcement}
|
|
454
|
+
</output>
|
|
455
|
+
|
|
456
|
+
<div id={keyboardHintId} style={VISUALLY_HIDDEN}>
|
|
457
|
+
{keyboardHintText}
|
|
458
|
+
</div>
|
|
279
459
|
|
|
280
460
|
<div
|
|
281
461
|
style={{
|
|
@@ -291,6 +471,7 @@ export const PuzzleCanvas = ({
|
|
|
291
471
|
>
|
|
292
472
|
{/* Instruction text */}
|
|
293
473
|
<div
|
|
474
|
+
id={instructionId}
|
|
294
475
|
style={{
|
|
295
476
|
backgroundColor: theme.palette.surface,
|
|
296
477
|
borderRadius: "20px 20px 0 0",
|
|
@@ -387,20 +568,41 @@ export const PuzzleCanvas = ({
|
|
|
387
568
|
);
|
|
388
569
|
})}
|
|
389
570
|
</div>
|
|
390
|
-
{/* Puzzle piece
|
|
571
|
+
{/* Puzzle piece.
|
|
572
|
+
|
|
573
|
+
`application` rather than `button`: in the browse mode
|
|
574
|
+
NVDA and JAWS default to, arrow keys move the reading
|
|
575
|
+
cursor through the page and never reach a button's key
|
|
576
|
+
handler. `application` is the role that hands them
|
|
577
|
+
straight to this element, which is what makes the drag
|
|
578
|
+
reachable without a pointer at all.
|
|
579
|
+
|
|
580
|
+
The role and class are a stable production selector,
|
|
581
|
+
which the data-cy below is deliberately not. A scripted
|
|
582
|
+
solver could already find this element — it is the only
|
|
583
|
+
draggable thing on the surface — whereas without them a
|
|
584
|
+
keyboard or screen-reader user cannot find it at all. */}
|
|
391
585
|
<div
|
|
392
586
|
// Test-only selector: gated on NODE_ENV !== "production"
|
|
393
587
|
// so esbuild constant-folds it out of production bundles.
|
|
394
|
-
//
|
|
395
|
-
// shouldn't be able to `querySelector` its way to the
|
|
396
|
-
// interactive element; shipping a stable data-cy would
|
|
397
|
-
// hand that to any scripted solver for free. Cypress
|
|
398
|
-
// builds the bundle with NODE_ENV=development
|
|
588
|
+
// Cypress builds the bundle with NODE_ENV=development
|
|
399
589
|
// (.github/workflows/cypress.yml:110) so the selector
|
|
400
590
|
// is present under test.
|
|
401
591
|
{...(process.env.NODE_ENV !== "production" && {
|
|
402
592
|
"data-cy": "prosopo-puzzle-piece",
|
|
403
593
|
})}
|
|
594
|
+
className={PIECE_CSS_CLASS}
|
|
595
|
+
role="application"
|
|
596
|
+
aria-roledescription={t("WIDGET.PUZZLE.PIECE_ROLE", {
|
|
597
|
+
defaultValue: "draggable puzzle piece",
|
|
598
|
+
})}
|
|
599
|
+
aria-label={t("WIDGET.PUZZLE.PIECE_LABEL", {
|
|
600
|
+
defaultValue: "Puzzle piece",
|
|
601
|
+
})}
|
|
602
|
+
aria-describedby={`${instructionId} ${keyboardHintId}`}
|
|
603
|
+
tabIndex={submitting ? -1 : 0}
|
|
604
|
+
onFocus={handlePieceFocus}
|
|
605
|
+
onKeyDown={handlePieceKeyDown}
|
|
404
606
|
onMouseDown={handlePieceMouseDown}
|
|
405
607
|
onTouchStart={handlePieceTouchStart}
|
|
406
608
|
style={{
|
|
@@ -34,6 +34,26 @@ import { PuzzleCanvas } from "../components/PuzzleCanvas.js";
|
|
|
34
34
|
* against a real render rather than calling the handlers directly.
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* The real locale package reaches for an http backend the moment a component
|
|
39
|
+
* asks it for a string, which jsdom refuses. The English defaults the canvas
|
|
40
|
+
* ships stand in instead, interpolated the way i18next would, so the
|
|
41
|
+
* assertions below read as the copy a user is actually given.
|
|
42
|
+
*/
|
|
43
|
+
vi.mock("@prosopo/locale", async (importOriginal) => {
|
|
44
|
+
const actual = await importOriginal<typeof import("@prosopo/locale")>();
|
|
45
|
+
const t = (
|
|
46
|
+
key: string,
|
|
47
|
+
options?: { defaultValue?: string } & Record<string, unknown>,
|
|
48
|
+
): string =>
|
|
49
|
+
(options?.defaultValue ?? key).replace(
|
|
50
|
+
/{{(\w+)}}/g,
|
|
51
|
+
(placeholder: string, name: string) =>
|
|
52
|
+
options && name in options ? String(options[name]) : placeholder,
|
|
53
|
+
);
|
|
54
|
+
return { ...actual, useTranslation: () => ({ t, ready: true }) };
|
|
55
|
+
});
|
|
56
|
+
|
|
37
57
|
const CONTAINER_WIDTH = 300;
|
|
38
58
|
const CONTAINER_HEIGHT = 200;
|
|
39
59
|
const PIECE_SIZE = 44;
|
|
@@ -151,7 +171,53 @@ const touchEnd = (): void => {
|
|
|
151
171
|
});
|
|
152
172
|
};
|
|
153
173
|
|
|
174
|
+
const keyDown = (key: string, options: { shiftKey?: boolean } = {}): void => {
|
|
175
|
+
act(() => {
|
|
176
|
+
piece().dispatchEvent(
|
|
177
|
+
new KeyboardEvent("keydown", {
|
|
178
|
+
key,
|
|
179
|
+
bubbles: true,
|
|
180
|
+
cancelable: true,
|
|
181
|
+
shiftKey: options.shiftKey ?? false,
|
|
182
|
+
}),
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* `isTrusted` is unforgeable, so jsdom can only ever produce the synthetic
|
|
189
|
+
* events the canvas refuses. Opening the allowance is how the firefox cypress
|
|
190
|
+
* leg drives the widget too; the gate itself is covered by the one test that
|
|
191
|
+
* shuts it again.
|
|
192
|
+
*/
|
|
193
|
+
const allowSyntheticEvents = (allowed: boolean): void => {
|
|
194
|
+
vi.stubGlobal("__PROSOPO_ALLOW_UNTRUSTED_EVENTS__", allowed);
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const required = (element: HTMLElement | null, what: string): HTMLElement => {
|
|
198
|
+
if (!element) throw new Error(`expected ${what} to be rendered`);
|
|
199
|
+
return element;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const dialog = (): HTMLElement =>
|
|
203
|
+
required(
|
|
204
|
+
overlay().querySelector<HTMLElement>('[role="dialog"]'),
|
|
205
|
+
"the dialog",
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
/** An `output` element carries the implicit `status` role a live region needs. */
|
|
209
|
+
const liveRegion = (): HTMLElement =>
|
|
210
|
+
required(overlay().querySelector<HTMLElement>("output"), "the live region");
|
|
211
|
+
|
|
212
|
+
/** The text a screen reader reads out when the piece takes focus. */
|
|
213
|
+
const pieceDescription = (): string =>
|
|
214
|
+
(piece().getAttribute("aria-describedby") ?? "")
|
|
215
|
+
.split(" ")
|
|
216
|
+
.map((id) => document.getElementById(id)?.textContent ?? "")
|
|
217
|
+
.join(" ");
|
|
218
|
+
|
|
154
219
|
beforeEach(() => {
|
|
220
|
+
allowSyntheticEvents(true);
|
|
155
221
|
onComplete =
|
|
156
222
|
vi.fn<
|
|
157
223
|
(finalX: number, finalY: number, puzzleEvents: PuzzleEvent[]) => void
|
|
@@ -169,6 +235,7 @@ afterEach(() => {
|
|
|
169
235
|
});
|
|
170
236
|
container.remove();
|
|
171
237
|
vi.useRealTimers();
|
|
238
|
+
vi.unstubAllGlobals();
|
|
172
239
|
vi.restoreAllMocks();
|
|
173
240
|
});
|
|
174
241
|
|
|
@@ -413,6 +480,199 @@ describe("dragging with a finger", () => {
|
|
|
413
480
|
});
|
|
414
481
|
});
|
|
415
482
|
|
|
483
|
+
describe("driving it from the keyboard", () => {
|
|
484
|
+
test("the piece takes focus as soon as the puzzle opens", () => {
|
|
485
|
+
render(props());
|
|
486
|
+
expect(document.activeElement).toBe(piece());
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
test("an arrow key walks the piece a step across the board", () => {
|
|
490
|
+
render(props());
|
|
491
|
+
keyDown("ArrowRight");
|
|
492
|
+
expect(piecePosition()).toEqual({ x: 30, y: 100 });
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
test("holding shift walks it a finer step", () => {
|
|
496
|
+
render(props());
|
|
497
|
+
keyDown("ArrowRight", { shiftKey: true });
|
|
498
|
+
expect(piecePosition()).toEqual({ x: 22, y: 100 });
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test("all four arrows move the piece the way they point", () => {
|
|
502
|
+
render(props());
|
|
503
|
+
keyDown("ArrowRight");
|
|
504
|
+
keyDown("ArrowDown");
|
|
505
|
+
keyDown("ArrowLeft");
|
|
506
|
+
keyDown("ArrowUp");
|
|
507
|
+
expect(piecePosition()).toEqual({ x: 20, y: 100 });
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
test("the board's edge stops the piece just as a drag does", () => {
|
|
511
|
+
render(props());
|
|
512
|
+
for (let press = 0; press < 5; press++) keyDown("ArrowLeft");
|
|
513
|
+
expect(piecePosition()).toEqual({ x: 0, y: 100 });
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
test("home puts the piece back where it started", () => {
|
|
517
|
+
render(props());
|
|
518
|
+
keyDown("ArrowRight");
|
|
519
|
+
keyDown("ArrowDown");
|
|
520
|
+
keyDown("Home");
|
|
521
|
+
expect(piecePosition()).toEqual({ x: 20, y: 100 });
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
test("enter hands over where the piece is, with the trail", () => {
|
|
525
|
+
render(props());
|
|
526
|
+
keyDown("ArrowRight");
|
|
527
|
+
keyDown("ArrowRight");
|
|
528
|
+
keyDown("Enter");
|
|
529
|
+
expect(onComplete).toHaveBeenCalledTimes(1);
|
|
530
|
+
const [finalX, finalY, events] = onComplete.mock.calls[0] ?? [];
|
|
531
|
+
expect(finalX).toBe(40);
|
|
532
|
+
expect(finalY).toBe(100);
|
|
533
|
+
expect(events?.map((event) => [event.x, event.y])).toEqual([
|
|
534
|
+
[30, 100],
|
|
535
|
+
[40, 100],
|
|
536
|
+
]);
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
test("space submits too", () => {
|
|
540
|
+
render(props());
|
|
541
|
+
keyDown("ArrowRight");
|
|
542
|
+
keyDown(" ");
|
|
543
|
+
expect(onComplete).toHaveBeenCalledWith(30, 100, [
|
|
544
|
+
expect.objectContaining({ x: 30, y: 100 }),
|
|
545
|
+
]);
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
test("a second keyboard go starts from a clean trail", () => {
|
|
549
|
+
render(props());
|
|
550
|
+
keyDown("ArrowRight");
|
|
551
|
+
keyDown("Enter");
|
|
552
|
+
keyDown("ArrowDown");
|
|
553
|
+
keyDown("Enter");
|
|
554
|
+
const events = onComplete.mock.calls[1]?.[2];
|
|
555
|
+
expect(events?.map((event) => [event.x, event.y])).toEqual([[30, 110]]);
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
test("a key press no user made is ignored", () => {
|
|
559
|
+
allowSyntheticEvents(false);
|
|
560
|
+
render(props());
|
|
561
|
+
keyDown("ArrowRight");
|
|
562
|
+
keyDown("Enter");
|
|
563
|
+
expect(piecePosition()).toEqual({ x: 20, y: 100 });
|
|
564
|
+
expect(onComplete).not.toHaveBeenCalled();
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
test("keys that mean nothing here are left to the page", () => {
|
|
568
|
+
render(props());
|
|
569
|
+
keyDown("a");
|
|
570
|
+
expect(piecePosition()).toEqual({ x: 20, y: 100 });
|
|
571
|
+
expect(onComplete).not.toHaveBeenCalled();
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
test("the piece cannot be moved or submitted while a solution is in flight", () => {
|
|
575
|
+
render(props({ submitting: true }));
|
|
576
|
+
keyDown("ArrowRight");
|
|
577
|
+
keyDown("Enter");
|
|
578
|
+
expect(piecePosition()).toEqual({ x: 20, y: 100 });
|
|
579
|
+
expect(onComplete).not.toHaveBeenCalled();
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
test("nor can it be tabbed to while a solution is in flight", () => {
|
|
583
|
+
render(props({ submitting: true }));
|
|
584
|
+
expect(piece().tabIndex).toBe(-1);
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
test("tab cannot leave the dialog for the page behind it", () => {
|
|
588
|
+
render(props());
|
|
589
|
+
act(() => {
|
|
590
|
+
document.dispatchEvent(
|
|
591
|
+
new KeyboardEvent("keydown", { key: "Tab", bubbles: true }),
|
|
592
|
+
);
|
|
593
|
+
});
|
|
594
|
+
expect(document.activeElement).toBe(piece());
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
test("closing hands focus back to whatever opened the puzzle", () => {
|
|
598
|
+
const opener = document.createElement("button");
|
|
599
|
+
document.body.appendChild(opener);
|
|
600
|
+
opener.focus();
|
|
601
|
+
|
|
602
|
+
render(props());
|
|
603
|
+
expect(document.activeElement).toBe(piece());
|
|
604
|
+
|
|
605
|
+
act(() => {
|
|
606
|
+
root.unmount();
|
|
607
|
+
});
|
|
608
|
+
expect(document.activeElement).toBe(opener);
|
|
609
|
+
|
|
610
|
+
opener.remove();
|
|
611
|
+
act(() => {
|
|
612
|
+
root = createRoot(container);
|
|
613
|
+
});
|
|
614
|
+
});
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
describe("what it says to a screen reader", () => {
|
|
618
|
+
test("the panel announces itself as a named dialog", () => {
|
|
619
|
+
render(props());
|
|
620
|
+
expect(dialog().getAttribute("aria-label")).toBe("Puzzle challenge");
|
|
621
|
+
expect(dialog().getAttribute("aria-modal")).toBe("true");
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
test("the piece carries a name and says it can be dragged", () => {
|
|
625
|
+
render(props());
|
|
626
|
+
expect(piece().getAttribute("aria-label")).toBe("Puzzle piece");
|
|
627
|
+
expect(piece().getAttribute("aria-roledescription")).toBe(
|
|
628
|
+
"draggable puzzle piece",
|
|
629
|
+
);
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
test("the piece is described by the instruction and the key help", () => {
|
|
633
|
+
render(props());
|
|
634
|
+
expect(pieceDescription()).toContain("Drag the piece to the target");
|
|
635
|
+
expect(pieceDescription()).toContain("arrow keys");
|
|
636
|
+
expect(pieceDescription()).toContain("Escape");
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
test("taking focus reports where the piece is, in proportions", () => {
|
|
640
|
+
render(props());
|
|
641
|
+
expect(liveRegion().textContent).toBe("7 percent across, 50 percent down");
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
test("a move is reported once the keys stop, not on every press", () => {
|
|
645
|
+
vi.useFakeTimers();
|
|
646
|
+
render(props());
|
|
647
|
+
keyDown("ArrowRight");
|
|
648
|
+
keyDown("ArrowRight");
|
|
649
|
+
expect(liveRegion().textContent).toBe("7 percent across, 50 percent down");
|
|
650
|
+
act(() => {
|
|
651
|
+
vi.advanceTimersByTime(500);
|
|
652
|
+
});
|
|
653
|
+
expect(liveRegion().textContent).toBe("13 percent across, 50 percent down");
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
test("a solution in flight is announced", () => {
|
|
657
|
+
render(props({ submitting: true }));
|
|
658
|
+
expect(liveRegion().textContent).toBe("Checking your answer");
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
test("a retry says the puzzle has been replaced", () => {
|
|
662
|
+
render(props({ showRetry: true }));
|
|
663
|
+
expect(liveRegion().textContent).toContain("A new puzzle has loaded");
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
test("the background tiles are left out of the reading order", () => {
|
|
667
|
+
render(props());
|
|
668
|
+
const images = Array.from(overlay().querySelectorAll("img"));
|
|
669
|
+
expect(images.length).toBeGreaterThan(0);
|
|
670
|
+
expect(images.every((image) => image.getAttribute("alt") === "")).toBe(
|
|
671
|
+
true,
|
|
672
|
+
);
|
|
673
|
+
});
|
|
674
|
+
});
|
|
675
|
+
|
|
416
676
|
describe("after it goes away", () => {
|
|
417
677
|
test("its document listeners go with it", () => {
|
|
418
678
|
render(props());
|