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.
@@ -0,0 +1,342 @@
1
+ /**
2
+ * fact_table.js — A SHEET OF FACTS: a name and its value, row after row, with
3
+ * no header row, nothing truncated and no height to give it. (0.7.0)
4
+ *
5
+ * Server db-main-01.internal
6
+ * Version 16.4
7
+ * Connection postgresql://tables@db-main-01.internal:5432/tables_control
8
+ * ?application_name=tables-api&sslmode=verify-full
9
+ *
10
+ * A DataTable is the wrong tool for this, for three reasons that are its
11
+ * design rather than its defects: it draws a header row, it clips a cell to
12
+ * one line (a long value becomes an ellipsis), and it fills a box with a
13
+ * height (in a box without one it draws its header and no rows). A fact
14
+ * sheet is the opposite on all three: the names are the header, every value
15
+ * is read whole, and the sheet is exactly as tall as its rows.
16
+ *
17
+ * So this is a plain `<table class="twm-facts">`:
18
+ *
19
+ * - the first column is the LABEL — muted, and kept on one line (pass
20
+ * `wrapLabels: true` to let a long one wrap, `labelColumn: false` for a
21
+ * sheet whose first column is ordinary data);
22
+ * - every value WRAPS — at spaces, and inside a long word or URL when it must
23
+ * (`overflow-wrap: anywhere`) — and keeps its own line breaks, so a
24
+ * pretty-printed JSON value reads as one; `wrap: 'words'` wraps at spaces
25
+ * only and keeps a long word whole (the sheet grows wider, for a sideways
26
+ * scroller around it), and `wrap: 'none'` keeps only the value's own breaks;
27
+ * - a value is text, a number or a NODE (a pill, a link, a button); an
28
+ * object or array is drawn as indented JSON; `null` is an empty cell, or
29
+ * `nullDisplay`;
30
+ * - `mono` draws values in the monospace face, for the whole sheet or one row;
31
+ * - `compare: true` gives each row a BEFORE and an AFTER, with an arrow
32
+ * between: a row whose two sides differ is `--changed`, one that has only
33
+ * an after is `--added`, only a before `--removed`, and the same on both
34
+ * sides `--same`. A row with a plain `value` spans both sides;
35
+ * - `headers` adds a header row after all, for the sheets that have several
36
+ * columns of facts (a list of schemas, a list of pools); rows are then
37
+ * arrays of cells, and `numeric` names the columns of figures.
38
+ *
39
+ * A row may be an OBJECT — `{label, value}` or `{label, before, after}`, with
40
+ * `mono`, `numeric`, `labelTitle`, `key` and `status` — or an ARRAY of cells,
41
+ * `[label, value, …]`, which is the positional form a multi-column sheet uses.
42
+ */
43
+
44
+ let SEQ = 0;
45
+ const isNode = (v) => typeof Node !== 'undefined' && v instanceof Node;
46
+
47
+ /**
48
+ * @typedef {Object} FactRow
49
+ * @property {string|Node} label
50
+ * @property {*} [value]
51
+ * @property {*} [before] - With `compare`: the value before.
52
+ * @property {*} [after] - With `compare`: the value after.
53
+ * @property {boolean} [mono] - This row's values in the monospace face.
54
+ * @property {boolean} [numeric] - This row's values in tabular numerals.
55
+ * @property {string} [labelTitle] - A tooltip on the label.
56
+ * @property {*} [key] - `data-key` on the row.
57
+ * @property {'changed'|'same'|'added'|'removed'} [status] - With `compare`:
58
+ * overrides the status worked out from the two sides.
59
+ */
60
+
61
+ /**
62
+ * @typedef {Object} FactTableConfig
63
+ * @property {Array<FactRow|Array>} [rows=[]]
64
+ * @property {string[]|null} [headers=null] - A header row. Off by default.
65
+ * @property {boolean} [compare=false] - A before and an after column.
66
+ * @property {boolean} [labelColumn=true] - The first column is a label.
67
+ * @property {boolean} [wrapLabels=false] - Let a long label wrap.
68
+ * @property {'anywhere'|'words'|'none'} [wrap='anywhere'] - How a value wraps:
69
+ * at spaces and inside a long word when it must (the default), at spaces
70
+ * only (`'words'`: a long word stays whole and the sheet grows wider — for a
71
+ * sheet inside a sideways scroller), or not at all beyond its own line
72
+ * breaks (`'none'`).
73
+ * @property {boolean} [mono=false] - Every value in the monospace face.
74
+ * @property {number[]} [numeric=[]] - Column indices drawn in tabular numerals.
75
+ * @property {string} [nullDisplay=''] - What `null`/`undefined` reads as.
76
+ * @property {string} [missing='—'] - With `compare`: an absent side.
77
+ * @property {Function} [rowClass] - `(row, idx) => string|string[]|null`.
78
+ * @property {Function} [rowAttrs] - `(row, idx) => {name: value}`.
79
+ * @property {string|Node|Function} [emptyState] - Shown when there are no rows.
80
+ * @property {string} [label] - The table's accessible name (`aria-label`).
81
+ * @property {string} [className] - Added to the `<table>`.
82
+ */
83
+
84
+ export class FactTable {
85
+ /**
86
+ * @param {HTMLElement|null} host appended to, if given
87
+ * @param {FactTableConfig} [config]
88
+ */
89
+ constructor(host, config = {}) {
90
+ this.config = {
91
+ rows: [],
92
+ headers: null,
93
+ compare: false,
94
+ labelColumn: true,
95
+ wrapLabels: false,
96
+ wrap: 'anywhere',
97
+ mono: false,
98
+ numeric: [],
99
+ nullDisplay: '',
100
+ missing: '—',
101
+ rowClass: null,
102
+ rowAttrs: null,
103
+ emptyState: null,
104
+ label: null,
105
+ className: null,
106
+ ...config,
107
+ };
108
+ this._rows = Array.isArray(this.config.rows) ? this.config.rows.slice() : [];
109
+ this._id = ++SEQ;
110
+ const table = document.createElement('table');
111
+ table.className = 'twm-facts'
112
+ + (this.config.compare ? ' twm-facts--compare' : '')
113
+ + (this.config.labelColumn ? '' : ' twm-facts--no-label')
114
+ + (this.config.wrapLabels ? ' twm-facts--wrap-labels' : '')
115
+ + (this.config.wrap === 'words' ? ' twm-facts--wrap-words'
116
+ : this.config.wrap === 'none' ? ' twm-facts--wrap-none' : '')
117
+ + (this.config.mono ? ' twm-facts--mono' : '')
118
+ + (this.config.className ? ` ${this.config.className}` : '');
119
+ if (this.config.label) table.setAttribute('aria-label', this.config.label);
120
+ this.element = table;
121
+ this._draw();
122
+ if (host) host.appendChild(table);
123
+ }
124
+
125
+ /** Replace the rows and draw them. */
126
+ setRows(rows) {
127
+ this._rows = Array.isArray(rows) ? rows.slice() : [];
128
+ this._draw();
129
+ }
130
+
131
+ getRows() {
132
+ return this._rows.slice();
133
+ }
134
+
135
+ /** The `<tr>` drawn for row `idx`, or null. */
136
+ getRow(idx) {
137
+ return this.element.tBodies[0]?.rows[idx]?.__factIndex === idx
138
+ ? this.element.tBodies[0].rows[idx] : null;
139
+ }
140
+
141
+ dispose() {
142
+ this.element.remove();
143
+ }
144
+
145
+ // ── drawing ──────────────────────────────────────────────────────────
146
+
147
+ /** How many columns the sheet has. */
148
+ _width() {
149
+ const { compare, headers } = this.config;
150
+ if (compare) return 4;
151
+ let n = 2;
152
+ if (Array.isArray(headers)) n = Math.max(n, headers.length);
153
+ for (const r of this._rows) if (Array.isArray(r)) n = Math.max(n, r.length);
154
+ return n;
155
+ }
156
+
157
+ _draw() {
158
+ const table = this.element;
159
+ table.replaceChildren();
160
+ const width = this._width();
161
+ const { headers, compare } = this.config;
162
+
163
+ if (Array.isArray(headers) && headers.length) {
164
+ const thead = document.createElement('thead');
165
+ const tr = document.createElement('tr');
166
+ // With `compare`, three names stand over label, before and after;
167
+ // the arrow column gets an empty one.
168
+ const names = compare && headers.length === 3
169
+ ? [headers[0], headers[1], '', headers[2]] : headers;
170
+ for (let i = 0; i < width; i++) {
171
+ const th = document.createElement('th');
172
+ th.scope = 'col';
173
+ appendContent(th, names[i] ?? '');
174
+ if (this._numeric(i)) th.classList.add('twm-facts__num');
175
+ tr.appendChild(th);
176
+ }
177
+ thead.appendChild(tr);
178
+ table.appendChild(thead);
179
+ }
180
+
181
+ const tbody = document.createElement('tbody');
182
+ this._rows.forEach((row, idx) => tbody.appendChild(this._row(row, idx, width)));
183
+ if (!this._rows.length) {
184
+ let content = this.config.emptyState;
185
+ if (typeof content === 'function') content = content();
186
+ if (content != null && content !== false && content !== '') {
187
+ const tr = document.createElement('tr');
188
+ tr.className = 'twm-facts__empty-row';
189
+ const td = document.createElement('td');
190
+ td.className = 'twm-facts__empty';
191
+ td.colSpan = width;
192
+ appendContent(td, content);
193
+ tr.appendChild(td);
194
+ tbody.appendChild(tr);
195
+ }
196
+ }
197
+ table.classList.toggle('twm-facts--empty', this._rows.length === 0);
198
+ table.appendChild(tbody);
199
+ }
200
+
201
+ _numeric(col) {
202
+ const n = this.config.numeric;
203
+ return Array.isArray(n) && n.includes(col);
204
+ }
205
+
206
+ _row(row, idx, width) {
207
+ const tr = document.createElement('tr');
208
+ tr.className = 'twm-facts__row';
209
+ tr.__factIndex = idx;
210
+ tr.__fact = row;
211
+ if (Array.isArray(row)) {
212
+ for (let i = 0; i < width; i++) {
213
+ const td = this._cell(i === 0 && this.config.labelColumn ? 'label' : 'value', row[i]);
214
+ if (this._numeric(i)) td.classList.add('twm-facts__num');
215
+ if (i === row.length - 1 && row.length < width) {
216
+ td.colSpan = width - i;
217
+ tr.appendChild(td);
218
+ break;
219
+ }
220
+ tr.appendChild(td);
221
+ }
222
+ } else {
223
+ const r = row && typeof row === 'object' ? row : { label: row };
224
+ if (r.key !== undefined && r.key !== null) tr.dataset.key = String(r.key);
225
+ const label = this._cell(this.config.labelColumn ? 'label' : 'value', r.label);
226
+ if (r.labelTitle) label.title = String(r.labelTitle);
227
+ tr.appendChild(label);
228
+ const tune = (td) => {
229
+ if (r.mono) td.classList.add('twm-facts__mono');
230
+ if (r.numeric) td.classList.add('twm-facts__num');
231
+ return td;
232
+ };
233
+ const hasBefore = Object.prototype.hasOwnProperty.call(r, 'before');
234
+ const hasAfter = Object.prototype.hasOwnProperty.call(r, 'after');
235
+ if (this.config.compare && (hasBefore || hasAfter)) {
236
+ const status = r.status || statusOf(r, hasBefore, hasAfter);
237
+ tr.classList.add(`twm-facts__row--${String(status).replace(/[^\w-]/g, '')}`);
238
+ tr.dataset.status = String(status);
239
+ const before = tune(hasBefore ? this._cell('value', r.before) : this._missing());
240
+ before.classList.add('twm-facts__before');
241
+ const arrow = document.createElement('td');
242
+ arrow.className = 'twm-facts__arrow';
243
+ arrow.setAttribute('aria-hidden', 'true');
244
+ arrow.textContent = '→';
245
+ const after = tune(hasAfter ? this._cell('value', r.after) : this._missing());
246
+ after.classList.add('twm-facts__after');
247
+ tr.append(before, arrow, after);
248
+ } else {
249
+ const value = tune(this._cell('value', r.value));
250
+ if (width > 2) value.colSpan = width - 1;
251
+ tr.appendChild(value);
252
+ }
253
+ }
254
+ this._decorate(tr, row, idx);
255
+ return tr;
256
+ }
257
+
258
+ _cell(kind, content) {
259
+ const isLabel = kind === 'label';
260
+ const td = document.createElement(isLabel ? 'th' : 'td');
261
+ td.className = isLabel ? 'twm-facts__label' : 'twm-facts__value';
262
+ if (isLabel) td.scope = 'row';
263
+ if (content === null || content === undefined) {
264
+ const said = isLabel ? '' : this.config.nullDisplay;
265
+ if (said) {
266
+ td.textContent = String(said);
267
+ td.classList.add('twm-facts__null');
268
+ }
269
+ } else {
270
+ appendContent(td, content);
271
+ }
272
+ return td;
273
+ }
274
+
275
+ _missing() {
276
+ const td = document.createElement('td');
277
+ td.className = 'twm-facts__value twm-facts__missing';
278
+ td.textContent = String(this.config.missing ?? '');
279
+ return td;
280
+ }
281
+
282
+ _decorate(tr, row, idx) {
283
+ const { rowClass, rowAttrs } = this.config;
284
+ if (typeof rowClass === 'function') {
285
+ const c = rowClass(row, idx);
286
+ const list = Array.isArray(c) ? c : String(c ?? '').split(/\s+/);
287
+ for (const name of list) if (name) tr.classList.add(String(name));
288
+ }
289
+ if (typeof rowAttrs === 'function') {
290
+ for (const [name, value] of Object.entries(rowAttrs(row, idx) || {})) {
291
+ if (value == null || value === false) continue;
292
+ try {
293
+ tr.setAttribute(name, value === true ? '' : String(value));
294
+ } catch (err) {
295
+ console.error(`[FactTable] rowAttrs: bad attribute "${name}"`, err);
296
+ }
297
+ }
298
+ }
299
+ }
300
+ }
301
+
302
+ /**
303
+ * Build a fact sheet, append it to `host` (if given) and draw it.
304
+ *
305
+ * @param {HTMLElement|null} host
306
+ * @param {FactTableConfig} [config]
307
+ * @returns {FactTable}
308
+ */
309
+ export function createFactTable(host, config = {}) {
310
+ return new FactTable(host, config);
311
+ }
312
+
313
+ /** What a before/after row says, from its two sides. Nodes cannot be
314
+ * compared, so a row that has one is `changed` unless it says otherwise;
315
+ * objects compare by their JSON. */
316
+ function statusOf(r, hasBefore, hasAfter) {
317
+ if (!hasBefore) return 'added';
318
+ if (!hasAfter) return 'removed';
319
+ if (isNode(r.before) || isNode(r.after)) return 'changed';
320
+ if (Object.is(r.before, r.after)) return 'same';
321
+ if (r.before && r.after && typeof r.before === 'object' && typeof r.after === 'object') {
322
+ try {
323
+ return JSON.stringify(r.before) === JSON.stringify(r.after) ? 'same' : 'changed';
324
+ } catch {
325
+ return 'changed';
326
+ }
327
+ }
328
+ return 'changed';
329
+ }
330
+
331
+ /** Text, a number, a node, or an object (drawn as indented JSON) into `el`. */
332
+ function appendContent(el, content) {
333
+ if (content === null || content === undefined) return;
334
+ if (isNode(content)) { el.appendChild(content); return; }
335
+ let text;
336
+ if (typeof content === 'object') {
337
+ try { text = JSON.stringify(content, null, 2); } catch { text = String(content); }
338
+ } else {
339
+ text = String(content);
340
+ }
341
+ el.appendChild(document.createTextNode(text));
342
+ }