A Web Component that provides typeahead suggestions for AT Protocol (Bluesky) handles. Uses the public app.bsky.actor.searchActorsTypeahead API directly from the client.

Initial release: AT Protocol actor typeahead Web Component

tijs.org b2d567fe

+417
+21
LICENSE
···
··· 1 + MIT License 2 + 3 + Copyright (c) 2025 Tijs Teulings 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy 6 + of this software and associated documentation files (the "Software"), to deal 7 + in the Software without restriction, including without limitation the rights 8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + copies of the Software, and to permit persons to whom the Software is 10 + furnished to do so, subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + SOFTWARE.
+66
README.md
···
··· 1 + # actor-typeahead 2 + 3 + A Web Component that provides typeahead suggestions for AT Protocol (Bluesky) 4 + handles. Uses the public `app.bsky.actor.searchActorsTypeahead` API directly 5 + from the client. 6 + 7 + ## Usage 8 + 9 + ```html 10 + <script type="module" src="https://esm.sh/jsr/@tijs/actor-typeahead"></script> 11 + 12 + <actor-typeahead> 13 + <input type="text" placeholder="alice.bsky.social" /> 14 + </actor-typeahead> 15 + ``` 16 + 17 + Or import in JavaScript/TypeScript: 18 + 19 + ```ts 20 + import "@tijs/actor-typeahead"; 21 + ``` 22 + 23 + ## Attributes 24 + 25 + | Attribute | Default | Description | 26 + | --------- | ----------------------------- | ------------------------- | 27 + | `host` | `https://public.api.bsky.app` | API host for actor search | 28 + | `rows` | `5` | Max suggestions to show | 29 + 30 + ## CSS Custom Properties 31 + 32 + Style the dropdown via custom properties on `<actor-typeahead>`: 33 + 34 + | Property | Default | 35 + | ------------------------- | --------- | 36 + | `--color-background` | `#ffffff` | 37 + | `--color-border` | `#e5e7eb` | 38 + | `--color-shadow` | `#000000` | 39 + | `--color-hover` | `#fff1f1` | 40 + | `--color-avatar-fallback` | `#fecaca` | 41 + | `--radius` | `8px` | 42 + | `--padding-menu` | `4px` | 43 + 44 + ## How it works 45 + 46 + Wrap any `<input>` element. The component listens for `input` events on the 47 + slotted input, fetches matching actors, and displays them in a dropdown. When a 48 + suggestion is selected (via click or keyboard), the input value is set and a 49 + native `input` event is dispatched so frameworks (React, etc.) can detect the 50 + change. 51 + 52 + Keyboard navigation: Arrow keys, Page Up/Down, Enter to select, Escape to 53 + dismiss. 54 + 55 + ## Disabling auto-registration 56 + 57 + Pass `?tag=none` in the import URL to prevent auto-registration: 58 + 59 + ```ts 60 + import { ActorTypeahead } from "@tijs/actor-typeahead?tag=none"; 61 + customElements.define("my-typeahead", ActorTypeahead); 62 + ``` 63 + 64 + ## License 65 + 66 + MIT
+17
deno.json
···
··· 1 + { 2 + "name": "@tijs/actor-typeahead", 3 + "version": "0.1.0", 4 + "exports": { 5 + ".": "./mod.ts" 6 + }, 7 + "compilerOptions": { 8 + "lib": ["dom", "dom.iterable", "deno.ns"] 9 + }, 10 + "tasks": { 11 + "quality": "deno fmt --check && deno lint && deno check mod.ts", 12 + "fmt": "deno fmt" 13 + }, 14 + "publish": { 15 + "include": ["mod.ts", "src/", "README.md", "LICENSE", "deno.json"] 16 + } 17 + }
+1
mod.ts
···
··· 1 + export { default as ActorTypeahead } from "./src/actor-typeahead.ts";
+312
src/actor-typeahead.ts
···
··· 1 + const template = document.createElement("template"); 2 + template.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, #e5e7eb); 11 + --color-shadow-inherited: var(--color-shadow, #000000); 12 + --color-hover-inherited: var(--color-hover, #fff1f1); 13 + --color-avatar-fallback-inherited: var(--color-avatar-fallback, #fecaca); 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 + z-index: 50; 43 + } 44 + 45 + .menu:empty { 46 + display: none; 47 + } 48 + 49 + .user { 50 + all: unset; 51 + box-sizing: border-box; 52 + display: flex; 53 + align-items: center; 54 + gap: 8px; 55 + padding: 6px 8px; 56 + width: 100%; 57 + height: calc(1.5rem + 6px * 2); 58 + border-radius: calc(var(--radius-inherited) - var(--padding-menu-inherited)); 59 + cursor: default; 60 + } 61 + 62 + .user:hover, 63 + .user[data-active="true"] { 64 + background-color: var(--color-hover-inherited); 65 + } 66 + 67 + .avatar { 68 + width: 1.5rem; 69 + height: 1.5rem; 70 + border-radius: 50%; 71 + background-color: var(--color-avatar-fallback-inherited); 72 + overflow: hidden; 73 + flex-shrink: 0; 74 + } 75 + 76 + .img { 77 + display: block; 78 + width: 100%; 79 + height: 100%; 80 + } 81 + 82 + .handle { 83 + white-space: nowrap; 84 + overflow: hidden; 85 + text-overflow: ellipsis; 86 + } 87 + </style> 88 + `; 89 + 90 + const user = document.createElement("template"); 91 + user.innerHTML = ` 92 + <li> 93 + <button class="user" part="user"> 94 + <div class="avatar" part="avatar"> 95 + <img class="img" part="img"> 96 + </div> 97 + <span class="handle" part="handle"></span> 98 + </button> 99 + </li> 100 + `; 101 + 102 + function clone<T extends Node>(tmpl: T): T { 103 + return tmpl.cloneNode(true) as T; 104 + } 105 + 106 + interface Actor { 107 + handle: string; 108 + avatar?: string; 109 + } 110 + 111 + export default class ActorTypeahead extends HTMLElement { 112 + static tag = "actor-typeahead"; 113 + 114 + static define(tag = this.tag): void { 115 + this.tag = tag; 116 + 117 + const name = customElements.getName(this); 118 + if (name && name !== tag) { 119 + return console.warn(`${this.name} already defined as <${name}>!`); 120 + } 121 + 122 + const ce = customElements.get(tag); 123 + if (ce && ce !== this) { 124 + return console.warn(`<${tag}> already defined as ${ce.name}!`); 125 + } 126 + 127 + customElements.define(tag, this); 128 + } 129 + 130 + static { 131 + const tag = new URL(import.meta.url).searchParams.get("tag") || this.tag; 132 + if (tag !== "none") this.define(tag); 133 + } 134 + 135 + #shadow = this.attachShadow({ mode: "closed" }); 136 + #actors: Actor[] = []; 137 + #index = -1; 138 + #pressed = false; 139 + 140 + constructor() { 141 + super(); 142 + 143 + this.#shadow.append(clone(template).content); 144 + this.#render(); 145 + this.addEventListener("input", this); 146 + this.addEventListener("focusout", this); 147 + this.addEventListener("keydown", this); 148 + this.#shadow.addEventListener("pointerdown", this); 149 + this.#shadow.addEventListener("pointerup", this); 150 + this.#shadow.addEventListener("click", this); 151 + } 152 + 153 + get #rows(): number { 154 + const rows = Number.parseInt(this.getAttribute("rows") ?? ""); 155 + if (Number.isNaN(rows)) return 5; 156 + return rows; 157 + } 158 + 159 + handleEvent(evt: Event) { 160 + switch (evt.type) { 161 + case "input": 162 + this.#oninput(evt as InputEvent & { target: HTMLInputElement }); 163 + break; 164 + case "keydown": 165 + this.#onkeydown(evt as KeyboardEvent); 166 + break; 167 + case "focusout": 168 + this.#onfocusout(); 169 + break; 170 + case "pointerdown": 171 + this.#onpointerdown(); 172 + break; 173 + case "pointerup": 174 + this.#onpointerup(evt as PointerEvent & { target: HTMLElement }); 175 + break; 176 + case "click": 177 + this.#onclick(evt as MouseEvent & { target: HTMLElement }); 178 + break; 179 + } 180 + } 181 + 182 + #onkeydown(evt: KeyboardEvent) { 183 + switch (evt.key) { 184 + case "ArrowDown": 185 + evt.preventDefault(); 186 + this.#index = Math.min(this.#index + 1, this.#rows - 1); 187 + this.#render(); 188 + break; 189 + case "PageDown": 190 + evt.preventDefault(); 191 + this.#index = this.#rows - 1; 192 + this.#render(); 193 + break; 194 + case "ArrowUp": 195 + evt.preventDefault(); 196 + this.#index = Math.max(this.#index - 1, 0); 197 + this.#render(); 198 + break; 199 + case "PageUp": 200 + evt.preventDefault(); 201 + this.#index = 0; 202 + this.#render(); 203 + break; 204 + case "Escape": 205 + evt.preventDefault(); 206 + this.#actors = []; 207 + this.#index = -1; 208 + this.#render(); 209 + break; 210 + case "Enter": 211 + if (this.#actors.length > 0 && this.#index >= 0) { 212 + evt.preventDefault(); 213 + this.#shadow.querySelectorAll("button")[this.#index]?.click(); 214 + } 215 + break; 216 + } 217 + } 218 + 219 + async #oninput(evt: InputEvent & { target: HTMLInputElement }) { 220 + const query = evt.target?.value; 221 + if (!query) { 222 + this.#actors = []; 223 + this.#render(); 224 + return; 225 + } 226 + 227 + const host = this.getAttribute("host") ?? "https://public.api.bsky.app"; 228 + const url = new URL("xrpc/app.bsky.actor.searchActorsTypeahead", host); 229 + url.searchParams.set("q", query); 230 + url.searchParams.set("limit", `${this.#rows}`); 231 + 232 + const res = await fetch(url); 233 + const json = await res.json(); 234 + this.#actors = json.actors; 235 + this.#index = -1; 236 + this.#render(); 237 + } 238 + 239 + #onfocusout() { 240 + setTimeout(() => { 241 + if ( 242 + !this.#pressed && 243 + document.activeElement !== this.querySelector("input") 244 + ) { 245 + this.#actors = []; 246 + this.#index = -1; 247 + this.#render(); 248 + } 249 + }, 150); 250 + } 251 + 252 + #render() { 253 + const fragment = document.createDocumentFragment(); 254 + let i = -1; 255 + for (const actor of this.#actors) { 256 + const li = clone(user).content; 257 + 258 + const button = li.querySelector("button"); 259 + if (button) { 260 + button.dataset.handle = actor.handle; 261 + if (++i === this.#index) button.dataset.active = "true"; 262 + } 263 + 264 + const avatar = li.querySelector("img"); 265 + if (avatar && actor.avatar) avatar.src = actor.avatar; 266 + 267 + const handle = li.querySelector(".handle"); 268 + if (handle) handle.textContent = actor.handle; 269 + 270 + fragment.append(li); 271 + } 272 + 273 + this.#shadow.querySelector(".menu")?.replaceChildren(...fragment.children); 274 + } 275 + 276 + #onpointerdown() { 277 + this.#pressed = true; 278 + } 279 + 280 + #onpointerup(evt: PointerEvent & { target: HTMLElement }) { 281 + this.#pressed = false; 282 + 283 + const button = evt.target?.closest("button") as HTMLButtonElement | null; 284 + const input = this.querySelector("input"); 285 + if (!input || !button) return; 286 + 287 + this.#actors = []; 288 + this.#index = -1; 289 + this.#render(); 290 + input.value = button.dataset.handle || ""; 291 + input.dispatchEvent(new Event("input", { bubbles: true })); 292 + input.focus(); 293 + } 294 + 295 + #onclick(evt: MouseEvent & { target: HTMLElement }) { 296 + const button = (evt.target as HTMLElement)?.closest( 297 + "button", 298 + ) as HTMLButtonElement | null; 299 + const input = this.querySelector("input"); 300 + if (!input || !button) return; 301 + 302 + evt.preventDefault(); 303 + evt.stopPropagation(); 304 + 305 + this.#actors = []; 306 + this.#index = -1; 307 + this.#render(); 308 + input.value = button.dataset.handle || ""; 309 + input.dispatchEvent(new Event("input", { bubbles: true })); 310 + input.focus(); 311 + } 312 + }