flexdesk 0.6.3 → 0.7.0

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.
@@ -0,0 +1,594 @@
1
+ /**
2
+ * record_list.js — A READ-ONLY LIST OF RECORDS, each drawn as two lines: a
3
+ * name, and under it a quiet line of facts; beside them pills and verbs.
4
+ * (0.7.0)
5
+ *
6
+ * ┌────────────────────────────────────────────────────────────────────┐
7
+ * │ ETL loader [paused] Resume Revoke │
8
+ * │ tblk_8f2 — acts with the account — last used 5 Oct │
9
+ * │ Resume: This key is revoked, and revoking cannot be undone. │
10
+ * └────────────────────────────────────────────────────────────────────┘
11
+ *
12
+ * It is the shape an administration screen reaches for when a table would
13
+ * be wrong: few records, each with its own verbs, read top to bottom rather
14
+ * than sorted and filtered. Before this component every consumer built it by
15
+ * hand, row by row, and each copy grew its own post-build loop to put a key
16
+ * or a state on the row afterwards. Here the record's key, its classes and
17
+ * its attributes are declared, and the list owns the rest.
18
+ *
19
+ * WHAT A RECORD SAYS ABOUT ITSELF (`describe(record, idx)`, or the record
20
+ * itself when there is no `describe`):
21
+ *
22
+ * {
23
+ * name: 'ETL loader' | Node, the first line
24
+ * mono: true, the name in the monospace face
25
+ * facts: ['tblk_8f2', null, 'last used 5 Oct'] | 'text' | Node,
26
+ * the quiet line; null/'' parts
27
+ * are left out, the rest joined
28
+ * with `factSeparator`
29
+ * pills: ['paused', {text: 'URL', tone: 'danger', title: '…'}],
30
+ * verbs: [{label: 'Resume', onClick(record, ev), refusal: '…' | null,
31
+ * danger, primary, icon, title, name}],
32
+ * state: 'paused' | 'off' | 'dead' | 'error' | 'active' | any word —
33
+ * `data-state` and `twm-record--state-<word>` on the row,
34
+ * error: 'The last delivery failed: 503' | Node, a line in the error colour
35
+ * key: 'k-1', overrides `getKey`
36
+ * }
37
+ *
38
+ * A REFUSED VERB IS A DISABLED BUTTON AND A SENTENCE ON THE SCREEN. The
39
+ * reason is on the button's `title` and `data-refusal` too, but a reason only
40
+ * on a tooltip reads as "the button does nothing" — nobody hovers a button
41
+ * they believe they have just pressed. So each distinct reason is a line of
42
+ * its own under the facts, named by the verbs it refuses
43
+ * (`data-refusal-for`), and the button is `aria-describedby` that line.
44
+ *
45
+ * OPENING A RECORD (`onActivate`) follows DataTable's `onRowActivate` rule,
46
+ * from the same module (`row_activation.js`): a press on a control in the
47
+ * row is that control's, a click that ends a drag selecting text opens
48
+ * nothing, and neither does the second click of a double-click.
49
+ *
50
+ * THE KEYBOARD. With `onActivate` (or `keyboard: true`) the RECORDS are one
51
+ * tab stop — the one last focused, else the open one, else the first — and
52
+ * each enabled verb keeps its own, so every verb is reachable by Tab.
53
+ * ArrowUp/ArrowDown move between records (from a record or from one of its
54
+ * verbs), Home/End go to the ends, and Enter on a record opens it. Enter on a
55
+ * verb is the verb's.
56
+ */
57
+ import { isRowActivation, isRowControl } from './row_activation.js';
58
+
59
+ /** A field, a select or editable text keeps its own arrow keys. */
60
+ const OWN_ARROWS = 'input, select, textarea, [contenteditable=""], [contenteditable="true"]';
61
+
62
+ /** The words a tone may be, after sanitising (`[^\w-]` dropped). */
63
+ const safeWord = (s) => String(s).replace(/[^\w-]/g, '');
64
+
65
+ let SEQ = 0;
66
+
67
+ /**
68
+ * @typedef {Object} RecordVerb
69
+ * @property {string} label - The button's words.
70
+ * @property {Function} [onClick] - `(record, ev) => void`.
71
+ * @property {string|null} [refusal] - Why it cannot be pressed. A disabled
72
+ * button, and the sentence drawn under the record's facts.
73
+ * @property {boolean} [danger]
74
+ * @property {boolean} [primary]
75
+ * @property {string} [icon] - A Material Symbols name, drawn before the label.
76
+ * @property {string} [title] - A tooltip, for a verb that is NOT refused.
77
+ * @property {string} [name] - A stable id: `data-verb`, and what focus is
78
+ * restored to after a redraw. Defaults to the label.
79
+ */
80
+
81
+ /**
82
+ * @typedef {Object} RecordListConfig
83
+ * @property {Array} [records=[]]
84
+ * @property {Function} [describe] - `(record, idx) => spec`. Without it the
85
+ * record is its own spec.
86
+ * @property {Function} [getKey] - `(record, idx) => string|number`. Defaults to
87
+ * `spec.key`, then `record.key`, then `record.id`, then the index.
88
+ * @property {Function} [onActivate] - `(record, idx, ev) => void`. Opens a record.
89
+ * @property {string|string[]} [activateOn=['click','enter']] - `'click'`,
90
+ * `'dblclick'`, `'enter'`, or several.
91
+ * @property {boolean} [keyboard] - Arrow keys between records. Defaults to on
92
+ * exactly when `onActivate` is set.
93
+ * @property {*} [activeKey] - The record that is open: `twm-record--active`
94
+ * and `aria-current="true"`.
95
+ * @property {Function} [rowClass] - `(record, idx) => string|string[]|null`.
96
+ * @property {Function} [rowAttrs] - `(record, idx) => {name: value}`;
97
+ * `null`/`false` leaves one off, `true` sets it empty.
98
+ * @property {string|Node|Function} [emptyState] - Shown when there are no
99
+ * records. A function is called with no arguments and may return either.
100
+ * @property {string} [factSeparator=' — ']
101
+ * @property {'wrap'|'ellipsis'} [facts='wrap'] - How a long fact line ends.
102
+ * `'ellipsis'` keeps it to one line and puts the whole line on `title`.
103
+ * @property {string|Function} [verbClass] - The verbs' class names, or
104
+ * `(verb) => string`, for a consumer with its own buttons. Defaults to
105
+ * FlexDesk's `twm-btn` with `--primary`/`--danger`.
106
+ * @property {string} [label] - The list's accessible name (`aria-label`).
107
+ * @property {string} [className] - Added to the root.
108
+ */
109
+
110
+ export class RecordList {
111
+ /**
112
+ * @param {HTMLElement|null} host appended to, if given
113
+ * @param {RecordListConfig} [config]
114
+ */
115
+ constructor(host, config = {}) {
116
+ this.config = {
117
+ records: [],
118
+ describe: null,
119
+ getKey: null,
120
+ onActivate: null,
121
+ activateOn: ['click', 'enter'],
122
+ keyboard: undefined,
123
+ activeKey: null,
124
+ rowClass: null,
125
+ rowAttrs: null,
126
+ emptyState: null,
127
+ factSeparator: ' — ',
128
+ facts: 'wrap',
129
+ verbClass: null,
130
+ label: null,
131
+ className: null,
132
+ ...config,
133
+ };
134
+ this._records = Array.isArray(this.config.records) ? this.config.records.slice() : [];
135
+ this._activeKey = this.config.activeKey ?? null;
136
+ this._tabKey = null;
137
+ this._items = new Map(); // key → <li>
138
+ this._disposed = false;
139
+ this._id = `twm-record-list-${++SEQ}`;
140
+
141
+ const root = document.createElement('div');
142
+ root.className = 'twm-record-list'
143
+ + (this.config.facts === 'ellipsis' ? ' twm-record-list--clip' : '')
144
+ + (this._activates() ? ' twm-record-list--clickable' : '')
145
+ + (this.config.className ? ` ${this.config.className}` : '');
146
+ const list = document.createElement('ul');
147
+ list.className = 'twm-record-list__items';
148
+ if (this.config.label) list.setAttribute('aria-label', this.config.label);
149
+ root.appendChild(list);
150
+ this.element = root;
151
+ this.listElement = list;
152
+
153
+ this._onClick = (ev) => this._gesture(ev, 'click');
154
+ this._onDblClick = (ev) => this._gesture(ev, 'dblclick');
155
+ this._onKeyDown = (ev) => this._key(ev);
156
+ // Only focus ON a record moves the tab stop. Focus on one of its verbs
157
+ // must not: Tab walks forward through the verbs of the records before
158
+ // the stop, and moving the stop behind the focus would leave no record
159
+ // ahead to Tab to (measured in Edge).
160
+ this._onFocusIn = (ev) => {
161
+ const li = this._itemOf(ev.target);
162
+ if (li && ev.target === li) this._setTabStop(li.__recordKey);
163
+ };
164
+ list.addEventListener('click', this._onClick);
165
+ list.addEventListener('dblclick', this._onDblClick);
166
+ list.addEventListener('keydown', this._onKeyDown);
167
+ list.addEventListener('focusin', this._onFocusIn);
168
+
169
+ this._draw();
170
+ if (host) host.appendChild(root);
171
+ }
172
+
173
+ // ── public ───────────────────────────────────────────────────────────
174
+
175
+ /** Replace the records and draw them. Focus inside the list is kept on
176
+ * the same record, and on the same verb of it, when it is still there. */
177
+ setRecords(records) {
178
+ if (this._disposed) return;
179
+ this._records = Array.isArray(records) ? records.slice() : [];
180
+ this._draw();
181
+ }
182
+
183
+ /** The records, as last given. */
184
+ getRecords() {
185
+ return this._records.slice();
186
+ }
187
+
188
+ /** Draw ONE record again (`describe` asked afresh), or every one with no key. */
189
+ refresh(key) {
190
+ if (this._disposed) return;
191
+ if (key === undefined) { this._draw(); return; }
192
+ const old = this._items.get(this._norm(key));
193
+ if (!old) return;
194
+ const idx = old.__recordIndex;
195
+ const focus = this._focusMemo();
196
+ const fresh = this._item(this._records[idx], idx);
197
+ old.replaceWith(fresh);
198
+ this._items.set(fresh.__recordKey, fresh);
199
+ this._paintTabStop();
200
+ this._restoreFocus(focus);
201
+ }
202
+
203
+ /** Mark the record that is open (`null` for none). */
204
+ setActiveKey(key) {
205
+ this._activeKey = key ?? null;
206
+ for (const li of this._items.values()) this._paintActive(li);
207
+ if (this._activeKey !== null && this._items.has(this._norm(this._activeKey))) {
208
+ this._setTabStop(this._norm(this._activeKey));
209
+ }
210
+ }
211
+
212
+ getActiveKey() {
213
+ return this._activeKey;
214
+ }
215
+
216
+ /** The `<li>` drawn for a key, or null. */
217
+ getItem(key) {
218
+ return this._items.get(this._norm(key)) || null;
219
+ }
220
+
221
+ /** Move the keyboard focus to a record. */
222
+ focusRecord(key) {
223
+ const li = this.getItem(key);
224
+ if (!li) return false;
225
+ this._setTabStop(li.__recordKey);
226
+ li.focus();
227
+ return true;
228
+ }
229
+
230
+ dispose() {
231
+ if (this._disposed) return;
232
+ this._disposed = true;
233
+ const list = this.listElement;
234
+ list.removeEventListener('click', this._onClick);
235
+ list.removeEventListener('dblclick', this._onDblClick);
236
+ list.removeEventListener('keydown', this._onKeyDown);
237
+ list.removeEventListener('focusin', this._onFocusIn);
238
+ this._items.clear();
239
+ this.element.remove();
240
+ }
241
+
242
+ // ── drawing ──────────────────────────────────────────────────────────
243
+
244
+ _activates() {
245
+ return typeof this.config.onActivate === 'function';
246
+ }
247
+
248
+ _keyboard() {
249
+ return this.config.keyboard ?? this._activates();
250
+ }
251
+
252
+ _gestures() {
253
+ const g = this.config.activateOn;
254
+ return new Set(Array.isArray(g) ? g : [g]);
255
+ }
256
+
257
+ /** Keys compare as strings: a record keyed `7` and an `activeKey` of `'7'` are one. */
258
+ _norm(key) {
259
+ return key === null || key === undefined ? key : String(key);
260
+ }
261
+
262
+ _spec(record, idx) {
263
+ const { describe } = this.config;
264
+ const spec = typeof describe === 'function' ? describe(record, idx) : record;
265
+ return spec && typeof spec === 'object' ? spec : { name: spec };
266
+ }
267
+
268
+ _keyOf(record, idx, spec) {
269
+ const { getKey } = this.config;
270
+ let key;
271
+ if (spec.key !== undefined && spec.key !== null) key = spec.key;
272
+ else if (typeof getKey === 'function') key = getKey(record, idx);
273
+ else if (record && typeof record === 'object' && (record.key ?? record.id) != null) key = record.key ?? record.id;
274
+ else key = idx;
275
+ return String(key);
276
+ }
277
+
278
+ _draw() {
279
+ const focus = this._focusMemo();
280
+ const list = this.listElement;
281
+ list.replaceChildren();
282
+ this._items.clear();
283
+ this.element.querySelector(':scope > .twm-record-list__empty')?.remove();
284
+
285
+ this._records.forEach((record, idx) => {
286
+ const li = this._item(record, idx);
287
+ if (this._items.has(li.__recordKey)) {
288
+ console.error(`[RecordList] two records share the key "${li.__recordKey}"`);
289
+ }
290
+ this._items.set(li.__recordKey, li);
291
+ list.appendChild(li);
292
+ });
293
+ this.element.classList.toggle('twm-record-list--empty', this._records.length === 0);
294
+ if (!this._records.length) this._drawEmpty();
295
+ this._paintTabStop();
296
+ this._restoreFocus(focus);
297
+ }
298
+
299
+ _drawEmpty() {
300
+ let content = this.config.emptyState;
301
+ if (typeof content === 'function') content = content();
302
+ if (content == null || content === false || content === '') return;
303
+ const p = document.createElement('p');
304
+ p.className = 'twm-record-list__empty';
305
+ if (typeof Node !== 'undefined' && content instanceof Node) p.appendChild(content);
306
+ else p.textContent = String(content);
307
+ this.element.appendChild(p);
308
+ }
309
+
310
+ _item(record, idx) {
311
+ const spec = this._spec(record, idx);
312
+ const key = this._keyOf(record, idx, spec);
313
+ const li = document.createElement('li');
314
+ li.className = 'twm-record';
315
+ li.__record = record;
316
+ li.__recordIndex = idx;
317
+ li.__recordKey = key;
318
+ li.dataset.recordKey = key;
319
+ if (this._keyboard()) li.tabIndex = -1;
320
+ if (spec.state) {
321
+ li.dataset.state = safeWord(spec.state);
322
+ li.classList.add(`twm-record--state-${safeWord(spec.state)}`);
323
+ }
324
+
325
+ const main = document.createElement('div');
326
+ main.className = 'twm-record__main';
327
+ const name = document.createElement('div');
328
+ name.className = 'twm-record__name' + (spec.mono ? ' twm-record__name--mono' : '');
329
+ appendContent(name, spec.name);
330
+ main.appendChild(name);
331
+
332
+ const factsText = this._facts(spec.facts);
333
+ if (factsText !== null) {
334
+ const facts = document.createElement('div');
335
+ facts.className = 'twm-record__facts';
336
+ appendContent(facts, factsText);
337
+ if (this.config.facts === 'ellipsis') facts.title = facts.textContent;
338
+ main.appendChild(facts);
339
+ }
340
+ if (spec.error != null && spec.error !== false && spec.error !== '') {
341
+ const err = document.createElement('div');
342
+ err.className = 'twm-record__error';
343
+ appendContent(err, spec.error);
344
+ main.appendChild(err);
345
+ }
346
+ li.appendChild(main);
347
+
348
+ const pills = (spec.pills || []).filter((p) => p != null && p !== false && p !== '');
349
+ if (pills.length) {
350
+ const box = document.createElement('div');
351
+ box.className = 'twm-record__pills';
352
+ for (const p of pills) box.appendChild(pill(p));
353
+ li.appendChild(box);
354
+ }
355
+
356
+ const verbs = (spec.verbs || []).filter(Boolean);
357
+ if (verbs.length) {
358
+ const box = document.createElement('div');
359
+ box.className = 'twm-record__verbs';
360
+ const reasons = new Map(); // sentence → [labels]
361
+ for (const v of verbs) {
362
+ const b = this._verb(v, record);
363
+ box.appendChild(b);
364
+ if (v.refusal) {
365
+ const said = String(v.refusal);
366
+ if (!reasons.has(said)) reasons.set(said, { labels: [], buttons: [] });
367
+ reasons.get(said).labels.push(v.label);
368
+ reasons.get(said).buttons.push(b);
369
+ }
370
+ }
371
+ li.appendChild(box);
372
+ if (reasons.size) {
373
+ const lines = document.createElement('div');
374
+ lines.className = 'twm-record__refusals';
375
+ let n = 0;
376
+ for (const [said, { labels, buttons }] of reasons) {
377
+ const line = document.createElement('div');
378
+ line.className = 'twm-record__refusal';
379
+ line.id = `${this._id}-r${idx}-${n++}`;
380
+ line.dataset.refusalFor = labels.join(', ');
381
+ line.textContent = labels.length === verbs.length && verbs.length > 1
382
+ ? said : `${labels.join(', ')}: ${said}`;
383
+ for (const b of buttons) b.setAttribute('aria-describedby', line.id);
384
+ lines.appendChild(line);
385
+ }
386
+ main.appendChild(lines);
387
+ }
388
+ }
389
+
390
+ this._decorate(li, record, idx);
391
+ return li;
392
+ }
393
+
394
+ _facts(facts) {
395
+ if (facts == null || facts === false) return null;
396
+ if (typeof Node !== 'undefined' && facts instanceof Node) return facts;
397
+ if (!Array.isArray(facts)) {
398
+ const s = String(facts);
399
+ return s === '' ? null : s;
400
+ }
401
+ const parts = facts.filter((f) => f != null && f !== false && f !== '');
402
+ if (!parts.length) return null;
403
+ if (parts.every((p) => !(typeof Node !== 'undefined' && p instanceof Node))) {
404
+ return parts.map(String).join(this.config.factSeparator);
405
+ }
406
+ const frag = document.createDocumentFragment();
407
+ parts.forEach((p, i) => {
408
+ if (i) frag.appendChild(document.createTextNode(this.config.factSeparator));
409
+ appendContent(frag, p);
410
+ });
411
+ return frag;
412
+ }
413
+
414
+ _verb(v, record) {
415
+ const b = document.createElement('button');
416
+ b.type = 'button';
417
+ const { verbClass } = this.config;
418
+ b.className = typeof verbClass === 'function' ? String(verbClass(v) || '')
419
+ : typeof verbClass === 'string' ? verbClass
420
+ : 'twm-btn' + (v.primary ? ' twm-btn--primary' : '') + (v.danger ? ' twm-btn--danger' : '');
421
+ b.classList.add('twm-record__verb');
422
+ b.dataset.verb = String(v.name ?? v.label);
423
+ if (v.icon) {
424
+ const glyph = document.createElement('span');
425
+ glyph.className = 'material-symbols-outlined twm-record__verb-icon';
426
+ glyph.setAttribute('aria-hidden', 'true');
427
+ glyph.textContent = String(v.icon);
428
+ b.appendChild(glyph);
429
+ }
430
+ const label = document.createElement('span');
431
+ label.className = 'twm-record__verb-label';
432
+ label.textContent = String(v.label ?? '');
433
+ b.appendChild(label);
434
+ if (v.refusal) {
435
+ b.disabled = true;
436
+ b.title = String(v.refusal);
437
+ b.dataset.refusal = String(v.refusal);
438
+ } else {
439
+ if (v.title) b.title = String(v.title);
440
+ if (typeof v.onClick === 'function') b.addEventListener('click', (ev) => v.onClick(record, ev));
441
+ }
442
+ return b;
443
+ }
444
+
445
+ _decorate(li, record, idx) {
446
+ const { rowClass, rowAttrs } = this.config;
447
+ if (typeof rowClass === 'function') {
448
+ const c = rowClass(record, idx);
449
+ const list = Array.isArray(c) ? c : String(c ?? '').split(/\s+/);
450
+ for (const name of list) if (name) li.classList.add(String(name));
451
+ }
452
+ if (typeof rowAttrs === 'function') {
453
+ for (const [name, value] of Object.entries(rowAttrs(record, idx) || {})) {
454
+ if (value == null || value === false) continue;
455
+ try {
456
+ li.setAttribute(name, value === true ? '' : String(value));
457
+ } catch (err) {
458
+ console.error(`[RecordList] rowAttrs: bad attribute "${name}"`, err);
459
+ }
460
+ }
461
+ }
462
+ this._paintActive(li);
463
+ }
464
+
465
+ _paintActive(li) {
466
+ const on = this._activeKey !== null && li.__recordKey === this._norm(this._activeKey);
467
+ li.classList.toggle('twm-record--active', on);
468
+ if (on) {
469
+ if (li.getAttribute('aria-current') !== 'true') {
470
+ li.setAttribute('aria-current', 'true');
471
+ li.__twmAriaCurrent = true;
472
+ }
473
+ } else if (li.__twmAriaCurrent) {
474
+ li.__twmAriaCurrent = false;
475
+ li.removeAttribute('aria-current');
476
+ }
477
+ }
478
+
479
+ // ── focus ────────────────────────────────────────────────────────────
480
+
481
+ /** The ONE record that takes Tab: the one last focused, else the open
482
+ * one, else the first. */
483
+ _paintTabStop() {
484
+ if (!this._keyboard()) return;
485
+ let key = this._tabKey;
486
+ if (key === null || !this._items.has(key)) {
487
+ const active = this._norm(this._activeKey);
488
+ key = active !== null && this._items.has(active) ? active : (this._items.keys().next().value ?? null);
489
+ }
490
+ this._setTabStop(key);
491
+ }
492
+
493
+ _setTabStop(key) {
494
+ if (!this._keyboard()) return;
495
+ this._tabKey = key ?? null;
496
+ for (const [k, li] of this._items) li.tabIndex = k === this._tabKey ? 0 : -1;
497
+ }
498
+
499
+ _focusMemo() {
500
+ const doc = this.element.ownerDocument;
501
+ const active = doc?.activeElement;
502
+ if (!active || !this.listElement.contains(active)) return null;
503
+ const li = this._itemOf(active);
504
+ if (!li) return null;
505
+ return { key: li.__recordKey, verb: active.closest('.twm-record__verb')?.dataset.verb ?? null };
506
+ }
507
+
508
+ _restoreFocus(memo) {
509
+ if (!memo) return;
510
+ const li = this._items.get(memo.key);
511
+ if (!li) return;
512
+ if (memo.verb !== null) {
513
+ const b = [...li.querySelectorAll('.twm-record__verb')].find((x) => x.dataset.verb === memo.verb);
514
+ if (b && !b.disabled) { b.focus(); return; }
515
+ }
516
+ if (this._keyboard()) { this._setTabStop(li.__recordKey); li.focus(); }
517
+ }
518
+
519
+ _itemOf(target) {
520
+ const li = target?.closest?.('.twm-record');
521
+ return li && this.listElement.contains(li) && li.parentNode === this.listElement ? li : null;
522
+ }
523
+
524
+ // ── gestures ─────────────────────────────────────────────────────────
525
+
526
+ _gesture(ev, gesture) {
527
+ if (!this._activates() || !this._gestures().has(gesture)) return;
528
+ const li = this._itemOf(ev.target);
529
+ if (!li) return;
530
+ if (!isRowActivation(ev, gesture, { scope: this.listElement })) return;
531
+ this.config.onActivate(li.__record, li.__recordIndex, ev);
532
+ }
533
+
534
+ _key(ev) {
535
+ if (ev.defaultPrevented) return;
536
+ const li = this._itemOf(ev.target);
537
+ if (!li) return;
538
+ if (ev.key === 'Enter') {
539
+ // Enter on a verb is the verb's; only Enter on the record itself opens it.
540
+ if (ev.target !== li || !this._activates() || !this._gestures().has('enter')) return;
541
+ if (ev.altKey || ev.ctrlKey || ev.metaKey || ev.shiftKey) return;
542
+ ev.preventDefault();
543
+ this.config.onActivate(li.__record, li.__recordIndex, ev);
544
+ return;
545
+ }
546
+ if (!this._keyboard()) return;
547
+ if (ev.target !== li && (ev.target.closest(OWN_ARROWS) || !isRowControl(ev.target, li))) return;
548
+ const items = [...this._items.values()];
549
+ const at = items.indexOf(li);
550
+ let next = null;
551
+ if (ev.key === 'ArrowDown') next = items[Math.min(items.length - 1, at + 1)];
552
+ else if (ev.key === 'ArrowUp') next = items[Math.max(0, at - 1)];
553
+ else if (ev.key === 'Home') next = items[0];
554
+ else if (ev.key === 'End') next = items[items.length - 1];
555
+ else return;
556
+ ev.preventDefault();
557
+ if (next) {
558
+ this._setTabStop(next.__recordKey);
559
+ next.focus();
560
+ }
561
+ }
562
+ }
563
+
564
+ /**
565
+ * Build a record list, append it to `host` (if given) and draw it.
566
+ *
567
+ * @param {HTMLElement|null} host
568
+ * @param {RecordListConfig} [config]
569
+ * @returns {RecordList}
570
+ */
571
+ export function createRecordList(host, config = {}) {
572
+ return new RecordList(host, config);
573
+ }
574
+
575
+ /** One pill: a string, or `{text, tone, title}`. A tone is a word
576
+ * (`muted`, `info`, `success`, `warn`, `danger` are drawn). */
577
+ function pill(p) {
578
+ const s = typeof p === 'object' && !(typeof Node !== 'undefined' && p instanceof Node) ? p : { text: p };
579
+ const span = document.createElement('span');
580
+ span.className = 'twm-record__pill' + (s.tone ? ` twm-record__pill--${safeWord(s.tone)}` : '');
581
+ if (typeof Node !== 'undefined' && s.text instanceof Node) span.appendChild(s.text);
582
+ else if (typeof Node !== 'undefined' && p instanceof Node) span.appendChild(p);
583
+ else span.textContent = String(s.text ?? '');
584
+ if (s.title) span.title = String(s.title);
585
+ if (s.tone) span.dataset.tone = safeWord(s.tone);
586
+ return span;
587
+ }
588
+
589
+ /** Text, a node or nothing, into `el`. */
590
+ function appendContent(el, content) {
591
+ if (content == null || content === false) return;
592
+ if (typeof Node !== 'undefined' && content instanceof Node) el.appendChild(content);
593
+ else el.appendChild(document.createTextNode(String(content)));
594
+ }