flexdesk 0.17.0 → 0.17.2
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/CHANGELOG.md +41 -0
- package/README.md +399 -3
- package/canvas.js +18 -0
- package/charts.js +21 -0
- package/core.js +14 -0
- package/css/base.css +23 -1
- package/css/flexdesk.css +61 -1
- package/dist/charts.js +3 -5
- package/dist/charts.js.map +1 -1
- package/dist/{chunk-Q36EPT2R.js → chunk-LWV23YVE.js} +1614 -138
- package/dist/chunk-LWV23YVE.js.map +7 -0
- package/dist/{chunk-MCIXFNVF.js → chunk-RMTRWWEO.js} +2 -2
- package/dist/flexdesk.css +61 -1
- package/dist/flow.js +2 -2
- package/dist/flow.js.map +2 -2
- package/dist/tiles.js +2 -2
- package/dist/widgets.js +186 -55
- package/dist/widgets.js.map +2 -2
- package/dist/wm.js +90 -10
- package/dist/wm.js.map +2 -2
- package/editor.js +20 -0
- package/flow.js +21 -0
- package/host.js +14 -0
- package/package.json +10 -1
- package/src/flow/canvas_editor/editor.js +1 -1
- package/src/tiling/command_palette.js +46 -7
- package/src/tiling/content_registry.js +3 -0
- package/src/tiling/entity_sources.js +10 -0
- package/src/tiling/wm.js +65 -3
- package/src/ui/components/cell_editor_host.js +100 -18
- package/src/ui/components/cell_editors.js +480 -0
- package/src/ui/components/cell_grid.js +121 -8
- package/src/ui/components/cell_paste.js +243 -0
- package/src/ui/components/data_table.js +96 -10
- package/src/ui/components/settings_view.js +192 -51
- package/src/ui/components/staged_edits.js +19 -1
- package/src/ui/components/tsv.js +74 -0
- package/src/ui/notification_center.js +5 -5
- package/tiles.js +27 -0
- package/widgets.js +69 -0
- package/wm.js +28 -0
- package/dist/chunk-Q36EPT2R.js.map +0 -7
- package/dist/chunk-QWQJUDPD.js +0 -610
- package/dist/chunk-QWQJUDPD.js.map +0 -7
- /package/dist/{chunk-MCIXFNVF.js.map → chunk-RMTRWWEO.js.map} +0 -0
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Unified, reusable data table component with support for:
|
|
5
5
|
* - Pagination (first/prev/next/last/jump to row)
|
|
6
6
|
* - Row selection (single, range with Shift, toggle with Ctrl/Cmd)
|
|
7
|
-
* - Keyboard shortcuts (Ctrl+A, Ctrl+C, Shift+Ctrl+C, Esc)
|
|
7
|
+
* - Keyboard shortcuts (Ctrl+A, Ctrl+C, Shift+Ctrl+C outside cell mode, Esc)
|
|
8
8
|
* - Context menu (copy as TSV/CSV)
|
|
9
9
|
* - Column sorting (internal or external via callback)
|
|
10
10
|
* - Column drag-to-resize, and double-click-to-auto-size on the same grip
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
import { isRowActivation, isRowControl } from './row_activation.js';
|
|
28
28
|
import { createChipsFilter } from './table_filter_chips.js';
|
|
29
29
|
import { CellGrid } from './cell_grid.js';
|
|
30
|
+
import { toTSV } from './tsv.js';
|
|
30
31
|
import { ColumnReorder, cellOrder, isColumnOrder, moveInOrder, naturalOrder } from './column_order.js';
|
|
31
32
|
import { ColumnGroups } from './column_groups.js';
|
|
32
33
|
import { Crosstab } from './crosstab.js';
|
|
@@ -415,7 +416,10 @@ const AUTOSIZE_MAX_PX = 520;
|
|
|
415
416
|
* ── STORY-0003, every one of them off unless asked for ─────────────────────
|
|
416
417
|
* @property {'rows'|'cells'} [selectionMode='rows'] - 'cells': CELL MODE (cell_grid.js, over cell_selection.js). A click selects one cell and puts the CURSOR on it, Shift+click selects the range from the anchor to it; every cell of the range is drawn `twm-dt-cell--selected` and the cursor `twm-dt-cell--cursor` (rows are not lit). On the focused table the arrows move the cursor (Shift extends), Ctrl+arrow jumps to the edge of the block of filled cells, Home/End go to the row's ends (Ctrl: the page's corners), Tab moves right and wraps to the next row (Shift+Tab back), Ctrl+A selects every cell of the page and Escape collapses the range onto the cursor. The cursor stays on the page drawn and is scrolled into view (under `virtualize` its row is drawn). `getSelection()` still answers ROWS — the original indices the range touches — and Ctrl+C copies those rows as before. A cell is `{row, col}`: `row` the position in the order shown (`getDisplayedRows()`), `col` the data column. Fixed for the table's life; `selectToggle`, `selectMode` and `keyboardNav` beside it are ignored with a console warning. See `getCellSelection`, `setCellSelection`.
|
|
417
418
|
* @property {boolean|number[]|Function} [editable=false] - Cell mode only (a warning, and ignored, without it). Which cells open an EDITOR: `true` every one, an array of data column indices, or `(colIdx, row, rowIdx) => boolean`. F2, Enter, a double-click or a printable key (which the editor starts with) opens it on the cursor cell; in it Enter commits and moves down, Tab commits and moves right, Escape restores the cell and calls nothing, and the focus leaving commits. While it is open a `render()` (a `setData` included) waits, and runs when it closes; its row leaving the virtual window commits it. An editor with no `getValue` (a factory's `{el}` around an element that is not an input, select or textarea) has no value to commit: Enter and Tab do nothing, the focus leaving does nothing, and its row leaving the window CANCELS it — it calls `commit(value)` itself.
|
|
418
|
-
* @property {Object} [editors] - `{[colIdx|type]: factory}`, the editor for a column (by index first, then by its type: `columnTypes`'
|
|
419
|
+
* @property {Object} [editors] - `{[colIdx|type]: factory}`, the editor for a column (by index first, then by its type: `editTypes`', else `columnTypes`', else the detected 'num'/'text' — as named, then as the built-in editors name it, 'num' being 'number' and 'date' 'datetime'). A factory is `({value, row, rowIdx, colIdx, column: {index, header, type, ...editTypes' entry}, locale, utc, messages, initialText, commit(value, move?), cancel()}) => Element | {el, getValue?, parse?, multiline?, focus?, destroy?}`; an input, select or textarea returned as it is commits its `value`. Absent: the built-in editor for the column's type (cell_editors.js `CELL_EDITORS`: text, multitext, number, bool, choice, datetime), else a text box (cell_editor_host.js `createTextEditor`). Every editor commits the value unchanged when left as it was. An injected editor replaces the built-in one for its type (or column) only.
|
|
420
|
+
* @property {Array|Object} [editTypes] - STORY-0087. Each column's EDITOR type, by position (array) or by data column index (object): a name ('text', 'multitext', 'number', 'bool', 'choice', 'datetime', or any name `editors` is keyed by) or `{type, options?, allowOther?, rows?}`. Unset, a column's editor type is its `columnTypes` type, else the detected one. 'choice' offers `options` (values, or `{value, label}`) through an AutocompleteField and refuses other text unless `allowOther`; 'multitext' is a text area of `rows` lines (3). Only editing reads it: drawing, sorting and filtering are as without it.
|
|
421
|
+
* @property {string} [locale] - STORY-0087. The locale the built-in number and datetime editors read and show their text in (Intl's; unset: the runtime's): '1.234,56' is 1234.56 under 'de-DE', '1,234.56' under 'en-US'; '31/12/2026' is a date under 'en-GB'. A datetime also takes ISO 8601 in any locale.
|
|
422
|
+
* @property {Object} [editorMessages] - STORY-0087. Words for the built-in editors' refusals, over `EDITOR_MESSAGES`: `{number, datetime, bool, choice}`.
|
|
419
423
|
* @property {Function} [onCellCommit] - `({rowIdx, colIdx, value, previous}) => void | {ok, error?} | Promise<…>`, once per commit (not for a value left as the cell showed it; `previous` is the value the row holds). Resolving `{ok: false, error}`, rejecting or throwing leaves the typed value in the cell drawn `twm-dt-cell--error` with the message as its `title` and `data-error`; until it settles the cell is `twm-dt-cell--pending`. Otherwise the value is written into the row the table holds for `rowIdx` when it settles (`rows[rowIdx][colIdx]`) and drawn. Committing a refused cell back to the row's value clears the mark without a call. New rows (`setData({rows})`) drop the marks.
|
|
420
424
|
* @property {Function} [onCellSelectionChange] - `(getCellSelection()) => void`, after every change of the cells selected.
|
|
421
425
|
* @property {Function} [headerMenu] - `(colIdx, {colIdx, header, hiddenColumns, table}) => items`, asked on a right-click on a data column's header (or its filter cell). The items (`{label, action, icon?, disabled?, danger?, title?}` or `{separator: true}`) open the table's menu; with `columnChooser` the chooser's items follow behind a separator. `[]` or `null` shows what would show without the hook: the chooser, or the page's own menu.
|
|
@@ -427,6 +431,9 @@ const AUTOSIZE_MAX_PX = 520;
|
|
|
427
431
|
* @property {Function} [onCommit] - With `staged`: `({edits}) => void | {ok?, error?, errors?} | Promise<…>`, called once by `commit()` with every staged cell as `{rowIdx, colIdx, value, previous}` (by row, then column; `previous` is the value the row holds). Resolving `{errors: [{rowIdx, colIdx, error}]}` (non-empty) marks each of those cells `twm-dt-cell--error` with the message as its `title` and `data-error`, and every staged value stays; `{ok: false, error}`, a rejection or a throw marks every staged cell with the message. Anything else is success: the values are written into the rows the table holds, and the staged cells and the ring are cleared. While it is out no editor opens and Ctrl+Z, Ctrl+Y and `discard()` do nothing.
|
|
428
432
|
* @property {Function} [onStagedChange] - With `staged`: `({count, canUndo, canRedo, committing}) => void` after every change of the staged cells.
|
|
429
433
|
* @property {boolean} [fillHandle=false] - STORY-0089. With `staged` (a warning, and ignored, without it): a FILL HANDLE (fill_handle.js) on the bottom-right corner of the cell range (`.twm-dt-fill-handle`, in the corner cell). Dragging it up, down, left or right over the cells beyond the range previews them `twm-dt-cell--fill-preview` and, on release, stages their values as ONE undo entry: each column of the range (each row, filling sideways) is continued by `projectSeries` (series.js) — one cell repeats, numbers, dates and "Item 1"-style texts a constant step apart continue, anything else repeats in a cycle — and Ctrl (Cmd) held at release repeats the block instead. A target cell `editable` refuses is skipped and drawn `twm-dt-cell--fill-skipped` until the selection moves. The range then covers what was filled. Escape during the drag cancels it. See `fill`.
|
|
434
|
+
* @property {boolean} [pasteable=false] - STORY-0090. With `staged` (a warning, and ignored, without it): Ctrl+V on the focused table (the browser's `paste` event; `paste(text)` from code) pastes a tab-separated block — what Excel and Google Sheets copy, read by `fromTSV` (tsv.js) — at the CURSOR cell, filling right over the columns shown and down over the rows in the order shown (cell_paste.js). Each value is read by its column's editor's parse (the built-in editor's for its type, or an injected `editors` factory's own `parse` property), and every cell that reads is staged as ONE undo entry; the range then covers the block, the cursor where it began. Columns past the last shown one are dropped with a warning naming how many (a console warning and a 'warn' toast). A value that does not parse marks its cell `twm-dt-cell--error` (message as `title` and `data-error`, the text kept) and the rest still stages; a cell `editable` refuses is skipped and drawn `twm-dt-cell--paste-skipped` until the selection moves. Rows past the last loaded row ask `onPasteOverrun`.
|
|
435
|
+
* @property {Function} [onPasteOverrun] - With `pasteable`: `({rows}) => boolean | Promise<boolean>`, asked when a paste runs `rows` rows past the last loaded row (the last row in the order shown; for a server's page, the last row it sent). Anything but `true` stages NOTHING; `true` stages the rows that fit and hands the extra ones to `onRowsAppend`. Without it, such a paste stages nothing, with a console warning.
|
|
436
|
+
* @property {Function} [onRowsAppend] - With `pasteable`: `({rows, errors}) => void | Promise`, after `onPasteOverrun` said `true`: `rows` the extra rows as data rows (one array per row, as long as `headers`, every pasted column read by its parse, the others null) and `errors` the values that did not read (`{row, colIdx, text, error}`, `row` an index into `rows`; that cell is null). Adding them is the consumer's; the rows that fit are staged once it settles. Without it, the extra rows are dropped with a warning.
|
|
430
437
|
* @property {string|Function} [stagedRefusal] - With `staged`: the reason a frozen control carries, or `(count) => string`. Absent: "Sorting, filtering and paging are held while N changes are not committed. Commit or discard them first."
|
|
431
438
|
*
|
|
432
439
|
* ── STORY-0066, every one of them off unless asked for. THE CONSUMER OWNS THE DATA: each edit is a call, answered with `setData` ──
|
|
@@ -450,9 +457,16 @@ const AUTOSIZE_MAX_PX = 520;
|
|
|
450
457
|
* it were this tall. A ResizeObserver corrects it the moment a real size exists. */
|
|
451
458
|
const VIRTUAL_FALLBACK_VIEWPORT_PX = 1000;
|
|
452
459
|
|
|
453
|
-
/** The copy menu
|
|
454
|
-
* `contextMenuItems`, and its tail with them (STORY-0010).
|
|
455
|
-
|
|
460
|
+
/** The copy menu — the whole right-click menu of a table without
|
|
461
|
+
* `contextMenuItems`, and its tail with them (STORY-0010). Row mode has two
|
|
462
|
+
* items, "Copy Row(s)" and "Copy as CSV"; cell mode (STORY-0085) has one,
|
|
463
|
+
* "Copy Cells", and no CSV copy. */
|
|
464
|
+
const COPY_CELLS_MENU_HTML = `
|
|
465
|
+
<div class="twm-context-menu-item" data-action="copy-tsv">
|
|
466
|
+
<span class="material-symbols-outlined">content_copy</span>
|
|
467
|
+
<span class="label">Copy Cells</span>
|
|
468
|
+
</div>`;
|
|
469
|
+
const copyMenuHtml = (cells) => cells ? COPY_CELLS_MENU_HTML : `
|
|
456
470
|
<div class="twm-context-menu-item" data-action="copy-tsv">
|
|
457
471
|
<span class="material-symbols-outlined">content_copy</span>
|
|
458
472
|
<span class="label">Copy Row(s)</span>
|
|
@@ -600,6 +614,9 @@ export class DataTable {
|
|
|
600
614
|
selectionMode: 'rows',
|
|
601
615
|
editable: false,
|
|
602
616
|
editors: null,
|
|
617
|
+
editTypes: null,
|
|
618
|
+
locale: null,
|
|
619
|
+
editorMessages: null,
|
|
603
620
|
onCellCommit: null,
|
|
604
621
|
onCellSelectionChange: null,
|
|
605
622
|
headerMenu: null,
|
|
@@ -624,6 +641,10 @@ export class DataTable {
|
|
|
624
641
|
onRowDelete: null,
|
|
625
642
|
// STORY-0089. Off — no fill handle — until set.
|
|
626
643
|
fillHandle: false,
|
|
644
|
+
// STORY-0090. Off — Ctrl+V is not the table's — until set.
|
|
645
|
+
pasteable: false,
|
|
646
|
+
onPasteOverrun: null,
|
|
647
|
+
onRowsAppend: null,
|
|
627
648
|
...config,
|
|
628
649
|
};
|
|
629
650
|
|
|
@@ -652,6 +673,9 @@ export class DataTable {
|
|
|
652
673
|
if (this.config.fillHandle && !this._cellGrid) {
|
|
653
674
|
console.warn('[DataTable] `fillHandle` needs `staged`; ignored.');
|
|
654
675
|
}
|
|
676
|
+
if (this.config.pasteable && !this._cellGrid) {
|
|
677
|
+
console.warn('[DataTable] `pasteable` needs `staged`; ignored.');
|
|
678
|
+
}
|
|
655
679
|
// STORY-0091. A crosstab has no group row: its spans are `headerRows`.
|
|
656
680
|
if (Array.isArray(this.config.columnGroups) && this.config.columnGroups.length && this._crosstab()) {
|
|
657
681
|
console.warn('[DataTable] `columnGroups` cannot be combined with `crosstab` (use crosstab.headerRows); ignored.');
|
|
@@ -1595,6 +1619,25 @@ export class DataTable {
|
|
|
1595
1619
|
return this._cellGrid?.fill ? this._cellGrid.fill.fill(to, { forceCopy }) : null;
|
|
1596
1620
|
}
|
|
1597
1621
|
|
|
1622
|
+
/**
|
|
1623
|
+
* STORY-0090. Paste tab-separated `text` at the cursor cell, as Ctrl+V
|
|
1624
|
+
* does: each value read by its column's parse, staged as ONE undo entry.
|
|
1625
|
+
* Resolves `{rows, cols, staged, skipped, refused, droppedColumns,
|
|
1626
|
+
* appended}` — `staged` the NUMBER of cells that changed, `skipped` the
|
|
1627
|
+
* cells `editable` refused (`{row, col, rowIdx}`), `refused` the values
|
|
1628
|
+
* that did not parse (`{row, col, rowIdx, text, error}`),
|
|
1629
|
+
* `droppedColumns` the columns past the last one, `appended` the rows
|
|
1630
|
+
* handed to `onRowsAppend` — or null when nothing was pasted (no
|
|
1631
|
+
* `pasteable`, empty text, no cursor, an editor open, a commit or another
|
|
1632
|
+
* paste out, an overrun `onPasteOverrun` did not accept).
|
|
1633
|
+
*
|
|
1634
|
+
* @param {string} text
|
|
1635
|
+
* @returns {Promise<object|null>}
|
|
1636
|
+
*/
|
|
1637
|
+
paste(text) {
|
|
1638
|
+
return this._cellGrid?.paste ? this._cellGrid.paste.paste(text) : Promise.resolve(null);
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1598
1641
|
/** Why sort, filters and pages are held — a staged edit — or null; with
|
|
1599
1642
|
* a reason, `what` is refused out loud. */
|
|
1600
1643
|
_stagedRefusal(what) {
|
|
@@ -2145,10 +2188,21 @@ export class DataTable {
|
|
|
2145
2188
|
}
|
|
2146
2189
|
|
|
2147
2190
|
/**
|
|
2148
|
-
* Copy selected rows to clipboard
|
|
2191
|
+
* Copy selected rows to clipboard — in cell mode (STORY-0085) the cells
|
|
2192
|
+
* selected, or the cursor cell alone, as tab-separated raw values with no
|
|
2193
|
+
* header line; cell mode has no CSV copy
|
|
2149
2194
|
* @param {'tsv'|'csv'} [format='tsv'] - Output format
|
|
2150
2195
|
*/
|
|
2151
2196
|
async copyToClipboard(format = 'tsv') {
|
|
2197
|
+
// STORY-0085. Cell mode copies the cells selected, not their rows,
|
|
2198
|
+
// and only as TSV.
|
|
2199
|
+
if (this._cellGrid) {
|
|
2200
|
+
if (format === 'csv') {
|
|
2201
|
+
console.warn('[DataTable] copyToClipboard: cell mode has no CSV copy; nothing copied');
|
|
2202
|
+
return;
|
|
2203
|
+
}
|
|
2204
|
+
return this._copyCells();
|
|
2205
|
+
}
|
|
2152
2206
|
const indices = this.getSelection();
|
|
2153
2207
|
if (indices.length === 0) {
|
|
2154
2208
|
this._notify('Copy', 'No rows selected.', 'warn');
|
|
@@ -2191,6 +2245,36 @@ export class DataTable {
|
|
|
2191
2245
|
this._hideContextMenu();
|
|
2192
2246
|
}
|
|
2193
2247
|
|
|
2248
|
+
/**
|
|
2249
|
+
* STORY-0085. Cell mode's copy: the range (or the cursor cell alone), its
|
|
2250
|
+
* raw values as TSV — Excel's quoting, `tsv.js` — with no header line.
|
|
2251
|
+
*/
|
|
2252
|
+
async _copyCells() {
|
|
2253
|
+
const matrix = this._cellGrid.copyMatrix();
|
|
2254
|
+
if (!matrix || !matrix.length) {
|
|
2255
|
+
this._notify('Copy', 'No cells selected.', 'warn');
|
|
2256
|
+
this._hideContextMenu();
|
|
2257
|
+
return;
|
|
2258
|
+
}
|
|
2259
|
+
const text = toTSV(matrix);
|
|
2260
|
+
let ok = false;
|
|
2261
|
+
try {
|
|
2262
|
+
await navigator.clipboard.writeText(text);
|
|
2263
|
+
ok = true;
|
|
2264
|
+
} catch (_) {
|
|
2265
|
+
ok = this._fallbackCopy(text);
|
|
2266
|
+
}
|
|
2267
|
+
if (ok) {
|
|
2268
|
+
const n = matrix.length;
|
|
2269
|
+
const m = matrix[0].length;
|
|
2270
|
+
const what = n * m === 1 ? '1 cell' : `${n} × ${m} cells`;
|
|
2271
|
+
this._notify('Copy', `Copied ${what}.`, 'info');
|
|
2272
|
+
} else {
|
|
2273
|
+
this._notify('Copy', 'Clipboard unavailable.', 'warn');
|
|
2274
|
+
}
|
|
2275
|
+
this._hideContextMenu();
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2194
2278
|
/**
|
|
2195
2279
|
* Download all data as CSV
|
|
2196
2280
|
* @param {string} [filename='data.csv'] - Filename for download
|
|
@@ -7047,7 +7131,9 @@ export class DataTable {
|
|
|
7047
7131
|
} else if (key === 'a' && selectable) {
|
|
7048
7132
|
event.preventDefault();
|
|
7049
7133
|
this._selectAllShown();
|
|
7050
|
-
} else if (key === 'c') {
|
|
7134
|
+
} else if (key === 'c' && !(event.shiftKey && this._cellGrid)) {
|
|
7135
|
+
// STORY-0085. Cell mode binds Ctrl+C only: it has no
|
|
7136
|
+
// CSV copy, so Ctrl+Shift+C is left to the page.
|
|
7051
7137
|
event.preventDefault();
|
|
7052
7138
|
this.copyToClipboard(event.shiftKey ? 'csv' : 'tsv');
|
|
7053
7139
|
}
|
|
@@ -7198,7 +7284,7 @@ export class DataTable {
|
|
|
7198
7284
|
menu.style.display = 'none';
|
|
7199
7285
|
menu.style.position = 'fixed';
|
|
7200
7286
|
menu.style.zIndex = '10001';
|
|
7201
|
-
menu.innerHTML =
|
|
7287
|
+
menu.innerHTML = copyMenuHtml(!!this._cellGrid);
|
|
7202
7288
|
document.body.appendChild(menu);
|
|
7203
7289
|
|
|
7204
7290
|
menu.addEventListener('click', (event) => {
|
|
@@ -7371,7 +7457,7 @@ export class DataTable {
|
|
|
7371
7457
|
this._contextMenuCtx = custom ? custom.ctx : null;
|
|
7372
7458
|
if (!custom) {
|
|
7373
7459
|
if (this._contextMenuCustom) {
|
|
7374
|
-
menu.innerHTML =
|
|
7460
|
+
menu.innerHTML = copyMenuHtml(!!this._cellGrid);
|
|
7375
7461
|
this._contextMenuCustom = false;
|
|
7376
7462
|
}
|
|
7377
7463
|
return;
|
|
@@ -7416,7 +7502,7 @@ export class DataTable {
|
|
|
7416
7502
|
sep.className = 'twm-context-menu__separator';
|
|
7417
7503
|
menu.appendChild(sep);
|
|
7418
7504
|
}
|
|
7419
|
-
menu.insertAdjacentHTML('beforeend',
|
|
7505
|
+
menu.insertAdjacentHTML('beforeend', copyMenuHtml(!!this._cellGrid));
|
|
7420
7506
|
}
|
|
7421
7507
|
}
|
|
7422
7508
|
|
|
@@ -50,6 +50,20 @@
|
|
|
50
50
|
* *Reset all* ask `confirm(…)` first (default `openConfirm`) and change
|
|
51
51
|
* nothing unless it resolves true.
|
|
52
52
|
*
|
|
53
|
+
* THE SIDEBAR ELSEWHERE (0.17.2). `sidebar: false` leaves the sidebar out:
|
|
54
|
+
* the view is the content alone. A `SettingsCategories` bound to it, mounted
|
|
55
|
+
* anywhere, draws the filter box, the categories and *Reset all* instead:
|
|
56
|
+
*
|
|
57
|
+
* const view = new SettingsView({ store, eventBus, sidebar: false });
|
|
58
|
+
* view.mount(pageHost);
|
|
59
|
+
* const cats = new SettingsCategories({ view });
|
|
60
|
+
* cats.mount(navigatorHost);
|
|
61
|
+
*
|
|
62
|
+
* Both draw from the view's state, so the filter text and the selected
|
|
63
|
+
* category are the same in both, whichever moved them; `view.subscribe(fn)`
|
|
64
|
+
* tells any other listener. *Reset all* there is `view.resetAll()`, so it asks
|
|
65
|
+
* to confirm as before.
|
|
66
|
+
*
|
|
53
67
|
* Every word on screen that comes from the schema, the store or the user is
|
|
54
68
|
* set as text, so a label or a query holding markup is shown as typed.
|
|
55
69
|
*
|
|
@@ -58,7 +72,9 @@
|
|
|
58
72
|
* + `.twm-settings__content` (`__header` > `__title`, `__description`,
|
|
59
73
|
* `__reset-category`; `section.__group` > `__group-title` + `.__row`
|
|
60
74
|
* [`--modified`] > `__info` (`__label`, `__marker`, `__description`,
|
|
61
|
-
* `__path`) + `__control` + `__reset`; `__empty`).
|
|
75
|
+
* `__path`) + `__control` + `__reset`; `__empty`). A `SettingsCategories` is
|
|
76
|
+
* `.twm-settings-categories` > `.twm-settings__search`, `.twm-settings__nav`
|
|
77
|
+
* and `.twm-settings__reset-all`, the same parts as the sidebar's.
|
|
62
78
|
*/
|
|
63
79
|
|
|
64
80
|
import { ComponentBase } from '../base/component_base.js';
|
|
@@ -121,6 +137,59 @@ function fitNumber(n, { min, max, step }) {
|
|
|
121
137
|
return at(k);
|
|
122
138
|
}
|
|
123
139
|
|
|
140
|
+
// ── the sidebar's parts, drawn alike by SettingsView and SettingsCategories ──
|
|
141
|
+
|
|
142
|
+
/** The filter box: `{ label, input }`; `onInput(text)` on every keystroke. */
|
|
143
|
+
function filterBox(onInput) {
|
|
144
|
+
const label = el('label', 'twm-settings__search');
|
|
145
|
+
const input = el('input', 'twm-settings__search-input');
|
|
146
|
+
input.type = 'search';
|
|
147
|
+
input.placeholder = 'Filter settings…';
|
|
148
|
+
input.autocomplete = 'off';
|
|
149
|
+
input.spellcheck = false;
|
|
150
|
+
input.setAttribute('aria-label', 'Filter settings');
|
|
151
|
+
input.addEventListener('input', () => onInput(input.value));
|
|
152
|
+
label.append(icon('search'), input);
|
|
153
|
+
return { label, input };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The category list; `onSelect(id)` when a category is clicked. */
|
|
157
|
+
function categoryNav(onSelect) {
|
|
158
|
+
const nav = el('nav', 'twm-settings__nav');
|
|
159
|
+
nav.setAttribute('aria-label', 'Settings categories');
|
|
160
|
+
nav.addEventListener('click', (e) => {
|
|
161
|
+
const btn = e.target.closest?.('[data-category]');
|
|
162
|
+
if (btn && nav.contains(btn)) onSelect(btn.dataset.category);
|
|
163
|
+
});
|
|
164
|
+
return nav;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Draw `categories` into `nav`, `active` (an id, or null) marked. */
|
|
168
|
+
function paintCategories(nav, categories, active) {
|
|
169
|
+
const buttons = categories.map((cat) => {
|
|
170
|
+
const b = el('button', 'twm-settings__cat');
|
|
171
|
+
b.type = 'button';
|
|
172
|
+
b.dataset.category = cat.id;
|
|
173
|
+
if (cat.id === active) {
|
|
174
|
+
b.classList.add('twm-settings__cat--active');
|
|
175
|
+
b.setAttribute('aria-current', 'page');
|
|
176
|
+
}
|
|
177
|
+
b.append(icon(cat.icon || 'tune'), el('span', 'twm-settings__cat-label', cat.label ?? cat.id));
|
|
178
|
+
if (cat.description) b.title = String(cat.description);
|
|
179
|
+
return b;
|
|
180
|
+
});
|
|
181
|
+
nav.replaceChildren(...buttons);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The *Reset all* button; `onClick()` when pressed. */
|
|
185
|
+
function resetAllButton(onClick) {
|
|
186
|
+
const b = el('button', 'twm-btn twm-settings__reset-all');
|
|
187
|
+
b.type = 'button';
|
|
188
|
+
b.append(icon('restart_alt'), el('span', '', 'Reset all'));
|
|
189
|
+
b.addEventListener('click', () => { onClick(); });
|
|
190
|
+
return b;
|
|
191
|
+
}
|
|
192
|
+
|
|
124
193
|
export class SettingsView extends ComponentBase {
|
|
125
194
|
/**
|
|
126
195
|
* @param {object} o
|
|
@@ -130,10 +199,12 @@ export class SettingsView extends ComponentBase {
|
|
|
130
199
|
* @param {string} [o.category] the category shown first (default: the first)
|
|
131
200
|
* @param {number} [o.textDebounceMs]
|
|
132
201
|
* @param {string} [o.title='Settings'] the sidebar heading
|
|
202
|
+
* @param {boolean} [o.sidebar=true] false leaves the sidebar (filter,
|
|
203
|
+
* categories, *Reset all*) out, for a `SettingsCategories` elsewhere
|
|
133
204
|
*/
|
|
134
205
|
constructor({
|
|
135
206
|
store, eventBus, confirm = openConfirm, category = null,
|
|
136
|
-
textDebounceMs = SETTINGS_TEXT_DEBOUNCE_MS, title = 'Settings', logger,
|
|
207
|
+
textDebounceMs = SETTINGS_TEXT_DEBOUNCE_MS, title = 'Settings', sidebar = true, logger,
|
|
137
208
|
} = {}) {
|
|
138
209
|
super({ eventBus, logger });
|
|
139
210
|
if (!store || typeof store.getSchema !== 'function') {
|
|
@@ -148,6 +219,9 @@ export class SettingsView extends ComponentBase {
|
|
|
148
219
|
this._query = '';
|
|
149
220
|
this._debounceMs = textDebounceMs;
|
|
150
221
|
this._title = title;
|
|
222
|
+
this._sidebar = sidebar !== false;
|
|
223
|
+
/** `fn({ category, query })` after the category, the filter or the categories change. */
|
|
224
|
+
this._listeners = new Set();
|
|
151
225
|
|
|
152
226
|
this.root = null;
|
|
153
227
|
this._nav = null;
|
|
@@ -166,39 +240,20 @@ export class SettingsView extends ComponentBase {
|
|
|
166
240
|
mount(container) {
|
|
167
241
|
if (this.root) return this.root;
|
|
168
242
|
const root = el('div', 'twm-settings');
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
input.setAttribute('aria-label', 'Filter settings');
|
|
180
|
-
input.addEventListener('input', () => this.setQuery(input.value));
|
|
181
|
-
search.append(icon('search'), input);
|
|
182
|
-
this._search = input;
|
|
183
|
-
|
|
184
|
-
const nav = el('nav', 'twm-settings__nav');
|
|
185
|
-
nav.setAttribute('aria-label', 'Settings categories');
|
|
186
|
-
nav.addEventListener('click', (e) => {
|
|
187
|
-
const btn = e.target.closest?.('[data-category]');
|
|
188
|
-
if (btn && nav.contains(btn)) this.selectCategory(btn.dataset.category);
|
|
189
|
-
});
|
|
190
|
-
this._nav = nav;
|
|
191
|
-
|
|
192
|
-
const resetAll = el('button', 'twm-btn twm-settings__reset-all');
|
|
193
|
-
resetAll.type = 'button';
|
|
194
|
-
resetAll.append(icon('restart_alt'), el('span', '', 'Reset all'));
|
|
195
|
-
resetAll.addEventListener('click', () => { this.resetAll(); });
|
|
196
|
-
|
|
197
|
-
side.append(head, search, nav, resetAll);
|
|
243
|
+
if (this._sidebar) {
|
|
244
|
+
const side = el('aside', 'twm-settings__sidebar');
|
|
245
|
+
const head = el('div', 'twm-settings__sidebar-title');
|
|
246
|
+
head.append(icon('settings'), el('span', '', this._title));
|
|
247
|
+
const search = filterBox((text) => this.setQuery(text));
|
|
248
|
+
this._search = search.input;
|
|
249
|
+
this._nav = categoryNav((id) => this.selectCategory(id));
|
|
250
|
+
side.append(head, search.label, this._nav, resetAllButton(() => this.resetAll()));
|
|
251
|
+
root.append(side);
|
|
252
|
+
}
|
|
198
253
|
|
|
199
254
|
const content = el('div', 'twm-settings__content');
|
|
200
255
|
this._content = content;
|
|
201
|
-
root.append(
|
|
256
|
+
root.append(content);
|
|
202
257
|
this.root = root;
|
|
203
258
|
container.appendChild(root);
|
|
204
259
|
this._mounted = true;
|
|
@@ -211,6 +266,7 @@ export class SettingsView extends ComponentBase {
|
|
|
211
266
|
|
|
212
267
|
this.#paintNav();
|
|
213
268
|
this.#paintContent();
|
|
269
|
+
this.#notify();
|
|
214
270
|
return root;
|
|
215
271
|
}
|
|
216
272
|
|
|
@@ -220,6 +276,19 @@ export class SettingsView extends ComponentBase {
|
|
|
220
276
|
/** The filter text. */
|
|
221
277
|
getQuery() { return this._query; }
|
|
222
278
|
|
|
279
|
+
/** True while the filter is in effect (its trimmed text is not empty). */
|
|
280
|
+
isFiltering() { return this.#searching(); }
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* `fn({ category, query })` after every change of the category, the filter
|
|
284
|
+
* or the registered categories, until the view is disposed. Answers the
|
|
285
|
+
* unsubscribe.
|
|
286
|
+
*/
|
|
287
|
+
subscribe(fn) {
|
|
288
|
+
this._listeners.add(fn);
|
|
289
|
+
return () => this._listeners.delete(fn);
|
|
290
|
+
}
|
|
291
|
+
|
|
223
292
|
/** Show one category; clears the filter. */
|
|
224
293
|
selectCategory(id) {
|
|
225
294
|
if (!this.root) return;
|
|
@@ -229,6 +298,7 @@ export class SettingsView extends ComponentBase {
|
|
|
229
298
|
if (this._search) this._search.value = '';
|
|
230
299
|
this.#paintNav();
|
|
231
300
|
this.#paintContent();
|
|
301
|
+
this.#notify();
|
|
232
302
|
}
|
|
233
303
|
|
|
234
304
|
/** Search every category; an empty query goes back to the category. */
|
|
@@ -239,6 +309,7 @@ export class SettingsView extends ComponentBase {
|
|
|
239
309
|
if (this._search && this._search.value !== q) this._search.value = q;
|
|
240
310
|
this.#paintNav();
|
|
241
311
|
this.#paintContent();
|
|
312
|
+
this.#notify();
|
|
242
313
|
}
|
|
243
314
|
|
|
244
315
|
/** Reset one category after `confirm`; resolves true when it was reset. */
|
|
@@ -287,6 +358,7 @@ export class SettingsView extends ComponentBase {
|
|
|
287
358
|
this._nav = null;
|
|
288
359
|
this._content = null;
|
|
289
360
|
this._search = null;
|
|
361
|
+
this._listeners.clear();
|
|
290
362
|
super.dispose();
|
|
291
363
|
}
|
|
292
364
|
|
|
@@ -310,12 +382,13 @@ export class SettingsView extends ComponentBase {
|
|
|
310
382
|
|| paths.some((p) => schema[p]?.category === active);
|
|
311
383
|
if (touches) {
|
|
312
384
|
this.#paintContent();
|
|
313
|
-
|
|
385
|
+
} else {
|
|
386
|
+
// The slice's defaults may have moved values on screen; an edit still
|
|
387
|
+
// being typed is written first, so the sync shows it rather than drop it.
|
|
388
|
+
this.#flushAll();
|
|
389
|
+
this.#syncAll();
|
|
314
390
|
}
|
|
315
|
-
|
|
316
|
-
// being typed is written first, so the sync shows it rather than drop it.
|
|
317
|
-
this.#flushAll();
|
|
318
|
-
this.#syncAll();
|
|
391
|
+
this.#notify();
|
|
319
392
|
}
|
|
320
393
|
|
|
321
394
|
/** True while a filter is in effect: the trimmed query is not empty. */
|
|
@@ -344,20 +417,14 @@ export class SettingsView extends ComponentBase {
|
|
|
344
417
|
}
|
|
345
418
|
|
|
346
419
|
#paintNav() {
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
}
|
|
356
|
-
b.append(icon(cat.icon || 'tune'), el('span', 'twm-settings__cat-label', cat.label ?? cat.id));
|
|
357
|
-
if (cat.description) b.title = String(cat.description);
|
|
358
|
-
return b;
|
|
359
|
-
});
|
|
360
|
-
this._nav.replaceChildren(...buttons);
|
|
420
|
+
if (!this._nav) return;
|
|
421
|
+
paintCategories(this._nav, this.store.getCategories(), this.#searching() ? null : this.getCategory());
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** Tell the listeners, after the content is drawn (it settles the category). */
|
|
425
|
+
#notify() {
|
|
426
|
+
const state = { category: this.getCategory(), query: this._query };
|
|
427
|
+
for (const fn of [...this._listeners]) fn(state);
|
|
361
428
|
}
|
|
362
429
|
|
|
363
430
|
#paintContent() {
|
|
@@ -614,6 +681,80 @@ export class SettingsView extends ComponentBase {
|
|
|
614
681
|
}
|
|
615
682
|
}
|
|
616
683
|
|
|
684
|
+
/**
|
|
685
|
+
* SettingsCategories — a SettingsView's filter box, category list and
|
|
686
|
+
* *Reset all*, mounted wherever the app wants them (0.17.2).
|
|
687
|
+
*
|
|
688
|
+
* const view = new SettingsView({ store, eventBus, sidebar: false });
|
|
689
|
+
* view.mount(pageHost);
|
|
690
|
+
* const cats = new SettingsCategories({ view });
|
|
691
|
+
* cats.mount(navigatorHost);
|
|
692
|
+
* cats.dispose(); // before or with the view
|
|
693
|
+
*
|
|
694
|
+
* It holds no state of its own: typing in its filter is `view.setQuery()`,
|
|
695
|
+
* clicking a category `view.selectCategory()`, *Reset all* `view.resetAll()`
|
|
696
|
+
* (which asks the view's `confirm` first), and it redraws from the view on
|
|
697
|
+
* every `view.subscribe()` call. So the filter text and the selected category
|
|
698
|
+
* are always the view's, however either side moved them, and a category
|
|
699
|
+
* registered later joins the list. Its clicks and keystrokes act once the
|
|
700
|
+
* view is mounted.
|
|
701
|
+
*
|
|
702
|
+
* Markup: `.twm-settings-categories` > `.twm-settings__search` (>
|
|
703
|
+
* `__search-input`), `.twm-settings__nav` > `button.twm-settings__cat`
|
|
704
|
+
* [`--active`], `.twm-settings__reset-all`.
|
|
705
|
+
*/
|
|
706
|
+
export class SettingsCategories extends ComponentBase {
|
|
707
|
+
/**
|
|
708
|
+
* @param {object} o
|
|
709
|
+
* @param {SettingsView} o.view the view it lists the categories of
|
|
710
|
+
*/
|
|
711
|
+
constructor({ view, logger } = {}) {
|
|
712
|
+
if (!(view instanceof SettingsView)) {
|
|
713
|
+
throw new TypeError('SettingsCategories: `view` must be a SettingsView');
|
|
714
|
+
}
|
|
715
|
+
super({ eventBus: view.eventBus, logger });
|
|
716
|
+
this.view = view;
|
|
717
|
+
this.root = null;
|
|
718
|
+
this._search = null;
|
|
719
|
+
this._nav = null;
|
|
720
|
+
this._unsubscribe = null;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/** Build the list into `container` (once). */
|
|
724
|
+
mount(container) {
|
|
725
|
+
if (this.root) return this.root;
|
|
726
|
+
const view = this.view;
|
|
727
|
+
const root = el('div', 'twm-settings-categories');
|
|
728
|
+
const search = filterBox((text) => view.setQuery(text));
|
|
729
|
+
this._search = search.input;
|
|
730
|
+
this._nav = categoryNav((id) => view.selectCategory(id));
|
|
731
|
+
root.append(search.label, this._nav, resetAllButton(() => view.resetAll()));
|
|
732
|
+
this.root = root;
|
|
733
|
+
container.appendChild(root);
|
|
734
|
+
this._mounted = true;
|
|
735
|
+
this._unsubscribe = view.subscribe(() => this.#paint());
|
|
736
|
+
this.#paint();
|
|
737
|
+
return root;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
dispose() {
|
|
741
|
+
this._unsubscribe?.();
|
|
742
|
+
this._unsubscribe = null;
|
|
743
|
+
this.root?.remove();
|
|
744
|
+
this.root = null;
|
|
745
|
+
this._search = null;
|
|
746
|
+
this._nav = null;
|
|
747
|
+
super.dispose();
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
#paint() {
|
|
751
|
+
const view = this.view;
|
|
752
|
+
const q = view.getQuery();
|
|
753
|
+
if (this._search.value !== q) this._search.value = q;
|
|
754
|
+
paintCategories(this._nav, view.store.getCategories(), view.isFiltering() ? null : view.getCategory());
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
617
758
|
/**
|
|
618
759
|
* A content factory for the shell: `(hostEl, props, ctx) -> { title, destroy }`.
|
|
619
760
|
* `props.category` picks the category shown first.
|
|
@@ -103,7 +103,12 @@ export class StagedEdits {
|
|
|
103
103
|
* Returns whether anything changed.
|
|
104
104
|
*/
|
|
105
105
|
stage(rowIdx, colIdx, value) {
|
|
106
|
-
if (this.committing
|
|
106
|
+
if (this.committing) return false;
|
|
107
|
+
const refusal = this._dropRefusal(rowIdx, colIdx);
|
|
108
|
+
if (!this._stageOne(rowIdx, colIdx, value)) {
|
|
109
|
+
if (refusal) this.grid._redraw(rowIdx, colIdx);
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
107
112
|
this.grid._redraw(rowIdx, colIdx);
|
|
108
113
|
this._changed();
|
|
109
114
|
return true;
|
|
@@ -121,7 +126,9 @@ export class StagedEdits {
|
|
|
121
126
|
this.history.beginGroup(label);
|
|
122
127
|
try {
|
|
123
128
|
for (const { rowIdx, colIdx, value } of cells) {
|
|
129
|
+
const refusal = this._dropRefusal(rowIdx, colIdx);
|
|
124
130
|
if (this._stageOne(rowIdx, colIdx, value)) { changed++; rows.add(rowIdx); }
|
|
131
|
+
else if (refusal) rows.add(rowIdx);
|
|
125
132
|
}
|
|
126
133
|
} finally {
|
|
127
134
|
this.history.endGroup();
|
|
@@ -131,6 +138,17 @@ export class StagedEdits {
|
|
|
131
138
|
return changed;
|
|
132
139
|
}
|
|
133
140
|
|
|
141
|
+
/** STORY-0087. A text that did not parse stays in its cell, marked, in
|
|
142
|
+
* the grid's own marks; anything staged into the cell ends that refusal,
|
|
143
|
+
* the value its row holds included. Whether there was one. */
|
|
144
|
+
_dropRefusal(rowIdx, colIdx) {
|
|
145
|
+
const k = key(rowIdx, colIdx);
|
|
146
|
+
if (!this.grid.errors.has(k)) return false;
|
|
147
|
+
this.grid.errors.delete(k);
|
|
148
|
+
this.grid.latest.delete(k);
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
|
|
134
152
|
/** One cell's new state and its delta in the ring; whether it changed. */
|
|
135
153
|
_stageOne(rowIdx, colIdx, value) {
|
|
136
154
|
const k = key(rowIdx, colIdx);
|