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 {