flexdesk 0.6.0 → 0.6.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flexdesk",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "A tiling window manager and widget set for desktop-class web applications. Zero runtime dependencies.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -37,6 +37,9 @@ export {
37
37
 
38
38
  const DEFAULT_PAGE_SIZE = 100;
39
39
 
40
+ /** 0.6.1 — the row statuses `diff` draws. */
41
+ const DIFF_STATUSES = new Set(['added', 'removed', 'changed', 'same', 'gap']);
42
+
40
43
  /** 0.5.0 — the column types that sort and filter by a value rather than by
41
44
  * the text drawn for it. */
42
45
  const TYPED_COLUMNS = new Set(['date', 'datetime', 'duration']);
@@ -218,6 +221,8 @@ const AUTOSIZE_MAX_PX = 520;
218
221
  * @property {Function} [rowClass] - `(row, rowIdx) => string|string[]|null`. Classes for the row's `<tr>`.
219
222
  * @property {Function} [rowAttrs] - `(row, rowIdx) => {name: value}`. Attributes for the row's `<tr>` (`null`/`false` leaves one off, `true` sets it empty).
220
223
  * @property {Function} [cellClass] - `(value, colIdx, row, rowIdx) => string|string[]|null`. Classes for a cell, after its type class.
224
+ * @property {Function} [diff] - 0.6.1. `(row, rowIdx) => {status, cells?}|null`. One side of a COMPARISON: `status` is 'added', 'removed', 'changed', 'same' or 'gap' and draws the row `twm-dt-diff twm-dt-diff--<status>`; `cells` (an array or Set of column indices) marks the cells that differ `twm-dt-diff__cell--changed`. A 'gap' row is the place a row holds on the OTHER side: its cells are drawn empty, whatever its values and `renderCell`, so two tables drawn from one aligned row list keep their rows level. `null` (or an unknown status) draws the row as without `diff`.
225
+ * @property {object} [scrollLock] - 0.6.1. A group from `createScrollLock()`: the table's body box joins it on every render and leaves it on the next render and on dispose, so tables in the same group scroll as one.
221
226
  * @property {Function} [rowIcon] - `(row, rowIdx) => string|{icon, title?, tone?}|null`. A Material Symbols icon at the start of the row's first cell; `tone` adds `twm-dt-row-icon--<tone>`. Drawn by CSS, so it is in neither the cell's text, its tooltip nor a copy.
222
227
  * @property {string|Node|Function} [emptyState] - What the box says when there are no rows: text, a node, or a function returning either (called on each render). Replaces `emptyMessage`, and is hidden while `setLoading(true)` — an empty table that is still loading is not empty.
223
228
  * @property {string} [nullDisplay] - Draw `null`/`undefined` as this text (and copy it so), with `twm-dt-cell--null` on the cell, instead of '-'.
@@ -313,6 +318,8 @@ export class DataTable {
313
318
  rowClass: null,
314
319
  rowAttrs: null,
315
320
  cellClass: null,
321
+ diff: null,
322
+ scrollLock: null,
316
323
  rowIcon: null,
317
324
  emptyState: undefined,
318
325
  nullDisplay: null,
@@ -956,6 +963,11 @@ export class DataTable {
956
963
 
957
964
  this._tableWrapEl = tableWrap;
958
965
  this.container.appendChild(this._wrapperEl);
966
+ // 0.6.1 `scrollLock`: THIS render's body box joins the group, and leaves
967
+ // it with the next render's cleanup or the dispose.
968
+ if (this.config.scrollLock && typeof this.config.scrollLock.attach === 'function') {
969
+ this._disposers.push(this.config.scrollLock.attach(tableWrap));
970
+ }
959
971
  // 0.5.0. Watched from here, so a table rendered again after an automatic
960
972
  // dispose (a `setData` into a re-attached host) is watched again.
961
973
  if (this.config.autoDispose) autoDisposeWatch(this);
@@ -2628,6 +2640,12 @@ export class DataTable {
2628
2640
  }
2629
2641
 
2630
2642
  const { cellClass, nullDisplay } = this.config;
2643
+ // 0.6.1 `diff`.
2644
+ const diff = this._diffOf(row, globalIdx);
2645
+ const gap = diff?.status === 'gap';
2646
+ const changedCells = diff?.cells
2647
+ ? (diff.cells instanceof Set ? diff.cells : new Set(diff.cells))
2648
+ : null;
2631
2649
  for (let colIdx = 0; colIdx < row.length; colIdx++) {
2632
2650
  const td = document.createElement('td');
2633
2651
  td.className = this._columnTypes[colIdx] || 'text';
@@ -2635,6 +2653,13 @@ export class DataTable {
2635
2653
  // Use custom cell renderer if provided
2636
2654
  const value = row[colIdx];
2637
2655
  let handled = false;
2656
+ if (gap) {
2657
+ // The other side's row: a place, not a value.
2658
+ td.classList.add('twm-dt-diff__gap');
2659
+ tr.appendChild(td);
2660
+ continue;
2661
+ }
2662
+ if (changedCells?.has(colIdx)) td.classList.add('twm-dt-diff__cell--changed');
2638
2663
  if (this.config.renderCell) {
2639
2664
  handled = !!this.config.renderCell(td, value, colIdx, globalIdx, row);
2640
2665
  if (!handled) {
@@ -2961,6 +2986,15 @@ export class DataTable {
2961
2986
  return null;
2962
2987
  }
2963
2988
 
2989
+ /** 0.6.1. The `diff` answer for one row, or `null` when there is none or
2990
+ * its status is not one this component draws. */
2991
+ _diffOf(row, rowIdx) {
2992
+ if (typeof this.config.diff !== 'function') return null;
2993
+ const answer = this.config.diff(row, rowIdx);
2994
+ if (!answer || !DIFF_STATUSES.has(answer.status)) return null;
2995
+ return answer;
2996
+ }
2997
+
2964
2998
  /** Everything `rowClass`, `rowAttrs` and the open mark put on a `<tr>`,
2965
2999
  * taken off again first — so `updateRow` cannot leave last state's class
2966
3000
  * behind. */
@@ -2977,6 +3011,12 @@ export class DataTable {
2977
3011
  // Never take away a class this component owns.
2978
3012
  tr.__twmClasses = added.filter((c) => c !== 'data-preview-row' && c !== 'selected');
2979
3013
  }
3014
+ // 0.6.1 `diff`: the row's status, taken off again first like `rowClass`.
3015
+ for (const c of [...tr.classList]) {
3016
+ if (c === 'twm-dt-diff' || c.startsWith('twm-dt-diff--')) tr.classList.remove(c);
3017
+ }
3018
+ const diff = this._diffOf(row, rowIdx);
3019
+ if (diff) tr.classList.add('twm-dt-diff', `twm-dt-diff--${diff.status}`);
2980
3020
  if (tr.__twmAttrs) {
2981
3021
  for (const name of tr.__twmAttrs) tr.removeAttribute(name);
2982
3022
  tr.__twmAttrs = null;
@@ -0,0 +1,111 @@
1
+ /**
2
+ * scroll_lock.js — 0.6.1. Several scroll boxes that move as one.
3
+ *
4
+ * A side-by-side comparison (two DataTables in two tiles, "now" and "then") is
5
+ * only readable if row 40 on the left stays beside row 40 on the right. A lock
6
+ * is a GROUP: every box attached to it copies the scroll position of whichever
7
+ * member the person scrolled, on the axes the lock names.
8
+ *
9
+ * const lock = createScrollLock({ axis: 'y' });
10
+ * new DataTable(left, { …, scrollLock: lock });
11
+ * new DataTable(right, { …, scrollLock: lock });
12
+ * lock.setEnabled(false); // the two scroll freely; true locks them again
13
+ *
14
+ * Any element can join with `lock.attach(el)`, which returns its detach. A
15
+ * DataTable given `scrollLock` attaches its own body box on every render and
16
+ * detaches it on the next render and on dispose, so a re-render (a sort, new
17
+ * data) never strands a dead box in the group.
18
+ *
19
+ * THE ECHO. Setting `scrollTop` on a member fires that member's own `scroll`
20
+ * event a frame later, and a naive group would then copy it back to the first
21
+ * box, which by then may have moved on — the two fight and the wheel stutters.
22
+ * So a position this lock WROTE is remembered per box, and the scroll event that
23
+ * reports exactly that position is the echo and is ignored.
24
+ *
25
+ * A box that joins is moved to the group's last position, so a pane opened
26
+ * beside a scrolled one starts level with it. Positions are copied as pixels:
27
+ * the lock aligns rows only when the members draw rows of the same height,
28
+ * which is the comparison's own contract (both sides drawn from one aligned
29
+ * row list, a gap row where a side has none).
30
+ */
31
+
32
+ /**
33
+ * @param {object} [options]
34
+ * @param {'x'|'y'|'both'} [options.axis='y'] which positions are copied
35
+ * @param {boolean} [options.enabled=true]
36
+ */
37
+ export function createScrollLock({ axis = 'y', enabled = true } = {}) {
38
+ const doY = axis === 'y' || axis === 'both';
39
+ const doX = axis === 'x' || axis === 'both';
40
+ const members = new Map(); // element -> { wrote: {top, left} | null, off }
41
+ let last = { top: 0, left: 0 };
42
+ let on = !!enabled;
43
+
44
+ function copyFrom(source) {
45
+ last = { top: source.scrollTop, left: source.scrollLeft };
46
+ if (!on) return;
47
+ for (const [el, state] of members) {
48
+ if (el === source) continue;
49
+ const top = doY ? last.top : el.scrollTop;
50
+ const left = doX ? last.left : el.scrollLeft;
51
+ if (el.scrollTop === top && el.scrollLeft === left) continue;
52
+ state.wrote = { top, left };
53
+ if (doY) el.scrollTop = top;
54
+ if (doX) el.scrollLeft = left;
55
+ }
56
+ }
57
+
58
+ function onScroll(event) {
59
+ const el = event.currentTarget;
60
+ const state = members.get(el);
61
+ if (!state) return;
62
+ const wrote = state.wrote;
63
+ state.wrote = null;
64
+ // THE ECHO of a position this lock set: not a person scrolling.
65
+ if (wrote && Math.abs(el.scrollTop - wrote.top) < 1
66
+ && Math.abs(el.scrollLeft - wrote.left) < 1) return;
67
+ copyFrom(el);
68
+ }
69
+
70
+ const lock = {
71
+ /** Join the group. Returns the function that leaves it. */
72
+ attach(el) {
73
+ if (!el || typeof el.addEventListener !== 'function') return () => {};
74
+ if (members.has(el)) return () => lock.detach(el);
75
+ const state = { wrote: null };
76
+ members.set(el, state);
77
+ el.addEventListener('scroll', onScroll, { passive: true });
78
+ if (on && (last.top || last.left)) {
79
+ state.wrote = { top: doY ? last.top : el.scrollTop,
80
+ left: doX ? last.left : el.scrollLeft };
81
+ if (doY) el.scrollTop = last.top;
82
+ if (doX) el.scrollLeft = last.left;
83
+ }
84
+ return () => lock.detach(el);
85
+ },
86
+ detach(el) {
87
+ if (!members.has(el)) return;
88
+ members.delete(el);
89
+ el.removeEventListener('scroll', onScroll);
90
+ },
91
+ /** Lock (true) or let the members scroll freely (false). Locking again
92
+ * brings every member level with the one scrolled last. */
93
+ setEnabled(value) {
94
+ const was = on;
95
+ on = !!value;
96
+ if (on && !was) {
97
+ const source = [...members.keys()].find(
98
+ (el) => el.scrollTop === last.top && el.scrollLeft === last.left)
99
+ || members.keys().next().value;
100
+ if (source) copyFrom(source);
101
+ }
102
+ },
103
+ get enabled() { return on; },
104
+ /** How many boxes are in the group (for a consumer's own checks). */
105
+ get size() { return members.size; },
106
+ dispose() {
107
+ for (const el of [...members.keys()]) lock.detach(el);
108
+ },
109
+ };
110
+ return lock;
111
+ }