Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?

ListBox

Screen Shot 2019-11-22 at 12 23 31 PM

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

Goal

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.

Naturally Composable API

<LisboxContainer>
  <ListboxButton />
  <ListboxPopup>
    <ListboxList>
      <ListboxOption
        value={123}
        collapsedLabel={
          <>
            <Avatar size={20} uid={123} /> Chance Strickland
          </>
        }
      >
        <Avatar size={50} uid={123} /> Chance Strickland
        <br />
        <small>chance@reacttraining.com</small>
      </ListboxOption>
      <ListboxOption
        value={123}
        collapsedLabel={
          <>
            <Avatar size={20} uid={123} /> Michael Jackson
          </>
        }
      >
        <Avatar size={50} uid={123} /> Michael Jackson
        <br />
        <small>michael@reacttraining.com</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:

  1. Manage focus in keyboard events (particularly arrow keys)
  2. Render the button's content with the option's collapsedLabel.

Implementations

DOM APIs

focus elements know index elements in button SSR
🚫 🚫 🚫
  • Focus: We can use DOM APIs to manage focus with a combination of data-* attributes and querySelectorAll.
  • 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.

Single Render Context Registration Refs

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.

Double Render Context Registration Refs: useDescendants

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.

Alternative API: aPROPcalypse

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>chance@reacttraining.com</small>
    </ListboxOption>,
    <ListboxOption
      value={456}
      collapsedLabel={
        <>
          <Avatar size={20} uid={456} /> Michael Jackson
        </>
      }
    >
      <Avatar size={50} uid={456} /> Michael Jackson
      <br />
      <small>michael@reacttraining.com</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.)

Bikeshedding

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment