diff --git a/examples/src/App.tsx b/examples/src/App.tsx index 1a63294..96f2b32 100644 --- a/examples/src/App.tsx +++ b/examples/src/App.tsx @@ -1,7 +1,12 @@ import React from "react"; import { Map } from "immutable"; -import { JSONTree, KeyPath, areKeyPathsEqual } from "react-json-tree"; -import { ScrollToPath } from "../../src/types"; +import { + JSONTree, + KeyPath, + areKeyPathsEqual, + doesNodeContainNode, + ScrollToPath, +} from "react-json-tree"; const getItemString = (type: string) => ( @@ -31,6 +36,26 @@ const data: Record = { bool: true, date: new Date(), error: new Error(longString), + arrayOfObjects: [ + Array.from({ length: 1000 }).map((e) => ({ + foo: { + bar: "baz", + nested: { + bar: "baz2", + moreNested: { + bar: "baz3", + evenMoreNested: { + veryNested: { + insanelyNested: { + ridiculouslyDeepValue: "Hello", + }, + }, + }, + }, + }, + }, + })), + ], object: { foo: { bar: "baz", @@ -108,11 +133,11 @@ const App = () => ( { - // Caller needs to ensure that parent node of scrollToPath is expanded for scrollTo to work on initial render, otherwise it will scroll to when the parent node/collection is expanded - return !!areKeyPathsEqual( - keyPath, - hugeArrayKeyPath.slice(keyPath.length * -1), - ); + // Caller needs to ensure that parent node of scrollToPath is expanded for scrollTo to work on initial render, otherwise it will scroll to when the parent node/collection is expanded + return !!areKeyPathsEqual( + keyPath, + hugeArrayKeyPath.slice(keyPath.length * -1), + ); }} scrollToPath={hugeArrayKeyPath} /> @@ -241,7 +266,56 @@ const App = () => ( }))} /> + +

Shift + click to toggle visualizing all child nodes

+
+ +
+
); +function OnExpandExample() { + const [keyPathsToExpand, setKeyPathsToExpand] = React.useState([]); + return ( +
+
+ KeyPaths that will be expanded: {JSON.stringify(keyPathsToExpand)} +
+ + { + for (let i = 0; i < keyPathsToExpand.length; i++) { + if (doesNodeContainNode(keyPathsToExpand[i], keyPath)) { + return true; + } + } + + return level < 1; + }} + data={data} + onExpand={(e, keyPath, expanded) => { + if (e.ctrlKey || e.shiftKey || e.altKey) { + if (expanded) { + setKeyPathsToExpand([ + ...keyPathsToExpand.filter( + (existingKeyPath) => + !areKeyPathsEqual(existingKeyPath, keyPath), + ), + keyPath, + ]); + } else { + if (keyPathsToExpand.length) { + setKeyPathsToExpand( + keyPathsToExpand.filter((k) => !areKeyPathsEqual(k, keyPath)), + ); + } + } + } + }} + /> +
+ ); +} + export default App; diff --git a/src/ItemRange.tsx b/src/ItemRange.tsx index b888364..d8229d9 100644 --- a/src/ItemRange.tsx +++ b/src/ItemRange.tsx @@ -4,6 +4,7 @@ import { CircularCache, CommonInternalProps, KeyPath, + OnExpandEvent, ScrollToPath, } from "./types.js"; import styles from "./styles/itemRange.module.scss"; @@ -21,7 +22,15 @@ interface Props extends CommonInternalProps { } export default function ItemRange(props: Props) { - const { from, to, renderChildNodes, nodeType, scrollToPath, keyPath } = props; + const { + from, + to, + renderChildNodes, + nodeType, + scrollToPath, + keyPath, + onExpand, + } = props; let initialExpanded = false; if (scrollToPath) { const [index] = scrollToPath; @@ -35,9 +44,16 @@ export default function ItemRange(props: Props) { } const [expanded, setExpanded] = useState(initialExpanded); - const handleClick = useCallback(() => { - setExpanded(!expanded); - }, [expanded]); + const handleClick = useCallback( + (e: OnExpandEvent) => { + setExpanded(!expanded); + + if (onExpand !== undefined) { + onExpand(e, keyPath, expanded); + } + }, + [expanded], + ); return expanded ? (
@@ -48,7 +64,7 @@ export default function ItemRange(props: Props) { {`${from} ... ${to}`} diff --git a/src/JSONArrow.tsx b/src/JSONArrow.tsx index cc95cb0..28805ae 100644 --- a/src/JSONArrow.tsx +++ b/src/JSONArrow.tsx @@ -1,31 +1,32 @@ import React, { EventHandler } from "react"; import styles from "./styles/JSONArrow.module.scss"; +import { OnExpandEvent } from "./types.ts"; interface Props { arrowStyle?: "single" | "double"; expanded: boolean; nodeType: string; - onClick: () => void; + onNodeExpand: (e: OnExpandEvent) => void; } export default function JSONArrow({ arrowStyle = "single", expanded, - onClick, + onNodeExpand, }: Props) { return (
{ if (event.key === "Enter" || event.key === " ") { event.preventDefault(); - onClick(); + onNodeExpand(event); } }} - className={`${styles.arrow} ${expanded ? styles.arrowExpanded : ""} ${arrowStyle === "single" ? styles.arrowArrowStyleSingle : styles.arrowArrowStyleDouble}`} + className={`${styles.arrow} ${expanded ? styles.arrowExpanded : ""}`} > {/* @todo let implementer define custom arrow object */} {"\u25B6"} diff --git a/src/JSONNestedNode.tsx b/src/JSONNestedNode.tsx index b79394b..db17325 100644 --- a/src/JSONNestedNode.tsx +++ b/src/JSONNestedNode.tsx @@ -1,9 +1,15 @@ -import React, { useCallback, useState } from "react"; +import React, { useCallback, useEffect, useState } from "react"; import JSONArrow from "./JSONArrow.js"; import getCollectionEntries from "./getCollectionEntries.js"; import JSONNode from "./JSONNode.js"; import ItemRange from "./ItemRange.js"; -import type { CircularCache, CommonInternalProps } from "./types.js"; +import { + CircularCache, + CommonInternalProps, + KeyPath, + OnExpandEvent, + ShouldExpandNode, +} from "./types.js"; import styles from "./styles/JSONNestedNode.module.scss"; import { NodeListItem } from "./components/NodeListItem.tsx"; @@ -116,6 +122,7 @@ export default function JSONNestedNode(props: Props) { nodeTypeIndicator, shouldExpandNodeInitially, scrollToPath, + onExpand, } = props; const isRoot = keyPath[0] === "root"; @@ -129,9 +136,17 @@ export default function JSONNestedNode(props: Props) { isCircular ? false : shouldExpandNodeInitially(keyPath, data, level), ); - const handleClick = useCallback(() => { - if (isNodeExpandable) setExpanded(!expanded); - }, [isNodeExpandable, expanded]); + const onNodeExpand = useCallback( + (e: OnExpandEvent) => { + if (isNodeExpandable) { + if (onExpand) { + onExpand(e, keyPath, !expanded); + } + setExpanded(!expanded); + } + }, + [isNodeExpandable, expanded], + ); const renderedChildren = expanded || (hideRoot && level === 0) @@ -179,18 +194,18 @@ export default function JSONNestedNode(props: Props) { )} {labelRenderer(...stylingArgs)} - + {renderedItemString} diff --git a/src/JSONNode.tsx b/src/JSONNode.tsx index 8639758..0ffee1f 100644 --- a/src/JSONNode.tsx +++ b/src/JSONNode.tsx @@ -17,6 +17,7 @@ export default function JSONNode({ valueRenderer, isCustomNode, valueWrap, + scrollToPath, ...rest }: Props) { diff --git a/src/index.tsx b/src/index.tsx index 5c3d617..4d93f2c 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -57,6 +57,7 @@ export function JSONTree({ sortObjectKeys = false, scrollToPath, valueWrap = '"', + onExpand = undefined, }: JSONTreeProps) { return (
); @@ -97,6 +99,22 @@ export const areKeyPathsEqual = (a: KeyPath, b: KeyPath) => { return true; }; +/** + * Returns true if source is a child node of target + * Remember child nodes have a longer key path + */ +export const doesNodeContainNode = ( + parent: KeyPath, + child: KeyPath, +): boolean => { + for (let i = parent.length - 1; i >= 0; i--) { + if (parent[parent.length - i] !== child[child.length - i]) { + return false; + } + } + return true; +}; + export type { Key, KeyPath, @@ -108,4 +126,5 @@ export type { IsCustomNode, SortObjectKeys, CommonExternalProps, + ScrollToPath, } from "./types.js"; diff --git a/src/types.ts b/src/types.ts index 20cf9fb..2c1808d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -40,6 +40,12 @@ export type ShouldExpandNodeInitially = ( level: number, ) => boolean; +export type ShouldExpandNode = ( + keyPath: KeyPath, + data: unknown, + level: number, +) => boolean | undefined; + export type PostprocessValue = (value: unknown) => unknown; export type IsCustomNode = (value: unknown) => boolean; @@ -48,10 +54,16 @@ export type SortObjectKeys = ((a: unknown, b: unknown) => number) | boolean; export type CircularCache = unknown[]; +export type OnExpandEvent = + | React.MouseEvent + | React.KeyboardEvent + | React.MouseEvent; + export interface CommonExternalProps { keyPath: KeyPath; labelRenderer: LabelRenderer; valueRenderer: ValueRenderer; + // Sets expanded state on initial render, will not update expanded state on subsequent renders to prevent changing nodes user has interacted with already shouldExpandNodeInitially: ShouldExpandNodeInitially; hideRoot: boolean; hideRootExpand: boolean; @@ -62,6 +74,7 @@ export interface CommonExternalProps { sortObjectKeys: SortObjectKeys; valueWrap: string; scrollToPath?: ScrollToPath; + onExpand?: (event: OnExpandEvent, keyPath: KeyPath, expand: boolean) => void; } export interface CommonInternalProps extends CommonExternalProps {