From ead5262b183fe55a8e12d894cef5d00cd86a27e3 Mon Sep 17 00:00:00 2001 From: PJ Date: Sat, 6 Jun 2026 12:57:49 +0530 Subject: [PATCH] test(replay-ui): pin keyboard-nav ownership and key routing --- replay-ui/src/__tests__/keyboard-nav.test.ts | 103 +++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 replay-ui/src/__tests__/keyboard-nav.test.ts diff --git a/replay-ui/src/__tests__/keyboard-nav.test.ts b/replay-ui/src/__tests__/keyboard-nav.test.ts new file mode 100644 index 0000000..5e4194c --- /dev/null +++ b/replay-ui/src/__tests__/keyboard-nav.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from "bun:test"; +import { + dispatchKey, + targetOwnsArrowKeys, + type KeyboardNavOptions, +} from "../lib/keyboard-nav"; + +function el( + over: Partial<{ + tagName: string; + isContentEditable: boolean; + role: string | null; + parent: HTMLElement | null; + }> = {}, +): HTMLElement { + const role = over.role ?? null; + return { + tagName: over.tagName ?? "DIV", + isContentEditable: over.isContentEditable ?? false, + getAttribute: (name: string) => (name === "role" ? role : null), + parentElement: over.parent ?? null, + } as unknown as HTMLElement; +} + +function spyOptions() { + const calls: string[] = []; + const make = (name: keyof KeyboardNavOptions) => () => { + calls.push(name); + }; + const options: KeyboardNavOptions = { + onPrev: make("onPrev"), + onNext: make("onNext"), + onJumpStart: make("onJumpStart"), + onJumpEnd: make("onJumpEnd"), + onJumpPrev10: make("onJumpPrev10"), + onJumpNext10: make("onJumpNext10"), + onJumpNextViolation: make("onJumpNextViolation"), + }; + return { calls, options }; +} + +describe("targetOwnsArrowKeys", () => { + it("walks ancestors and matches arrow-owning roles", () => { + expect(targetOwnsArrowKeys(el({ role: "option" }))).toBe(true); + const child = el({ role: null, parent: el({ role: "listbox" }) }); + expect(targetOwnsArrowKeys(child)).toBe(true); + expect(targetOwnsArrowKeys(el({ role: "banner" }))).toBe(false); + expect(targetOwnsArrowKeys(null)).toBe(false); + }); +}); + +// Bug class: navigation keys firing while the user types in a form field would +// scrub the timeline out from under them; and arrow keys must yield to a +// listbox/tab that owns them so its own roving focus still works. +describe("dispatchKey ownership", () => { + it("ignores keys originating in editable targets", () => { + const { calls, options } = spyOptions(); + expect(dispatchKey({ key: "j", target: el({ tagName: "INPUT" }) }, options)).toBe(false); + expect(dispatchKey({ key: "g", target: el({ isContentEditable: true }) }, options)).toBe(false); + expect(calls).toEqual([]); + }); + + it("yields arrow keys to an owning ancestor but still handles letters", () => { + const { calls, options } = spyOptions(); + const inListbox = el({ parent: el({ role: "listbox" }) }); + expect(dispatchKey({ key: "ArrowRight", target: inListbox }, options)).toBe(false); + expect(dispatchKey({ key: "j", target: inListbox }, options)).toBe(true); + expect(calls).toEqual(["onNext"]); + }); + + it("ignores keys combined with a modifier", () => { + const { calls, options } = spyOptions(); + expect(dispatchKey({ key: "j", metaKey: true }, options)).toBe(false); + expect(dispatchKey({ key: "j", ctrlKey: true }, options)).toBe(false); + expect(calls).toEqual([]); + }); +}); + +describe("dispatchKey routing", () => { + const cases: [string, boolean, keyof KeyboardNavOptions][] = [ + ["ArrowLeft", false, "onPrev"], + ["k", false, "onPrev"], + ["ArrowLeft", true, "onJumpPrev10"], + ["ArrowRight", false, "onNext"], + ["j", true, "onJumpNext10"], + ["g", false, "onJumpStart"], + ["G", false, "onJumpEnd"], + [".", false, "onJumpNextViolation"], + ]; + for (const [key, shiftKey, expected] of cases) { + it(`${shiftKey ? "shift+" : ""}${key} -> ${expected}`, () => { + const { calls, options } = spyOptions(); + expect(dispatchKey({ key, shiftKey }, options)).toBe(true); + expect(calls).toEqual([expected]); + }); + } + + it("leaves unmapped keys untouched", () => { + const { calls, options } = spyOptions(); + expect(dispatchKey({ key: "x" }, options)).toBe(false); + expect(calls).toEqual([]); + }); +});