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