wunderbaum 0.12.1 → 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
@@ -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";
@@ -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
@@ -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
@@ -597,7 +628,7 @@ export class Wunderbaum {
597
628
  /**
598
629
  * Return the icon-function -> icon-definition mapping.
599
630
  */
600
- get iconMap(): { [key: string]: string } {
631
+ get iconMap(): IconMapType {
601
632
  const map = this.options.iconMap!;
602
633
  if (typeof map === "string") {
603
634
  return iconMaps[map];
@@ -771,7 +802,10 @@ export class Wunderbaum {
771
802
  return <WunderbaumNode>node!;
772
803
  }
773
804
 
774
- /** 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
+ */
775
809
  getTopmostVpNode(complete = true) {
776
810
  const rowHeight = this.options.rowHeightPx!;
777
811
  const gracePx = 1; // ignore subpixel scrolling
@@ -806,30 +840,29 @@ export class Wunderbaum {
806
840
  return this._getNodeByRowIdx(bottomIdx)!;
807
841
  }
808
842
 
809
- /** Return preceeding visible node in the viewport. */
810
- 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
+
811
855
  this.visitRows(
812
856
  (n) => {
813
857
  node = n;
814
- if (ofs-- <= 0) {
858
+ if (options?.cb && options.cb(n)) {
815
859
  return false;
816
860
  }
817
- },
818
- { reverse: true, start: node || this.getActiveNode() }
819
- );
820
- return node;
821
- }
822
-
823
- /** Return following visible node in the viewport. */
824
- protected _getNextNodeInView(node?: WunderbaumNode, ofs = 1) {
825
- this.visitRows(
826
- (n) => {
827
- node = n;
828
861
  if (ofs-- <= 0) {
829
862
  return false;
830
863
  }
831
864
  },
832
- { reverse: false, start: node || this.getActiveNode() }
865
+ { reverse: reverse, start: node || this.getActiveNode() }
833
866
  );
834
867
  return node;
835
868
  }
@@ -972,9 +1005,11 @@ export class Wunderbaum {
972
1005
  case "first":
973
1006
  case "last":
974
1007
  case "left":
1008
+ case "nextMatch":
975
1009
  case "pageDown":
976
1010
  case "pageUp":
977
1011
  case "parent":
1012
+ case "prevMatch":
978
1013
  case "right":
979
1014
  case "up":
980
1015
  return node.navigate(cmd);
@@ -1190,6 +1225,12 @@ export class Wunderbaum {
1190
1225
  return visible ? this.treeRowCount : this.keyMap.size;
1191
1226
  }
1192
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
+
1193
1234
  /** @internal sanity check. */
1194
1235
  _check() {
1195
1236
  let i = 0;
@@ -1255,15 +1296,18 @@ export class Wunderbaum {
1255
1296
  */
1256
1297
  findNextNode(
1257
1298
  match: string | MatcherCallback,
1258
- startNode?: WunderbaumNode | null
1299
+ startNode?: WunderbaumNode | null,
1300
+ reverse = false
1259
1301
  ): WunderbaumNode | null {
1260
1302
  //, visibleOnly) {
1261
1303
  let res: WunderbaumNode | null = null;
1262
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;
1263
1307
 
1264
1308
  const matcher =
1265
1309
  typeof match === "string" ? makeNodeTitleStartMatcher(match) : match;
1266
- startNode = startNode || firstNode;
1310
+ startNode = startNode || (reverse ? lastNode : firstNode);
1267
1311
 
1268
1312
  function _checkNode(n: WunderbaumNode) {
1269
1313
  // console.log("_check " + n)
@@ -1277,12 +1321,14 @@ export class Wunderbaum {
1277
1321
  this.visitRows(_checkNode, {
1278
1322
  start: startNode,
1279
1323
  includeSelf: false,
1324
+ reverse: reverse,
1280
1325
  });
1281
1326
  // Wrap around search
1282
1327
  if (!res && startNode !== firstNode) {
1283
1328
  this.visitRows(_checkNode, {
1284
- start: firstNode,
1329
+ start: reverse ? lastNode : firstNode,
1285
1330
  includeSelf: true,
1331
+ reverse: reverse,
1286
1332
  });
1287
1333
  }
1288
1334
  return res;
@@ -1297,7 +1343,11 @@ export class Wunderbaum {
1297
1343
  * e.g. `$.ui.keyCode.LEFT` = 'left'.
1298
1344
  * @param includeHidden Not yet implemented
1299
1345
  */
1300
- findRelatedNode(node: WunderbaumNode, where: string, includeHidden = false) {
1346
+ findRelatedNode(
1347
+ node: WunderbaumNode,
1348
+ where: NavigationType,
1349
+ includeHidden = false
1350
+ ) {
1301
1351
  const rowHeight = this.options.rowHeightPx!;
1302
1352
  let res = null;
1303
1353
  const pageSize = Math.floor(
@@ -1353,7 +1403,7 @@ export class Wunderbaum {
1353
1403
  // }
1354
1404
  break;
1355
1405
  case "up":
1356
- res = this._getPrevNodeInView(node);
1406
+ res = this._getNextNodeInView(node, { reverse: true });
1357
1407
  break;
1358
1408
  case "down":
1359
1409
  res = this._getNextNodeInView(node);
@@ -1366,7 +1416,10 @@ export class Wunderbaum {
1366
1416
  if (node._rowIdx! < bottomNode._rowIdx!) {
1367
1417
  res = bottomNode;
1368
1418
  } else {
1369
- res = this._getNextNodeInView(node, pageSize);
1419
+ res = this._getNextNodeInView(node, {
1420
+ reverse: false,
1421
+ ofs: pageSize,
1422
+ });
1370
1423
  }
1371
1424
  }
1372
1425
  break;
@@ -1380,10 +1433,28 @@ export class Wunderbaum {
1380
1433
  if (node._rowIdx! > topNode._rowIdx!) {
1381
1434
  res = topNode;
1382
1435
  } else {
1383
- res = this._getPrevNodeInView(node, pageSize);
1436
+ res = this._getNextNodeInView(node, {
1437
+ reverse: true,
1438
+ ofs: pageSize,
1439
+ });
1384
1440
  }
1385
1441
  }
1386
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;
1387
1458
  default:
1388
1459
  this.logWarn("Unknown relation '" + where + "'.");
1389
1460
  }
@@ -1454,6 +1525,13 @@ export class Wunderbaum {
1454
1525
  return this.root.getFirstChild();
1455
1526
  }
1456
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
+
1457
1535
  /**
1458
1536
  * Return the node that currently has keyboard focus or null.
1459
1537
  * Alias for {@link Wunderbaum.focusNode}.
@@ -1816,6 +1894,53 @@ export class Wunderbaum {
1816
1894
  this._focusNode = node;
1817
1895
  }
1818
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
+
1819
1944
  /**
1820
1945
  * Schedule an update request to reflect a tree change.
1821
1946
  * The render operation is async and debounced unless the `immediate` option
@@ -2149,11 +2274,11 @@ export class Wunderbaum {
2149
2274
  return modified;
2150
2275
  }
2151
2276
 
2152
- protected _insertIcon(icon: string, elem: HTMLElement) {
2153
- const iconElem = document.createElement("i");
2154
- iconElem.className = icon;
2155
- elem.appendChild(iconElem);
2156
- }
2277
+ // protected _insertIcon(icon: string, elem: HTMLElement) {
2278
+ // const iconElem = document.createElement("i");
2279
+ // iconElem.className = icon;
2280
+ // elem.appendChild(iconElem);
2281
+ // }
2157
2282
 
2158
2283
  /** Create/update header markup from `this.columns` definition.
2159
2284
  * @internal
@@ -2257,6 +2382,111 @@ export class Wunderbaum {
2257
2382
  }
2258
2383
  }
2259
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
+
2260
2490
  /**
2261
2491
  * This is the actual update method, which is wrapped inside a throttle method.
2262
2492
  * It calls `updateColumns()` and `_updateRows()`.
@@ -2321,14 +2551,8 @@ export class Wunderbaum {
2321
2551
  // console.profileEnd(`_updateViewportImmediately()`)
2322
2552
  }
2323
2553
 
2324
- if (this.options.connectTopBreadcrumb) {
2325
- util.assert(
2326
- this.options.connectTopBreadcrumb.textContent != null,
2327
- `Invalid 'connectTopBreadcrumb' option (input element expected).`
2328
- );
2329
- let path = this.getTopmostVpNode(true)?.getPath(false, "title", " > ");
2330
- path = path ? path + " >" : "";
2331
- this.options.connectTopBreadcrumb.textContent = path;
2554
+ if (this.breadcrumb) {
2555
+ this._updateTopBreadcrumb();
2332
2556
  }
2333
2557
  this._callEvent("update");
2334
2558
  }