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.
Files changed (45) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +399 -3
  3. package/canvas.js +18 -0
  4. package/charts.js +21 -0
  5. package/core.js +14 -0
  6. package/css/base.css +23 -1
  7. package/css/flexdesk.css +61 -1
  8. package/dist/charts.js +3 -5
  9. package/dist/charts.js.map +1 -1
  10. package/dist/{chunk-Q36EPT2R.js → chunk-LWV23YVE.js} +1614 -138
  11. package/dist/chunk-LWV23YVE.js.map +7 -0
  12. package/dist/{chunk-MCIXFNVF.js → chunk-RMTRWWEO.js} +2 -2
  13. package/dist/flexdesk.css +61 -1
  14. package/dist/flow.js +2 -2
  15. package/dist/flow.js.map +2 -2
  16. package/dist/tiles.js +2 -2
  17. package/dist/widgets.js +186 -55
  18. package/dist/widgets.js.map +2 -2
  19. package/dist/wm.js +90 -10
  20. package/dist/wm.js.map +2 -2
  21. package/editor.js +20 -0
  22. package/flow.js +21 -0
  23. package/host.js +14 -0
  24. package/package.json +10 -1
  25. package/src/flow/canvas_editor/editor.js +1 -1
  26. package/src/tiling/command_palette.js +46 -7
  27. package/src/tiling/content_registry.js +3 -0
  28. package/src/tiling/entity_sources.js +10 -0
  29. package/src/tiling/wm.js +65 -3
  30. package/src/ui/components/cell_editor_host.js +100 -18
  31. package/src/ui/components/cell_editors.js +480 -0
  32. package/src/ui/components/cell_grid.js +121 -8
  33. package/src/ui/components/cell_paste.js +243 -0
  34. package/src/ui/components/data_table.js +96 -10
  35. package/src/ui/components/settings_view.js +192 -51
  36. package/src/ui/components/staged_edits.js +19 -1
  37. package/src/ui/components/tsv.js +74 -0
  38. package/src/ui/notification_center.js +5 -5
  39. package/tiles.js +27 -0
  40. package/widgets.js +69 -0
  41. package/wm.js +28 -0
  42. package/dist/chunk-Q36EPT2R.js.map +0 -7
  43. package/dist/chunk-QWQJUDPD.js +0 -610
  44. package/dist/chunk-QWQJUDPD.js.map +0 -7
  45. /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`' or the detected 'num'/'text'). A factory is `({value, row, rowIdx, colIdx, column: {index, header, type}, initialText, commit(value), cancel()}) => Element | {el, getValue?, focus?, destroy?}`; an input, select or textarea returned as it is commits its `value`. Absent: a text box (cell_editor_host.js `createTextEditor`), which commits the value unchanged when left as it was.
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's two items — the whole right-click menu of a table without
454
- * `contextMenuItems`, and its tail with them (STORY-0010). */
455
- const COPY_MENU_HTML = `
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 = COPY_MENU_HTML;
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 = COPY_MENU_HTML;
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', COPY_MENU_HTML);
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
- const side = el('aside', 'twm-settings__sidebar');
170
- const head = el('div', 'twm-settings__sidebar-title');
171
- head.append(icon('settings'), el('span', '', this._title));
172
-
173
- const search = el('label', 'twm-settings__search');
174
- const input = el('input', 'twm-settings__search-input');
175
- input.type = 'search';
176
- input.placeholder = 'Filter settings…';
177
- input.autocomplete = 'off';
178
- input.spellcheck = false;
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(side, content);
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
- return;
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
- // The slice's defaults may have moved values on screen; an edit still
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
- const active = this.#searching() ? null : this.getCategory();
348
- const buttons = this.store.getCategories().map((cat) => {
349
- const b = el('button', 'twm-settings__cat');
350
- b.type = 'button';
351
- b.dataset.category = cat.id;
352
- if (cat.id === active) {
353
- b.classList.add('twm-settings__cat--active');
354
- b.setAttribute('aria-current', 'page');
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 || !this._stageOne(rowIdx, colIdx, value)) return false;
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);