From e6fac7d53096ac38833606af2353c48bc0035087 Mon Sep 17 00:00:00 2001 From: Owen McGirr Date: Fri, 9 Oct 2026 14:07:52 +0100 Subject: [PATCH] Let apps consume a switch press with interceptPress - useKeyboardSwitches calls interceptPress when a switch goes down; returning true consumes the press and its release without a scan action, so an app can let any press stop a repeat - Test, document, and bump to 0.4.0 Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + package.json | 2 +- src/dom/keyboardSwitches.ts | 20 +++++++++++++++++--- test/keyboardSwitches.test.tsx | 20 +++++++++++++++++++- 4 files changed, 38 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index fe6d9cd..70a3926 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ const { held, prompt } = useKeyboardSwitches({ - Bound keys are kept from the page, so Space and Enter don't also click whatever has focus. - Keys pass through while focus is in a text field, unless you set `ignoreWhileTyping: false`. - Escape stops scanning. +- `interceptPress(switchId)` runs when a switch goes down. Return true to consume the whole press, for example to let any press stop something the app is repeating. - Automatic movement pauses while a switch is held. - `validateSwitchSettings` reports settings that can't drive scanning, for example ones with no Select, or manual scanning without Next and Previous. diff --git a/package.json b/package.json index e26e8b3..dce87b6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@switchify/scanning", - "version": "0.3.1", + "version": "0.4.0", "description": "Switch scanning for React and React Native, ported from Switchify PC.", "license": "AGPL-3.0-only", "type": "module", diff --git a/src/dom/keyboardSwitches.ts b/src/dom/keyboardSwitches.ts index 14c684c..4005b1d 100644 --- a/src/dom/keyboardSwitches.ts +++ b/src/dom/keyboardSwitches.ts @@ -13,6 +13,12 @@ export interface KeyboardSwitchOptions { */ ignoreWhileTyping?: boolean; onAction?: (action: ScanAction, switchId: string) => void; + /** + * Called when a switch goes down. Returning true consumes the whole press, which + * then never becomes a scan action. Use it to let any press stop something the + * app is repeating. + */ + interceptPress?: (switchId: string) => boolean; } export interface KeyboardSwitchState { @@ -45,16 +51,18 @@ export function useKeyboardSwitches({ enabled = true, ignoreWhileTyping = true, onAction, + interceptPress, }: KeyboardSwitchOptions): KeyboardSwitchState { const controller = useScanController(); const [state, setState] = useState({ held: false, prompt: undefined }); - const latest = useRef({ settings, ignoreWhileTyping, onAction }); - latest.current = { settings, ignoreWhileTyping, onAction }; + const latest = useRef({ settings, ignoreWhileTyping, onAction, interceptPress }); + latest.current = { settings, ignoreWhileTyping, onAction, interceptPress }; useEffect(() => { if (!enabled || typeof window === "undefined") return; const gestures = new SwitchGestures(); const captured = new Set(); + const intercepted = new Set(); let timer: ReturnType | undefined; const now = () => (globalThis.performance ?? Date).now(); const show = () => { @@ -75,6 +83,7 @@ export function useKeyboardSwitches({ const reset = () => { gestures.cancel(); captured.clear(); + intercepted.clear(); stopTimer(); controller.setSwitchHeld(false); show(); @@ -95,7 +104,11 @@ export function useKeyboardSwitches({ event.preventDefault(); event.stopPropagation(); captured.add(event.code); - if (event.repeat) return; + if (event.repeat || intercepted.has(event.code)) return; + if (!gestures.isHeld() && latest.current.interceptPress?.(match.id)) { + intercepted.add(event.code); + return; + } gestures.pressed(match.id, now(), latest.current.settings); controller.setSwitchHeld(true); timer ??= setInterval(show, PROMPT_TICK_MS); @@ -106,6 +119,7 @@ export function useKeyboardSwitches({ if (!captured.delete(event.code)) return; event.preventDefault(); event.stopPropagation(); + if (intercepted.delete(event.code)) return; const match = binding(event.code); const action = match ? gestures.released(match.id, now()) : undefined; if (!gestures.isHeld()) { diff --git a/test/keyboardSwitches.test.tsx b/test/keyboardSwitches.test.tsx index ac050ff..84f2367 100644 --- a/test/keyboardSwitches.test.tsx +++ b/test/keyboardSwitches.test.tsx @@ -16,8 +16,9 @@ function Item({ id, onActivate }: { id: string; onActivate?: () => void }) { let switches: ReturnType; let scanner: ReturnType; +let intercept: ((id: string) => boolean) | undefined; function Switches({ enabled = true }: { enabled?: boolean }) { - switches = useKeyboardSwitches({ settings, enabled }); + switches = useKeyboardSwitches({ settings, enabled, interceptPress: (id) => intercept?.(id) ?? false }); scanner = useScanner(); return null; } @@ -97,3 +98,20 @@ test("nothing is captured while disabled", async () => { expect(event.defaultPrevented).toBe(false); expect(scanner.snapshot.active).toBe(false); }); + +test("an intercepted press is consumed whole and never becomes a scan action", async () => { + await setup(); + tap("Space"); + expect(highlighted("a")).toBe(true); + let repeating = true; + intercept = vi.fn(() => { const was = repeating; repeating = false; return was; }); + const down = new KeyboardEvent("keydown", { code: "Enter", bubbles: true, cancelable: true }); + act(() => { window.dispatchEvent(down); }); + expect(down.defaultPrevented).toBe(true); + key("keyup", "Enter"); + expect(highlighted("a")).toBe(true); + expect(intercept).toHaveBeenCalledWith("next"); + tap("Enter"); + expect(highlighted("b")).toBe(true); + intercept = undefined; +});