a tiny atproto handle typeahead web component

Improve usability

+297
actor-typeahead.js
··· 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, #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 + } 59 + 60 + .user:hover, 61 + .user[data-active="true"] { 62 + background-color: var(--color-hover-inherited); 63 + } 64 + 65 + .avatar { 66 + width: 1.5rem; 67 + height: 1.5rem; 68 + border-radius: 50%; 69 + background-color: var(--color-avatar-fallback-inherited); 70 + overflow: hidden; 71 + flex-shrink: 0; 72 + } 73 + 74 + .img { 75 + display: block; 76 + width: 100%; 77 + height: 100%; 78 + } 79 + 80 + .handle { 81 + white-space: nowrap; 82 + overflow: hidden; 83 + text-overflow: ellipsis; 84 + } 85 + </style> 86 + `; 87 + 88 + const user = document.createElement("template"); 89 + user.innerHTML = ` 90 + <li> 91 + <button class="user" part="user"> 92 + <div class="avatar" part="avatar"> 93 + <img class="img" part="img"> 94 + </div> 95 + <span class="handle" part="handle"></span> 96 + </button> 97 + </li> 98 + `; 99 + 100 + export default class ActorTypeahead extends HTMLElement { 101 + static tag = "actor-typeahead"; 102 + 103 + static define(tag = this.tag) { 104 + this.tag = tag; 105 + 106 + const name = customElements.getName(this); 107 + if (name && name !== tag) return console.warn(`${this.name} already defined as <${name}>!`); 108 + 109 + const ce = customElements.get(tag); 110 + if (Boolean(ce) && ce !== this) return console.warn(`<${tag}> already defined as ${ce.name}!`); 111 + 112 + customElements.define(tag, this); 113 + } 114 + 115 + static { 116 + const tag = new URL(import.meta.url).searchParams.get("define") || this.tag; 117 + if (tag !== "false") this.define(tag); 118 + } 119 + 120 + #shadow = this.attachShadow({ mode: "closed" }); 121 + 122 + /** @type {Array<{ handle: string; avatar: string }>} */ 123 + #actors = []; 124 + #index = -1; 125 + #pressed = false; 126 + 127 + constructor() { 128 + super(); 129 + 130 + this.#shadow.append(template.cloneNode(true).content); 131 + this.#render(); 132 + this.addEventListener("input", this); 133 + this.addEventListener("focusout", this); 134 + this.addEventListener("keydown", this); 135 + this.#shadow.addEventListener("pointerdown", this); 136 + this.#shadow.addEventListener("pointerup", this); 137 + this.#shadow.addEventListener("click", this); 138 + } 139 + 140 + get #rows() { 141 + const rows = Number.parseInt(this.getAttribute("rows")); 142 + 143 + if (Number.isNaN(rows)) return 5; 144 + return rows; 145 + } 146 + 147 + /** @param {Event} evt */ 148 + handleEvent(evt) { 149 + switch (evt.type) { 150 + case "input": 151 + this.#oninput(/** @type {InputEvent} */ (evt)); 152 + break; 153 + 154 + case "keydown": 155 + this.#onkeydown(/** @type {KeyboardEvent} */ (evt)); 156 + break; 157 + 158 + case "click": 159 + this.#onclick(/** @type {PointerEvent} */ (evt)); 160 + break; 161 + 162 + case "focusout": 163 + this.#onfocusout(evt); 164 + break; 165 + 166 + case "pointerdown": 167 + this.#onpointerdown(evt); 168 + break; 169 + 170 + case "pointerup": 171 + this.#onpointerup(evt); 172 + break; 173 + } 174 + } 175 + 176 + /** @param {KeyboardEvent} evt */ 177 + #onkeydown(evt) { 178 + switch (evt.key) { 179 + case "ArrowDown": 180 + evt.preventDefault(); 181 + this.#index = Math.min(this.#index + 1, this.#rows - 1); 182 + this.#render(); 183 + break; 184 + 185 + case "PageDown": 186 + event.preventDefault(); 187 + this.#index = this.#rows - 1; 188 + this.#render(); 189 + break; 190 + 191 + case "ArrowUp": 192 + evt.preventDefault(); 193 + this.#index = Math.max(this.#index - 1, 0); 194 + this.#render(); 195 + break; 196 + 197 + case "PageUp": 198 + event.preventDefault(); 199 + this.#index = 0; 200 + this.#render(); 201 + break; 202 + 203 + case "Escape": 204 + evt.preventDefault(); 205 + this.#actors = []; 206 + this.#index = -1; 207 + this.#render(); 208 + break; 209 + 210 + case "Enter": 211 + evt.preventDefault(); 212 + this.#shadow.querySelectorAll(".user")[this.#index]?.click(); 213 + break; 214 + } 215 + } 216 + 217 + /** @param {PointerEvent} evt */ 218 + #onclick(evt) { 219 + const button = evt.target.closest(".user"); 220 + const input = this.querySelector("input"); 221 + if (!input || !button) return; 222 + 223 + input.value = button.dataset.handle; 224 + this.#actors = []; 225 + this.#render(); 226 + } 227 + 228 + /** @param {InputEvent} evt */ 229 + async #oninput(evt) { 230 + const query = evt.target.value; 231 + if (!query) { 232 + this.#actors = []; 233 + this.#render(); 234 + return; 235 + } 236 + 237 + const host = this.getAttribute("host") ?? "https://public.api.bsky.app"; 238 + const url = new URL("xrpc/app.bsky.actor.searchActorsTypeahead", host); 239 + url.searchParams.set("q", query); 240 + url.searchParams.set("limit", this.getAttribute("rows") ?? 5); 241 + 242 + const res = await fetch(url); 243 + const json = await res.json(); 244 + this.#actors = json.actors; 245 + this.#index = -1; 246 + this.#render(); 247 + } 248 + 249 + /** @param {Event} evt */ 250 + async #onfocusout(evt) { 251 + if (this.#pressed) return; 252 + 253 + this.#actors = []; 254 + this.#index = -1; 255 + this.#render(); 256 + } 257 + 258 + #render() { 259 + const fragment = document.createDocumentFragment(); 260 + let i = -1; 261 + for (const actor of this.#actors) { 262 + const li = user.cloneNode(true).content; 263 + 264 + const button = li.querySelector(".user"); 265 + button.dataset.handle = actor.handle; 266 + if (++i === this.#index) button.dataset.active = "true"; 267 + 268 + const avatar = li.querySelector(".img"); 269 + if (actor.avatar) avatar.src = actor.avatar; 270 + 271 + li.querySelector(".handle").textContent = actor.handle; 272 + 273 + fragment.append(li); 274 + } 275 + 276 + this.#shadow.querySelector(".menu").replaceChildren(...fragment.children); 277 + } 278 + 279 + /** @param {PointerEvent} evt */ 280 + #onpointerdown(evt) { 281 + const menu = this.#shadow.querySelector(".menu"); 282 + 283 + if (!menu.contains(evt.target)) return; 284 + menu.setPointerCapture(evt.pointerId); 285 + this.#pressed = true; 286 + } 287 + 288 + /** @param {PointerEvent} evt */ 289 + #onpointerup(evt) { 290 + const menu = this.#shadow.querySelector(".menu"); 291 + 292 + if (!menu.hasPointerCapture(evt.pointerId)) return; 293 + menu.releasePointerCapture(evt.pointerId); 294 + this.#pressed = false; 295 + this.querySelector("input")?.focus(); 296 + } 297 + }
+1 -1
index.html
··· 1 1 <!doctype html> 2 2 <html lang="en"> 3 3 <head> 4 - <script type="module" src="./index.js"></script> 4 + <script type="module" src="./actor-typeahead.js"></script> 5 5 </head> 6 6 <body> 7 7 <div style="width: min-content">
-168
index.js
··· 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, #00000022); 11 - --color-shadow-inherited: var(--color-shadow, #000000); 12 - --color-hover-inherited: var(--color-hover, #eeeeee); 13 - display: block; 14 - position: relative; 15 - font-family: system-ui; 16 - } 17 - 18 - *, *::before, *::after { 19 - margin: 0; 20 - padding: 0; 21 - box-sizing: border-box; 22 - } 23 - 24 - .menu { 25 - display: flex; 26 - flex-direction: column; 27 - position: absolute; 28 - left: 0; 29 - margin-top: 4px; 30 - width: 100%; 31 - list-style: none; 32 - overflow: hidden; 33 - background-color: var(--color-background-inherited); 34 - background-clip: padding-box; 35 - border: 1px solid var(--color-border-inherited); 36 - border-radius: 8px; 37 - box-shadow: 0 6px 6px -4px rgb(from var(--color-shadow-inherited) r g b / 20%); 38 - padding: 4px; 39 - } 40 - 41 - .menu:empty { 42 - display: none; 43 - } 44 - 45 - .user { 46 - all: unset; 47 - box-sizing: border-box; 48 - display: flex; 49 - align-items: center; 50 - gap: 8px; 51 - padding: 6px 8px; 52 - width: 100%; 53 - height: calc(1.5rem + 6px * 2); 54 - border-radius: 4px; 55 - } 56 - 57 - .user:hover { 58 - background-color: var(--color-hover-inherited); 59 - } 60 - 61 - .avatar { 62 - width: 1.5rem; 63 - border-radius: 50%; 64 - } 65 - 66 - .handle { 67 - white-space: nowrap; 68 - overflow: hidden; 69 - text-overflow: ellipsis; 70 - } 71 - </style> 72 - `; 73 - 74 - const user = document.createElement("template"); 75 - user.innerHTML = ` 76 - <li> 77 - <button class="user" part="user"> 78 - <img class="avatar" part="avatar"> 79 - <span class="handle" part="handle"></span> 80 - </button> 81 - </li> 82 - `; 83 - 84 - export default class ActorTypeahead extends HTMLElement { 85 - static tag = "actor-typeahead"; 86 - 87 - static define(tag = this.tag) { 88 - this.tag = tag; 89 - 90 - const name = customElements.getName(this); 91 - if (name && name !== tag) return console.warn(`${this.name} already defined as <${name}>!`); 92 - 93 - const ce = customElements.get(tag); 94 - if (Boolean(ce) && ce !== this) return console.warn(`<${tag}> already defined as ${ce.name}!`); 95 - 96 - customElements.define(tag, this); 97 - } 98 - 99 - static { 100 - const tag = new URL(import.meta.url).searchParams.get("define") || this.tag; 101 - if (tag !== "false") this.define(tag); 102 - } 103 - 104 - #shadow = this.attachShadow({ mode: "closed" }); 105 - 106 - constructor() { 107 - super(); 108 - 109 - this.#shadow.append(template.cloneNode(true).content); 110 - this.#render([]); 111 - this.addEventListener("input", this); 112 - // this.addEventListener("focusout", this); 113 - this.#shadow.addEventListener("click", this); 114 - } 115 - 116 - get #rows() { 117 - const rows = Number.parseInt(this.getAttribute("rows")); 118 - 119 - if (Number.isNaN(rows)) return 5; 120 - return rows; 121 - } 122 - 123 - /** @param {Event} evt */ 124 - handleEvent(evt) { 125 - switch (evt.type) { 126 - case "input": 127 - this.#search(evt.target.value); 128 - break; 129 - // case "focusout": 130 - // this.#render([]); 131 - // break; 132 - case "click": 133 - const button = evt.target.closest(".user"); 134 - const input = this.querySelector("input"); 135 - if (!input || !button) return; 136 - 137 - input.value = button.dataset.handle; 138 - this.#render([]); 139 - } 140 - } 141 - 142 - #render(actors) { 143 - const fragment = document.createDocumentFragment(); 144 - for (const actor of actors) { 145 - const li = user.cloneNode(true).content; 146 - li.querySelector(".user").dataset.handle = actor.handle; 147 - li.querySelector(".avatar").src = actor.avatar; 148 - li.querySelector(".handle").textContent = actor.handle; 149 - fragment.append(li); 150 - } 151 - 152 - this.#shadow.querySelector(".menu").replaceChildren(...fragment.children); 153 - } 154 - 155 - /** @param {string} query */ 156 - async #search(query) { 157 - if (!query) return this.#render([]); 158 - 159 - const host = this.getAttribute("host") ?? "https://public.api.bsky.app"; 160 - const url = new URL("xrpc/app.bsky.actor.searchActorsTypeahead", host); 161 - url.searchParams.set("q", query); 162 - url.searchParams.set("limit", this.getAttribute("rows") ?? 5); 163 - 164 - const res = await fetch(url); 165 - const { actors } = await res.json(); 166 - this.#render(actors); 167 - } 168 - }