const template = document.createElement("template"); template.innerHTML = ` `; const user = document.createElement("template"); user.innerHTML = `
  • `; 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(template.cloneNode(true).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} */ (evt)); break; case "keydown": this.#onkeydown(/** @type {KeyboardEvent} */ (evt)); break; case "focusout": this.#onfocusout(evt); break; case "pointerdown": this.#onpointerdown(/** @type {PointerEvent} */ (evt)); break; case "pointerup": this.#onpointerup(/** @type {PointerEvent} */ (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(".user")[this.#index]?.click(); break; } } /** @param {InputEvent} 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 = user.cloneNode(true).content; const button = li.querySelector(".user"); button.dataset.handle = actor.handle; if (++i === this.#index) button.dataset.active = "true"; const avatar = li.querySelector(".img"); if (actor.avatar) avatar.src = actor.avatar; li.querySelector(".handle").textContent = actor.handle; fragment.append(li); } this.#shadow.querySelector(".menu")?.replaceChildren(...fragment.children); } /** @param {PointerEvent} evt */ #onpointerdown(evt) { this.#pressed = true; } /** @param {PointerEvent} evt */ #onpointerup(evt) { this.#pressed = false; this.querySelector("input")?.focus(); const button = evt.target?.closest(".user"); const input = this.querySelector("input"); if (!input || !button) return; input.value = button.dataset.handle; this.#actors = []; this.#render(); } }