diff --git a/changes/reclaim-focus-removed.md b/changes/reclaim-focus-removed.md new file mode 100644 index 000000000..90f4c6b71 --- /dev/null +++ b/changes/reclaim-focus-removed.md @@ -0,0 +1,3 @@ +### Fixed + +- After a click on a control that then unmounts (a start screen's "Play", a menu's last button), `@jgengine/shell` returns focus to the play surface instead of leaving it on ``, so gameplay keys work without an extra click on the canvas. diff --git a/packages/shell/src/GamePlayerShell.tsx b/packages/shell/src/GamePlayerShell.tsx index 5053d17e5..b881b488b 100644 --- a/packages/shell/src/GamePlayerShell.tsx +++ b/packages/shell/src/GamePlayerShell.tsx @@ -67,6 +67,7 @@ import { observableShellTracker, attachShellInputPublication } from "./shellInpu import { attachShellControlSuspension } from "./shellControlSuspension"; import { LazyShell3dPresentation } from "./lazyShell3dPresentation"; import { createPresentationDiagnosticOwnership, createPlaySurfaceFocusOwnership, PresentationRecovery, type PresentationRecoveryEvent } from "./presentationRecovery"; +import { installPlayFocusReclaim } from "./playSurfaceFocus"; const DEV_USER_ID = "dev-player"; @@ -135,6 +136,8 @@ export function GamePlayerShell({ const presentationDiagnostics = useRef(createPresentationDiagnosticOwnership()); const [remotePlayers, setRemotePlayers] = useState([]); const wrapperRef = useRef(null); + // A start/menu button that unmounts on click drops focus to , where gameplay keys never arrive. + useEffect(() => (poster ? undefined : installPlayFocusReclaim(document, () => wrapperRef.current)), [poster]); const recoveredFocus = useRef(createPlaySurfaceFocusOwnership()); const yawRef = useRef(0); const pitchRef = useRef(0); diff --git a/packages/shell/src/playSurfaceFocus.test.ts b/packages/shell/src/playSurfaceFocus.test.ts new file mode 100644 index 000000000..a1478c71d --- /dev/null +++ b/packages/shell/src/playSurfaceFocus.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test"; +import { shouldReclaimPlayFocus } from "./playSurfaceFocus"; + +const el = (isConnected: boolean) => ({ isConnected }) as Element; +const body = el(true); + +describe("shouldReclaimPlayFocus", () => { + test("reclaims when the focused button unmounted and focus fell to the body", () => { + expect(shouldReclaimPlayFocus(el(false), body, body)).toBe(true); + expect(shouldReclaimPlayFocus(el(false), null, body)).toBe(true); + }); + + test("leaves focus alone when the old element still exists (a deliberate blur)", () => { + expect(shouldReclaimPlayFocus(el(true), body, body)).toBe(false); + }); + + test("leaves focus alone when something else already took it", () => { + expect(shouldReclaimPlayFocus(el(false), el(true), body)).toBe(false); + }); + + test("ignores focusout events without an element target", () => { + expect(shouldReclaimPlayFocus(null, body, body)).toBe(false); + }); +}); diff --git a/packages/shell/src/playSurfaceFocus.ts b/packages/shell/src/playSurfaceFocus.ts new file mode 100644 index 000000000..400873b55 --- /dev/null +++ b/packages/shell/src/playSurfaceFocus.ts @@ -0,0 +1,39 @@ +/** + * Focus recovery for the play surface. Gameplay keys are read on the shell's focusable wrapper, so + * when a focused menu button unmounts (a start screen's "Play", a closed panel's last control) the + * browser drops focus to `` and WASD silently does nothing until the player clicks the canvas. + * The one decision: reclaim only when the clicked element is gone from the document and + * nothing else took focus — a deliberate click elsewhere on the page keeps its focus. + * @internal + */ +export function shouldReclaimPlayFocus(lost: Element | null, active: Element | null, body: Element | null): boolean { + if (lost === null || lost.isConnected) return false; + return active === null || active === body; +} + +/** + * Watches clicks (pointer, Enter or Space on a button) and, right after the click's update commits, returns focus to `surface()` if the clicked control unmounted and focus fell to the body. + * Chromium moves focus to `` on removal without a `focusout`, so the click is the hook. + * Returns the cleanup. + * @internal + */ +export function installPlayFocusReclaim(doc: Document, surface: () => HTMLElement | null): () => void { + let timer: ReturnType | undefined; + const onClick = (event: MouseEvent) => { + const clicked = event.target instanceof Element ? event.target : null; + if (clicked === null) return; + clearTimeout(timer); + // React commits a click's update synchronously at the end of the event; a macrotask is enough and, + // unlike a frame callback, does not stall when the page renders slowly. + timer = setTimeout(() => { + const target = surface(); + if (target === null || !target.isConnected) return; + if (shouldReclaimPlayFocus(clicked, doc.activeElement, doc.body)) target.focus({ preventScroll: true }); + }); + }; + doc.addEventListener("click", onClick, true); + return () => { + clearTimeout(timer); + doc.removeEventListener("click", onClick, true); + }; +} diff --git a/scripts/export-manifest.json b/scripts/export-manifest.json index 919c4995b..28992ea28 100644 --- a/scripts/export-manifest.json +++ b/scripts/export-manifest.json @@ -862,6 +862,7 @@ "./map/terrainMap", "./materialOverride", "./multiplayer", + "./playSurfaceFocus", "./pointer/PointerOverlays", "./pointer/PointerProbe", "./pointer/pointerService",