1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ドワンゴAdvent Calendar 2024

Day 19

Panda CSSでデザイントークンの型制約を一部だけ緩めるプラグインを作成する

Posted at

はじめに

Panda CSSではデザイントークンの埋め込みをサポートしています。
定義したデザイントークンには型がつきIDEなどで入力の補助をしてくれるのでデザイントークンのタイピングミス等を防げます。

さらに、設定ファイルでstrictTokensを指定することでデザイントークン以外の値を利用できないような型の制約を課せます。

// ❌ デザイントークン以外の値は利用できない
css({ color: 'red' });
css({ color: 'sucess' });
css({ gap: '8px' });

// ⭕️ デザイントークンを利用する、もしくは値を[]で囲む
css({ color: 'success' });
css({ color: '[red]' });
css({ gap: 'sm' });

// ⭕️ 一部トークン以外で利用できる値が存在する
css({ width: 'screen' });
css({ width: '1/2' });

この制約はかなり厳しく、デザイントークンもしくはPanda CSSの定めた値(witdhscreenとか1/2のような値)を持つすべてのCSSプロパティの値に対して制約が課されます。
そのため、widthheightで利用できるデザイントークンsizesを定義していない場合、デフォルトで持つ型情報以外の値を使う時には[]で囲む必要があります(CSS変数等も使えます)。

// ⭕️ 制約後でも使える値と[]で囲んだ場合
css({ width: 'screen' });
css({ width: '1/2' });
css({ width: '[256px]' });

// ❌ それ以外
css({ width: '256px' });
css({ width: '16rem' });

Panda CSSにはtokensに定義可能なデザイントークンが20個強あります。これほどの種類がありますから、対象のデザイントークンを使いたくない場合や、デザイントークンを用いても自由な値も渡せるようにしたい場合は少なくないと思います。
しかし、それを汲み取って一部だけ型の制限を解除するようなオプションは用意されていませんから、[]を使わずに定義されていない値を使うためにはstrictTokensを外すしかありません。
そうするとデザイントークンだけを使いたい箇所でデザイントークン以外を使えてしまうので困っります。

さて、Panda CSSにはhooksを利用することでコマンドのライフサイクルごとに実装を挟み込むことができます。この機能を利用すれば型ファイルを生成するタイミングに割り込んで、一部分だけデザイントークンによる型の制約をなくし、先ほどの悩みを解決してくれそうです。

この記事ではそのようなプラグインを作成する方法を紹介します。

デザイントークン

デザイントークンは設定ファイルであるpanda.config.tsで定義します。

panda.config.ts
import { defineConfig } from '@pandacss/dev';
 
export default defineConfig({
  theme: {
    tokens: {
      colors: {
        primary: { value: '#50e2d2' },
        success: { value: '#15803D' },
        warning: { value: '#A16207' },
        error: { value: '#DC2626' },
      },
    },
  },
});

このように設定すると既存のデザイントークンの定義が削除され、宣言したものだけが使えるようになります(textStylesのようなtokensと同じ階層のものは別です)。

既存のものと併用したい場合はextendを間に挟みます。

import { defineConfig } from '@pandacss/dev';
 
export default defineConfig({
  theme: {
    extend: {
      tokens: {
        colors: {
          primary: { value: '#50e2d2' },
          success: { value: '#15803D' },
          warning: { value: '#A16207' },
          error: { value: '#DC2626' },
        },
      },
    },
  },
});

strictTokens

最初に記述しましたが、strictTokensはデザイントークンだけを使うような型を生成するように指示する設定です。

sizesについてのデザイントークンを定義していなくても、CSSの規定値である100%ですら型エラーが出るようになります。

// ❌
<div className={css({ h: '100%' })}>

// ⭕️
<div className={css({ h: '[100%]' })}>

エラーはPanda CSSのPlaygroundからも確認できます。

この時に利用可能な値はscreen1/2のようなPanda CSSから与えられている一部の値と、theme.tokens.sizestheme.extend.tokens.sizesに定義した値だけになります(theme.tokensに定義がない場合はデフォルトのデザイントークンの利用もできます)。

panda.config.ts
import { defineConfig } from '@pandacss/dev';
 
export default defineConfig({
  theme: {
    tokens: {
      sizes: {
        md: { value: '256px' },
      },
    },
  },
});

上記のように定義すると、mdsizesを使うCSSプロパティに渡せます(この記事で出てくるCSSプロパティとは標準のものではなく、Panda CSSに渡せるキーを指しています)。

// ⭕️
<div className={css({ h: 'md' })}>

strictTokensの挙動を確認できたところで、strictTokensが有効な時と無効な時でhに渡す値の型の違いを確認します。
CSSの各プロパティの型はstyled-system(コードの生成先)のtypes/style-props.d.tsSystemPropertiesで定義されています。
hの値の型はstrictTokensを使っていない場合は以下のようになっています。

ConditionalValue<UtilityValues["height"] | CssVars | CssProperties["height"] | AnyString>

strictTokensを有効にしたときは以下のようになっています。

ConditionalValue<WithEscapeHatch<UtilityValues["height"] | CssVars>>

strictTokensの方はWithEscapeHaschの層が追加され、CssProperties["height"]AnyStringが排除されています。

export type WithEscapeHatch<T> = T | `[${string}]` | WithColorOpacityModifier<T> | WithImportant<T>

type WithColorOpacityModifier<T> = [T] extends [string] ? `${T}/${string}` & { __colorOpacityModifier?: true } : never

type ImportantMark = "!" | "!important"
type WhitespaceImportant = ` ${ImportantMark}`
type Important = ImportantMark | WhitespaceImportant
type WithImportant<T> = [T] extends [string] ? `${T}${Important}` & { __important?: true } : never

WithEscapeHasch[]で囲んだあらゆる文字列の許可とTに追加して色のAlpha値、importantフラグを設定することを可能にしています。

そして、削除されたCssProperties["height"]は、CSSの標準で定義された値を使えるようにしています(少し長くなるのと、中身は重要じゃないので型の紹介は飛ばします)。

type AnyString = (string & {})

AnyStringはプリミティブな値をIDEで予測変換をさせつつ、すべての文字列を受け入れ可能にしています。

つまり、strictTokensが有効のときはデフォルトで指定できる値を排除してUtilityValues["height"]CssVarsだけを許可し、WithEscapeHatchによって[]を利用するなどの例外箇所をサポートしています。

次にstrictTokensで許可された値についてみていきます。
CssVarsはCSS変数を利用するための型です。

type CssVars = `var(--${string})`

UtilityValues["height"]tokens.sizesに定義した値とデフォルトでトークンに指定された値を提供します。

type UtilityValues["height"] = height: "auto" | Tokens["sizes"] | "svh" | "lvh" | "dvh" | "screen" | "1/2" | "1/3" | "2/3" | "1/4" | "2/4" | "3/4" | "1/5" | "2/5" | "3/5" | "4/5" | "1/6" | "2/6" | "3/6" | "4/6" | "5/6";
type Tokens["sizes"] = "md" | "breakpoint-sm" | "breakpoint-md" | "breakpoint-lg" | "breakpoint-xl" | "breakpoint-2xl"

UtilityValues["height"]に値が存在しないケースでは、デザイントークンを用いるCSSプロパティでも型の制約が行われません。
つまり、このケースに該当するデザイントークンの型の制約を行いたくない場合はデザイントークンを定義しないようにすることで、この記事で紹介するプラグインは不要になります。

ここまででstrictTokensが有効な時に渡せる値と渡せない値、それぞれの型の情報が理解できたと思います。
確かに、この型制約がデザイントークンを定義していないプロパティで設けられると、[]を使わない限りは身動きが取れなそうですね。

sizesに関係する型制約を緩和する

Panda CSSのhooksを利用して、型ファイルを生成するタイミングに割り込んで、デザイントークンのうち、sizesだけ型の制約をなくすようにします。

まずは、Panda CSSについての型情報を多く持つ@pandacss/node@pandacss/devをインストールします。

npm i -D @pandacss/node @pandacss/dev

@pandacss/devとバージョンを揃えて入れるようにしましょう。

次に、プラグインの入口の準備を作成します。

import type { PandaContext } from '@pandacss/node';
import type {
  LoggerInterface,
  PandaPlugin,
} from '@pandacss/types';

const pluginStrictTokensExcludeSizes = (): PandaPlugin => {
  let logger: LoggerInterface;
  let ctx: PandaContext;

  return {
    name: 'strict-tokens-exclude-sizes',
    hooks: {
      'context:created': (context) => {
        logger = context.logger;
        // @ts-expect-error -- ProcessorInterface型にcontextが存在しないため
        ctx = context.ctx.processor.context as PandaContext;
      },
      'codegen:prepare': (args) => {
        return transformPropTypes(args, ctx, logger);
      },
    },
  };
};

context:createdは本処理で利用するために、loggerとPanda CSSが生成する型情報やshorthandsを持つPandaContext取得しています。
実際の書き込みが始まる前にcodegen:preparetransformPropTypesという処理を割り込ませています。transformPropTypesでファイルに書き込む型情報を変更してsizesに関する型の制約をなくします。

transformPropTypesの全景は以下の通りです。

const transformPropTypes = (
  args: CodegenPrepareHookArgs,
  ctx: PandaContext,
  logger?: LoggerInterface,
) => {
  const artifact = args.artifacts.find((x) => x.id === 'types-styles');
  const content = artifact?.files.find((x) => x.file.includes('style-props'));
  if (!content?.code) return args.artifacts;

  const shorthandsByProp = new Map<string, string[]>();
  ctx.utility.shorthands.forEach((longhand, shorthand) => {
    shorthandsByProp.set(
      longhand,
      (shorthandsByProp.get(longhand) ?? []).concat(shorthand),
    );
  });

  const types = ctx.utility.getTypes();
  const excludeCategoryByProp = new Set<string>();
  types.forEach((values, prop) => {
    const categoryType = values.find((type) => type.includes('Tokens['));
    if (!categoryType) return;

    const tokenCategory = categoryType
      .replace('Tokens["', '')
      .replace('"]', '') as TokenCategory;
    if (tokenCategory !== 'sizes') return;

    excludeCategoryByProp.add(prop);
    const shorthands = shorthandsByProp.get(prop);

    if (!shorthands) return;
    shorthands.forEach((shorthand) => {
      excludeCategoryByProp.add(shorthand);
    });
  });

  const excludeTokenProps = Array.from(excludeCategoryByProp);
  if (!excludeTokenProps.length) return args.artifacts;

  if (logger) {
    logger.debug(
      'plugin',
      `🐼  Exclude token props: ${excludeTokenProps.join(', ')}`,
    );
  }

  const regex = /(\w+)\?: ConditionalValue<WithEscapeHatch<(.+)>>/g;

  content.code = content.code.replace(
    regex,
    (match, prop: string, value: string) => {
      if (excludeTokenProps.includes(prop)) {
        const longhand = ctx.utility.shorthands.get(prop);
        return `${prop}?: ConditionalValue<${value} | CssProperties["${longhand || prop}"]>`;
      }

      return match;
    },
  );

  return args.artifacts;
};

codegen:parepareは返ってきたartifactsをもとにファイルを生成するので、対象の型の制約が緩くなるようにartifactsを書き換えます。

すべての処理を一度に解説するのは難解そうなのでいくつかに分けて解説します。

const artifact = args.artifacts.find((x) => x.id === 'types-styles');
const content = artifact?.files.find((x) => x.file.includes('style-props'));
if (!content?.code) return args.artifacts;

この部分はtypes/style-props.d.tsに書き込む予定のコードを取得する部分です。

artifactsはファイルに生成する情報をhelperscvaなどのまとまりごとに持っています。
そのためスタイリングの型情報についての情報を持つtypes-stylesをピックアップして、その中からstyle-propsを含む対象のファイルを探し出しています。

const shorthandsByProp = new Map<string, string[]>();
ctx.utility.shorthands.forEach((longhand, shorthand) => {
  shorthandsByProp.set(
    longhand,
    (shorthandsByProp.get(longhand) ?? []).concat(shorthand),
  );
});

続いての部分ではlonghandshorthandを関連付けています。例えばpaddingだとpadding => ['p']のようになります。

const types = ctx.utility.getTypes();
const excludeCategoryByProp = new Set<string>();
types.forEach((values, prop) => {
  const categoryType = values.find((type) => type.includes('Tokens['));
  if (!categoryType) return;

  const tokenCategory = categoryType
    .replace('Tokens["', '')
    .replace('"]', '') as TokenCategory;
  if (tokenCategory !== 'sizes') return;

  excludeCategoryByProp.add(prop);
  const shorthands = shorthandsByProp.get(prop);

  if (!shorthands) return;
  shorthands.forEach((shorthand) => {
    excludeCategoryByProp.add(shorthand);
  });
});

こちらの部分は除外するCSSプロパティを抽出しています。
はじめに、型の制約を持つCSSプロパティとそれが持つ値をctx.utility.getTypes()で取得します。

heightはこんな感じ
'height' => [
  '"auto"', 'Tokens["sizes"]', '"svh"', '"lvh"',
  '"dvh"', '"screen"', '"1/2"', '"1/3"',
  '"2/3"', '"1/4"', '"2/4"', '"3/4"',
  '"1/5"', '"2/5"', '"3/5"', '"4/5"',
  '"1/6"', '"2/6"', '"3/6"', '"4/6"',
  '"5/6"',
],

excludeCategoryByPropは除外するCSSプロパティの集合です。

forEachの中では、型の制約を持つCSSプロパティの値からToken["sizes"]となっているものを探して、存在すればexcludeCategoryByPropにCSSプロパティを挿入しています。
存在した場合は先ほど作成したshorthandsByPropを活かしてshorthandexcludeCategoryByPropに挿入します。

これで除外するCSSプロパティ一覧を取得できました。

const excludeTokenProps = Array.from(excludeCategoryByProp);
if (!excludeTokenProps.length) return args.artifacts;

以後の処理で扱いやすいように除外するプロパティを配列に直し、プロパティが存在しなければ何もする必要がないのでartifactsをそのまま返します。

const regex = /(\w+)\?: ConditionalValue<WithEscapeHatch<(.+)>>/g;

content.code = content.code.replace(
  regex,
  (match, prop: string, value: string) => {
    if (excludeTokenProps.includes(prop)) {
      const longhand = ctx.utility.shorthands.get(prop);
      return `${prop}?: ConditionalValue<${value} | CssProperties["${longhand || prop}"] | AnyString>`;
    }

    return match;
  },
);

return args.artifacts;

最後に、types/style-props.d.tsの対象のコードを書き換えます。
regexWithEscapeHatchを使っている部分、つまりデザイントークンによる型の強い制約を与えている部分を抜き出して、そこに除外するプロパティが含まれていた場合にコードの書き換えを行なっています。
書き換えは、WithEscapeHatchの除外とCssPropertiesAnyStringを追加して、strictTokensを指定しなかった状態にします。

以上でプラグインの作成完了です。

panda.config.ts
import { defineConfig } from '@pandacss/dev';
 
export default defineConfig({
  theme: {
    tokens: {
      colors: {
        primary: { value: '#50e2d2' },
        success: { value: '#15803D' },
        warning: { value: '#A16207' },
        error: { value: '#DC2626' },
      },
      sizes: {
        md: { value: '256px' },
      },
    },
  },
  strictTokens: true,
  plugins: [pluginStrictTokensExcludeSizes()],
});

このように定義すると、colorはデザイントークンの値以外は利用できないが、heightwには好きな文字列を利用できる型情報が生成されます。

// ❌ デザイントークン以外の値は利用できない
css({ color: 'red' });
css({ gap: '8px' });

// ⭕️ デザイントークンを利用する、もしくは値を[]で囲む
css({ color: 'primary' });
css({ color: '[red]' });
css({ width: 'md' });

// ⭕️ 一部トークンが以外で利用できる値が存在する
css({ marginLeft: 'auto' });

// ⭕️ sizesのトークンを利用するCSSプロパティはなんでも渡せる
css({ width: '256px' });
css({ width: '1.5rem' });

一部のデザイントークンと要素の型制約を緩和する

より一般的な形にします。型の制約から解き放ちたいデザイントークンとCSSプロパティを外から与えられるようにします。

import type { PandaContext } from '@pandacss/node';
import type {
  CodegenPrepareHookArgs,
  CssProperties,
  LoggerInterface,
  PandaPlugin,
  TokenCategory,
} from '@pandacss/types';

type StrictTokensExcludeOptions = {
  categories?: TokenCategory[];
  props?: Array<keyof CssProperties | (string & object)>;
};

export const pluginStrictTokensExclude = (
  options: StrictTokensExcludeOptions,
): PandaPlugin => {
  let logger: LoggerInterface;
  let ctx: PandaContext;

  return {
    name: 'strict-tokens-exclude',
    hooks: {
      'context:created': (context) => {
        logger = context.logger;
        // @ts-expect-error -- ProcessorInterface型にcontextが存在しないため
        ctx = context.ctx.processor.context as PandaContext;
      },
      'codegen:prepare': (args) => {
        return transformPropTypes(args, options, ctx, logger);
      },
    },
  };
};

const transformPropTypes = (
  args: CodegenPrepareHookArgs,
  options: StrictTokensExcludeOptions,
  ctx: PandaContext,
  logger?: LoggerInterface,
) => {
  const { categories = [], props = [] } = options;
  if (!categories.length && !props.length) return args.artifacts;

  const artifact = args.artifacts.find((x) => x.id === 'types-styles');
  const content = artifact?.files.find((x) => x.file.includes('style-props'));
  if (!content?.code) return args.artifacts;

  const shorthandsByProp = new Map<string, string[]>();
  ctx.utility.shorthands.forEach((longhand, shorthand) => {
    shorthandsByProp.set(
      longhand,
      (shorthandsByProp.get(longhand) ?? []).concat(shorthand),
    );
  });

  const types = ctx.utility.getTypes();
  const excludeCategoryByProp = new Set<string>(props);
  types.forEach((values, prop) => {
    const categoryType = values.find((type) => type.includes('Tokens['));
    if (!categoryType) return;

    const tokenCategory = categoryType
      .replace('Tokens["', '')
      .replace('"]', '') as TokenCategory;
    if (!categories.includes(tokenCategory)) {
      return;
    }

    excludeCategoryByProp.add(prop);
    const shorthands = shorthandsByProp.get(prop);

    if (!shorthands) return;
    shorthands.forEach((shorthand) => {
      excludeCategoryByProp.add(shorthand);
    });
  });

  const excludeTokenProps = Array.from(excludeCategoryByProp);
  if (!excludeTokenProps.length) return args.artifacts;

  if (logger) {
    logger.debug(
      'plugin',
      `🐼  Exclude token props: ${excludeTokenProps.join(', ')}`,
    );
  }

  const regex = /(\w+)\?: ConditionalValue<WithEscapeHatch<(.+)>>/g;

  content.code = content.code.replace(
    regex,
    (match, prop: string, value: string) => {
      if (excludeTokenProps.includes(prop)) {
        const longhand = ctx.utility.shorthands.get(prop);
        return `${prop}?: ConditionalValue<${value} | CssProperties["${longhand || prop}"]>`;
      }

      return match;
    },
  );

  return args.artifacts;
};

pluginStrictTokensExcludeを呼び出すときにcategoriespropsを渡せるようにしました。

変更点は2箇所だけです。
1点目はctx.utility.getTypes()forEachを回している時に、対象のデザイントークンを探すところをtokenCategory !== 'sizes'からincludesを使ったものに変更しました。

!categories.includes(tokenCategory)

2点目はexcludeCategoryByPropを初期化するタイミングでpropsを追加するようにしたところです。

const excludeCategoryByProp = new Set<string>(props);

このプラグインをpluginStrictTokensExcludeSizesと同じ設定にするには以下のようにします。

panda.config.ts
import { defineConfig } from '@pandacss/dev';
 
export default defineConfig({
  theme: {
    tokens: {
      colors: {
        primary: { value: '#50e2d2' },
        success: { value: '#15803D' },
        warning: { value: '#A16207' },
        error: { value: '#DC2626' },
      },
      sizes: {
        md: { value: '256px' },
      },
    },
  },
  strictTokens: true,
  plugins: [pluginStrictTokensExclude({ categories: ['sizes'] })],
});

さらに、デザイントークンではshadows、CSSプロパティではtopleftbottomrightの型情報を緩めたいときは以下のようにします。

panda.config.ts
import { defineConfig } from '@pandacss/dev';
 
export default defineConfig({
  theme: {
    extend: {
      tokens: {
        colors: {
          primary: { value: '#50e2d2' },
          success: { value: '#15803D' },
          warning: { value: '#A16207' },
          error: { value: '#DC2626' },
        },
        sizes: {
          md: { value: '256px' },
        },
      },
    },
  },
  strictTokens: true,
  plugins: [
    pluginStrictTokensExclude({
      categories: ['sizes', 'shadows'],
      props: ['top', 'left', 'bottom', 'right'],
    }),
  ],
});

sizesshadowsを用いるCSSプロパティとtopleftbottomrightに自由な値を渡せます。topleftbottomrightと同じデザイントークンspacingを持つgapの型には制約は残っていることも確認してください。

// ❌ デザイントークン以外の値は利用できない
css({ color: 'red' });
css({ gap: '8px' });

// ⭕️ sizes・shadowのトークンを利用するCSSプロパティはなんでも渡せる
css({ width: '256px' });
css({ width: '1.5rem' });
css({ boxShadow: '10px 5px 5px red' });

// ⭕️ top・left・bottom・rightはなんでも渡せる
css({ top: '0px', left: '0px' });
css({ bottom: '50%', right: 'md' });

さいごに

Panda CSSでデザイントークンの型制約を一部だけ緩めるプラグインを作成する方法を紹介しました。

既存の型ファイルの生成方法に依存していますからバージョンアップに伴ったメンテナンスが必要になることがネックですが、これにより型安全で快適な開発を進めることができるので、同じような悩みを持っている方は利用してみてはいかがでしょうか。

hooksを用いた実装になっているのでカスタマイズもしやすいです。各々が実現したい世界を実現できると良いですね。

参考

今回紹介したプラグインはpandaboxを参考に作成しました。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?