@substrate-system/actor-typeahead 0.0.2 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -29,6 +29,7 @@ rewritten in TypeScript, and rendering in light DOM only. See
29
29
  * [ESM](#esm)
30
30
  * [Common JS](#common-js)
31
31
  * [Attributes](#attributes)
32
+ * [Properties](#properties)
32
33
  * [Events](#events)
33
34
  - [CSS](#css)
34
35
  * [Import CSS](#import-css)
@@ -56,7 +57,7 @@ Wrap an `<input>` with `<actor-typeahead>`, and import the JS and CSS.
56
57
 
57
58
  ```html
58
59
  <actor-typeahead>
59
- <input>
60
+ <input />
60
61
  </actor-typeahead>
61
62
  ```
62
63
 
@@ -69,6 +70,11 @@ Typing in the input shows a Suggestion list of matching accounts.
69
70
  Choosing one, with the arrow keys and Enter or with the pointer, fills in
70
71
  the input with the account's handle.
71
72
 
73
+ Enter and Escape are left alone when the component has nothing to do
74
+ with them. Enter with no highlighted Suggestion submits a surrounding
75
+ form, and Escape with the Suggestion list closed reaches a surrounding
76
+ `<dialog>`.
77
+
72
78
  ## API
73
79
 
74
80
  This exposes ESM and common JS via
@@ -95,17 +101,39 @@ it more than once is safe.
95
101
 
96
102
  ```html
97
103
  <actor-typeahead host="https://public.api.bsky.app" rows="3">
98
- <input>
104
+ <input />
99
105
  </actor-typeahead>
100
106
  ```
101
107
 
102
108
  Both are reflected as properties, so `el.rows = '3'` works too.
103
109
 
110
+ ### Properties
111
+
112
+ * `announce` -- A function `(count:number) => string` that returns the
113
+ text screen readers hear when a Typeahead request settles. `count` is
114
+ the number of Suggestions, and is 0 when nothing matched the Query.
115
+ The default is English, "No Suggestions", "1 Suggestion available" or
116
+ "N Suggestions available". Replace it to translate; your function
117
+ handles plurals and 0. It is read each time a request settles, so you
118
+ can set it at any time. There is no matching attribute.
119
+
120
+ ```ts
121
+ const el = document.querySelector('actor-typeahead')!
122
+ el.announce = count => {
123
+ if (count === 0) return 'Sin sugerencias'
124
+ return count === 1 ?
125
+ '1 sugerencia disponible' :
126
+ `${count} sugerencias disponibles`
127
+ }
128
+ ```
129
+
130
+ A request that fails is not announced.
131
+
104
132
  ### Events
105
133
 
106
134
  Events are namespaced by the tag name.
107
135
 
108
- * `actor-typeahead:select` -- A result was chosen. The event `detail` is
136
+ * `actor-typeahead:select` -- A result was chosen. The event's `detail` is
109
137
  `{ handle, avatar? }`.
110
138
 
111
139
  ```ts
@@ -138,7 +166,7 @@ import '@substrate-system/actor-typeahead/min/css'
138
166
  ```css
139
167
  actor-typeahead {
140
168
  --color-background: #fff;
141
- --color-border: #0002;
169
+ --color-border: #0007;
142
170
  --color-shadow: #000;
143
171
  --color-hover: #0001;
144
172
  --color-avatar-fallback: #0002;
@@ -158,21 +186,27 @@ of it. The Suggestion list markup is:
158
186
  <input role="combobox" aria-autocomplete="list" aria-expanded="true"
159
187
  aria-controls="actor-typeahead-1-list"
160
188
  aria-activedescendant="actor-typeahead-1-option-0">
161
- <ul class="suggestion-list" id="actor-typeahead-1-list"
189
+ <div class="suggestion-list" id="actor-typeahead-1-list"
162
190
  role="listbox">
163
- <li class="suggestion" id="actor-typeahead-1-option-0"
164
- role="option" aria-selected="true">
191
+ <button type="button" class="suggestion"
192
+ id="actor-typeahead-1-option-0" role="option"
193
+ tabindex="-1" aria-selected="true">
165
194
  <div class="avatar"><img class="img" alt=""></div>
166
195
  <span class="handle"></span>
167
- </li>
168
- </ul>
196
+ </button>
197
+ </div>
198
+ <!-- visually hidden, announces how many Suggestions arrived -->
199
+ <div class="suggestion-count" role="status"></div>
169
200
  </actor-typeahead>
170
201
  ```
171
202
 
172
203
  `aria-selected="true"` marks the Suggestion highlighted with the
173
204
  keyboard. The input also gets `autocomplete="off"` unless you set
174
- `autocomplete` yourself. If the input has a `<label>` with an `id`, the
175
- Suggestion list is labelled by it.
205
+ `autocomplete` yourself. The Suggestion list gets the same name as the
206
+ input, from the input's `aria-labelledby`, its `aria-label`, or its
207
+ `<label>`s, in that order. Your labels do not need an `id`: when any
208
+ label lacks one, the list gets the label text as its `aria-label`. The
209
+ component never writes attributes on your labels.
176
210
 
177
211
  ### Avoid a flash of undefined content
178
212
 
@@ -64,6 +64,15 @@
64
64
  "text": "string|null"
65
65
  }
66
66
  },
67
+ {
68
+ "kind": "field",
69
+ "name": "announce",
70
+ "type": {
71
+ "text": "(count:number) => string"
72
+ },
73
+ "default": "defaultAnnounce",
74
+ "description": "Returns the text announced when a Typeahead request settles with\n`count` Suggestions, including 0 when nothing matched the Query.\nDefaults to English. Replace it to translate; the replacement\nhandles plurals and 0. Read at announce time, so it can be set\nafter connect."
75
+ },
67
76
  {
68
77
  "kind": "field",
69
78
  "name": "#id",
@@ -90,7 +99,25 @@
90
99
  },
91
100
  {
92
101
  "kind": "field",
93
- "name": "#pressed",
102
+ "name": "#suggestionList",
103
+ "privacy": "private",
104
+ "type": {
105
+ "text": "HTMLDivElement|null"
106
+ },
107
+ "default": "null"
108
+ },
109
+ {
110
+ "kind": "field",
111
+ "name": "#suggestionCount",
112
+ "privacy": "private",
113
+ "type": {
114
+ "text": "HTMLDivElement|null"
115
+ },
116
+ "default": "null"
117
+ },
118
+ {
119
+ "kind": "field",
120
+ "name": "#nbsp",
94
121
  "privacy": "private",
95
122
  "type": {
96
123
  "text": "boolean"
@@ -99,10 +126,18 @@
99
126
  },
100
127
  {
101
128
  "kind": "field",
102
- "name": "#suggestionList",
129
+ "name": "#timer",
103
130
  "privacy": "private",
104
131
  "type": {
105
- "text": "HTMLUListElement|null"
132
+ "text": "ReturnType<typeof setTimeout>|undefined"
133
+ }
134
+ },
135
+ {
136
+ "kind": "field",
137
+ "name": "#controller",
138
+ "privacy": "private",
139
+ "type": {
140
+ "text": "AbortController|null"
106
141
  },
107
142
  "default": "null"
108
143
  },
@@ -153,14 +188,50 @@
153
188
  }
154
189
  ]
155
190
  },
191
+ {
192
+ "kind": "method",
193
+ "name": "#request",
194
+ "privacy": "private",
195
+ "parameters": [
196
+ {
197
+ "name": "query",
198
+ "type": {
199
+ "text": "string"
200
+ }
201
+ }
202
+ ]
203
+ },
204
+ {
205
+ "kind": "method",
206
+ "name": "#announce",
207
+ "privacy": "private",
208
+ "parameters": [
209
+ {
210
+ "name": "count",
211
+ "type": {
212
+ "text": "number"
213
+ }
214
+ }
215
+ ],
216
+ "description": "Write the Suggestion count. A trailing no-break space alternates\nbetween writes, so screen readers re-read an identical count."
217
+ },
156
218
  {
157
219
  "kind": "method",
158
220
  "name": "#onfocusout",
159
- "privacy": "private"
221
+ "privacy": "private",
222
+ "parameters": [
223
+ {
224
+ "name": "evt",
225
+ "type": {
226
+ "text": "FocusEvent"
227
+ }
228
+ }
229
+ ],
230
+ "description": "Close only when focus leaves the host. Some assistive technology\nfocuses a Suggestion before it sends the click that selects it."
160
231
  },
161
232
  {
162
233
  "kind": "method",
163
- "name": "#onpointerup",
234
+ "name": "#onclick",
164
235
  "privacy": "private",
165
236
  "parameters": [
166
237
  {
@@ -184,6 +255,12 @@
184
255
  }
185
256
  ]
186
257
  },
258
+ {
259
+ "kind": "method",
260
+ "name": "#cancel",
261
+ "privacy": "private",
262
+ "description": "Stop any pending Typeahead request: the debounce timer, and the\nrequest in flight, whose response is then dropped."
263
+ },
187
264
  {
188
265
  "kind": "method",
189
266
  "name": "#clear",
@@ -192,7 +269,7 @@
192
269
  {
193
270
  "kind": "method",
194
271
  "name": "render",
195
- "description": "Render into light DOM, next to the authored `<input>`. Only the\nSuggestion list is rebuilt, so the input element, and anything the\nconsumer attached to it, is never replaced. On the input, only the\nARIA combobox attributes are written, plus `autocomplete=\"off\"`\nwhen the page did not set `autocomplete`."
272
+ "description": "Render into light DOM. Only the suggestion list is rebuilt,\nso the input element, and anything the consumer attached to it,\nis never replaced. On the input, only the\nARIA combobox attributes are written, plus `autocomplete=\"off\"`\nwhen the page did not set `autocomplete`."
196
273
  },
197
274
  {
198
275
  "kind": "method",
package/dist/index.cjs CHANGED
@@ -25,6 +25,7 @@ module.exports = __toCommonJS(index_exports);
25
25
  var import_web_component = require("@substrate-system/web-component");
26
26
  const DEFAULT_HOST = "https://public.api.bsky.app";
27
27
  const DEFAULT_ROWS = 5;
28
+ const DEBOUNCE_MS = 150;
28
29
  const ANCHORED = typeof CSS !== "undefined" && CSS.supports("anchor-scope: --a");
29
30
  let instances = 0;
30
31
  class ActorTypeahead extends import_web_component.WebComponent.create("actor-typeahead") {
@@ -32,17 +33,35 @@ class ActorTypeahead extends import_web_component.WebComponent.create("actor-typ
32
33
  __name(this, "ActorTypeahead");
33
34
  }
34
35
  static reflectedStringAttributes = ["host", "rows"];
36
+ /**
37
+ * Returns the text announced when a Typeahead request settles with
38
+ * `count` Suggestions, including 0 when nothing matched the Query.
39
+ * Defaults to English. Replace it to translate; the replacement
40
+ * handles plurals and 0. Read at announce time, so it can be set
41
+ * after connect.
42
+ */
43
+ announce = defaultAnnounce;
35
44
  #id = `actor-typeahead-${++instances}`;
36
45
  #actors = [];
37
46
  #index = -1;
38
- #pressed = false;
39
47
  #suggestionList = null;
48
+ #suggestionCount = null;
49
+ #nbsp = false;
50
+ #timer;
51
+ #controller = null;
40
52
  constructor() {
41
53
  super();
42
54
  this.addEventListener("input", this);
43
55
  this.addEventListener("focusout", this);
44
56
  this.addEventListener("keydown", this);
45
57
  }
58
+ /**
59
+ * Stop any pending Typeahead request. The Suggestion list is left as
60
+ * is; the next Query rebuilds it.
61
+ */
62
+ disconnectedCallback() {
63
+ this.#cancel();
64
+ }
46
65
  get #limit() {
47
66
  const rows = Number.parseInt(this.rows ?? "");
48
67
  return Number.isNaN(rows) ? DEFAULT_ROWS : rows;
@@ -56,18 +75,23 @@ class ActorTypeahead extends import_web_component.WebComponent.create("actor-typ
56
75
  if (evt instanceof KeyboardEvent) this.#onkeydown(evt);
57
76
  break;
58
77
  case "focusout":
59
- this.#onfocusout();
78
+ if (evt instanceof FocusEvent) this.#onfocusout(evt);
60
79
  break;
61
- case "pointerdown":
62
- this.#pressed = true;
80
+ case "mousedown":
81
+ evt.preventDefault();
63
82
  break;
64
- case "pointerup":
65
- this.#onpointerup(evt);
83
+ case "click":
84
+ this.#onclick(evt);
66
85
  break;
67
86
  }
68
87
  }
69
88
  #onkeydown(evt) {
70
89
  const last = this.#actors.length - 1;
90
+ const moves = ["ArrowDown", "ArrowUp", "PageDown", "PageUp"];
91
+ if (last < 0 && moves.includes(evt.key)) {
92
+ evt.preventDefault();
93
+ return;
94
+ }
71
95
  switch (evt.key) {
72
96
  case "ArrowDown":
73
97
  evt.preventDefault();
@@ -81,7 +105,7 @@ class ActorTypeahead extends import_web_component.WebComponent.create("actor-typ
81
105
  break;
82
106
  case "ArrowUp":
83
107
  evt.preventDefault();
84
- this.#index = Math.max(this.#index - 1, 0);
108
+ this.#index = this.#index < 0 ? last : this.#index - 1;
85
109
  this.render();
86
110
  break;
87
111
  case "PageUp":
@@ -90,45 +114,78 @@ class ActorTypeahead extends import_web_component.WebComponent.create("actor-typ
90
114
  this.render();
91
115
  break;
92
116
  case "Escape":
93
- evt.preventDefault();
117
+ if (this.#actors.length) evt.preventDefault();
94
118
  this.#clear();
95
119
  break;
96
120
  case "Enter":
121
+ if (!this.#actors[this.#index]) {
122
+ this.#cancel();
123
+ break;
124
+ }
97
125
  evt.preventDefault();
98
126
  this.#select(this.#index);
99
127
  break;
100
128
  }
101
129
  }
102
- async #oninput(evt) {
130
+ #oninput(evt) {
103
131
  if (!(evt.target instanceof HTMLInputElement)) return;
104
132
  const query = evt.target.value;
105
133
  if (!query) {
106
134
  this.#clear();
107
135
  return;
108
136
  }
137
+ this.#cancel();
138
+ this.#timer = setTimeout(() => this.#request(query), DEBOUNCE_MS);
139
+ }
140
+ async #request(query) {
141
+ this.#timer = void 0;
109
142
  const url = new URL(
110
143
  "xrpc/app.bsky.actor.searchActorsTypeahead",
111
144
  this.host ?? DEFAULT_HOST
112
145
  );
113
146
  url.searchParams.set("q", query);
114
147
  url.searchParams.set("limit", String(this.#limit));
115
- const res = await fetch(url);
116
- const json = await res.json();
148
+ const controller = new AbortController();
149
+ this.#controller = controller;
150
+ const { signal } = controller;
151
+ let json;
152
+ try {
153
+ const res = await fetch(url, { signal });
154
+ if (signal.aborted) return;
155
+ if (!res.ok) throw new Error(`status ${res.status}`);
156
+ json = await res.json();
157
+ } catch (err) {
158
+ if (isAbortError(err)) return;
159
+ this.#clear();
160
+ return;
161
+ }
162
+ if (signal.aborted) return;
117
163
  this.#actors = parseActors(json);
118
164
  this.#index = -1;
119
165
  this.render();
166
+ this.#announce(this.#actors.length);
167
+ }
168
+ /**
169
+ * Write the Suggestion count. A trailing no-break space alternates
170
+ * between writes, so screen readers re-read an identical count.
171
+ */
172
+ #announce(count) {
173
+ if (!this.#suggestionCount) return;
174
+ this.#nbsp = !this.#nbsp;
175
+ this.#suggestionCount.textContent = this.announce(count) + (this.#nbsp ? "\xA0" : "");
120
176
  }
121
- #onfocusout() {
122
- if (this.#pressed) return;
177
+ /**
178
+ * Close only when focus leaves the host. Some assistive technology
179
+ * focuses a Suggestion before it sends the click that selects it.
180
+ */
181
+ #onfocusout(evt) {
182
+ const next = evt.relatedTarget;
183
+ if (next instanceof Node && this.contains(next)) return;
123
184
  this.#clear();
124
185
  }
125
- #onpointerup(evt) {
126
- this.#pressed = false;
186
+ #onclick(evt) {
127
187
  const option = evt.target instanceof Element ? evt.target.closest('[role="option"]') : null;
128
- if (!option) {
129
- this.qs("input")?.focus();
130
- return;
131
- }
188
+ if (!option) return;
132
189
  this.#select(Number(option.dataset.index));
133
190
  }
134
191
  #select(index) {
@@ -140,30 +197,49 @@ class ActorTypeahead extends import_web_component.WebComponent.create("actor-typ
140
197
  this.#clear();
141
198
  this.emit("select", { detail: actor });
142
199
  }
200
+ /**
201
+ * Stop any pending Typeahead request: the debounce timer, and the
202
+ * request in flight, whose response is then dropped.
203
+ */
204
+ #cancel() {
205
+ clearTimeout(this.#timer);
206
+ this.#timer = void 0;
207
+ this.#controller?.abort();
208
+ this.#controller = null;
209
+ }
143
210
  #clear() {
211
+ this.#cancel();
144
212
  this.#actors = [];
145
213
  this.#index = -1;
146
214
  this.render();
147
215
  }
148
216
  /**
149
- * Render into light DOM, next to the authored `<input>`. Only the
150
- * Suggestion list is rebuilt, so the input element, and anything the
151
- * consumer attached to it, is never replaced. On the input, only the
217
+ * Render into light DOM. Only the suggestion list is rebuilt,
218
+ * so the input element, and anything the consumer attached to it,
219
+ * is never replaced. On the input, only the
152
220
  * ARIA combobox attributes are written, plus `autocomplete="off"`
153
221
  * when the page did not set `autocomplete`.
154
222
  */
155
223
  render() {
156
224
  if (!this.#suggestionList) {
157
- this.#suggestionList = document.createElement("ul");
225
+ this.#suggestionList = document.createElement("div");
158
226
  this.#suggestionList.className = "suggestion-list";
159
227
  this.#suggestionList.id = `${this.#id}-list`;
160
228
  this.#suggestionList.setAttribute("role", "listbox");
161
229
  if (ANCHORED) this.#suggestionList.popover = "manual";
162
- this.#suggestionList.addEventListener("pointerdown", this);
163
- this.#suggestionList.addEventListener("pointerup", this);
230
+ this.#suggestionList.addEventListener("mousedown", this);
231
+ this.#suggestionList.addEventListener("click", this);
164
232
  }
165
233
  const list = this.#suggestionList;
166
234
  if (list.parentNode !== this) this.append(list);
235
+ if (!this.#suggestionCount) {
236
+ this.#suggestionCount = document.createElement("div");
237
+ this.#suggestionCount.className = "suggestion-count";
238
+ this.#suggestionCount.setAttribute("role", "status");
239
+ }
240
+ const count = this.#suggestionCount;
241
+ if (count.parentNode !== this) this.append(count);
242
+ if (!this.#actors.length) count.textContent = "";
167
243
  list.replaceChildren(...this.#actors.map((actor, i) => {
168
244
  const option = suggestion(actor, i, i === this.#index);
169
245
  option.id = this.#optionId(i);
@@ -179,11 +255,11 @@ class ActorTypeahead extends import_web_component.WebComponent.create("actor-typ
179
255
  }
180
256
  input.setAttribute("aria-controls", list.id);
181
257
  input.setAttribute("aria-expanded", String(open));
182
- const labels = [...input.labels ?? []].map((label) => label.id).filter(Boolean);
183
- if (labels.length) {
184
- list.setAttribute("aria-labelledby", labels.join(" "));
185
- } else {
186
- list.removeAttribute("aria-labelledby");
258
+ const name = nameSource(input, this);
259
+ for (const attr of ["aria-labelledby", "aria-label"]) {
260
+ const value = name[attr];
261
+ if (value) list.setAttribute(attr, value);
262
+ else list.removeAttribute(attr);
187
263
  }
188
264
  if (this.#actors[this.#index]) {
189
265
  input.setAttribute(
@@ -215,12 +291,14 @@ class ActorTypeahead extends import_web_component.WebComponent.create("actor-typ
215
291
  }
216
292
  ActorTypeahead.define();
217
293
  function suggestion(actor, index, active) {
218
- const li = document.createElement("li");
219
- li.className = "suggestion";
220
- li.setAttribute("role", "option");
221
- li.setAttribute("aria-selected", String(active));
222
- li.dataset.index = String(index);
223
- li.dataset.handle = actor.handle;
294
+ const button = document.createElement("button");
295
+ button.type = "button";
296
+ button.tabIndex = -1;
297
+ button.className = "suggestion";
298
+ button.setAttribute("role", "option");
299
+ button.setAttribute("aria-selected", String(active));
300
+ button.dataset.index = String(index);
301
+ button.dataset.handle = actor.handle;
224
302
  const avatar = document.createElement("div");
225
303
  avatar.className = "avatar";
226
304
  const img = document.createElement("img");
@@ -231,10 +309,40 @@ function suggestion(actor, index, active) {
231
309
  handle.className = "handle";
232
310
  handle.textContent = actor.handle;
233
311
  avatar.append(img);
234
- li.append(avatar, handle);
235
- return li;
312
+ button.append(avatar, handle);
313
+ return button;
236
314
  }
237
315
  __name(suggestion, "suggestion");
316
+ function nameSource(input, host) {
317
+ const labelledby = input.getAttribute("aria-labelledby");
318
+ if (labelledby) return { "aria-labelledby": labelledby };
319
+ const label = input.getAttribute("aria-label");
320
+ if (label) return { "aria-label": label };
321
+ const labels = [...input.labels ?? []];
322
+ if (!labels.length) return {};
323
+ if (labels.every((label2) => label2.id)) {
324
+ return {
325
+ "aria-labelledby": labels.map((label2) => label2.id).join(" ")
326
+ };
327
+ }
328
+ const text = labels.map((label2) => labelText(label2, host).trim()).filter(Boolean).join(" ");
329
+ return text ? { "aria-label": text } : {};
330
+ }
331
+ __name(nameSource, "nameSource");
332
+ function labelText(label, host) {
333
+ const walker = document.createTreeWalker(label, NodeFilter.SHOW_TEXT, {
334
+ acceptNode: /* @__PURE__ */ __name((node) => host.contains(node) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT, "acceptNode")
335
+ });
336
+ let text = "";
337
+ while (walker.nextNode()) text += walker.currentNode.nodeValue;
338
+ return text;
339
+ }
340
+ __name(labelText, "labelText");
341
+ function defaultAnnounce(count) {
342
+ if (count === 0) return "No Suggestions";
343
+ return count === 1 ? "1 Suggestion available" : `${count} Suggestions available`;
344
+ }
345
+ __name(defaultAnnounce, "defaultAnnounce");
238
346
  function parseActors(json) {
239
347
  if (!isRecord(json) || !Array.isArray(json.actors)) return [];
240
348
  return json.actors.flatMap((actor) => {
@@ -243,6 +351,10 @@ function parseActors(json) {
243
351
  });
244
352
  }
245
353
  __name(parseActors, "parseActors");
354
+ function isAbortError(err) {
355
+ return err instanceof DOMException && err.name === "AbortError";
356
+ }
357
+ __name(isAbortError, "isAbortError");
246
358
  function isRecord(value) {
247
359
  return typeof value === "object" && value !== null;
248
360
  }