diff --git a/web/src/actor-typeahead.d.ts b/web/src/actor-typeahead.d.ts
new file mode 100644
index 0000000..039cb08
--- /dev/null
+++ b/web/src/actor-typeahead.d.ts
@@ -0,0 +1,11 @@
+/**
+ * actor-typeahead ships JSDoc-annotated JavaScript with no declarations. We
+ * import it only for the side effect of registering the custom element, so the
+ * constructor is all the surface we need to name.
+ */
+declare module "actor-typeahead" {
+ const ActorTypeahead: CustomElementConstructor & {
+ define(tag?: string): void;
+ };
+ export default ActorTypeahead;
+}
diff --git a/web/src/camo/editor.ts b/web/src/camo/editor.ts
index cd3706e..150cdb3 100644
--- a/web/src/camo/editor.ts
+++ b/web/src/camo/editor.ts
@@ -19,6 +19,7 @@
*/
import { ApiError, fetchSession, startLogin, type Session } from "../api";
+import { withHandleTypeahead } from "../handle-typeahead";
import {
BACKGROUNDS,
CAMO_H,
@@ -344,6 +345,7 @@ export function camoEditor(onExit: () => void): Node[] {
spellcheck: false,
className: "camo-handle",
});
+ const field = withHandleTypeahead(input);
const go = el("button", { type: "submit", textContent: "Sign in" });
const cancel = el("button", {
type: "button",
@@ -364,7 +366,7 @@ export function camoEditor(onExit: () => void): Node[] {
" account",
]),
el("p", { className: "small", textContent: reason }),
- input,
+ field,
el("div", { className: "camo-actions" }, [go, cancel]),
el("p", {
className: "hint",
diff --git a/web/src/handle-typeahead.ts b/web/src/handle-typeahead.ts
new file mode 100644
index 0000000..3eb308d
--- /dev/null
+++ b/web/src/handle-typeahead.ts
@@ -0,0 +1,100 @@
+/**
+ * Handle autocomplete, for every field on the site that asks for one.
+ *
+ * `actor-typeahead` is a custom element that wraps a plain `` and hangs
+ * a list of matching accounts under it, backed by the public, unauthenticated
+ * `app.bsky.actor.searchActorsTypeahead`. Wiring it up this way keeps the input
+ * itself the real thing: it is created, styled, validated and submitted exactly
+ * as before, and the element around it is decoration. If the chunk never
+ * arrives, or the query endpoint is unreachable, the wrapper stays an inert
+ * unknown tag and the field behaves like any other text input.
+ *
+ * That last part is not free, and the escape hatch below is why this file
+ * exists rather than a bare `import`.
+ */
+
+/** Inputs we have wrapped, so the shared key handler ignores every other one. */
+const enhanced = new WeakSet();
+
+/** Field value as it stood before the component got a look at an Enter press. */
+const beforeEnter = new WeakMap();
+
+let loading: Promise | undefined;
+let listening = false;
+
+/**
+ * Pull in the component. Failure is deliberately silent: a missing chunk costs
+ * the visitor suggestions and nothing else, so it has no business putting an
+ * error in front of them. Clearing the handle lets a later focus try again,
+ * which is worth having on a flaky connection.
+ */
+function load(): Promise {
+ loading ??= import("actor-typeahead").then(
+ () => undefined,
+ () => {
+ loading = undefined;
+ },
+ );
+ return loading;
+}
+
+/**
+ * The component calls preventDefault() on every Enter, whether or not a
+ * suggestion is highlighted, so implicit form submission stops working the
+ * moment it loads. These two handlers bracket its own listener to put that
+ * back. They live on the document because the component attaches to the
+ * wrapper whenever its chunk happens to land, so registration order cannot be
+ * relied on any closer to the field than this.
+ */
+function rememberValue(event: KeyboardEvent): void {
+ const input = event.target;
+ if (!(input instanceof HTMLInputElement) || !enhanced.has(input)) return;
+ if (event.key === "Enter") beforeEnter.set(input, input.value);
+}
+
+function submitIfSwallowed(event: KeyboardEvent): void {
+ const input = event.target;
+ if (!(input instanceof HTMLInputElement) || !enhanced.has(input)) return;
+ if (event.key !== "Enter" || !event.defaultPrevented) return;
+ // Enter mid-composition commits the text. It has never meant submit.
+ if (event.isComposing) return;
+ // Choosing a suggestion rewrites the field. An unchanged value means the key
+ // was swallowed for nothing, so stand in for the submission it ate.
+ if (beforeEnter.get(input) === input.value) input.form?.requestSubmit();
+}
+
+/**
+ * Wrap `input` so it offers handle suggestions, and return the element to put
+ * where the input would have gone. The input keeps its own attributes, classes
+ * and listeners; nothing about it changes.
+ */
+export function withHandleTypeahead(input: HTMLInputElement): HTMLElement {
+ if (!listening) {
+ listening = true;
+ document.addEventListener("keydown", rememberValue, { capture: true });
+ document.addEventListener("keydown", submitIfSwallowed);
+ }
+ enhanced.add(input);
+
+ const wrapper = document.createElement("actor-typeahead");
+ wrapper.className = "typeahead";
+ wrapper.append(input);
+
+ // Loaded on first contact rather than at startup. The sign-in field is on the
+ // first screen a signed-out visitor sees, and suggestions are worth nothing
+ // until they start typing, so this stays off the critical path.
+ const arm = (): void => {
+ void load().then(() => {
+ // The component only reacts to input events, so a keystroke that landed
+ // while the chunk was still in flight would otherwise show nothing until
+ // the next one.
+ if (input.value && document.activeElement === input) {
+ input.dispatchEvent(new InputEvent("input", { bubbles: true }));
+ }
+ });
+ };
+ input.addEventListener("pointerdown", arm, { once: true });
+ input.addEventListener("focus", arm, { once: true });
+
+ return wrapper;
+}
diff --git a/web/src/main.ts b/web/src/main.ts
index 3a5e463..cd60cd1 100644
--- a/web/src/main.ts
+++ b/web/src/main.ts
@@ -25,6 +25,7 @@ import {
type Session,
} from "./api";
import { camoEditor, hasPendingCamo } from "./camo/editor";
+import { withHandleTypeahead } from "./handle-typeahead";
const root = document.querySelector("#app")!;
@@ -101,6 +102,7 @@ function signInScreen(error?: string): Node[] {
});
const button = el("button", { type: "submit", textContent: "Sign in" });
+ const field = withHandleTypeahead(input);
const status = el("p", { className: "error", role: "alert" });
if (error) status.textContent = error;
@@ -115,7 +117,7 @@ function signInScreen(error?: string): Node[] {
}),
" handle",
]),
- input,
+ field,
button,
status,
el("p", {
diff --git a/web/src/styles.css b/web/src/styles.css
index 6c371de..04bf816 100644
--- a/web/src/styles.css
+++ b/web/src/styles.css
@@ -112,6 +112,33 @@ button:focus-visible {
outline-offset: 2px;
}
+/* Handle autocomplete. The dropdown lives in a closed shadow root, so the only
+ way to dress it is the custom properties and ::part() names the component
+ publishes. Its --radius default is our own token already, and it reads the
+ theme through --panel and --line, so light mode follows for free. */
+.typeahead {
+ display: block;
+ /* Both handle fields sit in a flex column where the bare input used to. */
+ flex: 0 0 auto;
+ min-width: 0;
+ /* Beats the component's own system-ui default, which is not our body font. */
+ font: inherit;
+
+ --color-background: var(--panel);
+ --color-border: var(--line);
+ --color-shadow: #000;
+ --color-hover: var(--line);
+ --color-avatar-fallback: var(--line);
+}
+
+.typeahead input {
+ width: 100%;
+}
+
+.typeahead::part(user) {
+ cursor: pointer;
+}
+
button {
align-self: flex-start;
padding: 0.6rem 1.1rem;
@@ -582,6 +609,10 @@ footer .debug {
behind it. */
.camo-modal {
padding: 0;
+ /* A dialog scrolls its overflow by default, which crops the handle
+ suggestions to the box instead of letting them hang below it. The dialog
+ is six short lines tall and will not need to scroll on its own. */
+ overflow: visible;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--panel);
@@ -609,12 +640,11 @@ footer .debug {
margin: 0;
}
-/* The sign-in form is a flex column, so a growable basis here would stretch
- the field down the whole dialog. */
+/* The sign-in form is a flex column, so a growable basis would stretch the
+ field down the whole dialog. That now belongs to .typeahead, which is the
+ flex item; the input only has to fill it. */
.camo-handle {
- flex: 0 0 auto;
width: 100%;
- min-width: 0;
}
.camo-signin .hint {