diff --git a/README.md b/README.md index 5f4da4b3..34b7e786 100644 --- a/README.md +++ b/README.md @@ -272,80 +272,7 @@ const { ref, width, height } = useResizeObserver(); ## Tree Component Props These are all the props you can pass to the Tree component. - -```ts -interface TreeProps { - /* Data Options */ - data?: readonly T[]; - initialData?: readonly T[]; - - /* Data Handlers */ - onCreate?: handlers.CreateHandler; - onMove?: handlers.MoveHandler; - onRename?: handlers.RenameHandler; - onDelete?: handlers.DeleteHandler; - - /* Renderers*/ - children?: ElementType>; - renderRow?: ElementType>; - renderDragPreview?: ElementType; - renderCursor?: ElementType; - renderContainer?: ElementType<{}>; - - /* Sizes */ - rowHeight?: number; - overscanCount?: number; - width?: number | string; - height?: number; - indent?: number; - paddingTop?: number; - paddingBottom?: number; - padding?: number; - - /* Config */ - childrenAccessor?: string | ((d: T) => T[] | null); - idAccessor?: string | ((d: T) => string); - openByDefault?: boolean; - selectionFollowsFocus?: boolean; - disableMultiSelection?: boolean; - disableEdit?: string | boolean | BoolFunc; - disableDrag?: string | boolean | BoolFunc; - disableDrop?: - | string - | boolean - | ((args: { - parentNode: NodeApi; - dragNodes: NodeApi[]; - index: number; - }) => boolean); - - /* Event Handlers */ - onActivate?: (node: NodeApi) => void; - onSelect?: (nodes: NodeApi[]) => void; - onScroll?: (props: ListOnScrollProps) => void; - onToggle?: (id: string) => void; - onFocus?: (node: NodeApi) => void; - - /* Selection */ - selection?: string; - - /* Open State */ - initialOpenState?: OpenMap; - - /* Search */ - searchTerm?: string; - searchMatch?: (node: NodeApi, searchTerm: string) => boolean; - - /* Extra */ - className?: string | undefined; - rowClassName?: string | undefined; - - dndRootElement?: globalThis.Node | null; - onClick?: MouseEventHandler; - onContextMenu?: MouseEventHandler; - dndManager?: DragDropManager; -} -``` +You can see them [here](./modules/react-arborist/src/types/tree-props.ts). ## Row Component Props diff --git a/bin/publish b/bin/publish index 7e7f0065..421c22c3 100755 --- a/bin/publish +++ b/bin/publish @@ -2,4 +2,4 @@ yarn build cp README.md modules/react-arborist/README.md -yarn workspace react-arborist npm publish $@ +yarn workspace @jorgedanisc/react-arborist npm publish $@ diff --git a/modules/docs/.gitignore b/modules/docs/.gitignore index f4405997..2cc710a5 100644 --- a/modules/docs/.gitignore +++ b/modules/docs/.gitignore @@ -1,4 +1,5 @@ .DS_Store +.yarn node_modules resources public diff --git a/modules/react-arborist/package.json b/modules/react-arborist/package.json index 9ab9a2d9..638f0a8f 100644 --- a/modules/react-arborist/package.json +++ b/modules/react-arborist/package.json @@ -1,6 +1,9 @@ { - "name": "react-arborist", - "version": "3.4.3", + "name": "@jorgedanisc/react-arborist", + "version": "3.5.6", + "publishConfig": { + "access": "public" + }, "license": "MIT", "source": "src/index.ts", "main": "dist/main/index.js", @@ -22,10 +25,10 @@ ], "repository": { "type": "git", - "url": "https://github.com/brimdata/react-arborist.git" + "url": "https://github.com/jorgedanisc/react-arborist.git" }, "homepage": "https://react-arborist.netlify.app", - "bugs": "https://github.com/brimdata/react-arborist/issues", + "bugs": "https://github.com/jorgedanisc/react-arborist/issues", "keywords": [ "react", "arborist", diff --git a/modules/react-arborist/src/components/cursor.tsx b/modules/react-arborist/src/components/cursor.tsx index 658664c1..bf975da0 100644 --- a/modules/react-arborist/src/components/cursor.tsx +++ b/modules/react-arborist/src/components/cursor.tsx @@ -8,8 +8,8 @@ export function Cursor() { const indent = tree.indent; const top = tree.rowHeight * cursor.index + - (tree.props.padding ?? tree.props.paddingTop ?? 0); - const left = indent * cursor.level; + tree.paddingTop; + const left = indent * cursor.level + tree.paddingLeft; const Cursor = tree.renderCursor; return ; } diff --git a/modules/react-arborist/src/components/default-container.tsx b/modules/react-arborist/src/components/default-container.tsx index f92dd5cf..194a33a5 100644 --- a/modules/react-arborist/src/components/default-container.tsx +++ b/modules/react-arborist/src/components/default-container.tsx @@ -226,8 +226,8 @@ export function DefaultContainer() { itemSize={tree.rowHeight} overscanCount={tree.overscanCount} itemKey={(index) => tree.visibleNodes[index]?.id || index} - outerElementType={ListOuterElement} - innerElementType={ListInnerElement} + outerElementType={tree.props.outerElementType || ListOuterElement} + innerElementType={tree.props.innerElementType || ListInnerElement} onScroll={tree.props.onScroll} onItemsRendered={tree.onItemsRendered.bind(tree)} ref={tree.list} diff --git a/modules/react-arborist/src/components/list-inner-element.tsx b/modules/react-arborist/src/components/list-inner-element.tsx index e4ba3b9a..6a2349da 100644 --- a/modules/react-arborist/src/components/list-inner-element.tsx +++ b/modules/react-arborist/src/components/list-inner-element.tsx @@ -7,14 +7,15 @@ export const ListInnerElement = forwardRef(function InnerElement( ref ) { const tree = useTreeApi(); - const paddingTop = tree.props.padding ?? tree.props.paddingTop ?? 0; - const paddingBottom = tree.props.padding ?? tree.props.paddingBottom ?? 0; + const paddingTop = tree.paddingTop; + const paddingBottom = tree.paddingBottom; return (
diff --git a/modules/react-arborist/src/components/list-outer-element.tsx b/modules/react-arborist/src/components/list-outer-element.tsx index 672419e4..d227f6ca 100644 --- a/modules/react-arborist/src/components/list-outer-element.tsx +++ b/modules/react-arborist/src/components/list-outer-element.tsx @@ -40,3 +40,6 @@ const DropContainer = () => {
); }; + +// Export DropContainer so it can be used by custom outer elements +export { DropContainer }; diff --git a/modules/react-arborist/src/components/row-container.tsx b/modules/react-arborist/src/components/row-container.tsx index 1018d409..7a993640 100644 --- a/modules/react-arborist/src/components/row-container.tsx +++ b/modules/react-arborist/src/components/row-container.tsx @@ -52,9 +52,12 @@ export const RowContainer = React.memo(function RowContainer({ ...style, top: parseFloat(style.top as string) + - (tree.props.padding ?? tree.props.paddingTop ?? 0), + tree.paddingTop, + left: + parseFloat(style.left as string) + + tree.paddingLeft, }), - [style, tree.props.padding, tree.props.paddingTop] + [style, tree.paddingTop, tree.paddingLeft] ); const rowAttrs: React.HTMLAttributes = { role: "treeitem", diff --git a/modules/react-arborist/src/hooks/use-dnd-manager.ts b/modules/react-arborist/src/hooks/use-dnd-manager.ts new file mode 100644 index 00000000..27f86b0e --- /dev/null +++ b/modules/react-arborist/src/hooks/use-dnd-manager.ts @@ -0,0 +1,35 @@ +import { DragDropManager, createDragDropManager } from "dnd-core"; +import { HTML5Backend } from "react-dnd-html5-backend"; + +// Module-level singleton to ensure only one DnD manager exists across the entire application +let globalDndManager: DragDropManager | null = null; + +/** + * Hook to get the singleton DnD manager that is shared across all Tree instances. + * This prevents the "Cannot have two HTML5 backends at the same time" error when multiple + * trees are rendered in the same React application. + * + * The manager is created once at the module level and reused for all subsequent calls. + * + * @example + * ```tsx + * function App() { + * const dndManager = useDndManager(); + * + * return ( + * <> + * + * + * + * ); + * } + * ``` + */ +export function useDndManager(): DragDropManager { + if (!globalDndManager) { + // Lazy initialize the manager only once at module level + globalDndManager = createDragDropManager(HTML5Backend); + } + + return globalDndManager; +} diff --git a/modules/react-arborist/src/index.ts b/modules/react-arborist/src/index.ts index 854860fb..47341668 100644 --- a/modules/react-arborist/src/index.ts +++ b/modules/react-arborist/src/index.ts @@ -1,5 +1,7 @@ /* The Public Api */ export { Tree } from "./components/tree"; +export { DropContainer } from "./components/list-outer-element"; +export { useDndManager } from "./hooks/use-dnd-manager"; export * from "./types/handlers"; export * from "./types/renderers"; export * from "./types/state"; diff --git a/modules/react-arborist/src/interfaces/tree-api.ts b/modules/react-arborist/src/interfaces/tree-api.ts index cc0d28b6..7f197548 100644 --- a/modules/react-arborist/src/interfaces/tree-api.ts +++ b/modules/react-arborist/src/interfaces/tree-api.ts @@ -87,6 +87,52 @@ export class TreeApi { return this.props.overscanCount ?? 1; } + get paddingTop() { + // Priority: paddingTop > padding.top > padding.y > padding (number) > 0 + if (this.props.paddingTop !== undefined) return this.props.paddingTop; + if (this.props.padding && typeof this.props.padding === 'object') { + if (this.props.padding.top !== undefined) return this.props.padding.top; + if (this.props.padding.y !== undefined) return this.props.padding.y; + } + if (this.props.padding && typeof this.props.padding === 'number') return this.props.padding; + return 0; + } + + get paddingBottom() { + // Priority: paddingBottom > padding.bottom > padding.y > padding (number) > 0 + if (this.props.paddingBottom !== undefined) return this.props.paddingBottom; + if (this.props.padding && typeof this.props.padding === 'object') { + if (this.props.padding.bottom !== undefined) return this.props.padding.bottom; + if (this.props.padding.y !== undefined) return this.props.padding.y; + } + if (this.props.padding && typeof this.props.padding === 'number') return this.props.padding; + return 0; + } + + get paddingLeft() { + // Priority: padding.left > padding.x > padding (number) > 0 + if (this.props.padding && typeof this.props.padding === 'object') { + if (this.props.padding.left !== undefined) return this.props.padding.left; + if (this.props.padding.x !== undefined) return this.props.padding.x; + } + if (this.props.padding && typeof this.props.padding === 'number') return this.props.padding; + return 0; + } + + get paddingRight() { + // Priority: padding.right > padding.x > padding (number) > 0 + if (this.props.padding && typeof this.props.padding === 'object') { + if (this.props.padding.right !== undefined) return this.props.padding.right; + if (this.props.padding.x !== undefined) return this.props.padding.x; + } + if (this.props.padding && typeof this.props.padding === 'number') return this.props.padding; + return 0; + } + + get scrollToMargin() { + return this.props.scrollToMargin ?? 0; + } + get searchTerm() { return (this.props.searchTerm || "").trim(); } @@ -534,19 +580,91 @@ export class TreeApi { /* Scrolling */ + // Viewport height of the scroller (includes padding), fallback to prop height + private getViewportHeight() { + const el = this.listEl.current; + return el ? el.clientHeight : this.height; + } + + // Total scrollable content height (including vertical paddings) + private getContentHeight() { + return this.paddingTop + this.visibleNodes.length * this.rowHeight + this.paddingBottom; + } + + // Compute and apply scrollTop so that index is aligned correctly, honoring asymmetric paddings + private scrollToIndex(index: number, align: Align = "smart") { + const list = this.list.current; + const scroller = this.listEl.current; + if (!list || !scroller) return; + + const itemSize = this.rowHeight; + const margin = this.scrollToMargin; + const viewport = this.getViewportHeight(); + + // Coordinates in scrollable content space (0 at very top, before paddingTop) + const itemTop = this.paddingTop + index * itemSize; + const itemBottom = itemTop + itemSize; + + const current = scroller.scrollTop; + + // Visible window in content space + const topBound = current + margin; + const bottomBound = current + viewport - margin; + + // Helpers for targets + const scrollToStart = () => itemTop - margin; + const scrollToEnd = () => itemBottom - viewport + margin; + const scrollToCenter = () => itemTop - Math.round((viewport - itemSize) / 2); + + let target = current; + + switch (align) { + case "start": + target = scrollToStart(); + break; + case "end": + target = scrollToEnd(); + break; + case "center": + target = scrollToCenter(); + break; + case "auto": + case "smart": + default: { + const fullyVisible = itemTop >= topBound && itemBottom <= bottomBound; + if (fullyVisible) { + target = current; + } else if (itemTop < topBound) { + target = scrollToStart(); + } else { + target = scrollToEnd(); + } + break; + } + } + + // Clamp to scrollable range + const maxScrollTop = Math.max(0, this.getContentHeight() - viewport); + const clamped = Math.max(0, Math.min(Math.round(target), maxScrollTop)); + + list.scrollTo(clamped); + } + scrollTo(identity: Identity, align: Align = "smart") { if (!identity) return; const id = identify(identity); this.openParents(id); + + // Wait until: the node is indexed AND refs are ready (so measurements are reliable) return utils - .waitFor(() => id in this.idToIndex) + .waitFor(() => id in this.idToIndex && !!this.list.current && !!this.listEl.current) .then(() => { const index = this.idToIndex[id]; if (index === undefined) return; - this.list.current?.scrollToItem(index, align); + this.scrollToIndex(index, align); }) .catch(() => { - // Id: ${id} never appeared in the list. + // Id never appeared in the list. }); } diff --git a/modules/react-arborist/src/types/tree-props.ts b/modules/react-arborist/src/types/tree-props.ts index 440bcc40..35a37c9a 100644 --- a/modules/react-arborist/src/types/tree-props.ts +++ b/modules/react-arborist/src/types/tree-props.ts @@ -2,11 +2,22 @@ import { BoolFunc } from "./utils"; import * as handlers from "./handlers"; import * as renderers from "./renderers"; import { ElementType, MouseEventHandler } from "react"; -import { ListOnScrollProps } from "react-window"; +import { ListOnScrollProps, CommonProps as ReactWindowCommonProps } from "react-window"; import { NodeApi } from "../interfaces/node-api"; import { OpenMap } from "../state/open-slice"; import { useDragDropManager } from "react-dnd"; +export type PaddingValue = + | number + | { + top?: number; + right?: number; + bottom?: number; + left?: number; + y?: number; + x?: number; + }; + export interface TreeProps { /* Data Options */ data?: readonly T[]; @@ -31,9 +42,9 @@ export interface TreeProps { width?: number | string; height?: number; indent?: number; - paddingTop?: number; - paddingBottom?: number; - padding?: number; + padding?: PaddingValue; + paddingTop?: number; // @deprecated - use padding instead + paddingBottom?: number; // @deprecated - use padding instead /* Config */ childrenAccessor?: string | ((d: T) => readonly T[] | null); @@ -44,13 +55,16 @@ export interface TreeProps { disableEdit?: string | boolean | BoolFunc; disableDrag?: string | boolean | BoolFunc; disableDrop?: - | string - | boolean - | ((args: { - parentNode: NodeApi; - dragNodes: NodeApi[]; - index: number; - }) => boolean); + | string + | boolean + | ((args: { + parentNode: NodeApi; + dragNodes: NodeApi[]; + index: number; + }) => boolean); + + /* Scrolling */ + scrollToMargin?: number; /* Event Handlers */ onActivate?: (node: NodeApi) => void; @@ -77,4 +91,7 @@ export interface TreeProps { onClick?: MouseEventHandler; onContextMenu?: MouseEventHandler; dndManager?: ReturnType; + + outerElementType?: ReactWindowCommonProps["outerElementType"]; + innerElementType?: ReactWindowCommonProps["innerElementType"]; } diff --git a/modules/showcase/package.json b/modules/showcase/package.json index 99cc1e2a..82cd0828 100644 --- a/modules/showcase/package.json +++ b/modules/showcase/package.json @@ -10,11 +10,11 @@ "clean": "rimraf .next out" }, "dependencies": { + "@jorgedanisc/react-arborist": "workspace:*", "clsx": "^2.0.0", "nanoid": "^5.0.4", "next": "^14.0.4", "react": "^18.2.0", - "react-arborist": "workspace:*", "react-dom": "^18.2.0", "react-icons": "^4.12.0", "tree-model-improved": "^2.0.1", diff --git a/modules/showcase/pages/cities.tsx b/modules/showcase/pages/cities.tsx index ca3f8990..f94d0863 100644 --- a/modules/showcase/pages/cities.tsx +++ b/modules/showcase/pages/cities.tsx @@ -1,6 +1,6 @@ import clsx from "clsx"; import { useEffect, useRef, useState } from "react"; -import { NodeApi, NodeRendererProps, Tree, TreeApi } from "react-arborist"; +import { NodeApi, NodeRendererProps, Tree, TreeApi } from "@jorgedanisc/react-arborist"; import styles from "../styles/cities.module.css"; import { cities } from "../data/cities"; import { BsMapFill, BsMap, BsGeo, BsGeoFill } from "react-icons/bs"; @@ -44,7 +44,7 @@ export default function Cities() { selection={active?.id} className={styles.tree} rowClassName={styles.row} - padding={15} + padding={{ top: 76, bottom: 32 }} rowHeight={30} indent={INDENT_STEP} overscanCount={8} @@ -83,6 +83,13 @@ export default function Cities() { > Select San Francisco +