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