Welcome back 👋
This post is the second in a series on building a treeview component from scratch. In part 1 we created a mouse interactive treeview component, and here we are adding keyboard navigation and accessibility.
In this post, we will
- Introduce the ARIA treeview pattern
- Integrate our roving tabindex abstraction
- Create keyboard shortcuts
- And add the ARIA attributes for accessibility.
By the end we will have a keyboard interactive treeview:
import { useState } from "react"; import { Treeview } from "./treeview" import { data } from "./data" export default function App() { const [selected, select] = useState<string | null>(null) return ( <Treeview.Root value={selected} onChange={select} className="w-72 h-full border-[1.5px] border-slate-200 m-4" > {data.map(node => ( <Treeview.Node node={node} key={node.id} /> ))} </Treeview.Root> ) }
Treeview ARIA pattern
This post is based on the treeview pattern from the ARIA Authoring Practices Guide website. They have created a list of patterns that's helpful when you find yourself creating widgets from scratch.
Adding a roving tabindex
Let's integrate our roving tabindex into our current treeview.
RovingTabindexRoot
is a context provider for- holding the focusable state
- handling some edge cases
useRovingTabindex
is a prop getter hook for- registering DOM elements that are focusable
- controlling your tabindex value
RovingTabindexRoot
In the Root component, we can integrate RovingTabindexRoot
using the as
property to maintain the same DOM structure.
useRovingTabindex
Then in our Node
component, we can integrate useRovingTabindex
.
-
We move our existing props into the prop getter so that it can compose them
This prevents props specified in our prop getter from being clobbered by the props we were passing to the li directly.
-
We create a
onKeyDown
to see whatgetOrderedItems
returns
import { useState } from "react"; import { Treeview } from "./treeview" import { data } from "./data" export default function App() { const [selected, select] = useState<string | null>(null) return ( <Treeview.Root value={selected} onChange={select} className="w-72 h-full border-[1.5px] border-slate-200 m-4" > {data.map(node => ( <Treeview.Node node={node} key={node.id} /> ))} </Treeview.Root> ) }
If you focus on a Node
in this demo, hit a key on your keyboard, and look at the devtools console you will see a list like this one:
In the next, section we will use this list of items to create our shortcuts by iterating based on which element is currently focused.
What is getOrderedItems
doing?
Let's say you are an event in an onKeyDown handler. How do you know what element to focus next?
getOrderedItems
is great for answering this question.
-
It queries the DOM for all the DOM nodes that are registered with our roving tabindex
-
And creates a list of sorted
{id, element}
pairs.
Then back in the onKeyDown
we can use this array of pairs to
-
find our current position
-
and from that find the
next
element.
Keyboard navigation
Let's use this list of items to create the 7 keyboard shortcuts in the onKeyDown
of our Node.
This section is based on the Keyboard interaction section of ARIA authoring practice doc.
Up / Down
-
We use the
getPrevFocusableId
andgetNextFocusableId
to get these get previous and next items.These selectors are similar to the example in the previous section.
A full explanation can be found in "React roving tabindex"
-
We create a group of if statements for checking event type, calling its selector, and assigning the returned value to
nextItemToFocus
We then optionally chain
.focus()
. -
We
e.preventDefault()
for up and down to prevent the default scroll behavior. -
We
e.stropPropagation()
for all events bubbling. Without this, you can't move into nested nodes.This is important because we are rendering
Node
s withinNode
s.
import { useState } from "react"; import { Treeview } from "./treeview" import { data } from "./data" export default function App() { const [selected, select] = useState<string | null>(null) return ( <Treeview.Root value={selected} onChange={select} className="w-72 h-full border-[1.5px] border-slate-200 m-4" > {data.map(node => ( <Treeview.Node node={node} key={node.id} /> ))} </Treeview.Root> ) }
Right / Left
-
If our Node is closed then
Right
opens it. -
If our Node is open and has children, then
Right
moves to the first child.
Left is a bit trickier
-
If our
Node
is open thenLeft
closes it. -
But if our
Node
is closedLeft
moves focus to the parentNode
.
This is the one selector that getOrderedItems
doesn't cover.
From the list of items returned, there isn't an easy way to know which item belongs to the parent Node
.
Thankfully HTMLElement
s have a parentElement
property that we can use to traverse up the layers of our tree.
But first, I want to use a data attribute so we can identify the root of our tree as a base case in our traversal.
We modify our RovingTabindexRoot
to use this attribute.
Then we can create getParentFocusableId
to traverse up our tree:
-
We get the currently focus element from our "orderedItems"
-
We traverse the
.parentElement
property until we find a parent node(data-item
), the root node(data-root
), or the document root(possibleParent !== null
) -
Then we return the related item
Here is our onKeyDown
so far:
import { useState } from "react"; import { Treeview } from "./treeview" import { data } from "./data" export default function App() { const [selected, select] = useState<string | null>(null) return ( <Treeview.Root value={selected} onChange={select} className="w-72 h-full border-[1.5px] border-slate-200 m-4" > {data.map(node => ( <Treeview.Node node={node} key={node.id} /> ))} </Treeview.Root> ) }
Style
At this point, we are using the default focus ring, which is causing the entire Node
and its children to look focused.
Let's replace this outline with a border-based focus indicator.
-
We add
focus:outline-none
to remove the default outline -
We add a
group
class to the li andgroup-focus:border-slate-500
to the div, so that when the li is focused its corresponding div is highlighted -
We conditionally render
isFocusable && 'group-focus:border-slate-500'
to prevent childrenNode
s from having a border.Note that I don't have to toggle border classes here because
group-focus:
automatically gives me a higher specificity than -
We add a default transparent border
border-[1.5px] border-transparent
to prevent layout shifts on focus
Try undoing any of these parts to see how they interact.
import { useState } from "react"; import { Treeview } from "./treeview" import { data } from "./data" export default function App() { const [selected, select] = useState<string | null>(null) return ( <Treeview.Root value={selected} onChange={select} className="w-72 h-full border-[1.5px] border-slate-200 m-4" > {data.map(node => ( <Treeview.Node node={node} key={node.id} /> ))} </Treeview.Root> ) }
Fullwidth style
One pretty common way of styling selection/focus is to have it take the entire width of the treeview.
Doing this type of selection is tricky with the setup we have created.
In part 1 we created our Node
to recursively render Node
s like so:
This makes the left
keybinding easier because we can get the parentElement
from the DOM, but it also prevents us from easily creating a full-width selection UI.
We could remove this (^) nested structure, but we
- would need a new way of getting the parentElement and
- would need to track each
Node
's depth in the tree to dynamically add padding / create hierarchy.
Something else I tried is keeping the nested structure, and just absolutely positioning the selection indicator.
This doesn't work because I need the left
and right
properties from my Root
component and I need the top
and bottom
properties from my Node
.
Sure I could add a ref and getBoundingClient rect, but one of the hopes of this example was to keep things simple.
For now, I am content with this limitation of the current setup. I just wanted to bring it up in case that was the focus style you are going for.
Home / End
Home and End are quite easy.
We just need to create the new selectors in our roving-tabindex.tsx
file.
- We use
.at()
to safely access the first and last properties of our ordered items - We
e.preventDefault()
in theonKeyDown
to prevent default scrolling scrolling behavior.
import { useState } from "react"; import { Treeview } from "./treeview" import { data } from "./data" export default function App() { const [selected, select] = useState<string | null>(null) return ( <Treeview.Root value={selected} onChange={select} className="w-72 h-full border-[1.5px] border-slate-200 m-4" > {data.map(node => ( <Treeview.Node node={node} key={node.id} /> ))} </Treeview.Root> ) }
Typeahead
A typeahead listens to text input and moves focus to the next matching Node
.
We test e.key
against a regular expression containing all letters and use /i
to make it case insensitive.
For getNextFocusableIdByTypeahead
:
-
We create
wrapArray
.wrapArray
changes the starting point of your array to a specific index.We want to go through
items
once, in order, starting at a specific index. WithwrapArray
we can change ouritems
starting point to the currently focusedNode
. From there we can loop throughitems
without handling the0
anditem.length
cases.Note: this is a c/p from Radix source.
-
We iterate through our items stopping only if we find a match
-
We match based on the first letter of each element's name
-
And when we find a match we return from our selector.
import { useState } from "react"; import { Treeview } from "./treeview" import { data } from "./data" export default function App() { const [selected, select] = useState<string | null>(null) return ( <Treeview.Root value={selected} onChange={select} className="w-72 h-full border-[1.5px] border-slate-200 m-4" > {data.map(node => ( <Treeview.Node node={node} key={node.id} /> ))} </Treeview.Root> ) }
Focus types
The ARIA authoring practice guide walks through "widgets"(Radix calls these "primitives"). I think this subset of components is defined by being small, reusable, and not containing business logic.
When widgets have multiple interactive controls, there are keybindings for controlling "sub-focus" within them. These keybindings can be broken down into types.
-
Distinct focus and selection
-
Selection follows focus
Since we have already implemented the keybindings for focus, we can implement distinct focus and selection to our treeview by triggering selection on Space
.
If instead, we want to implement selection follows focus, the diff would look something like this:
Both types are valid for the treeview pattern, but most other patterns specify which focus type they should use. In our treeview, I am thinking about it like a file manager. When nodes are selected there will likely be a network request fetching the details of the new selection. Distinct focus and selection will prevent flickering as you navigate through the treeview.
I found this post on designing keyboard interfaces well worth the read.
ARIA
Now that our example is keyboard accessible let add ARIA attributes to make it usable with a screen reader. The ARIA authoring practices guide details which attributes are required in this section.
Root
-
We add a
label
prop to ensure that our screen reader can announce what our tree generally contains -
and then we set
aria-multiselectable
androle
to indicate that you can only select one node at a time and that this is a tree.
Node
For our Node
-
We set
aria-expanded
onNode
s that contain children.Our ternary statement is based on
children?.length
, which I am using to determine if children exist.If
children?.length
is truthy, then I want to coalesce isOpen to a boolean with the Boolean constructor since it is initially undefined.Otherwise, I can return undefined to prevent the property from being set.
-
We set
aria-selected
based onselectedId
-
And we set
role: 'treeitem'
to identify this as an item in our tree.
import { useState } from "react"; import { Treeview } from "./treeview" import { data } from "./data" export default function App() { const [selected, select] = useState<string | null>(null) return ( <Treeview.Root value={selected} onChange={select} className="w-72 h-full border-[1.5px] border-slate-200 m-4" > {data.map(node => ( <Treeview.Node node={node} key={node.id} /> ))} </Treeview.Root> ) }
What I learned
Initially, when I implemented the keybindings for this treeview I stored the tree structure/relationships in context. To create a keybinding for the "next" node I created a tree traversal for finding the post-order next node. This was complex, and the idea of writing the blog post to explain it all was daunting.
You can imagine my surprise when the roving tabindex pattern Radix uses worked out of the box so nicely.
The DOM APIs can be daunting, but using them can dramatically simplify your code. Hopefully, in future posts, I'll be able to demystify more of them as I learn.
I hope you learned something from this post — it's been fun to compile. Check out part 3, to see how easy animating layout is with Framer Motion.
Love hearing feedback so if you have any .