Open16

React TWC を触ってみる

myttymytty

ドキュメント
https://react-twc.vercel.app/

myttymytty

TWC は、簡単にいうと1行で Tailwind CSS を用いたスタイル付きコンポーネントを作成することができるライブラリ。

簡単なカードコンポーネントを React と Tailwind CSS を用いて実装する場合、通常以下のようになる。

import * as React from "react";
import clsx from "clsx";
 
const Card = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
  <div
    ref={ref}
    className={clsx(
      "rounded-lg border bg-slate-100 text-white shadow-sm",
      className,
    )}
    {...props}
  />
));

一方、TWC を利用すれば以下のように1行で書くことができる。

import { twc } from "react-twc";
 
const Card = twc.div`rounded-lg border bg-slate-100 text-white shadow-sm`;

めちゃくちゃ簡潔に書けるね。

myttymytty

RSC との互換性問題ないようだし、サードパーティー製のコンポーネント(例えば、Radiix など)でも利用できるのは非常にいい。

myttymytty
export const createTwc = <TCompose extends AbstractCompose = typeof clsx>(
  config: Config<TCompose> = {},
) => {
  const compose = config.compose || clsx;
  const defaultShouldForwardProp =
    config.shouldForwardProp || ((prop) => prop[0] !== "$");
  const wrap = (Component: React.ElementType) => {
    const createTemplate = (
      attrs?: Attributes,
      shouldForwardProp = defaultShouldForwardProp,
    ) => {
      const template = (
        stringsOrFn: TemplateStringsArray | Function,
        ...values: any[]
      ) => {
        const isClassFn = typeof stringsOrFn === "function";
        const tplClassName =
          !isClassFn && String.raw({ raw: stringsOrFn }, ...values);
        return React.forwardRef((p: any, ref) => {
          const { className: classNameProp, asChild, ...rest } = p;
          const rp =
            typeof attrs === "function" ? attrs(p) : attrs ? attrs : {};
          const fp = filterProps({ ...rp, ...rest }, shouldForwardProp);
          const Comp = asChild ? Slot : Component;
          const resClassName = isClassFn ? stringsOrFn(p) : tplClassName;
          return (
            <Comp
              ref={ref}
              className={
                typeof resClassName === "function"
                  ? (renderProps: any) =>
                      compose(
                        resClassName(renderProps),
                        typeof classNameProp === "function"
                          ? classNameProp(renderProps)
                          : classNameProp,
                      )
                  : compose(resClassName, classNameProp)
              }
              {...fp}
            />
          );
        });
      };

      template.transientProps = (
        fnOrArray: string[] | ((prop: string) => boolean),
      ) => {
        const shouldForwardProp =
          typeof fnOrArray === "function"
            ? (prop: string) => !fnOrArray(prop)
            : (prop: string) => !fnOrArray.includes(prop);
        return createTemplate(attrs, shouldForwardProp);
      };

      if (attrs === undefined) {
        template.attrs = (attrs: Attributes) => {
          return createTemplate(attrs);
        };
      }

      return template;
    };

    return createTemplate();
  };

  return new Proxy(
    (component: React.ComponentType) => {
      return wrap(component);
    },
    {
      get(_, name) {
        return wrap(name as keyof JSX.IntrinsicElements);
      },
    },
  ) as any as Twc<TCompose>;
};
myttymytty
const compose = config.compose || clsx;

compose は クラス名を結合するための関数やけど、デフォルトでは clsx になっている。
別の結合関数も、config で渡せるようになってるっぽい。

const template = (
        stringsOrFn: TemplateStringsArray | Function,
        ...values: any[]
      ) => {
        const isClassFn = typeof stringsOrFn === "function";
        const tplClassName =
          !isClassFn && String.raw({ raw: stringsOrFn }, ...values);
        return React.forwardRef((p: any, ref) => {
          const { className: classNameProp, asChild, ...rest } = p;
          const rp =
            typeof attrs === "function" ? attrs(p) : attrs ? attrs : {};
          const fp = filterProps({ ...rp, ...rest }, shouldForwardProp);
          const Comp = asChild ? Slot : Component;
          const resClassName = isClassFn ? stringsOrFn(p) : tplClassName;
          return (
            <Comp
              ref={ref}
              className={
                typeof resClassName === "function"
                  ? (renderProps: any) =>
                      compose(
                        resClassName(renderProps),
                        typeof classNameProp === "function"
                          ? classNameProp(renderProps)
                          : classNameProp,
                      )
                  : compose(resClassName, classNameProp)
              }
              {...fp}
            />
          );
        });
      };

template 関数で、コンポーネントを返してる。

const Comp = asChild ? Slot : Component;

ここもポイントやな。asChild が props として渡ってきた場合に、Slot と Componennt を切り替えれるようになってる。あとで試してみよ。

あと、forwardRef でラップしてるので、ref を渡すこともできる。

myttymytty

実際に使ってみる。

Card コンポーネントで色々試してみようか。

Card.tsx
import { twc } from "react-twc"

export const Card = twc.div`rounded-lg border bg-slate-100 shadow-lg p-4`
page.tsx
import { Card } from "./_components/Card";

export default function Home() {
  return (
    <div className="mx-4 space-y-4">
      <p>Card Sample</p>
      <Card>
        <p>Card Content</p>
      </Card>
    </div>
  );
}

描画されてる。

myttymytty

asChild を props に渡してみる。
p タグは、section タグでラップしておく。

page
import { Card } from "./_components/Card";

export default function Home() {
  return (
    <div className="mx-4 space-y-4">
      <p>Card Sample</p>
      <Card asChild>
        <section>
          <p>Card Content</p>
        </section>
      </Card>
    </div>
  );
}


div から section に変わってるので、asChild が問題なく動作している。

myttymytty

className を渡せるのか確認する

page.tsx
import { Card } from "./_components/Card";

export default function Home() {
  return (
    <div className="mx-4 space-y-4">
      <p>Card Sample</p>
      <Card className="border-2 border-red-400">
        <p>Card Content</p>
      </Card>
    </div>
  );
}

myttymytty

デフォルトだと、クラスのマージができないみたい。
rounded-sm を渡してみると、rounded-lg が残ったまま。

page.tsx
import { Card } from "./_components/Card";

export default function Home() {
  return (
    <div className="mx-4 space-y-4">
      <p>Card Sample</p>
      <Card className="border-2 border-red-400 rounded-sm">
        <p>Card Content</p>
      </Card>
    </div>
  );
}

マージしたいのであれば、例えば、以下のような tmMerge と clsx を組み合わせたユーティリティ関数を作成し、createTwc の compose に渡す必要がある。

utils.ts
import { twMerge } from "tailwind-merge";
import { ClassValue, clsx } from "clsx";
import { createTwc } from "react-twc";

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

export const twx = createTwc({ compose: cn });

Card コンポーネントを twc から twx に置き換える。

Card.tsx
import { twx } from "../utils";

export const Card = twx.div`rounded-lg border bg-slate-100 shadow-lg p-4`

再び確認してみる。

ちゃんとマージされてることを確認。

myttymytty

cva を用いた variants を実装してみる

Card.tsx
import { VariantProps, cva } from "class-variance-authority"
import { TwcComponentProps, twc } from "react-twc"

const cardVariants = cva("rounded-log border shadow-log p-4", {
  variants: {
    $color: {
      primary: "bg-slate-100 text-white",
      secondary: "bg-gray-500"
    }
  },
  defaultVariants: {
    $color: "primary"
  }
})

type cardProps = TwcComponentProps<"div"> & VariantProps<typeof cardVariants>

export const Card = twc.div<cardProps>(({ $color }) => cardVariants({ $color }))
page.tsx
import { Card } from "./_components/Card";

export default function Home() {
  return (
    <div className="mx-4 space-y-4">
      <p>Card Sample</p>
      <Card>
        <p>Card Content</p>
      </Card>
      <Card $color="secondary">
        <p>Card Content</p>
      </Card>
    </div>
  );
}

問題ないね。

myttymytty

以下のようなコンポーネントを twc で書き換えるとすると?

import { cva, type VariantProps } from 'class-variance-authority'

import { cn } from '@/lib/utils'
import React from 'react'

const ButtonIconWrapper = ({ children }: { children: React.ReactNode }) => {
  return <span className="inline-flex items-center">{children}</span>
}

const buttonVariants = cva(
  'disabled:hover inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap text-sm font-medium transition duration-200 focus:outline-none focus:ring disabled:cursor-not-allowed disabled:opacity-50 focus:disabled:ring-0',
  {
    variants: {
      variant: {
        primary:
          'bg-[#3ea8ff] text-white hover:bg-[#0f83fd] focus:ring-[#98c7ff] hover:disabled:bg-[#3ea8ff]',
        basic:
          'border border-[#d6e3ed] bg-white hover:bg-[#f5fbff] focus:ring-[#bfdcff] hover:disabled:bg-white ',
      },
      size: {
        sm: 'px-3 py-2 text-[0.95rem]',
        md: 'px-3.5 py-2.5 text-[0.95rem]',
        lg: 'px-4 py-3 text-[0.95rem]',
        icon: 'size-10',
      },
      fullWidth: {
        true: 'w-full',
      },
      radius: {
        sm: 'rounded-sm',
        md: 'rounded-md',
        lg: 'rounded-lg',
        full: 'rounded-full',
      },
    },
    defaultVariants: {
      variant: 'primary',
      size: 'md',
      radius: 'sm',
    },
  },
)

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  startContent?: React.ReactElement
  endContent?: React.ReactElement
}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  (
    {
      children,
      startContent,
      endContent,
      className,
      variant,
      size,
      radius,
      fullWidth,
      ...props
    },
    ref,
  ) => {
    return (
      <button
        className={cn(
          buttonVariants({ variant, size, radius, fullWidth, className }),
        )}
        ref={ref}
        type="button"
        {...props}
      >
        {startContent && <ButtonIconWrapper>{startContent}</ButtonIconWrapper>}
        {children}
        {endContent && <ButtonIconWrapper>{endContent}</ButtonIconWrapper>}
      </button>
    )
  },
)
Button.displayName = 'Button'

export { Button, buttonVariants }

実際に試さないけど、まず twc を用いて Button コンポーネントを作成する。

const Button = twc.button..........

そして、以下のように props を渡せる、ButtonWrapper コンポーネントを作成する。

const ButtonWrapper = ({ startContent }) => {
  return (
    <Button>
      {startContent && ...........}
       {children}
    </Button>
    )
}

おそらくこんな感じで、ラッパーを作成するしかないよな。

myttymytty

二度手間になるし、なんなら可読性も下がる。上記のケースではtwcを使うべきではないかも。

myttymytty

一旦試したいことは試せた。