From 4ca53446ae80fc1e1f7755dc6e4b7d31bb043d29 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Mon, 31 Aug 2026 07:44:08 +0200 Subject: [PATCH] fix(ui): give the wheel back after a and does not chain them on to the document, so once an option is picked the wheel is dead for as long as the pointer stays over that control. Page Up/Down goes to the select too, so the keyboard route out is closed as well. In the popup, whose viewport Chrome caps near 600px, the control sits directly over the fields still to be reached — it reads as the whole window having seized up, and the only way out is a click somewhere uninteresting. `releaseSelectAfterPointerChange` performs that click's one useful effect on the same gesture: it blurs the driven by the arrow keys fires `change` on every keystroke, and a blur there ejects focus mid-selection and resets tab position to the top of the document. A `keydown` therefore cancels the pending release, which also disarms a menu that was opened and then dismissed. The module is typed against a structural `SelectWheelHost` rather than `Document` because `HTMLSelectElement` does not exist in Node and the extension's tests run with no DOM. Separately, the new-vault-entry form pins its Save/Cancel to the bottom of the scrollport. That was aimed at this bug before the cause was known, and stands on its own merits: a did-self-issued entry is eight fields tall against a 600px viewport, and a form whose last step is only ever reachable by scrolling is a defect independent of whether the scrolling works. Signed-off-by: Glenn Gore --- packages/extension/src/confirm.tsx | 6 ++ packages/extension/src/options.tsx | 6 ++ packages/extension/src/popup.tsx | 6 ++ packages/extension/src/select-wheel.ts | 70 +++++++++++++ packages/extension/src/vault-panel.tsx | 30 +++++- .../extension/tests/select-wheel.test.mts | 97 +++++++++++++++++++ 6 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 packages/extension/src/select-wheel.ts create mode 100644 packages/extension/tests/select-wheel.test.mts 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); +});