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}