Using style objects and trim objects in custom components

A short aside on the example code

So far, we've looked at the example of a FormField, which resembles a component since most FormFields have similar features (some of which we captured in a style object). However, capturing FormField in a custom component that can support all use-cases is hard, and probably not a good idea. Since this chapter deals with styling custom components, I will switch to the example of a DialogButton.

Trim objects

A trim object is a type of style schema that solves two problems:

  • It checks that you are not forgetting to use some relevant part of the style object;
  • It allows you to capture certain combinations of styles (that are picked from style objects) in a reusable preset.

Here is an example of a trim object for a DialogButton:

DialogButton/trim.ts
DialogButton/styles.ts
DefaultDialogButtonTrim.ts
type DialogButtonTrimT = {
  base: {
    componentClassName: string,
    root: {
      border: any;
      color: any;
      fontSize: any;
      padding: any;
    }
    Icon: {
      margin: any;
      size: any;
      color: any;
    };
  },
  danger?: ModeOverlayT<FormFieldTrimT>,
  disabled?: ModeOverlayT<FormFieldTrimT>
}

Every trim object has a base key that defines the normal styles that are applied to the component. In the example, the root element of a DialogButton has a border, a color, a font size and a padding. We also have an Icon element that has a margin, a size and a color. As shown in DefaultDialogButtonTrim.ts, you will normally pick these styles from style objects, but you don't have to.

Apart from the base key, the trim object has optional keys that correspond to the different modes of the component. Since our DialogButton can be in a danger mode or in a disabled mode, we have two optional keys in the trim object. The style for a disabled DialogButton is created by layering the disabled mode-overlay on top of the base mode (using the getMode function that we will see shortly).

The componentName in the base mode is a special key that is used to determine the class name of the root element. This class name can be used in SCSS to add intrinsic styles to the component.

Using trim objects in a custom component

As the next example shows, we can now change the button style by passing in the appropriate trim object:

DialogButton/DialogButton.tsx
DialogButton/trims.ts
Teal-DialogButton.scss
export type PropsT = {
  className?: any;
  danger?: boolean;
  disabled?: boolean;
  iconName: string;
  label: string;
  trim: DialogButtonTrimT;
  // some other props omitted
};

export const DialogButton = (props: PropsT) => {
  const { trim, className, danger, disabled, iconName, label, ...rest } = props;
  const mode = getMode(trim, { danger, disabled });

  return (
    <button
      className={cn(
        trim.base.componentName,
        getModeCn(mode.root),
        [
          'select-none',
          'DialogButton',
          {
            'DialogButton--disabled': disabled,
            'DialogButton--danger': danger,
          },
        ],
        ['flex flex-row items-center', className]
      )}
      {...rest}
    >
      <Icon className={getModeCn(mode.Icon)} name={iconName} />
      {label}
    </button>
  );
};

In this example we see that the DialogButton component takes a trim prop. We can set this property to the TealDialogButtonTrim object that is defined in trims.ts. Note that TealDialogButtonTrim is based on DefaultDialogButtonTrim. This shows how you can easily create new trims based on existing ones.

It's a good idea to declare TealDialogButtonTrim to be of type DialogButtonTrimT so that Typescript immediately catches missing keys in the trim object, rather than reporting errors when you try to use it in DialogButton.

Note that DialogButton still adds the DialogButton class name to the root element, as well DialogButton--danger and DialogButton--disabled (depending on the mode that the button is in). This allows you to add more intrinsic styles, as shown in Teal-DialogButton.scss. However, in your code, you could leave out these extra classes.

The getMode, getModeCn and createTrim functions

As was mentioned above, the danger and disabled modes are layered on top of the base mode. We can do this with the getMode function. In the case of a disabled DialogButton, it executes a mergeDeepRight(trim.base, trim.disabled).

The createTrim function is used to create new trims based on existing trims. It also uses a mergeDeepRight to do this. In the example, we created TealDialogButtonTrim from DefaultDialogButtonTrim by setting the color in the base mode to DialogButtonS.color.teal().

Finally, since the contents of a mode are dictionaries, we need a way to convert these to class names. This is done using the getModeCn function. For example, the result of calling getModeCn(mode.root) is the concatenation of all the values in the mode.root dictionary.

Caching in getMode and getModeCn

Since getMode and getModeCn are performed inside the render function, the results of these functions are cached. The cache is stored in the trim object itself. Please check out the sample code to see how this is done.