Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/extension/src/confirm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -1328,6 +1329,11 @@ function TaskConsent() {
);
}

// Chromium leaves the wheel pointed at a focused <select>; 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(
Expand Down
6 changes: 6 additions & 0 deletions packages/extension/src/options.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
import { AppShell } from "./app-shell.js";
import { sendToBackground } from "./send-message.js";
import { VaultPanel } from "./vault-panel.js";
import { releaseSelectAfterPointerChange } from "./select-wheel.js";
import "./theme.css";

const inputStyle: React.CSSProperties = {
Expand Down Expand Up @@ -623,6 +624,11 @@ function VaultPane() {
);
}

// Chromium leaves the wheel pointed at a focused <select>; 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(
Expand Down
6 changes: 6 additions & 0 deletions packages/extension/src/popup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -990,6 +991,11 @@ function AffinidiFooter(): React.JSX.Element {
);
}

// Chromium leaves the wheel pointed at a focused <select>; 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(
Expand Down
70 changes: 70 additions & 0 deletions packages/extension/src/select-wheel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// Give the wheel back after a <select> is used with the mouse.
//
// Chromium routes wheel events to a focused menulist <select> and does not
// chain them on to the document, so once you have picked an option the wheel
// is dead for as long as the pointer stays over that control — and in the
// popup, whose viewport Chrome caps near 600px, the control sits directly
// over the fields you still have to reach. It reads as the whole window
// having seized up. The user's own workaround is to click somewhere
// uninteresting; this performs that click's one useful effect on the same
// gesture, the moment the choice is made.
//
// Deliberately scoped to *pointer* choices. A keyboard user changing a closed
// <select> 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 `<select>`, else null. Structural
* for the same reason: `HTMLSelectElement` does not exist outside a browser. */
function selectTarget(e: Event): { blur(): void } | null {
const target = e.target as { tagName?: unknown; blur?: unknown } | null;
if (!target || target.tagName !== "SELECT" || typeof target.blur !== "function") return null;
return target as { blur(): void };
}

/**
* Install the release on a document root. Returns a disposer; the surfaces
* that call this live as long as their page does and ignore it, but a
* listener with no way off a document is a leak waiting for the first caller
* who doesn't.
*/
export function releaseSelectAfterPointerChange(host: SelectWheelHost): () => void {
// The <select> 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);
};
}
30 changes: 29 additions & 1 deletion packages/extension/src/vault-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -920,7 +920,35 @@ function AddEntryForm({
<span style={{ color: "var(--w-muted)" }}>Notes (optional)</span>
<input value={notes} onChange={(e) => setNotes(e.target.value)} />
</label>
<div style={{ display: "flex", gap: 6, marginTop: 4 }}>
{/* 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 <select> menu has been
used, until an unrelated click restores it. That is the browser's
bug and not ours to fix, but a form whose last step is only ever
reachable by scrolling is ours: the actions now travel with the
viewport, so the flow can always be completed (or abandoned)
wherever the form happens to be scrolled to. Sticky costs nothing
when the form already fits — it only engages once it doesn't. */}
<div
style={{
display: "flex",
gap: 6,
position: "sticky",
bottom: 0,
// Negative side/bottom margins span the container's 8px padding so
// scrolled content passes *behind* the bar rather than through the
// gap beside it.
margin: "4px -8px -8px",
padding: 8,
background: "var(--w-surface)",
borderTop: "1px solid var(--w-line-soft)",
// Follow the container's rounding, or the bar squares off the two
// bottom corners it now sits over.
borderRadius: "0 0 3px 3px",
}}
>
<button
onClick={() => void onSubmit(buildOutput())}
disabled={!valid || busy}
Expand Down
97 changes: 97 additions & 0 deletions packages/extension/tests/select-wheel.test.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
// Wheel release after a pointer-driven <select> 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<string, ((e: Event) => 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);
});
Loading