@prosopo/procaptcha-puzzle 2.12.2 → 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.
@@ -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 { ChallengeSurface } from "@prosopo/procaptcha-common";
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 { useCallback, useEffect, useRef, useState } from "react";
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 SHAKE_KEYFRAMES = `
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 currentEvents = [...puzzleEvents.current];
153
- const lastEvent = currentEvents[currentEvents.length - 1];
154
- const finalX = lastEvent ? lastEvent.x : originX;
155
- const finalY = lastEvent ? lastEvent.y : originY;
156
-
157
- onComplete(finalX, finalY, currentEvents);
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
- : "Drag the piece to the target";
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
- {/* Inject shake keyframes */}
278
- <style>{SHAKE_KEYFRAMES}</style>
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
- // The whole point of the puzzle drag is that a bot
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());