/** * Tree — a hierarchy you can open a level at a time. * * The shape behind a file browser, a folder of settings, a category picker, a * table of contents: rows that contain other rows, where a parent's children * only exist on screen once you ask for them. Accordion is the one-level * version of this and stops there — its items cannot hold items. A tree's can, * to any depth, and everything that follows from that is what this component * owns: which node a row sits under, how far in it is drawn, whether it is a * branch at all, and which of its ancestors are open. * * ```tsx * * * * * src * * * * * * index.ts * * * * * * ``` * * A closed branch renders nothing below it — the subtree is unmounted, not * hidden — so the cost of a tree is what is open in it rather than what is in * it. A folder of ten thousand files that nobody has opened costs one row. * * An item is a branch because it holds a `Tree.Group`, not because it was * declared one, so there is no second fact to keep true. The exception is a * branch whose children have not been fetched yet: it has no group to be * detected by, so it says `hasChildren` and gets its chevron, and the fetch * hangs off `onExpandedChange`. * * Expansion and selection are separate pieces of state because they answer * separate questions — which parts of the hierarchy are open, and which row is * the chosen one — and a tree commonly needs one without the other. Either can * be controlled or left alone. */ import { Children, createContext, forwardRef, isValidElement, useCallback, useContext, useMemo, useState, type ReactNode, } from 'react'; import { Pressable, View, type PressableProps, type Text as RNText, type ViewProps, } from 'react-native'; import Animated, { LinearTransition, useAnimatedStyle, useDerivedValue, withTiming, } from 'react-native-reanimated'; import { tv } from 'tailwind-variants'; import { useCSSVariable } from 'uniwind'; import { ChevronRightIcon, IconColorProvider } from '../../icons'; import { useDirection } from '../../hooks/use-direction'; import { Text, type TextProps } from '../../primitives/text'; export type TreeSize = 'sm' | 'default'; /** `none` makes the rows expanders only — nothing is ever the chosen one. */ export type TreeSelectionMode = 'none' | 'single' | 'multiple'; /** How far one level is drawn in from its parent, in points. */ const DEFAULT_INDENT = 16; /** Matches the disclosure and layout timings the rest of the library uses. */ const TRANSITION_DURATION = 200; const treeVariants = tv({ slots: { root: 'w-full', item: '', trigger: 'flex-row items-center gap-2 rounded-lg', indicator: 'items-center justify-center', icon: 'items-center justify-center', label: 'flex-1 text-foreground', actions: 'flex-row items-center gap-1', // The colour is set unconditionally and the width is not: `showLines` puts // a border on the start edge, and a colour with no width draws nothing. group: 'border-border', }, variants: { size: { sm: { trigger: 'px-1.5 py-1', indicator: 'h-4 w-4', icon: 'h-4 w-4', label: 'text-xs', }, default: { trigger: 'px-2 py-1.5', indicator: 'h-5 w-5', icon: 'h-5 w-5', label: 'text-sm', }, }, isSelected: { true: { trigger: 'bg-accent', label: 'font-medium text-accent-foreground' }, }, isDisabled: { true: { trigger: 'opacity-50' }, }, }, defaultVariants: { size: 'default', }, }); interface TreeContextValue { expanded: string[]; toggleExpanded: (value: string) => void; selected: string[]; select: (value: string) => void; selectionMode: TreeSelectionMode; expandOnPress: boolean; showLines: boolean; indent: number; size: TreeSize; } interface TreeItemContextValue { value: string; /** 0 for a root item; one more than its parent's for anything nested. */ level: number; isExpanded: boolean; isSelected: boolean; isDisabled: boolean; isBranch: boolean; } const TreeContext = createContext(null); const TreeItemContext = createContext(null); function useTree(component: string): TreeContextValue { const context = useContext(TreeContext); if (!context) throw new Error(`${component} must be used within a `); return context; } function useTreeItem(component: string): TreeItemContextValue { const context = useContext(TreeItemContext); if (!context) throw new Error(`${component} must be used within a `); return context; } const toArray = (value: string | string[] | undefined): string[] => value === undefined ? [] : Array.isArray(value) ? value : [value]; export interface TreeProps extends ViewProps { className?: string; /** Row density. `sm` for a sidebar or a picker inside a sheet. */ size?: TreeSize; /** Whether a row can be the chosen one, and how many can be at once. */ selectionMode?: TreeSelectionMode; /** Selected value(s), controlled. An array when `selectionMode` is `multiple`. */ value?: string | string[]; defaultValue?: string | string[]; /** Handed back in the shape it was given — a string when single, an array when multiple. */ onValueChange?: (value: string | string[]) => void; /** Values of the open branches, controlled. */ expanded?: string[]; defaultExpanded?: string[]; /** Fires with the next set of open branches — the hook to load a branch's children on. */ onExpandedChange?: (expanded: string[]) => void; /** * Whether pressing anywhere on a branch's row opens it, as well as selecting * it. Turn it off when selecting a branch has to be possible without opening * it; the chevron still opens it either way. */ expandOnPress?: boolean; /** Draw a hairline down each level, connecting a branch to the rows inside it. */ showLines?: boolean; /** How far one level is drawn in from its parent, in points. */ indent?: number; children?: ReactNode; } const TreeRoot = forwardRef( ( { className, size = 'default', selectionMode = 'none', value, defaultValue, onValueChange, expanded, defaultExpanded, onExpandedChange, expandOnPress = true, showLines = false, indent = DEFAULT_INDENT, children, ...props }, ref ) => { const [internalExpanded, setInternalExpanded] = useState( () => defaultExpanded ?? [] ); const isExpandedControlled = expanded !== undefined; const expandedValues = isExpandedControlled ? expanded : internalExpanded; const [internalSelected, setInternalSelected] = useState(() => toArray(defaultValue) ); const isSelectionControlled = value !== undefined; const selected = isSelectionControlled ? toArray(value) : internalSelected; const toggleExpanded = useCallback( (itemValue: string) => { const next = expandedValues.includes(itemValue) ? expandedValues.filter((entry) => entry !== itemValue) : [...expandedValues, itemValue]; if (!isExpandedControlled) setInternalExpanded(next); onExpandedChange?.(next); }, [expandedValues, isExpandedControlled, onExpandedChange] ); const select = useCallback( (itemValue: string) => { if (selectionMode === 'none') return; const next = selectionMode === 'single' ? [itemValue] : selected.includes(itemValue) ? selected.filter((entry) => entry !== itemValue) : [...selected, itemValue]; if (!isSelectionControlled) setInternalSelected(next); // Hand back the shape the caller gave us, as Accordion does. onValueChange?.(selectionMode === 'single' ? (next[0] ?? '') : next); }, [selected, selectionMode, isSelectionControlled, onValueChange] ); const context = useMemo( () => ({ expanded: expandedValues, toggleExpanded, selected, select, selectionMode, expandOnPress, showLines, indent, size, }), [ expandedValues, toggleExpanded, selected, select, selectionMode, expandOnPress, showLines, indent, size, ] ); const { root } = treeVariants({ size }); return ( {children} ); } ); TreeRoot.displayName = 'Tree'; export interface TreeItemProps extends ViewProps { className?: string; /** Identifies this node in the tree's expanded and selected state. */ value: string; isDisabled?: boolean; /** * Marks the item as a branch when it has no `Tree.Group` to be detected by — * a folder whose contents are fetched the first time it is opened. It gets a * chevron, and opening it fires `onExpandedChange` with nothing to show yet. */ hasChildren?: boolean; children?: ReactNode; } /** * One node. Whether it is a branch or a leaf is read off its children rather * than declared: an item holding a `Tree.Group` is a branch, and anything else * is a leaf that renders a chevron-sized gap in place of the chevron so its * label still lines up with its siblings'. */ const TreeItem = forwardRef( ({ className, value, isDisabled = false, hasChildren, children, ...props }, ref) => { const { expanded, selected } = useTree('Tree.Item'); const parent = useContext(TreeItemContext); const { item } = treeVariants(); const isBranch = hasChildren ?? Children.toArray(children).some( (child) => isValidElement(child) && child.type === TreeGroup ); const context = useMemo( () => ({ value, level: parent ? parent.level + 1 : 0, isExpanded: expanded.includes(value), isSelected: selected.includes(value), isDisabled, isBranch, }), [value, parent, expanded, selected, isDisabled, isBranch] ); return ( {children} ); } ); TreeItem.displayName = 'Tree.Item'; /** Pressable props are forwarded; `onPress` runs after the tree updates its state. */ export interface TreeTriggerProps extends Omit { className?: string; children?: ReactNode; } /** * The node's row: everything you see on one line, and the thing you press. * * `aria-level` is set from the item's depth, but the role stays `button`. * React Native has no tree role that any platform screen reader implements, so * announcing the row as what it behaves like — a button that opens something, * with its expanded and selected state attached — is the description that * actually survives to the user. */ const TreeTrigger = forwardRef( ({ className, onPress, disabled = false, children, ...props }, ref) => { const { toggleExpanded, select, selectionMode, expandOnPress, size } = useTree('Tree.Trigger'); const { value, level, isExpanded, isSelected, isDisabled, isBranch } = useTreeItem('Tree.Trigger'); const accentForeground = useCSSVariable('--color-accent-foreground'); const selectedColor = typeof accentForeground === 'string' ? accentForeground : undefined; const triggerDisabled = Boolean(isDisabled || disabled); const { trigger } = treeVariants({ size, isSelected, isDisabled: triggerDisabled }); const handlePress: NonNullable = (event) => { if (isBranch && expandOnPress) toggleExpanded(value); select(value); onPress?.(event); }; return ( {children} ); } ); TreeTrigger.displayName = 'Tree.Trigger'; export interface TreeIndicatorProps extends ViewProps { className?: string; /** Replaces the default chevron. It is rotated for you while the branch is open. */ children?: ReactNode; } /** * The chevron, and the second way to open a branch. * * It is pressable in its own right, and because a press is consumed by the * innermost target that handles it, hitting the chevron opens the branch * without also selecting the row — which is the only way to look inside a * folder without choosing it when `expandOnPress` is off. * * On a leaf it becomes an empty box of the same size rather than disappearing, * so a leaf's label starts where its siblings' labels do. */ const TreeIndicator = forwardRef( ({ className, children, ...props }, ref) => { const { toggleExpanded, size } = useTree('Tree.Indicator'); const { value, isExpanded, isDisabled, isBranch } = useTreeItem('Tree.Indicator'); const { indicator } = treeVariants({ size }); const direction = useDirection(); /* * The glyph already points along the reading direction — it is drawn * mirrored in a right-to-left subtree — so the quarter turn that makes it * point downwards has to follow it round, or an open branch in Arabic * would have its chevron pointing at the ceiling. */ const openRotation = direction === 'rtl' ? -90 : 90; const rotation = useDerivedValue( () => withTiming(isExpanded ? openRotation : 0, { duration: TRANSITION_DURATION }), [isExpanded, openRotation] ); const rotationStyle = useAnimatedStyle(() => ({ transform: [{ rotate: `${rotation.value}deg` }], })); // After the hooks, never before them: a leaf still runs every one of them. if (!isBranch) { return ; } return ( toggleExpanded(value)} className={indicator({ className })} {...props} > {children ?? } ); } ); TreeIndicator.displayName = 'Tree.Indicator'; export interface TreeIconProps extends ViewProps { className?: string; children?: ReactNode; } /** The leading glyph, between the chevron and the label. */ const TreeIcon = forwardRef(({ className, ...props }, ref) => { const { size } = useTree('Tree.Icon'); const { icon } = treeVariants({ size }); return ; }); TreeIcon.displayName = 'Tree.Icon'; /** The row's text. Takes the selected colour with the rest of the row. */ const TreeLabel = forwardRef(({ className, ...props }, ref) => { const { size } = useTree('Tree.Label'); const { isSelected } = useTreeItem('Tree.Label'); const { label } = treeVariants({ size, isSelected }); return ; }); TreeLabel.displayName = 'Tree.Label'; export interface TreeActionsProps extends ViewProps { className?: string; children?: ReactNode; } /** The trailing slot: a count, a badge, a menu button for the node. */ const TreeActions = forwardRef(({ className, ...props }, ref) => { const { actions } = treeVariants(); return ; }); TreeActions.displayName = 'Tree.Actions'; export interface TreeGroupProps extends ViewProps { className?: string; children?: ReactNode; } /** * The rows inside a branch, and the reason its item is a branch at all. * * It unmounts when the branch is closed, so a subtree nobody has opened has * never rendered and a tree costs what is open in it rather than what is in it. * The item's layout transition animates the height the group's rows take up, * the same way `Accordion` animates a panel. * * The indent is `paddingStart`, not `paddingLeft`, and the guide line is a * start-edge border: an indent that always fell on the left would run the wrong * way in a right-to-left subtree and put every level further from its parent. */ const TreeGroup = forwardRef( ({ className, children, ...props }, ref) => { const { showLines, indent } = useTree('Tree.Group'); const { isExpanded } = useTreeItem('Tree.Group'); const { group } = treeVariants(); if (!isExpanded) return null; return ( {children} ); } ); TreeGroup.displayName = 'Tree.Group'; export const Tree = Object.assign(TreeRoot, { Item: TreeItem, Trigger: TreeTrigger, Indicator: TreeIndicator, Icon: TreeIcon, Label: TreeLabel, Actions: TreeActions, Group: TreeGroup, });