/** * 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; }