はじめに
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の定めた値(witdh
のscreen
とか1/2
のような値)を持つすべてのCSSプロパティの値に対して制約が課されます。
そのため、width
やheight
で利用できるデザイントークン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
で定義します。
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からも確認できます。
この時に利用可能な値はscreen
や1/2
のようなPanda CSSから与えられている一部の値と、theme.tokens.sizes
とtheme.extend.tokens.sizes
に定義した値だけになります(theme.tokens
に定義がない場合はデフォルトのデザイントークンの利用もできます)。
import { defineConfig } from '@pandacss/dev';
export default defineConfig({
theme: {
tokens: {
sizes: {
md: { value: '256px' },
},
},
},
});
上記のように定義すると、md
をsizes
を使うCSSプロパティに渡せます(この記事で出てくるCSSプロパティとは標準のものではなく、Panda CSSに渡せるキーを指しています)。
// ⭕️
<div className={css({ h: 'md' })}>
strictTokens
の挙動を確認できたところで、strictTokens
が有効な時と無効な時でh
に渡す値の型の違いを確認します。
CSSの各プロパティの型はstyled-system
(コードの生成先)のtypes/style-props.d.ts
のSystemProperties
で定義されています。
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:prepare
でtransformPropTypes
という処理を割り込ませています。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
はファイルに生成する情報をhelpers
やcva
などのまとまりごとに持っています。
そのためスタイリングの型情報についての情報を持つtypes-styles
をピックアップして、その中からstyle-props
を含む対象のファイルを探し出しています。
const shorthandsByProp = new Map<string, string[]>();
ctx.utility.shorthands.forEach((longhand, shorthand) => {
shorthandsByProp.set(
longhand,
(shorthandsByProp.get(longhand) ?? []).concat(shorthand),
);
});
続いての部分ではlonghand
とshorthand
を関連付けています。例えば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' => [
'"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
を活かしてshorthand
もexcludeCategoryByProp
に挿入します。
これで除外する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
の対象のコードを書き換えます。
regex
でWithEscapeHatch
を使っている部分、つまりデザイントークンによる型の強い制約を与えている部分を抜き出して、そこに除外するプロパティが含まれていた場合にコードの書き換えを行なっています。
書き換えは、WithEscapeHatch
の除外とCssProperties
とAnyString
を追加して、strictTokens
を指定しなかった状態にします。
以上でプラグインの作成完了です。
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
はデザイントークンの値以外は利用できないが、height
やw
には好きな文字列を利用できる型情報が生成されます。
// ❌ デザイントークン以外の値は利用できない
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
を呼び出すときにcategories
とprops
を渡せるようにしました。
変更点は2箇所だけです。
1点目はctx.utility.getTypes()
でforEach
を回している時に、対象のデザイントークンを探すところをtokenCategory !== 'sizes'
からincludes
を使ったものに変更しました。
!categories.includes(tokenCategory)
2点目はexcludeCategoryByProp
を初期化するタイミングでprops
を追加するようにしたところです。
const excludeCategoryByProp = new Set<string>(props);
このプラグインをpluginStrictTokensExcludeSizes
と同じ設定にするには以下のようにします。
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プロパティではtop
・left
・bottom
・right
の型情報を緩めたいときは以下のようにします。
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'],
}),
],
});
sizes
とshadows
を用いるCSSプロパティとtop
・left
・bottom
・right
に自由な値を渡せます。top
・left
・bottom
・right
と同じデザイントークン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を参考に作成しました。