React treeview component (Part 2)

Mar 5, 2023

This post is the second in a series on creating a Treeview in React. Until the series is complete, I am not pushing out this post and the level of polish will not be as high as usual. You are welcome to read it if you like though.

I love feedback, especially on draft posts. if you have any!

// todo add line break

This post will be split into two parts (this is just placeholder you don't have to have this in the final version).

By the end of this post we will have a keyboard interactive treeview:

//demo

//todo explain where the keyboard navigations come from

How did we get here?

Ok so recalling from the first post we have a Node component. Our keyboard navigation is ultimately going to start from an onKeyDown on that component.

Let's take the most basic keyboard interaction — moving to the next element in the list when a user presses the down arrow.

function Node() {
    return (
        <li
            className="flex flex-col cursor-pointer select-none"
            onKeyDown={(e: KeyboardEvent) => {
                // Find the next node and focus it here
                const { element, id } = getNextFocusableId()

                element.focus()
                selectId(id)
            }}
        />
    )
}

We need to be able to

  1. find the next id so that we can select it
  2. and find the related DOM element so that we can .focus() it

Creating roving tabindex components

In a previous post, I detailed how to create a roving tabindex right inside the component you are using. This post will have a different approach. I'll be creating a reusable abstraction that can be used in future posts.

It will have a root called RovingTabindexRoot and a hook called useRovingTabindex.

This section is going to contain a lot of assumptions, which I will explain in the immediately following section. Please bear with me and hang on :)

RovingTabindexRoot

The Root component here will communicate with all nodes that are apart of our roving tabindex via context:

export type RovingItem = {
    id: string
    element: HTMLElement
}

type RovingTabindexContextType = {
    getOrderedItems: () => RovingItem[]
    elements: MutableRefObject<Map<string, HTMLElement>>
}

const RovingTabindexContext = createContext<RovingTabindexContextType>({
    getOrderedItems: () => [],
    elements: { current: new Map<string, HTMLElement>() },
})

The crux of this solution is getOrderedItems.

  1. I create a type for for associating the id of a node with the element in the DOM

  2. I create context for providing a way for my nodes to get a list of items.

    This is the crux of our tabindex. A node needs to know where it is in the list of options, and it needs access to it's siblings so that it can select(id) and focus(element) them.

  3. I am also passing the ref to the Map of id to element

    More details on that to come

Now I can create a root component

type RovingTabindexRootBaseProps<T> = {
    children: ReactNode | ReactNode[]
    as?: T
}

type RovingTabindexRootProps<T extends ElementType> =
    RovingTabindexRootBaseProps<T> &
        Omit<ComponentPropsWithoutRef<T>, keyof RovingTabindexRootBaseProps<T>>

export function RovingTabindexRoot<T extends ElementType>({
    children,
    as,
    ...props
}: RovingTabindexRootProps<T>) {
    const Component = as || 'div'
    const elements = useRef<Map<string, HTMLElement>>(new Map())

    const getOrderedItems = useCallback(() => {
        //todo
    }, [])

    return (
        <RovingTabindexContext.Provider
            value={{
                getOrderedItems,
                elements,
            }}
        >
            <Component {...{ [ROOT_SELECTOR]: true }} ref={rootRef} {...props}>
                {children}
            </Component>
        </RovingTabindexContext.Provider>
    )
}

and then components that are apart of the RovingTabindex can subscribe use useRovingTabindex

export function useRovingTabindex<T extends ElementType>(id: string) {
    const { getOrderedItems, elements } = useContext(RovingTabindexContext)

    return {
        getOrderedItems,
        getRovingProps: (props: ComponentPropsWithoutRef<T>) => ({
            ...props,
            ref: (element: HTMLElement | null) => {
                if (element) {
                    elements.current.set(id, element)
                } else {
                    elements.current.delete(id)
                }
            },
            [NODE_SELECTOR]: true,
        }),
    }
}

and spread the prop getter getRovingProps.

  1. The component is genericly typed with an as props

    Our roving tabindex needs a root DOM node for reason I'll explain later. I am just doing this to prevent excessive nesting.

  2. Elements is provided as a ref through context so that getRovingProps

I hope you learned something from this post — it's the foundational piece to understanding the keyboard navigation, ARIA attributes, and animations to come in the next few parts.

Love hearing feedback so if you have any .

Subscribe to the newsletter

A monthly no filler update.

Contact me at

my reddit accountmy codepen accountmy twitter accountmy github account