a tiny atproto handle typeahead web component

Initial commit

Jake Lazaroff b50af16c

+1
.gitignore
··· 1 + .DS_Store
+14
index.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <script type="module" src="./index.js"></script> 5 + </head> 6 + <body> 7 + <div style="width: min-content"> 8 + <actor-typeahead> 9 + <input /> 10 + </actor-typeahead> 11 + <p>more text goes here</p> 12 + </div> 13 + </body> 14 + </html>
+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 + }