/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. * * Copyright (c) 2026 Jake Lazaroff * * Repository: https://tangled.org/jakelazaroff.com/actor-typeahead */ const template = document.createElement("template"); template.innerHTML = ` `; const user = document.createElement("template"); user.innerHTML = `
  • `; /** * @template {HTMLElement} T * @param {T} tmpl */ function clone(tmpl) { return /** @type {T} */ (tmpl.cloneNode(true)); } /** * @attribute {string} [host] - The host to which to make the typeahead API call. * @attribute {number} [rows] - The maximum number of rows to display in the dropdown. * * @csspart menu - The dropdown menu. * @csspart user - The user row. * @csspart avatar - The user avatar wrapper. * @csspart img - The user avatar image. * @csspart handle - The user handle text. * * @slot - The tag to progressively enhance. * * @cssprop --color-background - Controls the color of the dropdown background. * @cssprop --color-border - Controls the color of the dropdown border. * @cssprop --color-shadow - Controls the color of the dropdown shadow. * @cssprop --color-hover - Controls the background color of each row on hover. * @cssprop --color-avatar-fallback - Controls the background color of an avatar circle if the image fails to load. * @cssprop --radius - Controls the corner radius of the dropdown. * @cssprop --padding-menu - Controls the padding of the dropdown menu. * * @summary A small web component that progressively enhances an element into an autocomplete for ATProto handles! * * @tag actor-typeahead */ export default class ActorTypeahead extends HTMLElement { static tag = "actor-typeahead"; static define(tag = this.tag) { this.tag = tag; const name = customElements.getName(this); if (name && name !== tag) return console.warn(`${this.name} already defined as <${name}>!`); const ce = customElements.get(tag); if (ce && ce !== this) return console.warn(`<${tag}> already defined as ${ce.name}!`); customElements.define(tag, this); } static { const tag = new URL(import.meta.url).searchParams.get("tag") || this.tag; if (tag !== "none") this.define(tag); } #shadow = this.attachShadow({ mode: "closed" }); /** @type {Array<{ handle: string; avatar: string }>} */ #actors = []; #index = -1; #pressed = false; constructor() { super(); this.#shadow.append(clone(template).content); this.#render(); this.addEventListener("input", this); this.addEventListener("focusout", this); this.addEventListener("keydown", this); this.#shadow.addEventListener("pointerdown", this); this.#shadow.addEventListener("pointerup", this); this.#shadow.addEventListener("click", this); } get #rows() { const rows = Number.parseInt(this.getAttribute("rows") ?? ""); if (Number.isNaN(rows)) return 5; return rows; } /** @param {Event} evt */ handleEvent(evt) { switch (evt.type) { case "input": this.#oninput(/** @type {InputEvent & { target: HTMLInputElement }} */(evt)); break; case "keydown": this.#onkeydown(/** @type {KeyboardEvent} */(evt)); break; case "focusout": this.#onfocusout(evt); break; case "pointerdown": this.#onpointerdown(/** @type {PointerEvent & { target: HTMLElement }} */(evt)); break; case "pointerup": this.#onpointerup(/** @type {PointerEvent & { target: HTMLElement }} */(evt)); break; } } /** @param {KeyboardEvent} evt */ #onkeydown(evt) { switch (evt.key) { case "ArrowDown": evt.preventDefault(); this.#index = Math.min(this.#index + 1, this.#rows - 1); this.#render(); break; case "PageDown": evt.preventDefault(); this.#index = this.#rows - 1; this.#render(); break; case "ArrowUp": evt.preventDefault(); this.#index = Math.max(this.#index - 1, 0); this.#render(); break; case "PageUp": evt.preventDefault(); this.#index = 0; this.#render(); break; case "Escape": evt.preventDefault(); this.#actors = []; this.#index = -1; this.#render(); break; case "Enter": evt.preventDefault(); this.#shadow .querySelectorAll("button") [this.#index]?.dispatchEvent(new PointerEvent("pointerup", { bubbles: true })); break; } } /** @param {InputEvent & { target: HTMLInputElement }} evt */ async #oninput(evt) { const query = evt.target?.value; if (!query) { this.#actors = []; this.#render(); return; } const host = this.getAttribute("host") ?? "https://public.api.bsky.app"; const url = new URL("xrpc/app.bsky.actor.searchActorsTypeahead", host); url.searchParams.set("q", query); url.searchParams.set("limit", `${this.#rows}`); const res = await fetch(url); const json = await res.json(); this.#actors = json.actors; this.#index = -1; this.#render(); } /** @param {Event} evt */ async #onfocusout(evt) { if (this.#pressed) return; this.#actors = []; this.#index = -1; this.#render(); } #render() { const fragment = document.createDocumentFragment(); let i = -1; for (const actor of this.#actors) { const li = clone(user).content; const button = li.querySelector("button"); if (button) { button.dataset.handle = actor.handle; if (++i === this.#index) button.dataset.active = "true"; } const avatar = li.querySelector("img"); if (avatar && actor.avatar) avatar.src = actor.avatar; const handle = li.querySelector(".handle"); if (handle) handle.textContent = actor.handle; fragment.append(li); } this.#shadow.querySelector(".menu")?.replaceChildren(...fragment.children); } /** @param {PointerEvent} evt */ #onpointerdown(evt) { this.#pressed = true; } /** @param {PointerEvent & { target: HTMLElement }} evt */ #onpointerup(evt) { this.#pressed = false; this.querySelector("input")?.focus(); const button = evt.target?.closest("button"); const input = this.querySelector("input"); if (!input || !button) return; input.value = button.dataset.handle || ""; this.#actors = []; this.#render(); } }