A descendant index solution for better accessibility support in compound components
npm install @reach/descendants !MIT license
A descendant index solution for better accessibility support in compound components.
Important: This package is primarily intended for internal use by the Reach UI library. You should probably not use it directly in your production projects, as the APIs can still change with minor version bumps (at least until we're comfortable releasing a 1.0). You have been warned!
In React you can wrap up any elements into a component and then render the new component instead. It's beautiful.
``jsx
// oldTime zones
// new
function LocaleTimeZoneOptions() {
return (
<>
>
)
}
`
Everything will continue to work!
But when we want to create our own abstractions like this, we can't always abstract and compose the same way.
The Menu here will set an aria-activedescendant={activeElementId} so that assistive tech can announce correctly. The menu also needs a ref to the children so it can set them as the active descendant (or actually focus the node) from keyboard events like ArrowUp and ArrowDown.
Additionally, MenuItem needs to know if it is the active descendant so it can style itself differently.
`jsx`
There are a few ways to deal with this.
The solution most people turn to is to bail out of the element API and turn to arrays. This lets a single owner control the state and rendering, makes it way easier to know the index and set the active descendant.
`jsx
items={[
{ label: "Download", onSelect: download },
{ label: "Save", onSelect: save },
{ label: "Preview", onSelect: preview },
]}
/>;
function Menu({ items }) {
let [activeIndex, setActiveIndex] = React.useState();
return (
function MenuItem({ index, activeIndex, onSelect, children }) {
// and now we can style
let isActive = index === activeIndex;
return (
This is where most people live. You see these APIs everywhere because it's way easier when you own all the state and all the elements in one place. But you lose composition.
What happens when we want to add a className to all, one, or just a few of the elements? You end up with weird APIs like:
`jsx
options={[
{ label: "Download", onSelect: download },
{ label: "Save", onSelect: save },
{ label: "Preview", onSelect: preview },
]}
// stuff like this
optionClassNames="cool"
// or shoot, we need more than classNames
optionsProps={{
className: "cool",
onMouseEnter: handler,
}}
// dangit we need to do it differently depending on the option
getOptionProps={(option, index) => {
return index === 2 ? "bg-blue" : "bg-white";
}}
// ah forget it, here you do it, enjoy the branching!
renderOption={(option, index) => (
className={index === 2 ? "bg-blue" : "bg-white"}
aria-label={index === 2 ? "Preview Invoice" : undefined}
>
{index === 0 ? (
) : index === 1 ? (
) : index === 2 ? (
) : null}
{option.label}
)}
/>
`Because the rendering is in the same owner as the state, we have to poke holes in the component to change anything about how it renders.
All that, just so the stinking
MenuOption knows its index in the parent's element tree.Had we stuck to elements, we could have done this:
`jsx
`But how will the
MenuItem components know their index?Option 2: Type Checking and
cloneElementWe can use
React.cloneElement to keep (most of) the normal React composition. No more items prop. Instead we map the children, clone them, and pass them the state that we know in Menu.`jsx
function Menu({ children }) {
let [activeIndex, setActiveIndex] = React.useState();
return (
{React.Children.map(children, (child, index) =>
React.cloneElement(child, { index, activeIndex })
)}
);
}function MenuItem({ index, activeIndex, onSelect, children }) {
// index came from the clone
let isActive = index === activeIndex;
return (
{children}
);
}
`We've now seperated the state from the elements so that apps can compose however they please. If you want to put a
className on one item and not another, you can, and we don't have to poke holes into our Menu component just to meet every use case that pops up.Almost.
What if we need to put a div around one of the items?
`jsx
`This is totally broken now because we cloned the
div, not the MenuItem. You _could_ recurse down the tree and type check until you find a MenuItem, but…come on.A recursive type check could help a little, but it still limits composition, what if you wanted to do this?
`jsx
function BlueItem(props) {
return ;
};
`The type checking will fail 😭.
So now we need a way to define arbitrary components as a
MenuItem. One workaround is a static property of the component to check instead of just type. The type checking changes from this element.type === MenuItem to this: element.type.is === MenuItem, and of course make sure apps assign BlueItem.is = MenuItem.Our solution (for now): Descendants context registration
The
descendants package provides these key tools:-
createDescendantContext: Creates a special context object to deal with registering descendants in a tree (accepts a name string for better debugging)
- useDescendantsInit: A hook to create a state object containing a descendants array and setter function.
- DescendantProvider: A provider that accepts the descendants array, the state setter, and the component's context object for use at the top of the component tree
- useDescendant: A hook called in the body of a nested descendant component that registers its DOM node and returns its index relative to other descendants in the tree
- useDescendants: A hook that accepts the descendant context and returns descendants registered to the passed context.`jsx
import {
createDescendantContext,
DescendantProvider,
useDescendant,
useDescendantsInit,
} from "@reach/descendants";let DescendantContext = createDescendantContext("DescendantContext");
let MenuContext = createContext();
function Menu({ id, children }) {
// We could be less explicit here and set this up in the DescendantProvider,
// but you may want to do something with
descendants in your top-level
// component and we don't want to force creating an arbitrary child
// component just so we can consume the context.
let [descendants, setDescendants] = useDescendantsInit();
let [activeIndex, setActiveIndex] = React.useState(-1);
return (
context={DescendantContext}
items={descendants}
set={setDescendants}
>
value={{ buttonId: button-${useId(id)}, activeIndex, setActiveIndex }}
>
{children}
);
}function MenuList(props) {
let { buttonId, activeIndex } = useContext(MenuContext);
return (
role="menu"
aria-labelledby={buttonId}
aria-activedescendant={activeIndex}
tabIndex={-1}
>
{children}
function MenuItem({ index: explicitIndex, ...props }) {
let { activeIndex, setActiveIndex } = useContext(MenuContext);
let ref = React.useRef(null);
// We need a ref to the element for our descendant object, but we also
// need to update state after the ref is placed. We can set the ref in a
// callback and use the stateful element to ensure our descendant is
// updated once we have DOM node.
let [element, setElement] = useState(null);
let handleRefSet = useCallback((refValue) => {
ref.current = refValue;
setElement(refValue);
}, []);
// The descendant should be memoized to prevent endless render loops after
// the collection state is updated
let descendant = React.useMemo(() => {
return {
// Assign the DOM node using a stateful reference. This should be safer
// than passing the ref directly.
element,
// You can pass arbitrary data into a descendant object which can come
// in handy for features like typeahead!
key: props.label,
};
}, [element, props.label]);
let index = useDescendant(
descendant,
// Tell the useDescendant hook to use a specific context.
// This is key in case you have a compound component that needs index
// tracking in separate correlating descendant components (like Tabs)
DescendantContext,
// If you want to declare a specific index value, you can pass it as the
// third argument here. This is almost never needed but we provide it as an
// escape hatch for special circumstances.
explicitIndex
);
// Now we know the index, so let's use it!
let isSelected = index === activeIndex;
function select() {
if (!isSelected) {
setActiveIndex(index);
}
}
return (
You can also access the descendants array anywhere in the tree (below the top-level component) with
useDescendants:`jsx
let menuItems = useDescendants(DescendantContext);
`The key tradeoff here is that descendants won't be available on the first render, and as such any components that need this data for server-side rendering will need to manage their own state and pass descendant data from from the top of the tree. For example
`jsx
function Comp() {
{/*
The button needs to know which value is selected to render its label!
The label will be empty on the server if we depend on descendant hooks
*/}
;
}function CompSSR() {
// This limits composition, but now you have your data in one place at the top
let options = ["Apple", "Orange", "Banana"];
let [activeOption, setActiveOption] = React.useState(options[0]);
{/ The button needs to know which value is selected to render its label! /}
{activeOption}
{options.map((option) => (
))}
;
}
function ComposableSSR() {
// You can manage state at the top and still get back some composition, you'll
// just have to deal with a bit of repitition
let [activeOption, setActiveOption] = React.useState("Apple");
{/ The button needs to know which value is selected to render its label! /}
{activeOption}
Apple 🍎
value="Orange"
aria-labelledby="orange-label"
aria-describedby="orange-description"
>
Orange 🍊
Fun fact: Oranges are delicious!
Banana 🍌
;
}
``