flexdesk 0.6.3 → 0.8.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.
- package/README.md +339 -0
- package/css/base.css +408 -0
- package/css/flexdesk.css +408 -0
- package/dist/charts.js +2 -2
- package/dist/{chunk-WLASPRGP.js → chunk-JLCJWCRS.js} +1577 -56
- package/dist/chunk-JLCJWCRS.js.map +7 -0
- package/dist/{chunk-WAVLVXYV.js → chunk-WKTYKXWF.js} +2 -2
- package/dist/flexdesk.css +408 -0
- package/dist/flow.js +24 -8
- package/dist/flow.js.map +2 -2
- package/dist/tiles.js +2 -2
- package/dist/widgets.js +672 -1
- package/dist/widgets.js.map +4 -4
- package/dist/wm.js +86 -13
- package/dist/wm.js.map +2 -2
- package/package.json +1 -1
- package/src/flow/outline/editor.js +46 -8
- package/src/tiling/shell.js +6 -2
- package/src/tiling/tile_renderer.js +116 -18
- package/src/tiling/wm.js +24 -0
- package/src/ui/components/data_table.js +1650 -66
- package/src/ui/components/fact_table.js +342 -0
- package/src/ui/components/record_list.js +594 -0
- package/src/ui/components/table_filter_chips.js +153 -0
- package/dist/chunk-WLASPRGP.js.map +0 -7
- /package/dist/{chunk-WAVLVXYV.js.map → chunk-WKTYKXWF.js.map} +0 -0
|
@@ -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
|
+
}
|