Skip to content

Yamada UIのコンポーネントにスタイルを当てる仕組みを理解してメモ

前提

まずスモールスタートで理解していこう。
簡単そうなのはBadgeかな。

packages/components/badge/src/badge.tsx
export const Badge = forwardRef<BadgeProps, 'span'>((props, ref) => {
const [styles, mergedProps] = useComponentStyle('Badge', props)
const { className, ...rest } = omitThemeProps(mergedProps)
const css: CSSUIObject = {
display: 'inline-block',
whiteSpace: 'nowrap',
verticalAlign: 'middle',
...styles
}
return (
<ui.span
ref={ref}
className={cx('ui-badge', className)}
__css={css}
{...rest}
/>
)
})

このBadgeコンポーネントをこんな感じで使う場合を考える。

<Badge colorScheme="secondary" variant="outline">
Badge
</Badge>

このとき、Badgeコンポーネントに渡されるpropsは以下の様になっている。

{
colorScheme: "secondary",
variant: "outline",
children: "Badge"
}

useComponentStyle(+ setStyles)

まず、この行。
useComponentStyleというフックに"Tag"というコンポーネント名とpropsを渡して、その結果をタプルとして受け取っている。

const [styles, mergedProps] = useComponentStyle('Badge', props)

ここでまず疑問に思うべきことは2つ。

  • useComponentStyleとは何をしているフックなのか。
  • useComponentStyleフックの結果として得られるstylesmergedPropsとは何を表現しているのか。

useComponentStyleフックの実装を見てみる。

packages/core/src/components/use-component-style.tsx
export const useComponentStyle = <Props extends Dict = Dict>(
name: string,
props: Props,
options?: UseComponentStyleOptions
) => setStyles<Props>(name, props, options)

これを見ると、useComponentStyleフックの実体はほぼほぼsetStylesという関数であることがわかる。
じゃあ次にsetStyles関数の実装を見てみる。

const setStyles = <Props extends Dict = Dict, IsMulti extends boolean = false>(
name: string,
props: Props,
{ isMulti, isProcessSkip, styles }: SetStylesOptions<IsMulti> = {}
): [styles: Styles<IsMulti>, props: Props] => {
const { theme, themeScheme } = useTheme()
const { colorMode } = useColorMode()
const propsRef = useRef<Props>({} as Props)
const stylesRef = useRef<Styles<IsMulti>>(styles ?? {})
if (!isProcessSkip) {
const componentStyle = get<ComponentStyle | undefined>(
theme,
`components.${name}`
)
props = merge(componentStyle?.defaultProps ?? {}, filterUndefined(props))
if (componentStyle) {
const args = omitObject(props, ['children'])
let styles = getStyles<IsMulti>(componentStyle.baseStyle ?? {}, {
theme,
colorMode,
themeScheme,
...args
})({ isMulti })
const variantStyles = getModifierStyles<IsMulti>(
props.variant,
componentStyle.variants ?? {},
{ theme, colorMode, themeScheme, ...args }
)({ isMulti })
const sizeStyles = getModifierStyles<IsMulti>(
props.size,
componentStyle.sizes ?? {},
{ theme, colorMode, themeScheme, ...args }
)({ isMulti })
styles = merge(styles, sizeStyles)
styles = merge(styles, variantStyles)
const isStylesEqual = isEqual(stylesRef.current, styles)
if (!isStylesEqual) stylesRef.current = styles
}
}
const isPropsEqual = isEqual(propsRef.current, props)
if (!isPropsEqual) propsRef.current = props
return [stylesRef.current, propsRef.current]
}

この処理の全体をざっくり眺めると…

  1. 現在のテーマやカラーモードをする(フックはif文のコードブロック内で実行できないため)
  2. (isProcessSkiptrueであれば)テーマ、カラーモード、propsを考慮したスタイルの計算が実行される
  3. 計算結果を返す

という流れのようだ。

1. 現在のテーマやカラーモードをする(フックはif文のコードブロック内で実行できないため)

まず処理1について。これら2行で現在のテーマやカラーモードを取得している事がわかる。

const { theme, themeScheme } = useTheme()
const { colorMode } = useColorMode()

2. (isProcessSkiptrueであれば)テーマ、カラーモード、propsを考慮したスタイルの計算が実行される

setStylesの大部分を占めているのがこの処理2。
ここでコンポーネントに設定すべきスタイルの計算を実行していることが想像できる。
どこで使っているのかはわからないけど、isProcessSkipフラグを利用するとこの処理をスキップすることができるらしい。

そこまで分かったら、if(!isProcessSkip)の中身だけ読めば良い。

const componentStyle = get<ComponentStyle | undefined>(
theme,
`components.${name}`
)
props = merge(componentStyle?.defaultProps ?? {}, filterUndefined(props))
if (componentStyle) {
const args = omitObject(props, ['children'])
let styles = getStyles<IsMulti>(componentStyle.baseStyle ?? {}, {
theme,
colorMode,
themeScheme,
...args
})({ isMulti })
const variantStyles = getModifierStyles<IsMulti>(
props.variant,
componentStyle.variants ?? {},
{ theme, colorMode, themeScheme, ...args }
)({ isMulti })
const sizeStyles = getModifierStyles<IsMulti>(
props.size,
componentStyle.sizes ?? {},
{ theme, colorMode, themeScheme, ...args }
)({ isMulti })
styles = merge(styles, sizeStyles)
styles = merge(styles, variantStyles)
const isStylesEqual = isEqual(stylesRef.current, styles)
if (!isStylesEqual) stylesRef.current = styles
}

まずはこの処理。先述のuseThemeフックの実行によって取得したThemeオブジェクトの中から、今回対象となるコンポーネントのスタイルを取得している。

const componentStyle = get<ComponentStyle | undefined>(
theme,
`components.${name}`
)

例えば、Yamada UIのStorybookで使用されているThemeに相当するThemeオブジェクトは以下のような構造になっている。

{
animations: {},
blurs: {
sm: "4px",
md: "8px",
lg: "12px",
xl: "16px",
2xl: "24px"
},
borders: {},
breakpoints: {
sm: "30em",
md: "48em",
lg: "61em",
xl: "80em",
2xl: "90em",
base": "9999px"
},
colors: {
// ...
},
// ...,
components: {
Accordion: {
// ...
},
Alert: {
// ...
},
// ...,
Badge: {
baseStyle: {
fontSize: "xs",
fontWeight: "bold",
px: 1,
rounded: "sm",
textTransform: "uppercase"
},
defaultProps: {
colorScheme: "primary",
variant: "subtle",
},
sizes: {},
variants: {
outline: {
// ...
},
solid: {
// ...
},
subtle: {
//...
},
},
},
// ...
},
// ...
}

これを見ると、Badgeコンポーネントに関するテーマはcomponents.Badgeというプロパティパスで取得できることが分かる。

今回、useComponentStyleフックを以下のようにして実行していることを思い出す。

packages/components/badge/src/badge.tsx
const [styles, mergedProps] = useComponentStyle('Badge', props)

だからこの処理は、

const componentStyle = get<ComponentStyle | undefined>(
theme,
`components.${name}`
)

実際はこの様に実行されていることが分かる。

const componentStyle = get<ComponentStyle | undefined>(
theme,
`components.Badge`
)

というようになっている。

こうして得られたコンポーネントスタイルとpropsをマージして、仮引数propsを更新している。
propsそのものを更新しているのはあまりお利口ではないと思う…)

props = merge(componentStyle?.defaultProps ?? {}, filterUndefined(props))

例えば今回のケースでは、componentStyle?.defaultProps ?? {}の評価結果は以下のようになっており、

{
colorScheme: "primary",
variant: "subtle",
}

一方でfilterUndefined(props)の評価結果は以下のようになっている。

{
colorScheme: "secondary",
variant: "outline",
children: "Badge"
}

その結果、propsは以下の値で更新される。
componentStyle?.defaultProps ?? {}側のcolorSchemevariantが、props側のcolorSchemevariantでそれぞれ上書きされている。

{
colorScheme: "secondary",
variant: "outline",
children: "Badge"
}

そして次の処理に進む。今、componentStyleはTruthyであることが自明なので、以下の以下のif文の中の処理は実行される。

if (componentStyle) {
const args = omitObject(props, ['children'])
let styles = getStyles<IsMulti>(componentStyle.baseStyle ?? {}, {
theme,
colorMode,
themeScheme,
...args
})({ isMulti })
const variantStyles = getModifierStyles<IsMulti>(
props.variant,
componentStyle.variants ?? {},
{ theme, colorMode, themeScheme, ...args }
)({ isMulti })
const sizeStyles = getModifierStyles<IsMulti>(
props.size,
componentStyle.sizes ?? {},
{ theme, colorMode, themeScheme, ...args }
)({ isMulti })
styles = merge(styles, sizeStyles)
styles = merge(styles, variantStyles)
const isStylesEqual = isEqual(stylesRef.current, styles)
if (!isStylesEqual) stylesRef.current = styles
}

まず、以下の処理によってpropsからchildrenプロパティが削除され、argsという変数が作成される。

const args = omitObject(props, ['children'])

その結果、変数argsの中身はこうなっている。

{
colorScheme: "secondary",
variant: "outline",
}

次にこの部分の処理を見ていく。

let styles = getStyles<IsMulti>(componentStyle.baseStyle ?? {}, {
theme,
colorMode,
themeScheme,
...args
})({ isMulti })

getStyles関数についてはこの後ちゃんとメモる。
結論だけ書くと、変数stylesにはThemeオブジェクトのcomponents.Badge.baseStyleプロパティパスに格納されている以下のオブジェクトが格納される。

{
fontSize: "xs",
fontWeight: "bold",
px: 1,
rounded: "sm",
textTransform: "uppercase"
}

次にこの部分を見ていく。

const variantStyles = getModifierStyles<IsMulti>(
props.variant,
componentStyle.variants ?? {},
{ theme, colorMode, themeScheme, ...args }
)({ isMulti })

1つ前の処理から類推すれば、「変数variantStylesにはThemeオブジェクトのcomponents.Badge.variantsプロパティパスに格納されているオブジェクトが格納されるのでは?」と推測できる。

今、props.variant"outline"という文字列が格納されていることは自明。
一方で、componentStyle.variantsにはどんな値が格納されているだろう?

今までは省略していたので、ちゃんと確認しておく。

packages/theme/components/badge.ts
{
solid: ({ theme: t, colorMode: m, colorScheme: c = "primary" }) => ({
bg: [tintColor(`${c}.600`, 24)(t, m), shadeColor(`${c}.600`, 16)(t, m)],
color: `white`,
}),
subtle: ({ theme: t, colorMode: m, colorScheme: c = "primary" }) => ({
bg: [
isGray(c) ? `${c}.50` : `${c}.100`,
shadeColor(`${c}.300`, 58)(t, m),
],
color: [`${c}.800`, isGray(c) ? `${c}.50` : `${c}.200`],
}),
outline: ({ theme: t, colorMode: m, colorScheme: c = "primary" }) => {
const color = mode(
getColor(`${c}.500`)(t, m),
getColor(
isGray(c) ? `${c}.100` : transparentizeColor(`${c}.400`, 0.92)(t, m),
)(t, m),
)(m)
return {
color,
boxShadow: `inset 0 0 0px 1px ${color}`,
}
},
}

この結果から分かるように、componentStyle.variants下の各プロパティの値にはthemecolorModethemeScheme(をまとめたオブジェクト)を引数として受けとり、それを下に計算されたスタイル(をまとめたオブジェクト)を返す関数が定義されていることが分かる。

ということは、この処理でやっていることはprops.variantに対応する関数を実行して、その結果として得られるスタイルをvariantStylesに格納していることである事が分かる。 その際、パラメータとしてthemecolorModethemeSchemeargs(をまとめたオブジェクト)を渡している。

const variantStyles = getModifierStyles<IsMulti>(
props.variant,
componentStyle.variants ?? {},
{ theme, colorMode, themeScheme, ...args }
)({ isMulti })

だいたい想像がついたところでgetModifierStyles関数の実装を確認する。

const getModifierStyles =
<IsMulti extends boolean = false>(
value: ResponsiveObject<string> | ColorModeArray<string> | string,
modifierStyles: ModifierStyles,
props: UIStyleProps
) =>
({ isMulti = false }: GetStylesOptions): Styles<IsMulti> => {
let styles: Styles<IsMulti> = {}
if (isArray(value)) {
const [lightStyles, darkStyles] = getColorModeStyles<IsMulti>(
value,
modifierStyles,
props
)({ isMulti })
styles = merge(lightStyles, darkStyles)
} else if (isObject(value)) {
styles = getResponsiveStyles<IsMulti>(
value,
modifierStyles,
props
)({ isMulti })
} else {
styles = getStyles<IsMulti>(modifierStyles[value], props)({ isMulti })
}
return styles as Styles<IsMulti>
}

今、getModifierStyles関数の第一引数valueの実引数はprops.variant、つまり"outline"という文字列が格納されている。
なので、ここで実際に実行されるのはこの分岐である。

styles = getStyles<IsMulti>(modifierStyles[value], props)({ isMulti })

ここで、getStyles関数の実装も確認する。

const getStyles =
<IsMulti extends boolean = false>(
stylesOrFunc: UIStyle | Record<string, UIStyle>,
props: UIStyleProps
) =>
({ isMulti = false, query }: GetStylesOptions): Styles<IsMulti> => {
let styles = runIfFunc(stylesOrFunc, props)
if (isMulti) {
for (const [key, styleOrFunc] of Object.entries(
(styles ?? {}) as Record<string, UIStyle>
)) {
const style = runIfFunc(styleOrFunc, props)
if (query) {
styles = merge(styles, { [key]: { [query]: style } })
} else {
styles = merge(styles, { [key]: style })
}
}
} else if (query) {
return { [query]: styles } as Styles<IsMulti>
}
return styles as Styles<IsMulti>
}

これまでに登場している関数の呼び出し関係をきっちり整理していくと、引数stylesOrFuncには以下の関数が、

;({ theme: t, colorMode: m, colorScheme: c = 'primary' }) => {
const color = mode(
getColor(`${c}.500`)(t, m),
getColor(
isGray(c) ? `${c}.100` : transparentizeColor(`${c}.400`, 0.92)(t, m)
)(t, m)
)(m)
return {
color,
boxShadow: `inset 0 0 0px 1px ${color}`
}
}

引数propsにはthemecolorModethemeSchemeargs(をまとめたオブジェクト)が格納されている。

isMultifalseであることは自明なので、getStylesの処理は「stylesOrFuncに渡された関数を、propsを引数として渡して実行した結果をstylesに格納する」という処理に相当する。

引数stylesOrFuncに渡されている関数の中身の詳細は一旦忘れることにして、この関数の実行結果は以下のオブジェクトになる。

{
"color": "#895af6",
"boxShadow": "inset 0 0 0px 1px #895af6"
}

このオブジェクトが変数modifierStylesに格納される。

次にここの処理だが、ここは以下の理由からundefinedという空オブジェクトが格納される(詳しい考察は省略)。

  • Badgeコンポーネントにsizeはpropsとして設定してない
  • componentStyle.sizesには対応するテーマが定義されていない
const sizeStyles = getModifierStyles<IsMulti>(
props.size,
componentStyle.sizes ?? {},
{ theme, colorMode, themeScheme, ...args }
)({ isMulti })

そして、このようにして計算したsizeStylesvariantStylesstylesにマージしていく。

styles = merge(styles, sizeStyles)
styles = merge(styles, variantStyles)

その結果得られるstylesstylesRef.currentと一致していなければstylesRef.currentstylesの値で上書きする。
stylesRef.currentは空オブジェクト({})のはずなので、この上書きは当然実行される。

const isStylesEqual = isEqual(stylesRef.current, styles)
if (!isStylesEqual) stylesRef.current = styles

ここでやっとif(!isProcessSkip)内の処理は完了する。

setStyles関数の最後の処理として、この部分を実行する。

const isPropsEqual = isEqual(propsRef.current, props)
if (!isPropsEqual) propsRef.current = props

今、propsRef.currentは空オブジェクト{}でありpropsは以下のオブジェクトが格納されている。
そのため、propsRef.currentpropsの値で当然上書きされる。

{
colorScheme: "secondary",
variant: "outline",
children: "Badge"
}

3. 計算結果を返す

そして、setStylesstylesRef.currentpropsRef.currentをまとめたタプルを返す。

return [stylesRef.current, propsRef.current]

実際の値は以下のようになっているはず。

;[
{
fontSize: 'xs',
fontWeight: 'bold',
px: 1,
rounded: 'sm',
textTransform: 'uppercase',
color: '#895af6',
boxShadow: 'inset 0 0 0px 1px #895af6'
},
{
colorScheme: 'secondary',
variant: 'outline',
children: 'Badge'
}
]

omitThemeProps

useComponentStyleフックの処理を一通り確認できたところで、次にomitThemeProps関数を確認する。

const { className, ...rest } = omitThemeProps(mergedProps)

omitThemeProps関数は以下のような実装になっており、デフォルトではsizevariantcolorSchemeプロパティに対応する値を除外したオブジェクトを返す。

export const omitThemeProps = <
T extends ThemeProps,
K extends Exclude<keyof T, 'size' | 'variant' | 'colorScheme'> = never
>(
props: T,
keys: K[] = []
) => omitObject(props, ['size', 'variant', 'colorScheme', ...keys])

mergedPropsは今このようなオブジェクトが格納されている。

{
colorScheme: "secondary",
variant: "outline",
children: "Badge"
}

なので、omitThemeProps関数を実行した結果、変数classNameにはundefinedが、変数restには

{
children: 'Badge'
}

というオブジェクトが格納される。

CSSUIObject

次にこの部分について見ていく。

const css: CSSUIObject = {
display: 'inline-block',
whiteSpace: 'nowrap',
verticalAlign: 'middle',
...styles
}

ここでは、Badgeコンポーネントに当て込みたいスタイルを一つのオブジェクトとしてまとめている。

変数stylesには以下のオブジェクトが格納されているので、

{
fontSize: "xs",
fontWeight: "bold",
px: 1,
rounded: "sm",
textTransform: "uppercase",
color: "#895af6",
boxShadow: "inset 0 0 0px 1px #895af6"
}

変数cssは以下のオブジェクトが格納される。

{
display: "inline-block",
whiteSpace: "nowrap",
verticalAlign: "middle",
fontSize: "xs",
fontWeight: "bold",
px: 1,
rounded: "sm",
textTransform: "uppercase",
color: "#895af6",
boxShadow: "inset 0 0 0px 1px #895af6"
}

JSXを返す

最後に、今までの結果を素に、ネイティブの<span>タグにスタイルを当てたJSXを返す。
これがBadgeコンポーネントになる。

return (
<ui.span
ref={ref}
className={cx("ui-badge", className)}
__css={css}
{...rest}
/>
)

ここの処理はYamada UIにおけるfactoryの概念を理解する必要がある。
これは別のページにメモる。