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();
}
}