Listbox seems to have all of the constraints in one component. Making a decision here is likely to be easily expanded to the rest of the components. It has all the tricky parts we're dealing with right now:
- Index based focus management
- Children needing their index to render
- Parent/sibling component needing props from a sibling
- Case for SSR support
Additionally:
- Controlled/uncontrolled state
- A portal/popover
- Arbitrary markup around listbox components
Make a composable listbox where the options (like the select/option control we're imitating) get to define their value, collapsed label, and expanded label. But we also want to enable adding arbitrary markup around the options, inside the popup, so that designers are free to make better experiences than a normal select without sacrificing accessibility.
<LisboxContainer>
<ListboxButton />
<ListboxPopup>
<ListboxList>
<ListboxOption
value={123}
collapsedLabel={
<>
<Avatar size={20} uid={123} /> Chance Strickland
</>
}
>
<Avatar size={50} uid={123} /> Chance Strickland
<br />
<small>[email protected]</small>
</ListboxOption>
<ListboxOption
value={123}
collapsedLabel={
<>
<Avatar size={20} uid={123} /> Michael Jackson
</>
}
>
<Avatar size={50} uid={123} /> Michael Jackson
<br />
<small>[email protected]</small>
</ListboxOption>
</ListboxList>
<div>
<button>Add new user</button> <Link to="/users">Manage Users</Link>
</div>
</ListboxPopup>
</LisboxContainer>
This seems like the ideal API. It gives the developer the freedom to build out whatever elements they need. The trick is getting the ListboxOption
to communicate through arbitrary markup with LisboxContainer
for two reasons:
- Manage focus in keyboard events (particularly arrow keys)
- Render the button's content with the option's collapsedLabel.
focus | elements know index | elements in button | SSR |
---|---|---|---|
β | π« | π« | π« |
- Focus: We can use DOM APIs to manage focus with a combination of
data-*
attributes andquerySelectorAll
. - Index: While rendering, elements can't know their index with DOM apis because we can't use the dom until after render is complete.
- Elements in Button: We wouldn't be able to use React's rendering for the button label based on the option's props. However, we could do some tricks to get React to render it hidden somewhere and then we copy the HTML and put it in the button with direct DOM manipulation.
- SSR: DOM APIs are not available until after render, so we wouldn't be able to server render the contents of the button.
focus | elements know index | elements in button | SSR |
---|---|---|---|
β | π« | π« | π« |
- Focus: elements are registered after render and available in event handlers
- Index: elements still can't know their index with a single render pass
- Elements in button: On the initial render, the parent still doesn't know about the options in order to render the button.
- SSR: Same, not enough info to render.
focus | elements know index | elements in button | SSR |
---|---|---|---|
β | β | β | π« |
- Focus: elements are registered after render and available in event handlers
- Index: second render the elements now know their index, keeping normal element/component composition in-tact.
- Elements in button: button can be filled in on the second render pass with the props from the options.
- SSR: The button doesn't know about the options until the second render, so server rendering will produce an empty button.
In order to check all the boxes we have to sacrifice the normal element composition model and expose a handful of render props at the top. Every piece of the hold hierarchy becomes a prop to the container, this way the top component has access to everything without any context registration tricks or dom manipulation.
component | prop |
---|---|
ListboxButton |
button |
ListboxPopup |
renderPopup |
ListboxList |
renderList |
ListboxOption |
options |
We'll bikeshed this API after, but for now keeping it completely mapped to the naturally composable API.
<Listbox
button={<ListboxButton />}
options={[
<ListboxOption
value={123}
collapsedLabel={
<>
<Avatar size={20} uid={123} /> Chance Strickland
</>
}
>
<Avatar size={50} uid={123} /> Chance Strickland
<br />
<small>[email protected]</small>
</ListboxOption>,
<ListboxOption
value={456}
collapsedLabel={
<>
<Avatar size={20} uid={456} /> Michael Jackson
</>
}
>
<Avatar size={50} uid={456} /> Michael Jackson
<br />
<small>[email protected]</small>
</ListboxOption>
]}
renderList={options => <ListboxList>{options}</ListboxList>}
renderPopup={list => (
<ListboxPopup>
{list}
<div>
<button>Add new user</button> <Link to="/users">Manage Users</Link>
</div>
</ListboxPopup>
)}
/>
focus | elements know index | elements in button | SSR |
---|---|---|---|
β | β | β | β |
Seems like the way to go.
This still has problems where the props of ListboxOption
can't be composed away into a component, but that's okay, we can throw/warn, and design systems can still wrap it with the as
prop, they just need to keep the same required props interface that we have. (Though I haven't thought all the way through this, I have a spidey sense that this might not be a limitation.)
I'd probably want it to be designed like this, but the whole thing is at this point open to bikeshedding, but the implementation will remain the same
<ListboxInput> {/* <-- this is the button */}
{/* These must be direct children and must have */}
<ListboxOption collapsedLabel={</>} expandedLabel={</>}/>
<ListboxOption collapsedLabel={</>} expandedLabel={</>}/>
</ListboxInput>
And then you add in props to the top as needed, but the options remain the children of the button.
<ListboxInput
{...propsForTheButton}
// control the popup rendering
renderPopup={list => (
<ListboxPopup>
{list}
<div>
<button>Add new user</button> <Link to="/users">Manage Users</Link>
</div>
</ListboxPopup>
)}
// control the list rendering
renderList={options => <ListboxList>{options}</ListboxList>}
>
{/* options remain children because their collapsed labels
are the children of the button */}
<ListboxOption collapsedLabel={</>} expandedLabel={</>}/>
<ListboxOption collapsedLabel={</>} expandedLabel={</>}/>
</Listbox>