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 cursor: default;
59 }
60
61 .user:hover,
62 .user[data-active="true"] {
63 background-color: var(--color-hover-inherited);
64 }
65
66 .avatar {
67 width: 1.5rem;
68 height: 1.5rem;
69 border-radius: 50%;
70 background-color: var(--color-avatar-fallback-inherited);
71 overflow: hidden;
72 flex-shrink: 0;
73 }
74
75 .img {
76 display: block;
77 width: 100%;
78 height: 100%;
79 }
80
81 .handle {
82 white-space: nowrap;
83 overflow: hidden;
84 text-overflow: ellipsis;
85 }
86 </style>
87`;
88
89const user = document.createElement("template");
90user.innerHTML = `
91 <li>
92 <button class="user" part="user">
93 <div class="avatar" part="avatar">
94 <img class="img" part="img">
95 </div>
96 <span class="handle" part="handle"></span>
97 </button>
98 </li>
99`;
100
101export default class ActorTypeahead extends HTMLElement {
102 static tag = "actor-typeahead";
103
104 static define(tag = this.tag) {
105 this.tag = tag;
106
107 const name = customElements.getName(this);
108 if (name && name !== tag) return console.warn(`${this.name} already defined as <${name}>!`);
109
110 const ce = customElements.get(tag);
111 if (ce && ce !== this) return console.warn(`<${tag}> already defined as ${ce.name}!`);
112
113 customElements.define(tag, this);
114 }
115
116 static {
117 const tag = new URL(import.meta.url).searchParams.get("tag") || this.tag;
118 if (tag !== "none") this.define(tag);
119 }
120
121 #shadow = this.attachShadow({ mode: "closed" });
122
123 /** @type {Array<{ handle: string; avatar: string }>} */
124 #actors = [];
125 #index = -1;
126 #pressed = false;
127
128 constructor() {
129 super();
130
131 this.#shadow.append(template.cloneNode(true).content);
132 this.#render();
133 this.addEventListener("input", this);
134 this.addEventListener("focusout", this);
135 this.addEventListener("keydown", this);
136 this.#shadow.addEventListener("pointerdown", this);
137 this.#shadow.addEventListener("pointerup", this);
138 this.#shadow.addEventListener("click", this);
139 }
140
141 get #rows() {
142 const rows = Number.parseInt(this.getAttribute("rows") ?? "");
143
144 if (Number.isNaN(rows)) return 5;
145 return rows;
146 }
147
148 /** @param {Event} evt */
149 handleEvent(evt) {
150 switch (evt.type) {
151 case "input":
152 this.#oninput(/** @type {InputEvent} */ (evt));
153 break;
154
155 case "keydown":
156 this.#onkeydown(/** @type {KeyboardEvent} */ (evt));
157 break;
158
159 case "focusout":
160 this.#onfocusout(evt);
161 break;
162
163 case "pointerdown":
164 this.#onpointerdown(/** @type {PointerEvent} */ (evt));
165 break;
166
167 case "pointerup":
168 this.#onpointerup(/** @type {PointerEvent} */ (evt));
169 break;
170 }
171 }
172
173 /** @param {KeyboardEvent} evt */
174 #onkeydown(evt) {
175 switch (evt.key) {
176 case "ArrowDown":
177 evt.preventDefault();
178 this.#index = Math.min(this.#index + 1, this.#rows - 1);
179 this.#render();
180 break;
181
182 case "PageDown":
183 evt.preventDefault();
184 this.#index = this.#rows - 1;
185 this.#render();
186 break;
187
188 case "ArrowUp":
189 evt.preventDefault();
190 this.#index = Math.max(this.#index - 1, 0);
191 this.#render();
192 break;
193
194 case "PageUp":
195 evt.preventDefault();
196 this.#index = 0;
197 this.#render();
198 break;
199
200 case "Escape":
201 evt.preventDefault();
202 this.#actors = [];
203 this.#index = -1;
204 this.#render();
205 break;
206
207 case "Enter":
208 evt.preventDefault();
209 this.#shadow.querySelectorAll(".user")[this.#index]?.click();
210 break;
211 }
212 }
213
214 /** @param {InputEvent} evt */
215 async #oninput(evt) {
216 const query = evt.target?.value;
217 if (!query) {
218 this.#actors = [];
219 this.#render();
220 return;
221 }
222
223 const host = this.getAttribute("host") ?? "https://public.api.bsky.app";
224 const url = new URL("xrpc/app.bsky.actor.searchActorsTypeahead", host);
225 url.searchParams.set("q", query);
226 url.searchParams.set("limit", `${this.#rows}`);
227
228 const res = await fetch(url);
229 const json = await res.json();
230 this.#actors = json.actors;
231 this.#index = -1;
232 this.#render();
233 }
234
235 /** @param {Event} evt */
236 async #onfocusout(evt) {
237 if (this.#pressed) return;
238
239 this.#actors = [];
240 this.#index = -1;
241 this.#render();
242 }
243
244 #render() {
245 const fragment = document.createDocumentFragment();
246 let i = -1;
247 for (const actor of this.#actors) {
248 const li = user.cloneNode(true).content;
249
250 const button = li.querySelector(".user");
251 button.dataset.handle = actor.handle;
252 if (++i === this.#index) button.dataset.active = "true";
253
254 const avatar = li.querySelector(".img");
255 if (actor.avatar) avatar.src = actor.avatar;
256
257 li.querySelector(".handle").textContent = actor.handle;
258
259 fragment.append(li);
260 }
261
262 this.#shadow.querySelector(".menu")?.replaceChildren(...fragment.children);
263 }
264
265 /** @param {PointerEvent} evt */
266 #onpointerdown(evt) {
267 this.#pressed = true;
268 }
269
270 /** @param {PointerEvent} evt */
271 #onpointerup(evt) {
272 this.#pressed = false;
273
274 this.querySelector("input")?.focus();
275
276 const button = evt.target?.closest(".user");
277 const input = this.querySelector("input");
278 if (!input || !button) return;
279
280 input.value = button.dataset.handle;
281 this.#actors = [];
282 this.#render();
283 }
284}