Skip to content

[feat]: components - className - rewrite long string to multil-line for DX #8695

@tresorama

Description

@tresorama

Feature description

The idea is to rewrite component that has extremely long className string

<Comp
  className={cn(
    "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
    className
  )}
/>

to a multi line with + for concat, with a minimal structure for the styles applied

<Comp
  className={cn(
     // base
    "w-fit px-3 py-2 flex items-center justify-between gap-2"
    + " rounded-md border border-input bg-transparent shadow-xs"
    + " text-sm whitespace-nowrap"
    + " transition-[colorbox-shadow]"
    + " dark:bg-input/30 dark:hover:bg-input/50"
    // state
    + " data-[placeholder]:text-muted-foreground"
    + " data-[size=default]:h-9 data-[size=sm]:h-8"
    + " aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive"
    + " disabled:cursor-not-allowed disabled:opacity-50"
    + " outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
    // children
    + " [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
    + " *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2",
    className
  )}
/>

Affected component/components

all

Additional Context

Problem

This is the SelectTrigger of select component

function SelectTrigger({
  className,
  size = "default",
  children,
  ...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
  size?: "sm" | "default"
}) {
  return (
    <SelectPrimitive.Trigger
      data-slot="select-trigger"
      data-size={size}
      className={cn(
        "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
        className
      )}
      {...props}
    >
      {children}
      <SelectPrimitive.Icon asChild>
        <ChevronDownIcon className="size-4 opacity-50" />
      </SelectPrimitive.Icon>
    </SelectPrimitive.Trigger>
  )
}

the className doesn't convey any structure and it's extremely long, making hard to edit the component once it's imported.

Idea

After several test, when I need to customize a component, i usually rewrite the className string like this
The order is not extremely rigid, it's just enough to have base -> overrides -> children.

function SelectTrigger({
  className,
  size = "default",
  children,
  ...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
  size?: "sm" | "default";
}) {
  return (
    <SelectPrimitive.Trigger
      data-slot="select-trigger"
      data-size={size}
      className={cn(
         // base
        "w-fit px-3 py-2 flex items-center justify-between gap-2"
        + " rounded-md border border-input bg-transparent shadow-xs"
        + " text-sm whitespace-nowrap"
        + " transition-[colorbox-shadow]"
        + " dark:bg-input/30 dark:hover:bg-input/50"
        // state
        + " data-[placeholder]:text-muted-foreground"
        + " data-[size=default]:h-9 data-[size=sm]:h-8"
        + " aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive"
        + " disabled:cursor-not-allowed disabled:opacity-50"
        + " outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
        // children
        + " [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
        + " *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2",
        className
      )}
      {...props}
    >
      {children}
      <SelectPrimitive.Icon asChild>
        <ChevronDownIcon className="size-4 opacity-50" />
      </SelectPrimitive.Icon>
    </SelectPrimitive.Trigger>
  );
}

Performance

I landed on concat string with +, because it's faster compared to other two methods with similar DX.

plain (cn process everything as separate string)

cn(
  "w-fit px-3 py-2 flex items-center justify-between gap-2",
  "disabled:cursor-not-allowed disabled:opacity-50",
  "opacity-50",
   // ...
);

Array.join (cn see only one string)

cn(
  [
  "w-fit px-3 py-2 flex items-center justify-between gap-2",
  "disabled:cursor-not-allowed disabled:opacity-50",
  "opacity-50",
   // ...
  ].join(" ")
);

+ (cn see only one string)

cn(
  "w-fit px-3 py-2 flex items-center justify-between gap-2"
  + " disabled:cursor-not-allowed disabled:opacity-50"
  + " opacity-50"
   // ...
);

Perf

The time difference is not night-n-day different, but considered that we usually customize the base components not so often, it's better the degrade the DX a little and save compute time in the app re-renders.

Iterations plain Array.join +
1_000 14ms 3ms 2ms
10_000 43ms 22ms 3ms
100_000 295ms 215ms 12ms

NOTE: The time is not the time taken for a single cn() run, but the sum of multiple identical cn() calls in sequence (for loop).

See test
import { describe, it } from "vitest";
import { cn } from "./cn";
import { repeatSyncFn } from "../utils/vitest.utils";

const ITERATIONS = 100_000;

describe(`shadcn - cn - multi tring patterns - ITERATIONS:${ITERATIONS}`, () => {
  it("plain", () => {
    repeatSyncFn(ITERATIONS, () => {
      const finalClasses = cn(
        "w-fit px-3 py-2 flex items-center justify-between gap-2",
        "rounded-md border border-input bg-transparent shadow-xs",
        "text-sm whitespace-nowrap",
        "data-[placeholder]:text-muted-foreground",
        "[&_svg:not([class*='text-'])]:text-muted-foreground",
        "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
        "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
        "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
        "dark:bg-input/30 dark:hover:bg-input/50",
        "transition-[colorbox-shadow] outline-none",
        "",
        "disabled:cursor-not-allowed disabled:opacity-50",
        "data-[size=default]:h-9 data-[size=sm]:h-8",
        "*:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2",
      );
    });
  });
  it("concat with +", () => {
    repeatSyncFn(ITERATIONS, () => {
      const finalClasses = cn(
        "w-fit px-3 py-2 flex items-center justify-between gap-2"
        + " rounded-md border border-input bg-transparent shadow-xs"
        + " text-sm whitespace-nowrap"
        + " data-[placeholder]:text-muted-foreground"
        + " [&_svg:not([class*='text-'])]:text-muted-foreground"
        + " [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
        + " focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
        + " aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive"
        + " dark:bg-input/30 dark:hover:bg-input/50"
        + " transition-[colorbox-shadow] outline-none"
        + " "
        + " disabled:cursor-not-allowed disabled:opacity-50"
        + " data-[size=default]:h-9 data-[size=sm]:h-8"
        + " *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2",
      );
    });
  });
  it('concat with Array.join', () => {
    repeatSyncFn(ITERATIONS, () => {
      const finalClasses = cn(
        [
          "w-fit px-3 py-2 flex items-center justify-between gap-2",
          "rounded-md border border-input bg-transparent shadow-xs",
          "text-sm whitespace-nowrap",
          "data-[placeholder]:text-muted-foreground",
          "[&_svg:not([class*='text-'])]:text-muted-foreground",
          "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
          "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
          "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
          "dark:bg-input/30 dark:hover:bg-input/50",
          "transition-[colorbox-shadow] outline-none",
          "",
          "disabled:cursor-not-allowed disabled:opacity-50",
          "data-[size=default]:h-9 data-[size=sm]:h-8",
          "*:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2",
        ].join(" ")
      );
    });
  });
});

Before submitting

  • I've made research efforts and searched the documentation
  • I've searched for existing issues and PRs

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions