import React , { ReactNode , useState , useEffect } from "react" ; import { ThemeableElement , useTheme } from "@devtools-ds/themes" ; import makeClass from "clsx" ; import TreeContext from "./TreeContext" ; import styles from "./Tree.css" ; export interface TreeProps extends ThemeableElement < "ul" > { /** The label for this node */ label : string | ReactNode ; /** Whether this node is open */ open : boolean ; /** Whether to add hover styles to children */ hover : boolean ; /** Send state updates so parent can track them */ onUpdate ? : ( value : boolean ) => void ; /**
     * Called when the given node is selected/focused
     * For nodes w/ children, this is equivalent to them updating their state
     */ onSelect ? : ( ) => void ; } /** A keyboard accessible expanding tree view. */ export const Tree = ( props ) => { const { theme , hover , colorScheme , children , label , className , onUpdate , onSelect , open , ... html } = props ; const { themeClass , currentTheme } = useTheme ( { theme , colorScheme } , styles ) ; const [ isOpen , setOpen ] = useState ( open ) ; useEffect ( ( ) => { setOpen ( open ) ; } , [ open ] ) ; /** Update state and call callback */ const updateState = ( value ) => { setOpen ( value ) ; if ( onUpdate ) onUpdate ( value ) ; } ; const hasChildren = React . Children . count ( children ) > 0 ; /** Set focus and aria-selected onto a new <li>, unselect old if provided */ const updateFocus = ( newNode , previousNode ? ) => { if ( newNode . isSameNode ( previousNode || null ) ) return ; const focusableNode = newNode . querySelector < HTMLLIElement > ( '[tabindex="-1"]' ) ; focusableNode ?. focus ( ) ; newNode . setAttribute ( "aria-selected" , "true" ) ; previousNode ?. removeAttribute ( "aria-selected" ) ; } ; param node const getParent = ( node , role ) => { let parent = node ; while ( parent && parent . parentElement ) { if ( parent . getAttribute ( "role" ) === role ) { return parent ; } parent = parent . parentElement ; } return null ; } ; /** Get all list elements for the current tree. */ const getListElements = ( node ) => { const tree = getParent ( node , "tree" ) ; if ( ! tree ) return [ ] ; return Array . from ( tree . querySelectorAll < HTMLLIElement > ( "li" ) ) ; } ; /** Move focus up to the tree node above */ const moveBack = ( node ) => { const group = getParent ( node , "group" ) ; const toggle = group ?. previousElementSibling ; if ( toggle && toggle . getAttribute ( "tabindex" ) === "-1" ) { const toggleParent = toggle . parentElement as HTMLLIElement ; const nodeParent = node . parentElement as HTMLLIElement ; updateFocus ( toggleParent , nodeParent ) ; } } ; /** Move the focus to the start or end of the tree */ const moveHome = ( node , direction ) => { const elements = getListElements ( node ) ; elements . forEach ( ( element ) => { element . removeAttribute ( "aria-selected" ) ; } ) ; if ( direction === "start" && elements [ 0 ] ) { updateFocus ( elements [ 0 ] ) ; } if ( direction === "end" && elements [ elements . length - 1 ] ) { updateFocus ( elements [ elements . length - 1 ] ) ; } } ; /** Move focus up or down a level from the provided Element */ const moveFocusAdjacent = ( node , direction ) => { const elements = getListElements ( node ) || [ ] ; for ( let i = 0 ; i < elements . length ; i ++ ) { const currentNode = elements [ i ] ; if ( currentNode . getAttribute ( "aria-selected" ) === "true" ) { if ( direction === "up" && elements [ i - 1 ] ) { updateFocus ( elements [ i - 1 ] , currentNode ) ; } else if ( direction === "down" && elements [ i + 1 ] ) { updateFocus ( elements [ i + 1 ] , currentNode ) ; } return ; } } updateFocus ( elements [ 0 ] ) ; } ; /** Handle all keyboard events from tree nodes */ const handleKeypress = ( event , isChild ? ) => { const node = event . target as HTMLElement ; if ( event . key === "Enter" || event . key === " " ) { updateState ( ! isOpen ) ; } if ( event . key === "ArrowRight" && isOpen && ! isChild ) { moveFocusAdjacent ( node , "down" ) ; } else if ( event . key === "ArrowRight" ) { updateState ( true ) ; } if ( event . key === "ArrowLeft" && ( ! isOpen || isChild ) ) { moveBack ( node ) ; } else if ( event . key === "ArrowLeft" ) { updateState ( false ) ; } if ( event . key === "ArrowDown" ) { moveFocusAdjacent ( node , "down" ) ; } if ( event . key === "ArrowUp" ) { moveFocusAdjacent ( node , "up" ) ; } if ( event . key === "Home" ) { moveHome ( node , "start" ) ; } if ( event . key === "End" ) { moveHome ( node , "end" ) ; } } ; /** Set selected and focus states on click events */ const handleClick = ( event , isChild ? ) => { const node = event . target as HTMLElement ; const parent = getParent ( node , "treeitem" ) as HTMLLIElement ; const elements = getListElements ( node ) || [ ] ; let found = false ; for ( let i = 0 ; i < elements . length ; i ++ ) { const currentNode = elements [ i ] ; if ( currentNode . getAttribute ( "aria-selected" ) === "true" ) { if ( parent ) { found = true ; updateFocus ( parent , currentNode ) ; } break ; } } if ( ! found && parent ) { updateFocus ( parent ) ; } if ( ! isChild ) { updateState ( ! isOpen ) ; } } ; /** When the tree is blurred make it focusable again */ const handleBlur = ( event ) => { const node = event . currentTarget ; if ( ! node . contains ( document . activeElement ) && node . getAttribute ( "role" ) === "tree" ) { node . setAttribute ( "tabindex" , "0" ) ; } } ; /** Move focus back to the selected tree node, or focus the first one */ const handleFocus = ( event ) => { const node = event . target ; if ( node . getAttribute ( "role" ) === "tree" ) { const selected = node . querySelector < HTMLLIElement > ( '[aria-selected="true"]' ) ; if ( selected ) { updateFocus ( selected ) ; } else { moveFocusAdjacent ( node as HTMLElement , "down" ) ; } node . setAttribute ( "tabindex" , "-1" ) ; } } ; /** Detect when a button has been focused */ const handleButtonFocus = ( ) => { onSelect ?. ( ) ; } ; /** Get the styles for padding based on depth */ const getPaddingStyles = ( depth ) => { const space = depth * 0.9 + 0.3 ; return { paddingLeft : `${ space }em` , width : `calc(100% - ${ space }em)` , } ; } ; const { isChild , depth , hasHover } = React . useContext ( TreeContext ) ; const showHover = hasHover ? hover : false ; if ( ! isChild ) { return ( < ul role = "tree" tabIndex = { 0 } className = { makeClass ( styles . tree , styles . group , themeClass , className ) } onFocus = { handleFocus } onBlur = { handleBlur } { ... html } > < TreeContext . Provider value = { { isChild : true , depth : 0 , hasHover : showHover } } > < Tree { ... props } / > < /TreeContext.Provider> < /ul>); } if ( ! hasChildren ) { return ( < li role = "treeitem" className = { styles . item } { ... ( html as any ) } > < div role = "button" className = { makeClass ( styles . label , { [ styles . hover ] : showHover , [ styles . focusWhite ] : currentTheme === "firefox" , } ) } tabIndex = { - 1 } style = { getPaddingStyles ( depth ) } onKeyDown = { ( e ) => { handleKeypress ( e , isChild ) ; } } onClick = { ( e ) => handleClick ( e , true ) } onFocus = { handleButtonFocus } > < span > { label } < /span> < /div> < /li>); } const arrowClass = makeClass ( styles . arrow , { [ styles . open ] : isOpen } ) ; return ( < li role = "treeitem" aria - expanded = { isOpen } className = { styles . item } > < div role = "button" tabIndex = { - 1 } className = { makeClass ( styles . label , { [ styles . hover ] : showHover , [ styles . focusWhite ] : currentTheme === "firefox" , } ) } style = { getPaddingStyles ( depth ) } onClick = { ( e ) => handleClick ( e ) } onKeyDown = { ( e ) => handleKeypress ( e ) } onFocus = { handleButtonFocus } > < span > < span aria - hidden className = { arrowClass } / > < span > { label } < /span> < /span> < /div> < ul role = "group" className = { makeClass ( className , styles . group ) } { ... html } > { isOpen && React . Children . map ( children , ( child ) => { return ( < TreeContext . Provider value = { { isChild : true , depth : depth + 1 , hasHover : showHover } } > { child } < /TreeContext.Provider>); } ) } < /ul> < /li>); } ; Tree . defaultProps = { open : false , hover : true , } ;