Discover books, shows, and movies at your level. Track your progress by filling your Shelf with what you find, and share with other language learners. *No dusting required. shlf.space
4
fork

Configure Feed

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

feat(static): add actor typeahead

Signed-off-by: brookjeynes <me@brookjeynes.dev>

authored by

brookjeynes and committed by
Tangled
0a782306 ece5eb47

+330
+330
static/actor-typeahead.js
··· 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 + 10 + const template = document.createElement("template"); 11 + template.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 + 98 + const user = document.createElement("template"); 99 + user.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 + */ 114 + function 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 + */ 142 + export 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 + }