diff --git a/src/main.tsx b/src/main.tsx index aa0490b42..b2109ff6f 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -16,6 +16,7 @@ import { auxiliaryWindow } from "./platform/auxWindow"; import { installRepeatedDoubleClick } from "./platform/doubleClick"; import { installErrorLogging } from "./platform/errorLog"; import { installKeyboardDispatcher } from "./platform/keymap"; +import { installPointerFocus } from "./platform/pointerFocus"; import { isTauri } from "./platform/ipc"; import { hideSplash } from "./platform/splash"; import { prefetchLazyComponents } from "./ui"; @@ -28,6 +29,7 @@ if (import.meta.env.DEV && isTauri()) { } installRepeatedDoubleClick(); +installPointerFocus(); installErrorLogging(); // Auxiliary windows (Settings `settings-`, comparisons `comparison--`, Sync with diff --git a/src/platform/pointerFocus.test.ts b/src/platform/pointerFocus.test.ts new file mode 100644 index 000000000..613d2bfc1 --- /dev/null +++ b/src/platform/pointerFocus.test.ts @@ -0,0 +1,45 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { installPointerFocus } from "./pointerFocus"; + +let uninstall: () => void; +beforeEach(() => { + document.body.innerHTML = ` + + +
row
`; + uninstall = installPointerFocus(); +}); +afterEach(() => uninstall()); + +const el = (id: string) => document.getElementById(id)!; +const press = (id: string) => el(id).dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })); +const key = (k: string) => document.dispatchEvent(new KeyboardEvent("keydown", { key: k, bubbles: true })); +const marked = (id: string) => el(id).hasAttribute("data-pointer-focus"); + +describe("installPointerFocus", () => { + it("marks the control under a press, not other focusable elements", () => { + press("label"); + press("row"); + expect([marked("tab"), marked("row")]).toEqual([true, false]); + }); + + it("keeps the mark when focus comes back without keyboard navigation (a popup closed with Esc)", () => { + press("tab"); + el("tab").focus(); + el("other").focus(); + key("Escape"); + el("tab").focus(); + expect(marked("tab")).toBe(true); + }); + + it("clears the mark when Tab or an arrow key moves focus onto the control", () => { + press("tab"); + press("other"); + key("Tab"); + el("tab").focus(); + expect(marked("tab")).toBe(false); + key("ArrowRight"); + el("other").focus(); + expect(marked("other")).toBe(false); + }); +}); diff --git a/src/platform/pointerFocus.ts b/src/platform/pointerFocus.ts new file mode 100644 index 000000000..3901470b5 --- /dev/null +++ b/src/platform/pointerFocus.ts @@ -0,0 +1,34 @@ +/** + * No focus ring on a control the user only clicked. The webview focuses a clicked button and + * the focus outlives the click (across window-tab switches, and popups hand it back to their + * trigger); the next keystroke then switches the engine to keyboard modality and rings a button + * that was never reached by keyboard. Focus itself stays put (actions resolve their context from + * it): a pressed control is marked, `global.css` drops the ring of marked controls, and moving + * focus onto one with Tab or the arrow keys clears its mark. + */ +const CONTROL = "button, [role='button'], [role='tab'], a[href], summary"; +const MARK = "data-pointer-focus"; +const NAVIGATION = new Set(["Tab", "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"]); + +export function installPointerFocus(win: Window = window): () => void { + const doc = win.document; + let navigating = false; + const onPointerDown = (e: PointerEvent) => { + navigating = false; + if (e.target instanceof Element) e.target.closest(CONTROL)?.setAttribute(MARK, ""); + }; + const onKeyDown = (e: KeyboardEvent) => { + navigating = NAVIGATION.has(e.key); + }; + const onFocusIn = (e: FocusEvent) => { + if (navigating && e.target instanceof Element) e.target.removeAttribute(MARK); + }; + doc.addEventListener("pointerdown", onPointerDown, true); + doc.addEventListener("keydown", onKeyDown, true); + doc.addEventListener("focusin", onFocusIn, true); + return () => { + doc.removeEventListener("pointerdown", onPointerDown, true); + doc.removeEventListener("keydown", onKeyDown, true); + doc.removeEventListener("focusin", onFocusIn, true); + }; +} diff --git a/src/styles/global.css b/src/styles/global.css index 31ff0c06f..dbb9e1f43 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -152,6 +152,12 @@ a:hover { outline-offset: -2px; } +/* A control the user only clicked keeps no ring once a keystroke turns on keyboard modality + (`platform/pointerFocus.ts`); `!important` covers the components' own focus rings. */ +[data-pointer-focus]:focus-visible { + outline: none !important; +} + ::selection { background: var(--bg-editor-selection); }