diff --git a/packages/extension/src/confirm.tsx b/packages/extension/src/confirm.tsx index 6583ccc..0b6b493 100644 --- a/packages/extension/src/confirm.tsx +++ b/packages/extension/src/confirm.tsx @@ -2,6 +2,7 @@ import { StrictMode, useEffect, useState } from "react"; import { createRoot } from "react-dom/client"; +import { releaseSelectAfterPointerChange } from "./select-wheel.js"; import { collapseDid, splitDid, type DidPart } from "./did-display.js"; import { HOLDER_IDENTITY } from "./site-identity.js"; import { extractAgentNames, withoutScheme } from "./agent-name.js"; @@ -1328,6 +1329,11 @@ function TaskConsent() { ); } +// Chromium leaves the wheel pointed at a focused ; without this, +// picking an option kills scrolling over that control until the user +// clicks elsewhere. See src/select-wheel.ts. +releaseSelectAfterPointerChange(document); + const root = document.getElementById("root"); if (root) { createRoot(root).render( diff --git a/packages/extension/src/popup.tsx b/packages/extension/src/popup.tsx index 1bad201..c28d3a4 100644 --- a/packages/extension/src/popup.tsx +++ b/packages/extension/src/popup.tsx @@ -8,6 +8,7 @@ import { c, t } from "./theme.js"; import { encryptHolderSecretInPopup } from "./encrypt-holder.js"; import { readActiveVtaDid } from "./active-vta.js"; import { CopyButton, VaultPanel } from "./vault-panel.js"; +import { releaseSelectAfterPointerChange } from "./select-wheel.js"; import { useActiveConnection, useConnectionStore, @@ -990,6 +991,11 @@ function AffinidiFooter(): React.JSX.Element { ); } +// Chromium leaves the wheel pointed at a focused is used with the mouse. +// +// Chromium routes wheel events to a focused menulist with the arrow keys fires `change` on every keystroke, and blurring +// there would throw focus out of the form mid-selection and reset tab position +// to the top of the document — trading a mouse annoyance for a keyboard trap. +// A `keydown` therefore cancels the pending release: the sequence that ends in +// a blur has to be pointer-opened and uninterrupted by the keyboard. + +/** The slice of `Document` this needs. Typed structurally rather than as + * `Document` so the behaviour is testable in plain Node, which has no DOM — + * see `tests/select-wheel.test.mts`. */ +export interface SelectWheelHost { + addEventListener(type: string, listener: (e: Event) => void, capture?: boolean): void; + removeEventListener(type: string, listener: (e: Event) => void, capture?: boolean): void; +} + +/** An event's target when that target is a ` a pointer opened, and so the only one a `change` may blur. + let opened: { blur(): void } | null = null; + + const onPointerDown = (e: Event) => { + opened = selectTarget(e); + }; + // Any key cancels: either the user is driving the select from the keyboard, + // or a pointer-opened menu was abandoned and `opened` is now stale. + const onKeyDown = () => { + opened = null; + }; + const onChange = (e: Event) => { + const el = selectTarget(e); + if (el && el === opened) el.blur(); + opened = null; + }; + + // Capture, so a component that stops propagation on its own control does + // not silently opt that control out of the fix. + host.addEventListener("pointerdown", onPointerDown, true); + host.addEventListener("keydown", onKeyDown, true); + host.addEventListener("change", onChange, true); + + return () => { + host.removeEventListener("pointerdown", onPointerDown, true); + host.removeEventListener("keydown", onKeyDown, true); + host.removeEventListener("change", onChange, true); + }; +} diff --git a/packages/extension/src/vault-panel.tsx b/packages/extension/src/vault-panel.tsx index 337eb95..8be800f 100644 --- a/packages/extension/src/vault-panel.tsx +++ b/packages/extension/src/vault-panel.tsx @@ -920,7 +920,35 @@ function AddEntryForm({ Notes (optional) setNotes(e.target.value)} /> -
+ {/* Pinned to the bottom of the scrollport, not left at the end of the + form. A did-self-issued entry is eight fields tall and the popup's + viewport is capped near 600px, so Save sat below the fold — and the + only way to reach it was to scroll a document that Chromium will + sometimes refuse to scroll after a native change — see +// src/select-wheel.ts. +// +// The pointer/keyboard split is the whole design: blurring on every `change` +// would fix a mouse annoyance by breaking keyboard selection, which fires +// `change` on every arrow keystroke. These pin both halves. + +import test from "node:test"; +import assert from "node:assert/strict"; +import { releaseSelectAfterPointerChange, type SelectWheelHost } from "../src/select-wheel.ts"; + +/** A stand-in document that just dispatches to whatever was registered. */ +function host() { + const listeners = new Map void)[]>(); + const h: SelectWheelHost = { + addEventListener(type, listener) { + listeners.set(type, [...(listeners.get(type) ?? []), listener]); + }, + removeEventListener(type, listener) { + listeners.set(type, (listeners.get(type) ?? []).filter((l) => l !== listener)); + }, + }; + const fire = (type: string, target: unknown) => { + for (const l of listeners.get(type) ?? []) l({ target } as unknown as Event); + }; + return { h, fire, count: () => [...listeners.values()].flat().length }; +} + +function select() { + let blurred = 0; + return { tagName: "SELECT", blur: () => void blurred++, blurs: () => blurred }; +} + +test("a pointer-opened select is released once the choice lands", () => { + const { h, fire } = host(); + releaseSelectAfterPointerChange(h); + const el = select(); + fire("pointerdown", el); + fire("change", el); + assert.equal(el.blurs(), 1); +}); + +test("a keyboard change keeps focus — blurring would reset tab position", () => { + const { h, fire } = host(); + releaseSelectAfterPointerChange(h); + const el = select(); + fire("keydown", el); + fire("change", el); + assert.equal(el.blurs(), 0); +}); + +test("a key pressed after the pointer cancels the pending release", () => { + const { h, fire } = host(); + releaseSelectAfterPointerChange(h); + const el = select(); + fire("pointerdown", el); + fire("keydown", el); + fire("change", el); + assert.equal(el.blurs(), 0); +}); + +test("an abandoned menu leaves nothing armed for the next change", () => { + const { h, fire } = host(); + releaseSelectAfterPointerChange(h); + const el = select(); + fire("pointerdown", el); + fire("pointerdown", { tagName: "DIV" }); // dismissed by clicking elsewhere + fire("change", el); + assert.equal(el.blurs(), 0); +}); + +test("only the select the pointer opened is released", () => { + const { h, fire } = host(); + releaseSelectAfterPointerChange(h); + const opened = select(); + const other = select(); + fire("pointerdown", opened); + fire("change", other); + assert.equal(other.blurs(), 0); + assert.equal(opened.blurs(), 0); +}); + +test("a change on a non-select target is left alone", () => { + const { h, fire } = host(); + releaseSelectAfterPointerChange(h); + const input = { tagName: "INPUT", blur: () => assert.fail("blurred an input") }; + fire("pointerdown", input); + fire("change", input); +}); + +test("the disposer removes every listener it added", () => { + const { h, count } = host(); + const dispose = releaseSelectAfterPointerChange(h); + assert.equal(count(), 3); + dispose(); + assert.equal(count(), 0); +});