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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wunderbaum",
3
- "version": "0.12.1",
3
+ "version": "0.13.0",
4
4
  "title": "A treegrid control.",
5
5
  "description": "JavaScript tree/grid/treegrid control.",
6
6
  "homepage": "https://github.com/mar10/wunderbaum",
package/src/common.ts CHANGED
@@ -4,7 +4,14 @@
4
4
  * @VERSION, @DATE (https://github.com/mar10/wunderbaum)
5
5
  */
6
6
 
7
- import { MatcherCallback, SourceListType, SourceObjectType } from "./types";
7
+ import {
8
+ ApplyCommandType,
9
+ NavigationType,
10
+ SourceListType,
11
+ SourceObjectType,
12
+ IconMapType,
13
+ MatcherCallback,
14
+ } from "./types";
8
15
  import * as util from "./util";
9
16
  import { WunderbaumNode } from "./wb_node";
10
17
 
@@ -42,7 +49,7 @@ export const TEST_IMG = new RegExp(/\.|\//);
42
49
  * - 'fontawesome6' {@link https://fontawesome.com/icons}
43
50
  *
44
51
  */
45
- export const iconMaps: { [key: string]: { [key: string]: string } } = {
52
+ export const iconMaps: { [key: string]: IconMapType } = {
46
53
  bootstrap: {
47
54
  error: "bi bi-exclamation-triangle",
48
55
  // loading: "bi bi-hourglass-split wb-busy",
@@ -140,7 +147,24 @@ export const RESERVED_TREE_SOURCE_KEYS: Set<string> = new Set([
140
147
  // ]);
141
148
 
142
149
  /** Map `KeyEvent.key` to navigation action. */
143
- export const KEY_TO_ACTION_DICT: { [key: string]: string } = {
150
+ export const KEY_TO_NAVIGATION_MAP: { [key: string]: NavigationType } = {
151
+ ArrowDown: "down",
152
+ ArrowLeft: "left",
153
+ ArrowRight: "right",
154
+ ArrowUp: "up",
155
+ Backspace: "parent",
156
+ End: "lastCol",
157
+ Home: "firstCol",
158
+ "Control+End": "last",
159
+ "Control+Home": "first",
160
+ "Meta+ArrowDown": "last", // macOs
161
+ "Meta+ArrowUp": "first", // macOs
162
+ PageDown: "pageDown",
163
+ PageUp: "pageUp",
164
+ };
165
+
166
+ /** Map `KeyEvent.key` to navigation action. */
167
+ export const KEY_TO_COMMAND_MAP: { [key: string]: ApplyCommandType } = {
144
168
  " ": "toggleSelect",
145
169
  "+": "expand",
146
170
  Add: "expand",
package/src/types.ts CHANGED
@@ -173,6 +173,32 @@ export interface WbNodeData {
173
173
  [key: string]: unknown;
174
174
  }
175
175
 
176
+ /** A plain object (dictionary) that defines node icons. */
177
+ export interface IconMapType {
178
+ error: string;
179
+ loading: string;
180
+ noData: string;
181
+ expanderExpanded: string;
182
+ expanderCollapsed: string;
183
+ expanderLazy: string;
184
+ checkChecked: string;
185
+ checkUnchecked: string;
186
+ checkUnknown: string;
187
+ radioChecked: string;
188
+ radioUnchecked: string;
189
+ radioUnknown: string;
190
+ folder: string;
191
+ folderOpen: string;
192
+ folderLazy: string;
193
+ doc: string;
194
+ colSortable: string;
195
+ colSortAsc: string;
196
+ colSortDesc: string;
197
+ colFilter: string;
198
+ colFilterActive: string;
199
+ colMenu: string;
200
+ [key: string]: string;
201
+ }
176
202
  /* -----------------------------------------------------------------------------
177
203
  * EVENT CALLBACK TYPES
178
204
  * ---------------------------------------------------------------------------*/
@@ -471,6 +497,21 @@ export interface ColumnDefinition {
471
497
 
472
498
  export type ColumnDefinitionList = Array<ColumnDefinition>;
473
499
 
500
+ /**
501
+ * Used by {@link Wunderbaum.getState} and {@link Wunderbaum.setState}.
502
+ */
503
+ export interface TreeStateDefinition {
504
+ /** The active node's key if any. */
505
+ activeKey: string | null;
506
+ /** The active column index if any. */
507
+ activeColIdx: number | null;
508
+ /** List of selected node's keys. */
509
+ selectedKeys: Array<string> | undefined;
510
+ /** List of expanded node's keys. */
511
+ expandedKeys: Array<string> | undefined;
512
+ /** List of checked node's keys. */
513
+ }
514
+
474
515
  /**
475
516
  * Column information (passed to the `render` event).
476
517
  */
@@ -518,29 +559,43 @@ export interface WbEventInfo {
518
559
  // export type WbNodeCallbackType = (e: WbNodeEventType) => any;
519
560
  // export type WbRenderCallbackType = (e: WbRenderEventType) => void;
520
561
 
521
- export type FilterModeType = null | "dim" | "hide";
562
+ export type FilterModeType = null | "mark" | "dim" | "hide";
522
563
  export type SelectModeType = "single" | "multi" | "hier";
564
+
565
+ export type NavigationType =
566
+ | "down"
567
+ | "first"
568
+ | "firstCol"
569
+ | "last"
570
+ | "lastCol"
571
+ | "left"
572
+ | "nextMatch"
573
+ | "pageDown"
574
+ | "pageUp"
575
+ | "parent"
576
+ | "prevMatch"
577
+ | "right"
578
+ | "up";
579
+
523
580
  export type ApplyCommandType =
581
+ | NavigationType
524
582
  | "addChild"
525
583
  | "addSibling"
584
+ | "collapse"
585
+ | "collapseAll"
526
586
  | "copy"
527
587
  | "cut"
528
- | "down"
529
- | "first"
588
+ | "edit"
589
+ | "expand"
590
+ | "expandAll"
530
591
  | "indent"
531
- | "last"
532
- | "left"
533
592
  | "moveDown"
534
593
  | "moveUp"
535
594
  | "outdent"
536
- | "pageDown"
537
- | "pageUp"
538
- | "parent"
539
595
  | "paste"
540
596
  | "remove"
541
597
  | "rename"
542
- | "right"
543
- | "up";
598
+ | "toggleSelect";
544
599
 
545
600
  export type NodeFilterResponse = "skip" | "branch" | boolean | void;
546
601
  export type NodeFilterCallback = (node: WunderbaumNode) => NodeFilterResponse;
@@ -607,6 +662,23 @@ export enum NavModeEnum {
607
662
  row = "row",
608
663
  }
609
664
 
665
+ /** Translatable strings. */
666
+ export type TranslationsType = {
667
+ /** @default "Loading..." */
668
+ loading: string;
669
+ /** @default "Error" */
670
+ loadError: string;
671
+ /** @default "No data" */
672
+ noData: string;
673
+ /** @default " » " */
674
+ breadcrumbDelimiter: string;
675
+ /** @default "Found ${matches} of ${count}" */
676
+ queryResult: string;
677
+ /** @default "No result" */
678
+ noMatch: string;
679
+ /** @default "${match} of ${matches}" */
680
+ matchIndex: string;
681
+ };
610
682
  /* -----------------------------------------------------------------------------
611
683
  * METHOD OPTIONS TYPES
612
684
  * ---------------------------------------------------------------------------*/
@@ -693,6 +765,22 @@ export interface FilterNodesOptions {
693
765
  noData?: boolean | string;
694
766
  }
695
767
 
768
+ /** Possible values for {@link Wunderbaum.getState}. */
769
+ export interface GetStateOptions {
770
+ // /** Include the activated key. @default true */
771
+ // activeKey?: boolean;
772
+ /** Include the expanded keys. @default true */
773
+ expandedKeys?: boolean;
774
+ /** Include the selected keys. @default true */
775
+ selectedKeys?: boolean;
776
+ }
777
+
778
+ /** Possible values for {@link Wunderbaum.setState}. */
779
+ export interface SetStateOptions {
780
+ /** Recursively load lazy nodes as needed. @default false */
781
+ expandLazy?: boolean;
782
+ }
783
+
696
784
  /** Possible values for {@link WunderbaumNode.makeVisible}. */
697
785
  export interface MakeVisibleOptions {
698
786
  /** Do not animate expand (currently not implemented). @default false */
@@ -897,6 +985,19 @@ export interface VisitRowsOptions {
897
985
  /* -----------------------------------------------------------------------------
898
986
  * wb_ext_filter
899
987
  * ---------------------------------------------------------------------------*/
988
+
989
+ /**
990
+ * Passed as tree option.filer.connect to configure automatic integration of
991
+ * filter UI controls. @experimental
992
+ */
993
+ export interface FilterConnectType {
994
+ inputElem: string | HTMLInputElement | null;
995
+ modeButton?: string | HTMLButtonElement | null;
996
+ nextButton?: string | HTMLButtonElement | HTMLAnchorElement | null;
997
+ prevButton?: string | HTMLButtonElement | HTMLAnchorElement | null;
998
+ matchInfoElem?: string | HTMLElement | null;
999
+ }
1000
+
900
1001
  /**
901
1002
  * Passed as tree options to configure default filtering behavior.
902
1003
  *
@@ -905,10 +1006,12 @@ export interface VisitRowsOptions {
905
1006
  */
906
1007
  export type FilterOptionsType = {
907
1008
  /**
908
- * Element or selector of an input control for filter query strings
1009
+ * Element or selector of input controls and buttons for filter query strings.
1010
+ * @experimental
1011
+ * @since 0.13
909
1012
  * @default null
910
1013
  */
911
- connectInput?: null | string | Element;
1014
+ connect?: null | FilterConnectType;
912
1015
  /**
913
1016
  * Re-apply last filter if lazy data is loaded
914
1017
  * @default true
package/src/util.ts CHANGED
@@ -424,19 +424,6 @@ export function elemFromSelector<T = HTMLElement>(obj: string | T): T | null {
424
424
  return obj as T;
425
425
  }
426
426
 
427
- // /** Return a EventTarget from selector or cast an existing element. */
428
- // export function eventTargetFromSelector(
429
- // obj: string | EventTarget
430
- // ): EventTarget | null {
431
- // if (!obj) {
432
- // return null;
433
- // }
434
- // if (typeof obj === "string") {
435
- // return document.querySelector(obj) as EventTarget;
436
- // }
437
- // return obj as EventTarget;
438
- // }
439
-
440
427
  /**
441
428
  * Return a canonical descriptive string for a keyboard or mouse event.
442
429
  *
@@ -381,7 +381,7 @@ export class EditExtension extends WunderbaumExtension<EditOptionsType> {
381
381
  this.relatedNode = node;
382
382
 
383
383
  // Don't filter new nodes:
384
- newNode.match = true;
384
+ newNode.match = -1;
385
385
 
386
386
  newNode.makeVisible({ noAnimation: true }).then(() => {
387
387
  this.startEditTitle(newNode);
@@ -13,6 +13,7 @@ import {
13
13
  onEvent,
14
14
  } from "./util";
15
15
  import {
16
+ FilterConnectType,
16
17
  FilterNodesOptions,
17
18
  FilterOptionsType,
18
19
  NodeFilterCallback,
@@ -29,7 +30,11 @@ const RE_START_MARKER = new RegExp(escapeRegex(START_MARKER), "g");
29
30
  const RE_END_MARTKER = new RegExp(escapeRegex(END_MARKER), "g");
30
31
 
31
32
  export class FilterExtension extends WunderbaumExtension<FilterOptionsType> {
32
- public queryInput?: HTMLInputElement;
33
+ public queryInput: HTMLInputElement | null = null;
34
+ public prevButton: HTMLElement | HTMLAnchorElement | null = null;
35
+ public nextButton: HTMLElement | HTMLAnchorElement | null = null;
36
+ public modeButton: HTMLButtonElement | null = null;
37
+ public matchInfoElem: HTMLElement | null = null;
33
38
  public lastFilterArgs: IArguments | null = null;
34
39
 
35
40
  constructor(tree: Wunderbaum) {
@@ -37,7 +42,7 @@ export class FilterExtension extends WunderbaumExtension<FilterOptionsType> {
37
42
  autoApply: true, // Re-apply last filter if lazy data is loaded
38
43
  autoExpand: false, // Expand all branches that contain matches while filtered
39
44
  matchBranch: false, // Whether to implicitly match all children of matched nodes
40
- connectInput: null, // Element or selector of an input control for filter query strings
45
+ connect: null, // Element or selector of an input control for filter query strings
41
46
  fuzzy: false, // Match single characters in order, e.g. 'fb' will match 'FooBar'
42
47
  hideExpanders: false, // Hide expanders if all child nodes are hidden by filter
43
48
  highlight: true, // Highlight matches by wrapping inside <mark> tags
@@ -49,35 +54,118 @@ export class FilterExtension extends WunderbaumExtension<FilterOptionsType> {
49
54
 
50
55
  init() {
51
56
  super.init();
52
- const connectInput = this.getPluginOption("connectInput");
53
- if (connectInput) {
54
- this.queryInput = elemFromSelector(connectInput) as HTMLInputElement;
55
- assert(
56
- this.queryInput,
57
- `Invalid 'filter.connectInput' option: ${connectInput}.`
58
- );
59
- onEvent(
60
- this.queryInput,
61
- "input",
62
- debounce((e) => {
63
- // this.tree.log("query", e);
64
- this.filterNodes(this.queryInput!.value.trim(), {});
65
- }, 700)
66
- );
57
+ const connect: FilterConnectType = this.getPluginOption("connect");
58
+ if (connect) {
59
+ this._connectControls();
67
60
  }
68
61
  }
69
62
 
70
63
  setPluginOption(name: string, value: any): void {
71
- // alert("filter opt=" + name + ", " + value)
72
64
  super.setPluginOption(name, value);
73
65
  switch (name) {
74
66
  case "mode":
75
- this.tree.filterMode = value === "hide" ? "hide" : "dim";
67
+ this.tree.filterMode =
68
+ value === "hide" ? "hide" : value === "mark" ? "mark" : "dim";
76
69
  this.tree.updateFilter();
77
70
  break;
78
71
  }
79
72
  }
80
73
 
74
+ _updatedConnectedControls() {
75
+ const filterActive = this.tree.filterMode !== null;
76
+ const activeNode = this.tree.getActiveNode();
77
+ const matchCount = filterActive ? this.countMatches() : 0;
78
+ const strings = this.treeOpts.strings!;
79
+ let matchIdx: string | number = "?";
80
+
81
+ if (this.matchInfoElem) {
82
+ if (filterActive) {
83
+ let info;
84
+ if (matchCount === 0) {
85
+ info = strings.noMatch;
86
+ } else if (activeNode && activeNode.match! >= 1) {
87
+ matchIdx = activeNode.match ?? "?";
88
+ info = strings.matchIndex;
89
+ } else {
90
+ info = strings.queryResult;
91
+ }
92
+ info = info
93
+ .replace("${count}", this.tree.count().toLocaleString())
94
+ .replace("${match}", "" + matchIdx)
95
+ .replace("${matches}", matchCount.toLocaleString());
96
+ this.matchInfoElem.textContent = info;
97
+ } else {
98
+ this.matchInfoElem.textContent = "";
99
+ }
100
+ }
101
+
102
+ if (this.nextButton instanceof HTMLButtonElement) {
103
+ this.nextButton.disabled = !matchCount;
104
+ }
105
+ if (this.prevButton instanceof HTMLButtonElement) {
106
+ this.prevButton.disabled = !matchCount;
107
+ }
108
+ if (this.modeButton) {
109
+ this.modeButton.disabled = !filterActive;
110
+ this.modeButton.classList.toggle(
111
+ "wb-filter-hide",
112
+ this.tree.filterMode === "hide"
113
+ );
114
+ }
115
+ }
116
+ _connectControls() {
117
+ const tree = this.tree;
118
+ const connect: FilterConnectType = this.getPluginOption("connect");
119
+ if (!connect) {
120
+ return;
121
+ }
122
+ this.queryInput = elemFromSelector(connect.inputElem);
123
+ if (!this.queryInput) {
124
+ throw new Error(`Invalid 'filter.connect' option: ${connect.inputElem}.`);
125
+ }
126
+ this.prevButton = elemFromSelector(connect.prevButton!);
127
+ this.nextButton = elemFromSelector(connect.nextButton!);
128
+ this.modeButton = elemFromSelector(connect.modeButton!);
129
+ this.matchInfoElem = elemFromSelector(connect.matchInfoElem!);
130
+ if (this.prevButton) {
131
+ onEvent(this.prevButton, "click", () => {
132
+ tree.findRelatedNode(
133
+ tree.getActiveNode() || tree.getFirstChild()!,
134
+ "prevMatch"
135
+ );
136
+ this._updatedConnectedControls();
137
+ });
138
+ }
139
+ if (this.nextButton) {
140
+ onEvent(this.nextButton, "click", () => {
141
+ tree.findRelatedNode(
142
+ tree.getActiveNode() || tree.getFirstChild()!,
143
+ "nextMatch"
144
+ );
145
+ this._updatedConnectedControls();
146
+ });
147
+ }
148
+ if (this.modeButton) {
149
+ onEvent(this.modeButton, "click", (e) => {
150
+ if (!this.tree.filterMode) {
151
+ return;
152
+ }
153
+ this.setPluginOption(
154
+ "mode",
155
+ tree.filterMode === "dim" ? "hide" : "dim"
156
+ );
157
+ });
158
+ }
159
+ onEvent(
160
+ this.queryInput,
161
+ "input",
162
+ debounce((e) => {
163
+ this.filterNodes(this.queryInput!.value.trim(), {});
164
+ }, 700)
165
+ );
166
+ this._updatedConnectedControls();
167
+ }
168
+
81
169
  _applyFilterNoUpdate(
82
170
  filter: string | RegExp | NodeFilterCallback,
83
171
  _opts: FilterNodesOptions
@@ -98,7 +186,7 @@ export class FilterExtension extends WunderbaumExtension<FilterOptionsType> {
98
186
  const treeOpts = tree.options;
99
187
  const prevAutoCollapse = treeOpts.autoCollapse;
100
188
  // Use default options from `tree.options.filter`, but allow to override them
101
- const opts = extend({}, treeOpts.filter, _opts);
189
+ const opts: FilterOptionsType = extend({}, treeOpts.filter, _opts);
102
190
  const hideMode = opts.mode === "hide";
103
191
  const matchBranch = !!opts.matchBranch;
104
192
  const leavesOnly = !!opts.leavesOnly && !matchBranch;
@@ -176,12 +264,12 @@ export class FilterExtension extends WunderbaumExtension<FilterOptionsType> {
176
264
  };
177
265
  }
178
266
 
179
- tree.filterMode = opts.mode;
267
+ tree.filterMode = opts.mode ?? "dim";
180
268
  // eslint-disable-next-line prefer-rest-params
181
269
  this.lastFilterArgs = arguments;
182
270
 
183
271
  tree.element.classList.toggle("wb-ext-filter-hide", !!hideMode);
184
- tree.element.classList.toggle("wb-ext-filter-dim", !hideMode);
272
+ tree.element.classList.toggle("wb-ext-filter-dim", opts.mode === "dim");
185
273
  tree.element.classList.toggle(
186
274
  "wb-ext-filter-hide-expanders",
187
275
  !!opts.hideExpanders
@@ -193,10 +281,6 @@ export class FilterExtension extends WunderbaumExtension<FilterOptionsType> {
193
281
  delete node.titleWithHighlight;
194
282
  node.subMatchCount = 0;
195
283
  });
196
- // statusNode = tree.root.findDirectChild(KEY_NODATA);
197
- // if (statusNode) {
198
- // statusNode.remove();
199
- // }
200
284
  tree.setStatus(NodeStatusType.ok);
201
285
 
202
286
  // Adjust node.hide, .match, and .subMatchCount properties
@@ -210,7 +294,7 @@ export class FilterExtension extends WunderbaumExtension<FilterOptionsType> {
210
294
 
211
295
  if (res === "skip") {
212
296
  node.visit(function (c) {
213
- c.match = false;
297
+ c.match = undefined;
214
298
  }, true);
215
299
  return "skip";
216
300
  }
@@ -223,7 +307,7 @@ export class FilterExtension extends WunderbaumExtension<FilterOptionsType> {
223
307
 
224
308
  if (res) {
225
309
  count++;
226
- node.match = true;
310
+ node.match = count;
227
311
  node.visitParents((p) => {
228
312
  if (p !== node) {
229
313
  p.subMatchCount! += 1;
@@ -252,6 +336,8 @@ export class FilterExtension extends WunderbaumExtension<FilterOptionsType> {
252
336
  tree.logDebug(
253
337
  `Filter '${filter}' found ${count} nodes in ${Date.now() - start} ms.`
254
338
  );
339
+ this._updatedConnectedControls();
340
+
255
341
  return count;
256
342
  }
257
343
 
@@ -309,6 +395,7 @@ export class FilterExtension extends WunderbaumExtension<FilterOptionsType> {
309
395
  } else {
310
396
  tree.logWarn("updateFilter(): no filter active.");
311
397
  }
398
+ this._updatedConnectedControls();
312
399
  }
313
400
 
314
401
  /**
@@ -316,30 +403,17 @@ export class FilterExtension extends WunderbaumExtension<FilterOptionsType> {
316
403
  */
317
404
  clearFilter() {
318
405
  const tree = this.tree;
319
- // statusNode = tree.root.findDirectChild(KEY_NODATA),
320
- // escapeTitles = tree.options.escapeTitles;
321
406
  tree.enableUpdate(false);
322
407
 
323
- // if (statusNode) {
324
- // statusNode.remove();
325
- // }
326
408
  tree.setStatus(NodeStatusType.ok);
327
409
  // we also counted root node's subMatchCount
328
410
  delete tree.root.match;
329
411
  delete tree.root.subMatchCount;
330
412
 
331
413
  tree.visit((node) => {
332
- // if (node.match && node._rowElem) {
333
- // let titleElem = node._rowElem.querySelector("span.wb-title")!;
334
- // node._callEvent("enhanceTitle", { titleElem: titleElem });
335
- // }
336
414
  delete node.match;
337
415
  delete node.subMatchCount;
338
416
  delete node.titleWithHighlight;
339
- // if (node.subMatchBadge) {
340
- // node.subMatchBadge.remove();
341
- // delete node.subMatchBadge;
342
- // }
343
417
  if (node._filterAutoExpanded && node.expanded) {
344
418
  node.setExpanded(false, {
345
419
  noAnimation: true,
@@ -355,7 +429,8 @@ export class FilterExtension extends WunderbaumExtension<FilterOptionsType> {
355
429
  "wb-ext-filter-dim",
356
430
  "wb-ext-filter-hide"
357
431
  );
358
- // tree._callHook("treeStructureChanged", this, "clearFilter");
432
+ this._updatedConnectedControls();
433
+
359
434
  tree.enableUpdate(true);
360
435
  }
361
436
  }
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import * as util from "./util";
8
+ import { WunderbaumOptions } from "./wb_options";
8
9
  import { Wunderbaum } from "./wunderbaum";
9
10
 
10
11
  export type ExtensionsDict = { [key: string]: WunderbaumExtension<any> };
@@ -13,7 +14,7 @@ export abstract class WunderbaumExtension<TOptions> {
13
14
  public enabled = true;
14
15
  readonly id: string;
15
16
  readonly tree: Wunderbaum;
16
- readonly treeOpts: any;
17
+ readonly treeOpts: WunderbaumOptions;
17
18
  readonly extensionOpts: any;
18
19
 
19
20
  constructor(tree: Wunderbaum, id: string, defaults: TOptions) {
@@ -23,7 +24,7 @@ export abstract class WunderbaumExtension<TOptions> {
23
24
 
24
25
  const opts = tree.options as any;
25
26
 
26
- if (this.treeOpts[id] === undefined) {
27
+ if ((<any>this.treeOpts)[id] === undefined) {
27
28
  opts[id] = this.extensionOpts = util.extend({}, defaults);
28
29
  } else {
29
30
  // TODO: do we break existing object instance references here?