a tiny atproto handle typeahead web component
at main 7.0 kB view raw
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}