a tiny atproto handle typeahead web component
atproto
bluesky
1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at https://mozilla.org/MPL/2.0/.
4 *
5 * Copyright (c) 2026 Jake Lazaroff
6 *
7 * Repository: https://tangled.org/jakelazaroff.com/actor-typeahead
8 */
9
10const template = document.createElement("template");
11template.innerHTML = `
12 <slot></slot>
13
14 <ul class="menu" part="menu"></ul>
15
16 <style>
17 :host {
18 --color-background-inherited: var(--color-background, #ffffff);
19 --color-border-inherited: var(--color-border, #00000022);
20 --color-shadow-inherited: var(--color-shadow, #000000);
21 --color-hover-inherited: var(--color-hover, #00000011);
22 --color-avatar-fallback-inherited: var(--color-avatar-fallback, #00000022);
23 --radius-inherited: var(--radius, 8px);
24 --padding-menu-inherited: var(--padding-menu, 4px);
25 display: block;
26 position: relative;
27 font-family: system-ui;
28 }
29
30 *, *::before, *::after {
31 margin: 0;
32 padding: 0;
33 box-sizing: border-box;
34 }
35
36 .menu {
37 display: flex;
38 flex-direction: column;
39 position: absolute;
40 left: 0;
41 margin-top: 4px;
42 width: 100%;
43 list-style: none;
44 overflow: hidden;
45 background-color: var(--color-background-inherited);
46 background-clip: padding-box;
47 border: 1px solid var(--color-border-inherited);
48 border-radius: var(--radius-inherited);
49 box-shadow: 0 6px 6px -4px rgb(from var(--color-shadow-inherited) r g b / 20%);
50 padding: var(--padding-menu-inherited);
51 }
52
53 .menu:empty {
54 display: none;
55 }
56
57 .user {
58 all: unset;
59 box-sizing: border-box;
60 display: flex;
61 align-items: center;
62 gap: 8px;
63 padding: 6px 8px;
64 width: 100%;
65 height: calc(1.5rem + 6px * 2);
66 border-radius: calc(var(--radius-inherited) - var(--padding-menu-inherited));
67 cursor: default;
68 }
69
70 .user:hover,
71 .user[data-active="true"] {
72 background-color: var(--color-hover-inherited);
73 }
74
75 .avatar {
76 width: 1.5rem;
77 height: 1.5rem;
78 border-radius: 50%;
79 background-color: var(--color-avatar-fallback-inherited);
80 overflow: hidden;
81 flex-shrink: 0;
82 }
83
84 .img {
85 display: block;
86 width: 100%;
87 height: 100%;
88 }
89
90 .handle {
91 white-space: nowrap;
92 overflow: hidden;
93 text-overflow: ellipsis;
94 }
95 </style>
96`;
97
98const user = document.createElement("template");
99user.innerHTML = `
100 <li>
101 <button class="user" part="user">
102 <div class="avatar" part="avatar">
103 <img class="img" part="img">
104 </div>
105 <span class="handle" part="handle"></span>
106 </button>
107 </li>
108`;
109
110/**
111 * @template {HTMLElement} T
112 * @param {T} tmpl
113 */
114function clone(tmpl) {
115 return /** @type {T} */ (tmpl.cloneNode(true));
116}
117
118/**
119 * @attribute {string} [host] - The host to which to make the typeahead API call.
120 * @attribute {number} [rows] - The maximum number of rows to display in the dropdown.
121 *
122 * @csspart menu - The dropdown menu.
123 * @csspart user - The user row.
124 * @csspart avatar - The user avatar wrapper.
125 * @csspart img - The user avatar image.
126 * @csspart handle - The user handle text.
127 *
128 * @slot - The <input> tag to progressively enhance.
129 *
130 * @cssprop --color-background - Controls the color of the dropdown background.
131 * @cssprop --color-border - Controls the color of the dropdown border.
132 * @cssprop --color-shadow - Controls the color of the dropdown shadow.
133 * @cssprop --color-hover - Controls the background color of each row on hover.
134 * @cssprop --color-avatar-fallback - Controls the background color of an avatar circle if the image fails to load.
135 * @cssprop --radius - Controls the corner radius of the dropdown.
136 * @cssprop --padding-menu - Controls the padding of the dropdown menu.
137 *
138 * @summary A small web component that progressively enhances an <input> element into an autocomplete for ATProto handles!
139 *
140 * @tag actor-typeahead
141 */
142export default class ActorTypeahead extends HTMLElement {
143 static tag = "actor-typeahead";
144
145 static define(tag = this.tag) {
146 this.tag = tag;
147
148 const name = customElements.getName(this);
149 if (name && name !== tag) return console.warn(`${this.name} already defined as <${name}>!`);
150
151 const ce = customElements.get(tag);
152 if (ce && ce !== this) return console.warn(`<${tag}> already defined as ${ce.name}!`);
153
154 customElements.define(tag, this);
155 }
156
157 static {
158 const tag = new URL(import.meta.url).searchParams.get("tag") || this.tag;
159 if (tag !== "none") this.define(tag);
160 }
161
162 #shadow = this.attachShadow({ mode: "closed" });
163
164 /** @type {Array<{ handle: string; avatar: string }>} */
165 #actors = [];
166 #index = -1;
167 #pressed = false;
168
169 constructor() {
170 super();
171
172 this.#shadow.append(clone(template).content);
173 this.#render();
174 this.addEventListener("input", this);
175 this.addEventListener("focusout", this);
176 this.addEventListener("keydown", this);
177 this.#shadow.addEventListener("pointerdown", this);
178 this.#shadow.addEventListener("pointerup", this);
179 this.#shadow.addEventListener("click", this);
180 }
181
182 get #rows() {
183 const rows = Number.parseInt(this.getAttribute("rows") ?? "");
184
185 if (Number.isNaN(rows)) return 5;
186 return rows;
187 }
188
189 /** @param {Event} evt */
190 handleEvent(evt) {
191 switch (evt.type) {
192 case "input":
193 this.#oninput(/** @type {InputEvent & { target: HTMLInputElement }} */ (evt));
194 break;
195
196 case "keydown":
197 this.#onkeydown(/** @type {KeyboardEvent} */ (evt));
198 break;
199
200 case "focusout":
201 this.#onfocusout(evt);
202 break;
203
204 case "pointerdown":
205 this.#onpointerdown(/** @type {PointerEvent & { target: HTMLElement }} */ (evt));
206 break;
207
208 case "pointerup":
209 this.#onpointerup(/** @type {PointerEvent & { target: HTMLElement }} */ (evt));
210 break;
211 }
212 }
213
214 /** @param {KeyboardEvent} evt */
215 #onkeydown(evt) {
216 switch (evt.key) {
217 case "ArrowDown":
218 evt.preventDefault();
219 this.#index = Math.min(this.#index + 1, this.#rows - 1);
220 this.#render();
221 break;
222
223 case "PageDown":
224 evt.preventDefault();
225 this.#index = this.#rows - 1;
226 this.#render();
227 break;
228
229 case "ArrowUp":
230 evt.preventDefault();
231 this.#index = Math.max(this.#index - 1, 0);
232 this.#render();
233 break;
234
235 case "PageUp":
236 evt.preventDefault();
237 this.#index = 0;
238 this.#render();
239 break;
240
241 case "Escape":
242 evt.preventDefault();
243 this.#actors = [];
244 this.#index = -1;
245 this.#render();
246 break;
247
248 case "Enter":
249 evt.preventDefault();
250 this.#shadow
251 .querySelectorAll("button")
252 [this.#index]?.dispatchEvent(new PointerEvent("pointerup", { bubbles: true }));
253 break;
254 }
255 }
256
257 /** @param {InputEvent & { target: HTMLInputElement }} evt */
258 async #oninput(evt) {
259 const query = evt.target?.value;
260 if (!query) {
261 this.#actors = [];
262 this.#render();
263 return;
264 }
265
266 const host = this.getAttribute("host") ?? "https://public.api.bsky.app";
267 const url = new URL("xrpc/app.bsky.actor.searchActorsTypeahead", host);
268 url.searchParams.set("q", query);
269 url.searchParams.set("limit", `${this.#rows}`);
270
271 const res = await fetch(url);
272 const json = await res.json();
273 this.#actors = json.actors;
274 this.#index = -1;
275 this.#render();
276 }
277
278 /** @param {Event} evt */
279 async #onfocusout(evt) {
280 if (this.#pressed) return;
281
282 this.#actors = [];
283 this.#index = -1;
284 this.#render();
285 }
286
287 #render() {
288 const fragment = document.createDocumentFragment();
289 let i = -1;
290 for (const actor of this.#actors) {
291 const li = clone(user).content;
292
293 const button = li.querySelector("button");
294 if (button) {
295 button.dataset.handle = actor.handle;
296 if (++i === this.#index) button.dataset.active = "true";
297 }
298
299 const avatar = li.querySelector("img");
300 if (avatar && actor.avatar) avatar.src = actor.avatar;
301
302 const handle = li.querySelector(".handle");
303 if (handle) handle.textContent = actor.handle;
304
305 fragment.append(li);
306 }
307
308 this.#shadow.querySelector(".menu")?.replaceChildren(...fragment.children);
309 }
310
311 /** @param {PointerEvent} evt */
312 #onpointerdown(evt) {
313 this.#pressed = true;
314 }
315
316 /** @param {PointerEvent & { target: HTMLElement }} evt */
317 #onpointerup(evt) {
318 this.#pressed = false;
319
320 this.querySelector("input")?.focus();
321
322 const button = evt.target?.closest("button");
323 const input = this.querySelector("input");
324 if (!input || !button) return;
325
326 input.value = button.dataset.handle || "";
327 this.#actors = [];
328 this.#render();
329 }
330}