@gfazioli/mantine-json-tree 2.1.0 → 3.1.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.
@@ -1,12 +1,14 @@
1
1
  'use client';
2
- import React, { useMemo, useEffect, useCallback } from 'react';
3
- import { IconCopy, IconLibraryMinus, IconLibraryPlus, IconChevronRight } from '@tabler/icons-react';
4
- import { createVarsResolver, rem, factory, useProps, useStyles, useRandomClassName, getTreeExpandedState, useTree, Box, Group, ActionIcon, ScrollArea, Tree, Text, Code, Badge, Tooltip } from '@mantine/core';
2
+ import React, { useMemo, useEffect, useCallback, useState, useRef } from 'react';
3
+ import { IconCheck, IconSearch, IconCopy, IconArrowBarToUp, IconArrowBarToDown, IconChevronRight } from '@tabler/icons-react';
4
+ import { createVarsResolver, rem, factory, useProps, useStyles, useRandomClassName, getTreeExpandedState, useTree, Box, Group, Badge, ActionIcon, Divider, TextInput, CloseButton, ScrollArea, Paper, Tree, Text, Code, Tooltip } from '@mantine/core';
5
+ import { useDebouncedValue } from '@mantine/hooks';
5
6
  import { JsonTreeMediaVariables } from './JsonTreeMediaVariables.mjs';
6
- import { convertToTreeData, findNodeByPath, isExpandable, formatValue } from './lib/utils.mjs';
7
+ import { convertToTreeData, findNodeByPath, getItemCount, searchTree, filterTreeBySearch, isExpandable, formatValue } from './lib/utils.mjs';
7
8
  import classes from './JsonTree.module.css.mjs';
8
9
 
9
10
  const defaultProps = {
11
+ rootName: "root",
10
12
  defaultExpanded: false,
11
13
  maxDepth: 2,
12
14
  withExpandAll: false,
@@ -17,10 +19,71 @@ const defaultProps = {
17
19
  showPathOnHover: false,
18
20
  stickyHeader: false,
19
21
  displayFunctions: "as-string",
20
- expandAllControlIcon: /* @__PURE__ */ React.createElement(IconLibraryPlus, { size: 16 }),
21
- collapseAllControlIcon: /* @__PURE__ */ React.createElement(IconLibraryMinus, { size: 16 }),
22
- copyToClipboardIcon: /* @__PURE__ */ React.createElement(IconCopy, { size: 12 })
22
+ expandAllControlIcon: /* @__PURE__ */ React.createElement(IconArrowBarToDown, { size: 16 }),
23
+ collapseAllControlIcon: /* @__PURE__ */ React.createElement(IconArrowBarToUp, { size: 16 }),
24
+ copyToClipboardIcon: /* @__PURE__ */ React.createElement(IconCopy, { size: 12 }),
25
+ withBorder: false,
26
+ borderRadius: "sm",
27
+ withKeyCountBadge: false,
28
+ withCopyAll: false,
29
+ withSearch: false,
30
+ copyAllIcon: /* @__PURE__ */ React.createElement(IconCopy, { size: 16 }),
31
+ searchIcon: /* @__PURE__ */ React.createElement(IconSearch, { size: 16 }),
32
+ searchPlaceholder: "Filter keys and values...",
33
+ searchDebounce: 300
23
34
  };
35
+ function highlightText(text, query, getStyles) {
36
+ if (!query) {
37
+ return text;
38
+ }
39
+ const lowerText = text.toLowerCase();
40
+ const lowerQuery = query.toLowerCase();
41
+ const idx = lowerText.indexOf(lowerQuery);
42
+ if (idx === -1) {
43
+ return text;
44
+ }
45
+ return /* @__PURE__ */ React.createElement(React.Fragment, null, text.substring(0, idx), /* @__PURE__ */ React.createElement("span", { ...getStyles("searchHighlight") }, text.substring(idx, idx + query.length)), text.substring(idx + query.length));
46
+ }
47
+ function CopyNodeButton({
48
+ icon,
49
+ getStyles,
50
+ onCopy
51
+ }) {
52
+ const [copied, setCopied] = useState(false);
53
+ const timeoutRef = useRef(null);
54
+ useEffect(() => {
55
+ return () => {
56
+ if (timeoutRef.current) {
57
+ clearTimeout(timeoutRef.current);
58
+ }
59
+ };
60
+ }, []);
61
+ const handleClick = async (e) => {
62
+ const success = await onCopy(e);
63
+ if (!success) {
64
+ return;
65
+ }
66
+ setCopied(true);
67
+ if (timeoutRef.current) {
68
+ clearTimeout(timeoutRef.current);
69
+ }
70
+ timeoutRef.current = setTimeout(() => {
71
+ setCopied(false);
72
+ timeoutRef.current = null;
73
+ }, 1500);
74
+ };
75
+ return /* @__PURE__ */ React.createElement(
76
+ ActionIcon,
77
+ {
78
+ size: "xs",
79
+ variant: "subtle",
80
+ color: copied ? "green" : "gray",
81
+ onClick: handleClick,
82
+ ...getStyles("copyButton")
83
+ },
84
+ copied ? /* @__PURE__ */ React.createElement(IconCheck, { size: 12 }) : icon
85
+ );
86
+ }
24
87
  function renderJSONNode({ node, expanded, hasChildren, elementProps, tree }, props, ctx, onNodeClick) {
25
88
  const {
26
89
  getStyles,
@@ -60,7 +123,9 @@ function renderJSONNode({ node, expanded, hasChildren, elementProps, tree }, pro
60
123
  const copy = JSON.stringify(value, null, 2);
61
124
  await navigator.clipboard.writeText(copy);
62
125
  onCopy?.(copy, value);
63
- } catch (error) {
126
+ return true;
127
+ } catch {
128
+ return false;
64
129
  }
65
130
  };
66
131
  const handleClick = () => {
@@ -117,26 +182,21 @@ function renderJSONNode({ node, expanded, hasChildren, elementProps, tree }, pro
117
182
  wrap: "nowrap",
118
183
  ...elementProps,
119
184
  onClick: handleClick,
120
- style: { cursor: onNodeClick ? "pointer" : "default", position: "relative" }
185
+ style: {
186
+ cursor: onNodeClick ? "pointer" : "default",
187
+ position: "relative",
188
+ backgroundColor: ctx.directMatches?.has(node.value) ? "rgba(251, 191, 36, 0.15)" : void 0,
189
+ borderRadius: ctx.directMatches?.has(node.value) ? "4px" : void 0
190
+ }
121
191
  },
122
192
  lineNumber,
123
193
  renderIndentGuides(),
124
- key !== void 0 && /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(Text, { component: "span", ...getStyles("key"), "data-key": key }, key), /* @__PURE__ */ React.createElement(Text, { component: "span", ...getStyles("keyValueSeparator") }, ":")),
194
+ key !== void 0 && /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(Text, { component: "span", ...getStyles("key"), "data-key": key }, ctx.searchQuery ? highlightText(String(key), ctx.searchQuery, getStyles) : key), /* @__PURE__ */ React.createElement(Text, { component: "span", ...getStyles("keyValueSeparator") }, ":")),
125
195
  (() => {
126
196
  const formattedValue = formatValue(value, type);
127
- return /* @__PURE__ */ React.createElement(Code, { ...getStyles("value"), "data-type": type, "data-value": formattedValue }, formattedValue);
197
+ return /* @__PURE__ */ React.createElement(Code, { ...getStyles("value"), "data-type": type, "data-value": formattedValue }, ctx.searchQuery ? highlightText(formattedValue, ctx.searchQuery, getStyles) : formattedValue);
128
198
  })(),
129
- withCopyToClipboard && /* @__PURE__ */ React.createElement(
130
- ActionIcon,
131
- {
132
- size: "xs",
133
- variant: "subtle",
134
- color: "gray",
135
- onClick: handleCopy,
136
- ...getStyles("copyButton")
137
- },
138
- copyToClipboardIcon
139
- )
199
+ withCopyToClipboard && /* @__PURE__ */ React.createElement(CopyNodeButton, { icon: copyToClipboardIcon, getStyles, onCopy: handleCopy })
140
200
  )
141
201
  );
142
202
  }
@@ -180,7 +240,12 @@ function renderJSONNode({ node, expanded, hasChildren, elementProps, tree }, pro
180
240
  "data-expanded": expanded,
181
241
  "data-has-children": hasChildren,
182
242
  "data-type": type,
183
- style: { cursor: onNodeClick ? "pointer" : "default", position: "relative" }
243
+ style: {
244
+ cursor: onNodeClick ? "pointer" : "default",
245
+ position: "relative",
246
+ backgroundColor: ctx.directMatches?.has(node.value) ? "rgba(251, 191, 36, 0.15)" : void 0,
247
+ borderRadius: ctx.directMatches?.has(node.value) ? "4px" : void 0
248
+ }
184
249
  },
185
250
  lineNumber,
186
251
  renderIndentGuides(),
@@ -194,7 +259,7 @@ function renderJSONNode({ node, expanded, hasChildren, elementProps, tree }, pro
194
259
  },
195
260
  expandCollapseIcon
196
261
  ),
197
- key !== void 0 && /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(Text, { component: "span", ...getStyles("key") }, key), /* @__PURE__ */ React.createElement(Text, { component: "span", ...getStyles("keyValueSeparator") }, ":")),
262
+ key !== void 0 && /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(Text, { component: "span", ...getStyles("key") }, ctx.searchQuery ? highlightText(String(key), ctx.searchQuery, getStyles) : key), /* @__PURE__ */ React.createElement(Text, { component: "span", ...getStyles("keyValueSeparator") }, ":")),
198
263
  /* @__PURE__ */ React.createElement(Text, { component: "span", ...getStyles("bracket") }, openBracket),
199
264
  !expanded && /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(Text, { component: "span", size: "xs", ...getStyles("ellipsis") }, "..."), /* @__PURE__ */ React.createElement(Text, { component: "span", ...getStyles("bracket") }, closeBracket), itemCount !== void 0 && showItemsCount && /* @__PURE__ */ React.createElement(Badge, { size: "xs", variant: "light", color: "gray", ...getStyles("itemsCount") }, itemCount)),
200
265
  withCopyToClipboard && /* @__PURE__ */ React.createElement(
@@ -255,14 +320,25 @@ const varsResolver = createVarsResolver(
255
320
  lineNumber: { "--json-tree-color-line-number": "var(--mantine-color-gray-5)" },
256
321
  itemsCount: {},
257
322
  controls: {},
258
- copyButton: {}
323
+ copyButton: {},
324
+ paper: {},
325
+ toolbar: {},
326
+ keyCountBadge: {},
327
+ copyAllButton: {},
328
+ searchToggle: {},
329
+ searchBar: {},
330
+ searchInput: {},
331
+ searchHighlight: {
332
+ "--json-tree-search-highlight-color": "var(--mantine-color-yellow-3)"
333
+ }
259
334
  };
260
335
  }
261
336
  );
262
- const JsonTree = factory((_props, ref) => {
337
+ const JsonTree = factory((_props) => {
263
338
  const props = useProps("JsonTree", defaultProps, _props);
264
339
  const {
265
340
  data,
341
+ rootName,
266
342
  defaultExpanded,
267
343
  maxDepth,
268
344
  onNodeClick,
@@ -289,6 +365,19 @@ const JsonTree = factory((_props, ref) => {
289
365
  expandControlIcon,
290
366
  collapseControlIcon,
291
367
  size,
368
+ withBorder,
369
+ borderRadius,
370
+ withKeyCountBadge,
371
+ keyCountBadgeLabel,
372
+ withCopyAll,
373
+ copyAllIcon,
374
+ onCopyAll,
375
+ withSearch,
376
+ searchIcon,
377
+ searchPlaceholder,
378
+ searchQuery: controlledSearchQuery,
379
+ onSearchChange,
380
+ searchDebounce,
292
381
  classNames,
293
382
  style,
294
383
  styles,
@@ -311,8 +400,8 @@ const JsonTree = factory((_props, ref) => {
311
400
  });
312
401
  const responsiveClassName = useRandomClassName();
313
402
  const treeData = useMemo(
314
- () => [convertToTreeData(data, void 0, "root", 0, displayFunctions)],
315
- [data, displayFunctions]
403
+ () => [convertToTreeData(data, rootName ?? "root", rootName ?? "root", 0, displayFunctions)],
404
+ [data, rootName, displayFunctions]
316
405
  );
317
406
  const initialExpandedState = useMemo(() => {
318
407
  if (controlledExpanded) {
@@ -329,7 +418,7 @@ const JsonTree = factory((_props, ref) => {
329
418
  const expandedNodes = [];
330
419
  const traverse = (nodes, depth) => {
331
420
  nodes.forEach((node) => {
332
- if (depth < maxDepth && node.children) {
421
+ if (depth < (maxDepth ?? Infinity) && node.children) {
333
422
  expandedNodes.push(node.value);
334
423
  traverse(node.children, depth + 1);
335
424
  }
@@ -351,7 +440,7 @@ const JsonTree = factory((_props, ref) => {
351
440
  });
352
441
  tree.setExpandedState(state);
353
442
  }
354
- }, [controlledExpanded, tree.setExpandedState]);
443
+ }, [controlledExpanded]);
355
444
  const handleKeyDown = useCallback(
356
445
  async (e) => {
357
446
  if ((e.metaKey || e.ctrlKey) && e.key === "c" && withCopyToClipboard) {
@@ -377,6 +466,83 @@ const JsonTree = factory((_props, ref) => {
377
466
  },
378
467
  [withCopyToClipboard, treeData, onCopy]
379
468
  );
469
+ const totalKeyCount = useMemo(() => getItemCount(data), [data]);
470
+ const [searchOpen, setSearchOpen] = useState(false);
471
+ const [searchQueryInternal, setSearchQueryInternal] = useState("");
472
+ const activeSearchQuery = controlledSearchQuery ?? searchQueryInternal ?? "";
473
+ const [debouncedQuery] = useDebouncedValue(activeSearchQuery, searchDebounce ?? 300);
474
+ const preSearchExpandedRef = useRef(null);
475
+ const searchResults = useMemo(
476
+ () => searchTree(treeData, debouncedQuery),
477
+ [treeData, debouncedQuery]
478
+ );
479
+ const filteredTreeData = useMemo(() => {
480
+ if (!debouncedQuery || searchResults.matchedPaths.size === 0) {
481
+ return treeData;
482
+ }
483
+ return filterTreeBySearch(treeData, searchResults.matchedPaths);
484
+ }, [treeData, debouncedQuery, searchResults]);
485
+ useEffect(() => {
486
+ if (debouncedQuery && searchResults.expandedPaths.length > 0) {
487
+ if (!preSearchExpandedRef.current) {
488
+ preSearchExpandedRef.current = { ...tree.expandedState };
489
+ }
490
+ const newState = {};
491
+ searchResults.expandedPaths.forEach((p) => {
492
+ newState[p] = true;
493
+ });
494
+ if (onExpandedChange) {
495
+ onExpandedChange(Object.keys(newState).filter((k) => newState[k]));
496
+ } else {
497
+ tree.setExpandedState(newState);
498
+ }
499
+ }
500
+ }, [debouncedQuery, searchResults]);
501
+ const handleClearSearch = useCallback(() => {
502
+ setSearchQueryInternal("");
503
+ onSearchChange?.("");
504
+ if (preSearchExpandedRef.current) {
505
+ if (onExpandedChange) {
506
+ onExpandedChange(
507
+ Object.keys(preSearchExpandedRef.current).filter((k) => preSearchExpandedRef.current[k])
508
+ );
509
+ } else {
510
+ tree.setExpandedState(preSearchExpandedRef.current);
511
+ }
512
+ preSearchExpandedRef.current = null;
513
+ }
514
+ }, [onExpandedChange, onSearchChange, tree]);
515
+ const handleCloseSearch = useCallback(() => {
516
+ setSearchOpen(false);
517
+ handleClearSearch();
518
+ }, [handleClearSearch]);
519
+ const handleExpandAll = useCallback(() => {
520
+ const allState = getTreeExpandedState(treeData, "*");
521
+ if (onExpandedChange) {
522
+ onExpandedChange(Object.keys(allState).filter((k) => allState[k]));
523
+ } else {
524
+ tree.expandAllNodes();
525
+ }
526
+ }, [treeData, onExpandedChange, tree]);
527
+ const handleCollapseAll = useCallback(() => {
528
+ if (onExpandedChange) {
529
+ onExpandedChange([]);
530
+ } else {
531
+ tree.collapseAllNodes();
532
+ }
533
+ }, [onExpandedChange, tree]);
534
+ const [copiedAll, setCopiedAll] = useState(false);
535
+ const handleCopyAll = useCallback(async () => {
536
+ try {
537
+ const json = JSON.stringify(data, null, 2);
538
+ await navigator.clipboard.writeText(json);
539
+ onCopyAll?.(json);
540
+ onCopy?.(json, data);
541
+ setCopiedAll(true);
542
+ setTimeout(() => setCopiedAll(false), 1500);
543
+ } catch {
544
+ }
545
+ }, [data, onCopyAll, onCopy]);
380
546
  const renderCtx = {
381
547
  getStyles,
382
548
  copyToClipboardIcon,
@@ -384,60 +550,99 @@ const JsonTree = factory((_props, ref) => {
384
550
  collapseControlIcon,
385
551
  onExpand,
386
552
  onCollapse,
387
- onExpandedChange
553
+ onExpandedChange,
554
+ searchQuery: debouncedQuery || void 0,
555
+ matchedPaths: debouncedQuery ? searchResults.matchedPaths : void 0,
556
+ directMatches: debouncedQuery ? searchResults.directMatches : void 0
388
557
  };
389
558
  const treeComponent = /* @__PURE__ */ React.createElement(
390
559
  Tree,
391
560
  {
392
- data: treeData,
561
+ data: filteredTreeData,
393
562
  tree,
394
563
  levelOffset: 32,
395
564
  renderNode: (payload) => renderJSONNode(payload, props, renderCtx, onNodeClick)
396
565
  }
397
566
  );
398
- return /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(JsonTreeMediaVariables, { size, selector: `.${responsiveClassName}` }), /* @__PURE__ */ React.createElement(
567
+ const showHeader = title || withExpandAll || withKeyCountBadge || withCopyAll || withSearch;
568
+ const content = /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(JsonTreeMediaVariables, { size, selector: `.${responsiveClassName}` }), /* @__PURE__ */ React.createElement(
399
569
  Box,
400
570
  {
401
- ref,
402
571
  ...getStyles("root", { className: responsiveClassName }),
403
572
  ...others,
404
573
  "data-line-numbers": showLineNumbers || void 0,
574
+ "data-searching": debouncedQuery ? true : void 0,
405
575
  onKeyDown: handleKeyDown
406
576
  },
407
- (title || withExpandAll) && /* @__PURE__ */ React.createElement(Group, { ...getStyles("header"), justify: "space-between", mod: { sticky: stickyHeader } }, title || /* @__PURE__ */ React.createElement("div", null), withExpandAll && isExpandable(data) && /* @__PURE__ */ React.createElement(Group, { gap: "xs", style: { top: 10, zIndex: 1 } }, /* @__PURE__ */ React.createElement(
577
+ showHeader && /* @__PURE__ */ React.createElement(Group, { ...getStyles("header"), justify: "space-between", mod: { sticky: stickyHeader } }, /* @__PURE__ */ React.createElement(Group, { gap: "xs" }, title || /* @__PURE__ */ React.createElement("div", null), withKeyCountBadge && isExpandable(data) && /* @__PURE__ */ React.createElement(Badge, { size: "sm", variant: "light", color: "gray", ...getStyles("keyCountBadge") }, keyCountBadgeLabel ? keyCountBadgeLabel(totalKeyCount) : `${totalKeyCount} ${Array.isArray(data) ? "items" : "keys"}`)), /* @__PURE__ */ React.createElement(Group, { gap: 4, ...getStyles("toolbar") }, withSearch && /* @__PURE__ */ React.createElement(
408
578
  ActionIcon,
409
579
  {
410
- size: "xs",
411
- variant: "transparent",
580
+ size: "sm",
581
+ variant: searchOpen ? "light" : "subtle",
582
+ color: "gray",
412
583
  onClick: () => {
413
- const allState = getTreeExpandedState(treeData, "*");
414
- if (onExpandedChange) {
415
- onExpandedChange(Object.keys(allState).filter((k) => allState[k]));
584
+ if (searchOpen) {
585
+ handleCloseSearch();
416
586
  } else {
417
- tree.expandAllNodes();
587
+ setSearchOpen(true);
418
588
  }
419
589
  },
590
+ ...getStyles("searchToggle")
591
+ },
592
+ searchIcon
593
+ ), withExpandAll && isExpandable(data) && /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(
594
+ ActionIcon,
595
+ {
596
+ size: "sm",
597
+ variant: "subtle",
598
+ color: "gray",
599
+ onClick: handleExpandAll,
420
600
  ...getStyles("controls")
421
601
  },
422
602
  expandAllControlIcon
423
603
  ), /* @__PURE__ */ React.createElement(
424
604
  ActionIcon,
425
605
  {
426
- size: "xs",
427
- variant: "transparent",
428
- onClick: () => {
429
- if (onExpandedChange) {
430
- onExpandedChange([]);
431
- } else {
432
- tree.collapseAllNodes();
433
- }
434
- },
606
+ size: "sm",
607
+ variant: "subtle",
608
+ color: "gray",
609
+ onClick: handleCollapseAll,
435
610
  ...getStyles("controls")
436
611
  },
437
612
  collapseAllControlIcon
613
+ )), withCopyAll && /* @__PURE__ */ React.createElement(
614
+ ActionIcon,
615
+ {
616
+ size: "sm",
617
+ variant: "subtle",
618
+ color: copiedAll ? "green" : "gray",
619
+ onClick: handleCopyAll,
620
+ ...getStyles("copyAllButton")
621
+ },
622
+ copiedAll ? /* @__PURE__ */ React.createElement(IconCheck, { size: 16 }) : copyAllIcon
623
+ ))),
624
+ searchOpen && withSearch && /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement(Divider, null), /* @__PURE__ */ React.createElement(Box, { ...getStyles("searchBar"), p: "xs" }, /* @__PURE__ */ React.createElement(
625
+ TextInput,
626
+ {
627
+ placeholder: searchPlaceholder,
628
+ value: activeSearchQuery,
629
+ onChange: (e) => {
630
+ const val = e.currentTarget.value;
631
+ setSearchQueryInternal(val);
632
+ onSearchChange?.(val);
633
+ },
634
+ leftSection: /* @__PURE__ */ React.createElement(IconSearch, { size: 14 }),
635
+ rightSection: activeSearchQuery ? /* @__PURE__ */ React.createElement(CloseButton, { size: "sm", onClick: handleClearSearch }) : null,
636
+ size: "sm",
637
+ ...getStyles("searchInput")
638
+ }
438
639
  ))),
439
640
  maxHeight ? /* @__PURE__ */ React.createElement(ScrollArea.Autosize, { mah: maxHeight }, treeComponent) : treeComponent
440
641
  ));
642
+ if (withBorder) {
643
+ return /* @__PURE__ */ React.createElement(Paper, { withBorder: true, radius: borderRadius, ...getStyles("paper") }, content);
644
+ }
645
+ return content;
441
646
  });
442
647
  JsonTree.classes = classes;
443
648
  JsonTree.displayName = "JsonTree";