a tiny atproto handle typeahead web component
atproto bluesky
43
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 330 lines 8.9 kB view raw
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}