+297
actor-typeahead.js
+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
index.html
-168
index.js
-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
-
}