wunderbaum 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/wunderbaum.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * A treegrid control.
5
5
  *
6
- * Copyright (c) 2021-2024, Martin Wendt (https://wwWendt.de).
6
+ * Copyright (c) 2021-2025, Martin Wendt (https://wwWendt.de).
7
7
  * https://github.com/mar10/wunderbaum
8
8
  *
9
9
  * Released under the MIT license.
@@ -33,7 +33,10 @@ import {
33
33
  ExpandAllOptions,
34
34
  FilterModeType,
35
35
  FilterNodesOptions,
36
+ IconMapType,
37
+ GetStateOptions,
36
38
  MatcherCallback,
39
+ NavigationType,
37
40
  NavModeEnum,
38
41
  NodeFilterCallback,
39
42
  NodeRegion,
@@ -46,10 +49,12 @@ import {
46
49
  ScrollToOptions,
47
50
  SetActiveOptions,
48
51
  SetColumnOptions,
52
+ SetStateOptions,
49
53
  SetStatusOptions,
50
54
  SortByPropertyOptions,
51
55
  SortCallback,
52
56
  SourceType,
57
+ TreeStateDefinition,
53
58
  UpdateOptions,
54
59
  VisitRowsOptions,
55
60
  WbEventInfo,
@@ -62,6 +67,7 @@ import {
62
67
  nodeTitleSorter,
63
68
  RENDER_MAX_PREFETCH,
64
69
  DEFAULT_ROW_HEIGHT,
70
+ TEST_IMG,
65
71
  } from "./common";
66
72
  import { WunderbaumNode } from "./wb_node";
67
73
  import { Deferred } from "./deferred";
@@ -125,14 +131,14 @@ export class Wunderbaum {
125
131
  protected _focusNode: WunderbaumNode | null = null;
126
132
 
127
133
  /** Currently active node if any.
128
- * Use @link {WunderbaumNode.setActive|setActive} to modify.
134
+ * Use {@link WunderbaumNode.setActive|setActive} to modify.
129
135
  */
130
136
  public get activeNode() {
131
137
  // Check for deleted node, i.e. node.tree === null
132
138
  return this._activeNode?.tree ? this._activeNode : null;
133
139
  }
134
140
  /** Current node hat has keyboard focus if any.
135
- * Use @link {WunderbaumNode.setFocus|setFocus()} to modify.
141
+ * Use {@link WunderbaumNode.setFocus|setFocus()} to modify.
136
142
  */
137
143
  public get focusNode() {
138
144
  // Check for deleted node, i.e. node.tree === null
@@ -171,6 +177,10 @@ export class Wunderbaum {
171
177
  // /** @internal */
172
178
  // public selectRangeAnchor: WunderbaumNode | null = null;
173
179
 
180
+ // --- BREADCRUMB ---
181
+ /** Filter options (used as defaults for calls to {@link Wunderbaum.filterNodes} ) */
182
+ public breadcrumb: HTMLElement | null = null;
183
+
174
184
  // --- FILTER ---
175
185
  /** Filter options (used as defaults for calls to {@link Wunderbaum.filterNodes} ) */
176
186
  public filterMode: FilterModeType = null;
@@ -210,10 +220,10 @@ export class Wunderbaum {
210
220
  emptyChildListExpandable: false,
211
221
  // updateThrottleWait: 200,
212
222
  skeleton: false,
213
- connectTopBreadcrumb: null, // HTMLElement that receives the top nodes breadcrumb
223
+ connectTopBreadcrumb: null,
214
224
  selectMode: "multi", // SelectModeType
215
225
  // --- KeyNav ---
216
- navigationModeOption: null, // NavModeEnum.startRow,
226
+ navigationModeOption: null, // NavModeEnum,
217
227
  quicksearch: true,
218
228
  // --- Events ---
219
229
  iconBadge: null,
@@ -225,8 +235,11 @@ export class Wunderbaum {
225
235
  strings: {
226
236
  loadError: "Error",
227
237
  loading: "Loading...",
228
- // loading: "Loading…",
229
238
  noData: "No data",
239
+ breadcrumbDelimiter: " » ",
240
+ queryResult: "Found ${matches} of ${count}",
241
+ noMatch: "No results",
242
+ matchIndex: "${match} of ${matches}",
230
243
  },
231
244
  },
232
245
  options
@@ -319,7 +332,7 @@ export class Wunderbaum {
319
332
  // User existing header markup to define `this.columns`
320
333
  util.assert(
321
334
  !this.columns,
322
- "`opts.columns` must not be set if markup already contains a header"
335
+ "`opts.columns` must not be set if table markup already contains a header"
323
336
  );
324
337
  this.columns = [];
325
338
  const rowElement =
@@ -368,6 +381,24 @@ export class Wunderbaum {
368
381
 
369
382
  this.element.classList.toggle("wb-grid", this.columns.length > 1);
370
383
 
384
+ if (this.options.connectTopBreadcrumb) {
385
+ this.breadcrumb = util.elemFromSelector(
386
+ this.options.connectTopBreadcrumb
387
+ )!;
388
+ util.assert(
389
+ !this.breadcrumb || this.breadcrumb.innerHTML != null,
390
+ `Invalid 'connectTopBreadcrumb' option: ${this.breadcrumb}.`
391
+ );
392
+ this.breadcrumb.addEventListener("click", (e) => {
393
+ // const node = Wunderbaum.getNode(e)!;
394
+ const elem = e.target as HTMLElement;
395
+ if (elem && elem.matches("a.wb-breadcrumb")) {
396
+ const node = this.keyMap.get(elem.dataset.key!);
397
+ node?.setActive();
398
+ e.preventDefault();
399
+ }
400
+ });
401
+ }
371
402
  this._initExtensions();
372
403
 
373
404
  // --- apply initial options
@@ -380,8 +411,7 @@ export class Wunderbaum {
380
411
  // --- Load initial data
381
412
  if (opts.source) {
382
413
  if (opts.showSpinner) {
383
- this.nodeListElement.innerHTML =
384
- "<progress class='spinner'>loading...</progress>";
414
+ this.nodeListElement.innerHTML = `<progress class='spinner'>${opts.strings.loading}</progress>`;
385
415
  }
386
416
  this.load(opts.source)
387
417
  .then(() => {
@@ -598,7 +628,7 @@ export class Wunderbaum {
598
628
  /**
599
629
  * Return the icon-function -> icon-definition mapping.
600
630
  */
601
- get iconMap(): { [key: string]: string } {
631
+ get iconMap(): IconMapType {
602
632
  const map = this.options.iconMap!;
603
633
  if (typeof map === "string") {
604
634
  return iconMaps[map];
@@ -772,7 +802,10 @@ export class Wunderbaum {
772
802
  return <WunderbaumNode>node!;
773
803
  }
774
804
 
775
- /** Return the topmost visible node in the viewport. */
805
+ /** Return the topmost visible node in the viewport.
806
+ * @param complete If `false`, the node is considered visible if at least one
807
+ * pixel is visible.
808
+ */
776
809
  getTopmostVpNode(complete = true) {
777
810
  const rowHeight = this.options.rowHeightPx!;
778
811
  const gracePx = 1; // ignore subpixel scrolling
@@ -807,30 +840,29 @@ export class Wunderbaum {
807
840
  return this._getNodeByRowIdx(bottomIdx)!;
808
841
  }
809
842
 
810
- /** Return preceeding visible node in the viewport. */
811
- protected _getPrevNodeInView(node?: WunderbaumNode, ofs = 1) {
843
+ /** Return following visible node in the viewport. */
844
+ protected _getNextNodeInView(
845
+ node?: WunderbaumNode,
846
+ options?: {
847
+ ofs?: number;
848
+ reverse?: boolean;
849
+ cb?: (n: WunderbaumNode) => boolean;
850
+ }
851
+ ) {
852
+ let ofs = options?.ofs || 1;
853
+ const reverse = !!options?.reverse;
854
+
812
855
  this.visitRows(
813
856
  (n) => {
814
857
  node = n;
815
- if (ofs-- <= 0) {
858
+ if (options?.cb && options.cb(n)) {
816
859
  return false;
817
860
  }
818
- },
819
- { reverse: true, start: node || this.getActiveNode() }
820
- );
821
- return node;
822
- }
823
-
824
- /** Return following visible node in the viewport. */
825
- protected _getNextNodeInView(node?: WunderbaumNode, ofs = 1) {
826
- this.visitRows(
827
- (n) => {
828
- node = n;
829
861
  if (ofs-- <= 0) {
830
862
  return false;
831
863
  }
832
864
  },
833
- { reverse: false, start: node || this.getActiveNode() }
865
+ { reverse: reverse, start: node || this.getActiveNode() }
834
866
  );
835
867
  return node;
836
868
  }
@@ -973,9 +1005,11 @@ export class Wunderbaum {
973
1005
  case "first":
974
1006
  case "last":
975
1007
  case "left":
1008
+ case "nextMatch":
976
1009
  case "pageDown":
977
1010
  case "pageUp":
978
1011
  case "parent":
1012
+ case "prevMatch":
979
1013
  case "right":
980
1014
  case "up":
981
1015
  return node.navigate(cmd);
@@ -1191,6 +1225,12 @@ export class Wunderbaum {
1191
1225
  return visible ? this.treeRowCount : this.keyMap.size;
1192
1226
  }
1193
1227
 
1228
+ /** Return the number of *unique* nodes in the data model, i.e. unique `node.refKey`.
1229
+ */
1230
+ countUnique(): number {
1231
+ return this.refKeyMap.size;
1232
+ }
1233
+
1194
1234
  /** @internal sanity check. */
1195
1235
  _check() {
1196
1236
  let i = 0;
@@ -1256,15 +1296,18 @@ export class Wunderbaum {
1256
1296
  */
1257
1297
  findNextNode(
1258
1298
  match: string | MatcherCallback,
1259
- startNode?: WunderbaumNode | null
1299
+ startNode?: WunderbaumNode | null,
1300
+ reverse = false
1260
1301
  ): WunderbaumNode | null {
1261
1302
  //, visibleOnly) {
1262
1303
  let res: WunderbaumNode | null = null;
1263
1304
  const firstNode = this.getFirstChild()!;
1305
+ // Last visible node (calculation is expensive, so do only if we need it):
1306
+ const lastNode = reverse ? this.findRelatedNode(firstNode, "last")! : null;
1264
1307
 
1265
1308
  const matcher =
1266
1309
  typeof match === "string" ? makeNodeTitleStartMatcher(match) : match;
1267
- startNode = startNode || firstNode;
1310
+ startNode = startNode || (reverse ? lastNode : firstNode);
1268
1311
 
1269
1312
  function _checkNode(n: WunderbaumNode) {
1270
1313
  // console.log("_check " + n)
@@ -1278,12 +1321,14 @@ export class Wunderbaum {
1278
1321
  this.visitRows(_checkNode, {
1279
1322
  start: startNode,
1280
1323
  includeSelf: false,
1324
+ reverse: reverse,
1281
1325
  });
1282
1326
  // Wrap around search
1283
1327
  if (!res && startNode !== firstNode) {
1284
1328
  this.visitRows(_checkNode, {
1285
- start: firstNode,
1329
+ start: reverse ? lastNode : firstNode,
1286
1330
  includeSelf: true,
1331
+ reverse: reverse,
1287
1332
  });
1288
1333
  }
1289
1334
  return res;
@@ -1298,7 +1343,11 @@ export class Wunderbaum {
1298
1343
  * e.g. `$.ui.keyCode.LEFT` = 'left'.
1299
1344
  * @param includeHidden Not yet implemented
1300
1345
  */
1301
- findRelatedNode(node: WunderbaumNode, where: string, includeHidden = false) {
1346
+ findRelatedNode(
1347
+ node: WunderbaumNode,
1348
+ where: NavigationType,
1349
+ includeHidden = false
1350
+ ) {
1302
1351
  const rowHeight = this.options.rowHeightPx!;
1303
1352
  let res = null;
1304
1353
  const pageSize = Math.floor(
@@ -1354,7 +1403,7 @@ export class Wunderbaum {
1354
1403
  // }
1355
1404
  break;
1356
1405
  case "up":
1357
- res = this._getPrevNodeInView(node);
1406
+ res = this._getNextNodeInView(node, { reverse: true });
1358
1407
  break;
1359
1408
  case "down":
1360
1409
  res = this._getNextNodeInView(node);
@@ -1367,7 +1416,10 @@ export class Wunderbaum {
1367
1416
  if (node._rowIdx! < bottomNode._rowIdx!) {
1368
1417
  res = bottomNode;
1369
1418
  } else {
1370
- res = this._getNextNodeInView(node, pageSize);
1419
+ res = this._getNextNodeInView(node, {
1420
+ reverse: false,
1421
+ ofs: pageSize,
1422
+ });
1371
1423
  }
1372
1424
  }
1373
1425
  break;
@@ -1381,10 +1433,28 @@ export class Wunderbaum {
1381
1433
  if (node._rowIdx! > topNode._rowIdx!) {
1382
1434
  res = topNode;
1383
1435
  } else {
1384
- res = this._getPrevNodeInView(node, pageSize);
1436
+ res = this._getNextNodeInView(node, {
1437
+ reverse: true,
1438
+ ofs: pageSize,
1439
+ });
1385
1440
  }
1386
1441
  }
1387
1442
  break;
1443
+
1444
+ case "prevMatch":
1445
+ // fallthrough
1446
+ case "nextMatch":
1447
+ if (!this.isFilterActive) {
1448
+ this.logWarn(`${where}: Filter is not active.`);
1449
+ break;
1450
+ }
1451
+ res = this.findNextNode(
1452
+ (n) => n.isMatched(),
1453
+ node,
1454
+ where === "prevMatch"
1455
+ );
1456
+ res?.setActive();
1457
+ break;
1388
1458
  default:
1389
1459
  this.logWarn("Unknown relation '" + where + "'.");
1390
1460
  }
@@ -1455,6 +1525,13 @@ export class Wunderbaum {
1455
1525
  return this.root.getFirstChild();
1456
1526
  }
1457
1527
 
1528
+ /**
1529
+ * Return the last top level node if any (not the invisible root node).
1530
+ */
1531
+ getLastChild() {
1532
+ return this.root.getLastChild();
1533
+ }
1534
+
1458
1535
  /**
1459
1536
  * Return the node that currently has keyboard focus or null.
1460
1537
  * Alias for {@link Wunderbaum.focusNode}.
@@ -1817,6 +1894,53 @@ export class Wunderbaum {
1817
1894
  this._focusNode = node;
1818
1895
  }
1819
1896
 
1897
+ /** Return the current selection/expansion/activation status. @experimental */
1898
+ getState(options: GetStateOptions): TreeStateDefinition {
1899
+ let expandedKeys = undefined;
1900
+ if (options.expandedKeys !== false) {
1901
+ expandedKeys = [];
1902
+ for (const node of this) {
1903
+ if (node.expanded) {
1904
+ expandedKeys.push(node.key);
1905
+ }
1906
+ }
1907
+ }
1908
+
1909
+ const state: TreeStateDefinition = {
1910
+ activeKey: this.activeNode?.key ?? null,
1911
+ activeColIdx: this.activeColIdx,
1912
+ selectedKeys:
1913
+ options.selectedKeys === false
1914
+ ? undefined
1915
+ : this.getSelectedNodes().flatMap((n) => n.key),
1916
+ expandedKeys: expandedKeys,
1917
+ };
1918
+ return state;
1919
+ }
1920
+
1921
+ /** Apply selection/expansion/activation status. @experimental */
1922
+ setState(state: TreeStateDefinition, options: SetStateOptions) {
1923
+ this.runWithDeferredUpdate(() => {
1924
+ if (state.selectedKeys) {
1925
+ this.selectAll(false);
1926
+ for (const key of state.selectedKeys) {
1927
+ this.findKey(key)?.setSelected(true);
1928
+ }
1929
+ }
1930
+ if (state.expandedKeys) {
1931
+ for (const key of state.expandedKeys) {
1932
+ this.findKey(key)?.setExpanded(true);
1933
+ }
1934
+ }
1935
+ if (state.activeKey) {
1936
+ this.setActiveNode(state.activeKey);
1937
+ }
1938
+ if (state.activeColIdx != null) {
1939
+ this.setColumn(state.activeColIdx);
1940
+ }
1941
+ });
1942
+ }
1943
+
1820
1944
  /**
1821
1945
  * Schedule an update request to reflect a tree change.
1822
1946
  * The render operation is async and debounced unless the `immediate` option
@@ -2150,11 +2274,11 @@ export class Wunderbaum {
2150
2274
  return modified;
2151
2275
  }
2152
2276
 
2153
- protected _insertIcon(icon: string, elem: HTMLElement) {
2154
- const iconElem = document.createElement("i");
2155
- iconElem.className = icon;
2156
- elem.appendChild(iconElem);
2157
- }
2277
+ // protected _insertIcon(icon: string, elem: HTMLElement) {
2278
+ // const iconElem = document.createElement("i");
2279
+ // iconElem.className = icon;
2280
+ // elem.appendChild(iconElem);
2281
+ // }
2158
2282
 
2159
2283
  /** Create/update header markup from `this.columns` definition.
2160
2284
  * @internal
@@ -2258,6 +2382,111 @@ export class Wunderbaum {
2258
2382
  }
2259
2383
  }
2260
2384
 
2385
+ /** @internal */
2386
+ public _createNodeIcon(
2387
+ node: WunderbaumNode,
2388
+ showLoading: boolean,
2389
+ showBadge: boolean
2390
+ ): HTMLElement | null {
2391
+ const iconMap = this.iconMap;
2392
+ let iconElem;
2393
+ let icon = node.getOption("icon");
2394
+ if (node._errorInfo) {
2395
+ icon = iconMap.error;
2396
+ } else if (node._isLoading && showLoading) {
2397
+ // Status nodes, or nodes without expander (< minExpandLevel) should
2398
+ // display the 'loading' status with the i.wb-icon span
2399
+ icon = iconMap.loading;
2400
+ }
2401
+ if (icon === false) {
2402
+ return null; // explicitly disabled: don't try default icons
2403
+ }
2404
+ if (typeof icon === "string") {
2405
+ // Callback returned an icon definition
2406
+ // icon = icon.trim()
2407
+ } else if (node.statusNodeType) {
2408
+ icon = (<any>iconMap)[node.statusNodeType];
2409
+ } else if (node.expanded) {
2410
+ icon = iconMap.folderOpen;
2411
+ } else if (node.children) {
2412
+ icon = iconMap.folder;
2413
+ } else if (node.lazy) {
2414
+ icon = iconMap.folderLazy;
2415
+ } else {
2416
+ icon = iconMap.doc;
2417
+ }
2418
+
2419
+ if (!icon) {
2420
+ iconElem = document.createElement("i");
2421
+ iconElem.className = "wb-icon";
2422
+ } else if (icon.indexOf("<") >= 0) {
2423
+ // HTML
2424
+ iconElem = util.elemFromHtml(icon);
2425
+ } else if (TEST_IMG.test(icon)) {
2426
+ // Image URL
2427
+ iconElem = util.elemFromHtml(
2428
+ `<i class="wb-icon" style="background-image: url('${icon}');">`
2429
+ );
2430
+ } else {
2431
+ // Class name
2432
+ iconElem = document.createElement("i");
2433
+ iconElem.className = "wb-icon " + icon;
2434
+ }
2435
+
2436
+ // Event handler `tree.iconBadge` can return a badge text or HTMLSpanElement
2437
+ const cbRes =
2438
+ showBadge && node._callEvent("iconBadge", { iconSpan: iconElem });
2439
+
2440
+ let badge = null;
2441
+ if (cbRes != null && cbRes !== false) {
2442
+ let classes = "";
2443
+ let tooltip = "";
2444
+ if (util.isPlainObject(cbRes)) {
2445
+ badge = "" + cbRes.badge;
2446
+ classes = cbRes.badgeClass ? " " + cbRes.badgeClass : "";
2447
+ tooltip = cbRes.badgeTooltip ? ` title="${cbRes.badgeTooltip}"` : "";
2448
+ } else if (typeof cbRes === "number") {
2449
+ badge = "" + cbRes;
2450
+ } else {
2451
+ badge = cbRes; // string or HTMLSpanElement
2452
+ }
2453
+ if (typeof badge === "string") {
2454
+ badge = util.elemFromHtml(
2455
+ `<span class="wb-badge${classes}"${tooltip}>${util.escapeHtml(
2456
+ badge
2457
+ )}</span>`
2458
+ );
2459
+ }
2460
+ if (badge) {
2461
+ iconElem.append(<HTMLSpanElement>badge);
2462
+ }
2463
+ }
2464
+ return iconElem;
2465
+ }
2466
+
2467
+ private _updateTopBreadcrumb() {
2468
+ const breadcrumb = this.breadcrumb!;
2469
+ const topmost = this.getTopmostVpNode(true);
2470
+ const parentList = topmost?.getParentList(false, false);
2471
+ if (parentList?.length) {
2472
+ breadcrumb.innerHTML = "";
2473
+ for (const n of topmost.getParentList(false, false)) {
2474
+ const icon = this._createNodeIcon(n, false, false);
2475
+ if (icon) {
2476
+ breadcrumb.append(icon, " ");
2477
+ }
2478
+ const part = document.createElement("a");
2479
+ part.textContent = n.title;
2480
+ part.href = "#";
2481
+ part.classList.add("wb-breadcrumb");
2482
+ part.dataset.key = n.key;
2483
+ breadcrumb.append(part, this.options.strings!.breadcrumbDelimiter);
2484
+ }
2485
+ } else {
2486
+ breadcrumb.innerHTML = "&nbsp;";
2487
+ }
2488
+ }
2489
+
2261
2490
  /**
2262
2491
  * This is the actual update method, which is wrapped inside a throttle method.
2263
2492
  * It calls `updateColumns()` and `_updateRows()`.
@@ -2322,14 +2551,8 @@ export class Wunderbaum {
2322
2551
  // console.profileEnd(`_updateViewportImmediately()`)
2323
2552
  }
2324
2553
 
2325
- if (this.options.connectTopBreadcrumb) {
2326
- util.assert(
2327
- this.options.connectTopBreadcrumb.textContent != null,
2328
- `Invalid 'connectTopBreadcrumb' option (input element expected).`
2329
- );
2330
- let path = this.getTopmostVpNode(true)?.getPath(false, "title", " > ");
2331
- path = path ? path + " >" : "";
2332
- this.options.connectTopBreadcrumb.textContent = path;
2554
+ if (this.breadcrumb) {
2555
+ this._updateTopBreadcrumb();
2333
2556
  }
2334
2557
  this._callEvent("update");
2335
2558
  }
@@ -2400,8 +2623,9 @@ export class Wunderbaum {
2400
2623
  // this.debug("render", opts);
2401
2624
  const obsoleteNodes = new Set<WunderbaumNode>();
2402
2625
  this.nodeListElement.childNodes.forEach((elem) => {
2403
- const tr = elem as HTMLTableRowElement;
2404
- obsoleteNodes.add((<any>tr)._wb_node);
2626
+ if ((<any>elem)._wb_node) {
2627
+ obsoleteNodes.add((<any>elem)._wb_node);
2628
+ }
2405
2629
  });
2406
2630
 
2407
2631
  let idx = 0;