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.

at master 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}