clubmate.fi

A good[ish] website

Web development blog, loads of UI and JavaScript topics

Highly reusable tabs with compound components

Filed under: UI components— Tagged with: React

In my previous post I wrote about tabs. That post circles around the general logic and accessibility features of tabs, but the tab system presented there is very much hard-coded with one design, which might serve most purposes, but it isn’t very flexible to be used in larger environments with more complex business requirements.

Jump to the full component 👇 if you’re just looking for that.

In this post I’ll be using compound components paradigm to build a tab system that has no limitations in styling and is flexible at handling data in various forms.

Compound components

The idea of compound components is to export each piece of the UI separately, then reconstruct the UI from these pieces at the target. This way the dev has a full control of all the elements, and can reshuffle and style them as they wish.

Here’s my tab system as is, without any styles it looks like nothing, but it works:

And here’s the code for it:

import { Tabs, TabList, TabPanels, Tab, TabPanel } from './CompoundTabs'

return (
  <Tabs id="my-tabs">
    <TabList>
      <Tab>Foo</Tab>
      <Tab>Bar</Tab>
      <Tab>Baz</Tab>
    </TabList>
    <TabPanels>
      <TabPanel>Foo content</TabPanel>
      <TabPanel>Bar content</TabPanel>
      <TabPanel>Baz content</TabPanel>
    </TabPanels>
  </Tabs>
)

It’s just components. If you want the tabs on the bottom, just put them in the bottom, no need to add props and change the base component.

Styling layer

Here’s an example of a styled version:

I’m extending the components with styled-components, but the CSS styling layer can be anything, every component receives the className prop.

Here’s what the component for that above example looks like:

// StyledTabs.js
import styled from 'styled-components'
import { resetButton } from '@src/utils/style-utils'
import { Tabs, TabList, Tab, TabPanel } from './CompoundTabs'

const StyledTabs = styled(Tabs)({  marginBottom: '20px',
  maxWidth: '700px'
})

const StyledTabList = styled(TabList)({
  display: 'flex',
  gap: '1px',
  listStyleType: 'none',
  margin: 0
})

const StyledTab = styled(Tab)({
  ...resetButton,  backgroundColor: 'rgb(242, 242, 235)',
  borderBottom: '1px solid rgb(255, 255, 255)',
  borderRadius: '18px 18px 0 0',
  display: 'flex',
  flex: '1 0 auto',
  padding: '10px 15px',
  textTransform: 'uppercase',

  ':focus': {
    position: 'relative',
    zIndex: 100
  },

  '&[data-selected]': {    backgroundColor: 'rgb(230, 230, 230)',
    borderBottom: '1px solid rgb(230, 230, 230)'
  }
})

const StyledTabPanels = TabPanels

const StyledTabPanel = styled(TabPanel)({
  backgroundColor: 'var(--gray-light)',
  borderRadius: '0 0 18px 18px',
  marginBottom: '1px',
  minHeight: '300px',
  padding: '15px'
})

const App = () => (
  <StyledTabs id="my-tabs">
    <StyledTabList>
      <StyledTab>Foo</StyledTab>
      <StyledTab>Bar</StyledTab>
      <StyledTab>Baz</StyledTab>
    </StyledTabList>
    <StyledTabPanels>
      <StyledTabPanel>Foo content</StyledTabPanel>
      <StyledTabPanel>Bar content</StyledTabPanel>
      <StyledTabPanel>Baz content</StyledTabPanel>
    </StyledTabPanels>
  </StyledTabs>
)
L6 styled(Tabs)
I’m just extending the tab components with styled-components, could be any lib tho.
L19 ...resetButton
See my post on how to reset a button reset.
L33 [data-selected]
This data attribute is for styling.

The underlying base component

This is the good stuff. It’s not black magic, but there are few interesting bits:

  • Context is used to share some of the handlers and helpers.
  • A hook called useDescendants is used to solve the current index issue.

What is the current index issue?

The compound component current index problem

With compound components we don‘t have access to the current index, like we do with the old school comps that take their data as an array.

Data as an array

Forget the component approach and go with arrays, it‘s easy to pull the current index and the length if needed:

<Tabs
  data={[
    { name: 'Foo', content: 'Foo content' },
    { name: 'Bar', content: 'Bar content' },
    { name: 'Baz', content: 'Baz content' }
  ]}
/>

But this is super inflexible, and soon enough you’ll find yourself drilling in tens of different props if you want to reorder the elements or whatever.

Clone the child

The other option is to use the Top-Level React.Children.map() to loop over the children, then leverage the React.cloneElement() to add an index into the child component without actually passing in a prop:

// Inside a parent component, TabList.js or Tabs.js
const children = React.Children.map(props.children, (child, index) => {
  return React.cloneElement(child, { index })
})

Downside of this approach is that it breaks if we wrap the children in any way, for example if we’d want to make the TabList a unordered list:

<Tabs id="tabs">
  <TabList as="ul">
    {/* ❌ breaks ❌ */}
    <li>
      <Tab>Foo</Tab>
    </li>
    <li>
      <Tab>Bar</Tab>
    </li>
  </TabList>
  <TabPanels>
    <TabPanel>Foo content</TabPanel>
    <TabPanel>Bar content</TabPanel>
  </TabPanels>
</Tabs>

You could give the components the indices as props:

<Tabs id="tabs">
  <TabList as="ul">
    <li>
      <Tab index={1}>Foo</Tab>
    </li>
    <li>
      <Tab index={2}>Bar</Tab>
    </li>
  </TabList>
  <TabPanels>
    <TabPanel>Foo content</TabPanel>
    <TabPanel>Bar content</TabPanel>
  </TabPanels>
</Tabs>

That would work, but you have to remember to add the props, and props are not the ethos compound components. There is a solution tho.

The useDescendants hook

There’s a package called useDescendants, and it works like below.

I forked the package, because it’s in beta and has a bug. See my fork here. This post uses the fork, I’ll make an update here when the real package is fixed.

import { Descendants, useDescendant, useDescendants } from './useDescendants'

export const TabList = ({ as: Comp, className, children }) => {
  // Get the context by calling `useDescendants`  const context = useDescendants()

  return (
    // Wrap the children in `Descendants` and pass it the context that you get    // from `useDescendants`    <Descendants value={context}>
      <Comp className={className} role="tablist">
        {children}
      </Comp>
    </Descendants>
  )
}

export const Tab = ({ as: Comp, children }) => {
  const ref = React.useRef()
  // Get the index and the context (returning the context here is part of my  // fork), you can also pass it any props you want and they’ll appear in  // `context.map.current`  const { index, context } = useDescendant({ ref })
  const isActive = index === activeTabIndex
  const { handleKeyboard } = React.useContext(TabContext)

  return (
    <Comp
      aria-selected={isActive}
      key={index}
      // `context` and `index` are needed in the keyboard handler      onKeyDown={handleKeyboard(index, context)}
      ref={ref}
      role="tab"
      tabIndex={isActive ? 0 : -1}
      {/* Bunch or other props... see the full comp below */}
    >
      {children}
    </Comp>
  )
}

The full component

Check the full code behind the fold, interesting lines highlighted.

The full components
import React from 'react'
import PropTypes from 'prop-types'
import { Descendants, useDescendant, useDescendants } from './useDescendants'

const TabContext = React.createContext()
const sharedPropTypes = {
  as: PropTypes.string,
  children: PropTypes.oneOfType([PropTypes.elementType, PropTypes.array])
    .isRequired,
  className: PropTypes.string
}
const sharedDefaultProps = {
  as: 'div'
}

export const Tab = props => {
  const { as: Comp, children, className } = props
  const ref = React.useRef()
  const { index, context } = useDescendant({ ref })  const { handleClick, handleKeyboard, getId, activeTabIndex } =
    React.useContext(TabContext)
  const isActive = index === activeTabIndex

  return (
    <Comp
      aria-controls={getId(index, 'tabpanel')}
      aria-selected={isActive}
      className={className}
      data-selected={isActive ? '' : undefined}
      href={Comp === 'a' ? `#${getId(index, 'tabpanel')}` : undefined}
      id={getId(index)}
      key={index}
      onClick={handleClick(index)}
      onKeyDown={handleKeyboard(index, context)}      ref={ref}
      role="tab"
      tabIndex={isActive ? 0 : -1}
      type={Comp === 'button' ? 'button' : undefined}
    >
      {children}
    </Comp>
  )
}

Tab.defaultProps = { as: 'button' }
Tab.propTypes = sharedPropTypes

export const TabList = ({ as: Comp, className, children }) => {
  const context = useDescendants()
  return (
    <Descendants value={context}>      <Comp className={className} role="tablist">
        {children}
      </Comp>
    </Descendants>
  )
}

TabList.defaultProps = sharedDefaultProps
TabList.propTypes = sharedPropTypes

export const TabPanels = ({ as: Comp, className, children }) => {
  const context = useDescendants()
  return (
    <Descendants value={context}>      <Comp className={className} tole="tablist">
        {children}
      </Comp>
    </Descendants>
  )
}

TabPanels.defaultProps = sharedDefaultProps
TabPanels.propTypes = sharedPropTypes

export const TabPanel = props => {
  const { as: Comp, className, children } = props
  const { index } = useDescendant()  const { activeTabIndex, getId } = React.useContext(TabContext)
  const isActive = activeTabIndex === index

  return (
    <Comp
      aria-labelledby={getId(index)}
      className={className}
      id={getId(index, 'tabpanel')}
      hidden={!isActive}
      key={index}
      tabIndex={0}
      role="tabpanel"
    >
      {children}
    </Comp>
  )
}

TabPanel.defaultProps = sharedDefaultProps
TabPanel.propTypes = sharedPropTypes

export const Tabs = ({ children, id, initialIndex, className }) => {
  const [activeTabIndex, setActiveTabIndex] = React.useState(initialIndex)  const getId = (...rest) => [...rest, id].join('-')

  const handleClick = index => event => {
    event.preventDefault()
    setActiveTabIndex(index)
  }

  const focusTab = currentTab => {    setActiveTabIndex(currentTab.index)
    currentTab.props.ref.current.focus()  }

  const handleKeyboard = (index, context) => event => {    const tabCount = Object.keys(context.map.current).length - 1

    const getTab = index => {      const tabs = context.map.current      const id = Object.keys(tabs).find(id => tabs[id].index === index)      return tabs[id]    }
    if (event.key === 'ArrowRight') {
      if (tabCount > index) return focusTab(getTab(index + 1))
      return focusTab(getTab(0))
    }

    if (event.key === 'ArrowLeft') {
      if (index > 0) return focusTab(getTab(index - 1))
      return focusTab(getTab(tabCount))
    }

    if (event.key === 'Home') {
      event.preventDefault()
      return focusTab(getTab(0))
    }

    if (event.key === 'End') {
      event.preventDefault()
      return focusTab(getTab(tabCount))
    }
  }

  return (
    <TabContext.Provider      value={{ activeTabIndex, handleClick, handleKeyboard, getId }}    >      <div className={className}>{children}</div>    </TabContext.Provider>  )
}

Tabs.defaultProps = { initialIndex: 0 }
Tabs.propTypes = {
  ...sharedPropTypes,
  initialIndex: PropTypes.number
}

Conclusions

Was an interesting trip developing this. Compound components feels like a technological singularity when it comes to React components.

In the Reach GitHub there is a great piece on the journey of discovering the concept of descendant components. Also be sure to check Ryan Florence’s talk on compound components.

Hope this was helpful, thanks for reading.

Comments would go here, but the commenting system isn’t ready yet, sorry.

  • © 2022 Antti Hiljá
  • About
  • All rights reserved yadda yadda.
  • I can put just about anything here, no one reads the footer anyways.
  • I love u!