Extendable UIs: How to build better UIs for developers

ni
nitsan7702 years ago

I was recently tasked with building a user-card component for the bit.cloud platform. I was also tasked with building the employee-card component along with it.

Before we begin, let's see how easy it is to extend the user-card component by plugging in independent components. Go ahead and drag

and into component. Watch out for my head! (notice what happens to its name once you drag them all):

It's just like playing with lego! 😉

In this post, we will learn how to construct composable UIs.

A beginning of a card story ❤

As you have already seen in the intro, the employee card consumes the user card and extends its abilities. We have the employee info on the right and the social links at the bottom of the card.

How would you compose them together?

If you are used to working with monoliths, your first thought might have been to create all of these components (user card, employee card, social links) in the src folder, and then compose them together.

That is doable but not scalable. If we couple these components together, our ability to create new compositions in the future will be limited.

What happens if you want to add memory to your computer? You plug out the old memory card and plug in a better one with a larger memory capacity. At Bit, we believe software components should behave the same. Let's see how we solve this task using independent components created with Bit.

CSS bootcamp

We have decided that we want to be able to extend our card from the bottom and the right side of it.

CSS-wise, it will be easy to achieve. We will define our card container as a flexbox. The first default div that will enter this container will be our user card. Every div that will enter after it will be on its right as this is the default behavior of CSS flexbox.

We figured out how to extend it from the right, but what about the bottom? By defining the user-card div as a flexbox with a flex-direction of a column, we will get precisely the behavior we want. Every div we insert inside the user card div will take its position right below the existing content.

One last CSS tip. We will give our user-card a width of 50% so that when we inject the plugin on the right, they both take an equal amount of space. But we will also add the pseudo-class :only-child to give it 100% width when we don't render any plugins.

Please make sure that the Bit binary is installed on your machine:

npx @teambit/bvm install
CopiedCopy

Go ahead and fork the user-card component to your local Workspace. It will be easier to follow the code coming up.

$bit
Copiedcopy

Planning the extendable card API

Let's plan the user card API.

Here is the API for the user-card:

export type UserCardProps = {
  /**
   * The username to fetch the user profile from the cloud API.
   * */
  username: string;

  /**
   * Plugins to be injected to the bottom and right side of the card.
   * */
  plugins?: UserCardPlugin<unknown, unknown>[];
} & React.HTMLAttributes<HTMLDivElement>;
CopiedCopy

The plugins array will contain objects that store a component either on the right or the bottom key.

Here's the interface:

import { ComponentType } from 'react';

export interface UserCardPlugin<T = {}, P = {}> {
  bottom?: ComponentType<T>;
  right?: ComponentType<P>;
}
CopiedCopy

We are then using the find() method to store the first plugin we encounter in that array (we do it to limit the consumers of our component, in case they decide to pass more than one plugin:

const BottomPlugin = plugins?.find((plugin) => plugin.bottom);
const RightPlugin = plugins?.find((plugin) => plugin.right);
CopiedCopy

Finally, we render the bottom plugin inside the first div (the column), and the right plugin in the main div:

<div className={classNames(styles.cardContainer, className)} {...rest}>
  <div className={styles.userCard}>
    ...
    {BottomPlugin?.bottom && <BottomPlugin.bottom />}
  </div>

  {RightPlugin?.right && (
    <>
      <div className={styles.verticalLine} />
      <RightPlugin.right />
    </>
  )}
</div>
CopiedCopy

Creating the plugins

We have to create a class that implements our UserCardPlugin interface and injects a component in the right place. Here's how it looks:

export class SocialLinksPlugin implements UserCardPlugin {
  constructor(readonly links: SocialLinksProps['links']) {}
  bottom? = () => <SocialLinks links={this.links} />;
}
CopiedCopy

Now we can initiate the class inside the plugins array:

<UserCard
  className={styles.employeeCard}
  {...rest}
  username={username}
  plugins={[
    new SocialLinksPlugin([
      { url: 'https://github.com/NitsanCohen770', name: 'github' },
      { url: 'https://www.linkedin.com/in/nitsan-cohen/', name: 'linkedin' },
      { url: 'soon', name: 'website' },
      { url: 'https://twitter.com/bitdev_', name: 'twitter' },
    ]),
    new UserInfoPlugin(userInfo),
  ]}
/>
CopiedCopy

Look how happy I am after this task!

 

     

     

The benefits of composable software

Let's say that later on, another task was open: create a developer card. The developer card structure is similar to the employee card, but instead of employee info, it has developer information on the right and 'frameworks/programming languages' at the bottom.

So how would we compose the developer card?

We could create a plugin for the developers' information and the frameworks/programming languages. We would then inject them into the plugins array, and there you go - a developer card!

The place where this really shines is when it comes to collaboration. You can have a team of developers working on plugins, and you can share them throughout your projects. This is true autonomy. Each plugin is independently versioned and has its own release pipeline. If a new version of a plugin is released, each team using it can decide whether they want to update the plugins' version or not.

P.S. If you were wondering, I don't live in San Francisco.