import { type ReactNode, useCallback, useState, useEffect, useMemo, } from 'react'; import cn from 'classnames'; import type { Except } from 'type-fest'; import DropdownButton, { type DropdownButtonProps } from './dropdown-button'; import Button from './button'; import Autocomplete from './autocomplete'; import { type BasicItem, getNodePaths, prepareTreeDataForAutocomplete, getSingleChildren, } from '../utils'; import type { AutocompleteItemType } from './autocomplete-item'; import '../styles/components/tree-select.scss'; export type TreeSelectProps> = { /** * The tree structure */ data: Item[]; /** * What happens when something is selected */ onSelect: (item: Omit) => void; /** * Contains autocomplete functionality to search through tree */ autocomplete?: boolean; /** * Placeholder for the autocomplete input box */ autocompletePlaceholder?: string; autocompleteFilter?: boolean; /** * The displayed label on the button */ label?: ReactNode; /** * Array of default active nodes for initialisation */ defaultActiveNodes?: Item['id'][]; }; type SetShowDropdownMenu = (show: boolean) => void; const TreeSelect = >({ data, onSelect, autocomplete = false, autocompletePlaceholder = '', autocompleteFilter = true, defaultActiveNodes = [], label, ...props }: Except & TreeSelectProps) => { const [activeNodes, setActiveNodes] = useState(defaultActiveNodes); const [openNodes, setOpenNodes] = useState['id'][]>([]); // When data changes, we need to update which nodes need to be open useEffect(() => { setOpenNodes(Array.from(getSingleChildren(data))); }, [data]); const [autocompleteShowDropdown, setAutocompleteShowDropdown] = useState(false); const autocompleteData = useMemo( () => prepareTreeDataForAutocomplete(getNodePaths(data)), [data] ); const toggleNode = useCallback( (node: AutocompleteItemType | BasicItem) => setOpenNodes((openNodes) => openNodes.includes(node.id) ? openNodes.filter((id) => id !== node.id) : [...openNodes, node.id] ), [] ); const handleNodeClick = useCallback( ( node: AutocompleteItemType | BasicItem | string, setShowDropdownMenu: SetShowDropdownMenu ) => { // Don't register when user hasn't specifically selected something if (typeof node === 'string') { return; } if (node.items) { toggleNode(node); } else { const path = getNodePaths(data, node.id)[0]; const leafNode = path[path.length - 1]; setActiveNodes(path.map((d) => d.id)); setOpenNodes(path.map((d) => d.id)); onSelect(leafNode); setShowDropdownMenu(false); } }, [data, onSelect, toggleNode] ); const buildTree = useCallback( ( items: BasicItem[], setShowDropdownMenu: SetShowDropdownMenu, first = false ) => (
    {items.map((node) => { let ariaExpanded: undefined | 'true' | 'false'; if (node.items) { ariaExpanded = openNodes.includes(node.id) ? 'true' : 'false'; } return (
  • {node.items && buildTree(node.items, setShowDropdownMenu)}
  • ); })}
), [activeNodes, handleNodeClick, openNodes] ); return ( {(setShowDropdownMenu: SetShowDropdownMenu) => ( <> {autocomplete && ( handleNodeClick(node, setShowDropdownMenu) } placeholder={autocompletePlaceholder} filter={autocompleteFilter} clearOnSelect autoFocus /> )} {!autocompleteShowDropdown && (
{buildTree(data, setShowDropdownMenu, true)}
)} )}
); }; export default TreeSelect;