Categories
Complex problems Front end

Fresh composition examples from our component library

Built over a compositional architecture, our component library allows us to reuse and combine a lot of code. Today I’ll share my two favourite examples where that composability really shines.

If you’ve been following this blog for the past few weeks you already know that we’re doing some serious component work for our Circuit for Teams rewrite.

We are getting close to the three-month mark in that project (and super close to a production deploy, stay tuned) and some of the seeds that we planted earlier in the project are starting to sprout. 🌱

Like our humble but ever-growing component library. Built over a compositional architecture, it allows us to reuse and combine a lot of code. And today I’ll share my two favourite examples where that composability really shines.

Let’s get into some code for a change, shall we?

Complex form fields

Creating reliable, intuitive, accessible input fields is surprisingly complicated. Great form UX needs proper error handing, great keyboard navigation and just enough information to guide your user without polluting the UI.

And to make things even more complicated you need all that “extra” stuff for not only text fields but checkboxes, select fields, autocompletes, etc. So either you find a way to share some code or duplicate a lot of it.

We solved that problem by separating the “core” field (think an <input> or <select> element) from “extras” like labels, error messages and aria props. That way we get just enough separation of concerns to keep the “extras” DRY and maintainable while allowing the developer to just slap labels to any new use case.

Here’s an example, this piece of code:

<FormField
  label="Input label"
  description="Input description"
  cornerHint="This is an extra hint"
  caption="Input caption"
>
  <input />
</FormField>

<FormField
  label="Checkbox label"
  description="Checkbox description"
  cornerHint="This is an extra hint"
  caption="Checkbox caption"
>
  <input type="checkbox" />
</FormField>

Renders something like this:

Under the hood, FormField handles how each “extra” element is styled and proxies the label, description (and the not pictured error) props into relevant aria attributes like aria-labelledby and aria-describedby. A pretty neat solution if you’d ask me.

FormField implementation is inspired by Material UI FormControl API with additional changes to better match our library specifications. We’re using it with text fields, autocomplete fields, time fields, toggles, and selects.

This abstraction has proved itself as a reliable, accessible, easy to test and flexible solution. It required some research and elbow grease but just looking at it makes me happy. 🙂

Dropdown atoms

At the end of the day, component composition is the art of carefully placing implementation details so you can do the same job with fewer lines of code.

It is an art of abstraction, attention and balance. Push too hard on specificity and you and end up with components that are too tight and a pain to extend, abstract too loosely and you end up with duplication and poor maintainability.

And that applies not only to business logic but to styles as well. An extensive Design System has a lot of intersection between components and finding a good abstraction for those shared styles is key for UI consistency.

Luckily, for the sake of this article’s completeness, Circuit’s web Design System has a great example of UI intersection. We have components for regular Select, Address, and Time autocomplete.

All of them with a dropdown list that looks a bit like this:

Which does not look super complex, right? You could very easily write a few lines of CSS each for Select, Address, and Time and that wouldn’t hurt your maintainability much.

But just like good design is 99% invisible, good UI needs just enough complexity to convey what users need to see. In our case, that extra information comes as optional icons for each available option.

That extra Pin icon is an important variation to help our users notice that they’re selecting an Address, but not very useful for the Time component as you might imagine.

What we did to find balance was to identify what is an intersection and what is a variation. Then make all intersections easy to reach and every variation strictly optional.

For instance, the element that wraps a list of selectable options (like a simple <ul>, for example) has the same behaviour for Select, Address, and Time.

That’s an intersection, good! We can write that atom once and reuse it everywhere we need it. Like this:

// Component creation with Twin Macro
export const DropdownList = styled.ul(({ isOpen }: { isOpen: boolean }) => [
  focusStyles,
  tw`
    absolute
    w-full
    hidden
    border
    border-blueGray-300
    border-solid
    mt-1.5
    py-4
    rounded-md
    shadow-sm
    bg-white
    z-10
    font-functional
  `,
  isOpen && tw`block`,
])

// Implementation example
<DropdownList> 
  {items?.map((item, index) => (
    <li>{item}</li>
  ))}
</DropdownList>

That Pin icon, on the other hand, only appears on Address items, that’s a variation that should not be allowed on Select and Time. So we keep it optional by using slots on option item components.

Like so:

// Component creation with Twin Macro
const ItemRow = styled.li(({ isSelected }: { isSelected: boolean }) => [
  tw`flex justify-between items-start py-2 px-4 cursor-pointer`,
  isSelected && tw`bg-blueGray-100`,
])

const LeftSlot = tw.div`mt-1 mr-4`
const ItemContent = tw.div`flex-1`
const RightSlot = tw.div`mt-1 ml-4`

export const DropdownListItem = ({
  isSelected,
  children,
  startSlot,
  endSlot,
}) => {
  return (
    <ItemRow isSelected={isSelected}>
      {startSlot && <LeftSlot>{startSlot}</LeftSlot>}
      <ItemContent>{children}</ItemContent>
      {endSlot && <RightSlot>{endSlot}</RightSlot>}
    </ItemRow>
  )
}

// Implementation example 
<DropdownList>
  {items?.map((item, index) => (
    <DropdownListItem
      startSlot={item.startSlot}
      endSlot={item.endSlot}
    >
      {item.title && <DropdownTitle>{item.title}</DropdownTitle>}
      {item.subtitle && (
        <DropdownSubtitle>{item.subtitle}</DropdownSubtitle>
      )}
    </DropdownListItem>
  ))}
</DropdownList>

That way we can have the best of both worlds, super DRY basic styles and optional variations that keep custom display logic all in the same file!

Wrapping up

Trying to find the best way to write and combine components feels like a puzzle that’s super rewarding when you get things right.

That being said, despite multiple iterations and being happy with the current state of these two abstractions I’m sure that they will change and adapt in the future.

UI work is dynamic, users needs and requirements change all the time, and so should our code. Create the best components you can with the most elegant abstractions, but try to keep the flexibility and don’t grow too attached. 😄

Photo by Ross Sneddon on Unsplash

Leave a Reply

Your email address will not be published. Required fields are marked *