import React, { ComponentType, ReactNode, useCallback, useEffect, useState, } from "react"; import { Button, Icon, Spinner, TooltipContent, TooltipPortal, TooltipProvider, TooltipRoot, TooltipTrigger, } from "@sparkle/components/"; import { ArrowDownSIcon, ArrowRightSIcon } from "@sparkle/icons/app"; import { cn } from "@sparkle/lib/utils"; import { Checkbox, CheckboxProps } from "./Checkbox"; export interface TreeProps { children?: ReactNode; isBoxed?: boolean; isLoading?: boolean; tailwindIconTextColor?: string; variant?: "navigator" | "finder"; className?: string; } export function Tree({ children, isLoading, isBoxed = false, tailwindIconTextColor, variant = "finder", className, }: TreeProps) { const modifiedChildren = React.Children.map(children, (child) => { // /!\ Limitation: This stops on the first invalid element. // Meaning that if Tree.Item is not the first child, it will not work. if (React.isValidElement(child)) { // Clone the child element and pass the necessary props const childProps: Partial = {}; if (variant === "navigator") { childProps.isNavigatable = true; } if (tailwindIconTextColor) { childProps.tailwindIconTextColor = tailwindIconTextColor; } return React.cloneElement(child, childProps); } return child; }); return ( <>
{modifiedChildren}
{isLoading && ( // add the spinner below modifiedChildren to keep the layout // thus preventing re-render in case of pagination
)}
); } const treeItemStyleClasses = { base: "s-group/tree s-flex s-cursor-default s-flex-row s-items-center s-gap-2 s-h-9", isNavigatableBase: "s-rounded-xl s-pl-1.5 s-pr-3 s-transition-colors s-duration-300 s-ease-out s-cursor-pointer", isNavigatableUnselected: cn( "s-bg-primary-100/0 dark:s-bg-primary-100-night/0", "hover:s-bg-primary-100 dark:hover:s-bg-primary-100-night" ), isNavigatableSelected: cn( "s-font-medium", "s-bg-primary-100 dark:s-bg-primary-100-night" ), }; interface TreeItemProps { label?: string; type?: "node" | "item" | "leaf"; tailwindIconTextColor?: string; visual?: ComponentType<{ className?: string }>; checkbox?: CheckboxProps; onChevronClick?: () => void; collapsed?: boolean; defaultCollapsed?: boolean; className?: string; labelClassName?: string; actions?: React.ReactNode; areActionsFading?: boolean; isNavigatable?: boolean; isSelected?: boolean; onItemClick?: () => void; id?: string; } export interface TreeItemPropsWithChildren extends TreeItemProps { renderTreeItems?: never; children?: React.ReactNode; } export interface TreeItemPropsWithRender extends TreeItemProps { renderTreeItems: () => React.ReactNode; children?: never; } Tree.Item = React.forwardRef< HTMLDivElement, TreeItemPropsWithChildren | TreeItemPropsWithRender >( ( { label, type = "node", className = "", labelClassName = "", tailwindIconTextColor = "s-text-foreground dark:s-text-foreground-night", visual, checkbox, onChevronClick, collapsed, defaultCollapsed, actions, areActionsFading = true, renderTreeItems, children, isNavigatable = false, isSelected = false, onItemClick, id, }, ref ) => { const [isTruncated, setIsTruncated] = useState(false); const labelRef = React.useRef(null); const [collapsedState, setCollapsedState] = useState( defaultCollapsed ?? true ); const isControlledCollapse = collapsed !== undefined; const effectiveCollapsed = isControlledCollapse ? collapsed : collapsedState; const effectiveOnChevronClick = isControlledCollapse ? onChevronClick : () => setCollapsedState(!collapsedState); const canExpand = effectiveOnChevronClick && type === "node"; const getChildren = () => { if (effectiveCollapsed) { return []; } return typeof renderTreeItems === "function" ? renderTreeItems() : children; }; const childrenToRender = getChildren(); const checkTruncation = useCallback(() => { if (labelRef.current) { setIsTruncated( labelRef.current.scrollWidth > labelRef.current.clientWidth ); } }, []); useEffect(() => { const observer = new ResizeObserver(checkTruncation); if (labelRef.current) { observer.observe(labelRef.current); checkTruncation(); } return () => observer.disconnect(); }, [checkTruncation]); const isExpanded = childrenToRender && !effectiveCollapsed; return ( <>
{ // Skip if click on checkbox or any button if ( e.target instanceof HTMLElement && e.target.tagName !== "BUTTON" ) { e.stopPropagation(); if (checkbox?.onCheckedChange) { checkbox.onCheckedChange?.(!checkbox.checked); } else if (canExpand) { effectiveOnChevronClick(); } } }) } > {type === "node" && (
{React.Children.count(childrenToRender) > 0 && (
{childrenToRender}
)} ); } ); interface TreeEmptyProps { label: string; onItemClick?: () => void; } Tree.Empty = function ({ label, onItemClick }: TreeEmptyProps) { return (
{label}
); };