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