デザインシステムのi18n対応と運用

ogp

こんにちは。enechainで働いている takurinton です。
enechainではプロダクト開発において多言語化を行っています。
今回は、enechainデザインシステムでのi18n対応と運用について紹介します。

i18n対応の前提

プロダクトでi18nをする際、一般的にはjsonで言語の定数を記述し、それにライブラリを使って型をつけてhooks経由で呼び出して使うことがほとんどかなと思います。
全てのテキストを抜け漏れなく定義し、それぞれの言語で表現するのであればそれは妥当な方法であると自分は考えています。

UIコンポーネントライブラリではそこが少し異なります。今回話す部分はReactで作られたUIコンポーネントライブラリなので、基本的にprops経由でテキストが差し込まれます。差し込む場合、そのテキストに関してはプロダクト側で管理・保持してほしいです。 ではどのようなときにデザインシステムのi18n対応が必要になるのか。それは以下の2つのケースです。

  • optionalかつ初期値が定義されているprops
  • デザインシステム内で保持している固定値

それぞれについてもう少し抽象度を落として書いていきます。

optionalかつ初期値が定義されているprops

1つ目はoptionalかつ初期値が定義されているpropsです。 例として、Inputコンポーネントのplaceholderがそれにあたります。

const Input = ({
  placeholder = "テキストを入力してください", // <- こういうやつ
  ...rest
}) => {
  return <input placeholder={placeholder} {...rest} />;
};

placeholderのような、初期値をUIコンポーネントライブラリ側で持っておきつつ言語によってそれを切り替えたい場合には、デザインシステム側でi18n対応が必要になります。 このようなケースの場合、propsが渡されたらそれを優先して、渡されなかったらデザインシステム側で保持している言語に応じたテキストを使うように実装します。

デザインシステム内で保持している固定値

2つ目はデザインシステム内で保持している固定値です。 例として、Calendarコンポーネントが保持している曜日の定数がそれにあたります。これは基本的にはプロダクト側から差し込むことはないはずです。 また、プロダクトの説明テキストをUIコンポーネントライブラリ側で保持している場合も同様にこのケースにあたります。

このような固定値においても、UIコンポーネントライブラリ側で日本語と英語(と任意の言語)のkey-valueを持っておきたくなります。

i18n対応の方法

デザインシステムでのi18n対応の方法はいくつかありますが、今回はライブラリを使わずにシンプルに実装する方法を取ったので、その方法を紹介します。
大枠としては各言語の定数を定義しつつ、それをReact contextで持ち回り、各コンポーネントからhooks経由で取得するという流れになります。

定数とインターフェイスの定義

まずはじめに言語の定数とその型定義をします。
今回は、たまたまシンプルかつ例として使いやすそうだったenechainで使用しているUploadAreaコンポーネントを例にしています。

型定義のdefaultProps keyには、先ほど述べたように初期値が定義されているoptionalなpropsをpickして定義します。
このようにしておくことで、コンポーネント側のpropsと型が一致するようになります。

// 型定義
import { UploadAreaProps } from '../../../components/UploadArea'

export type I18n = {
  components: {
    UploadArea: {
      // default propsをpickして定義
      defaultProps: Pick<
        UploadAreaProps,
        'title' | 'conditionLabel' | 'buttonLabel'
      >
    }
  };
};

同じ形式で各言語の定数を定義します。
今回は英語と日本語を定義していますが、必要に応じて他の言語も追加できます。

// 各言語の定数定義
export const en: I18n = {
  components: {
    UploadArea: {
      defaultProps: {
        title: 'Drag and drop files here',
        conditionLabel: 'or',
        buttonLabel: 'Select files',
      },
    },
  },
} as const;

export const ja: I18n = {
  components: {
    UploadArea: {
      defaultProps: {
        title: 'ファイルをここにドラッグ&ドロップ',
        conditionLabel: 'または',
        buttonLabel: 'ファイルを選択',
      },
    },
  },
} as const;

React contextの定義

次にcontextとproviderの定義をします。
ここでは言語を切り替えるための、locale というpropsを持たせています。これはプロダクト側からもらう値になっていて、デフォルトは日本語にしています。

import * as React from "react";

export type I18nProviderProps = {
  locale?: "ja" | "en";
  children?: React.ReactNode;
};

export const I18nContext = createContext<I18nProviderProps>({
  locale: "ja",
});

export const I18nProvider: React.FunctionComponent<I18nProviderProps> = ({
  locale = "ja",
  children,
}) => {
  return (
    <I18nContext.Provider value={{ locale }}>{children}</I18nContext.Provider>
  );
};

hooksの定義

次に、各コンポーネントで呼び出すhooksを定義します。

hooksではpropsの全てのオブジェクトを取得し、それに対して定数が見つかったらそれを返すようにしています。
現在プロダクト側で選択中の言語に関しては、contextから取得します。

また、ジェネリクスを使ってpropsを定義することで、固定値に対しても型がついた状態でアクセスできます。

import { PropsWithChildren, useContext } from "react";

import { ComponentName, locales } from "./constants";
import { I18nContext } from "./context";

/**
 * @param props Tはpropsの型,Uはprops経由ではないがデザインシステム側で保持している定数を明示するための型
 * @param name コンポーネントの名前
 */
export const useI18n = <T, U = object>({
  props,
  name,
}: {
  props: PropsWithChildren<T> & Partial<U>
  name: ComponentName
}): PropsWithChildren<T> & U => {
  const { locale: localeProp } = useContext(I18nContext)
  if (localeProp === undefined) {
    return props as PropsWithChildren<T> & U
  }

  const locale = locales[localeProp].components
  const defaultProps = locale[name].defaultProps as U

  return {
    ...props,
    // recursivelyAssignDefaultPropsはpropsの型に合わせてdefaultPropsを再帰的に代入する関数
    ...recursivelyAssignDefaultProps<U>({ ...props }, defaultProps),
  } as PropsWithChildren<T> & U
}

呼び出し

最後に、コンポーネント側で呼び出します。

const UploadArea = forwardRef<UploadAreaProps, 'input'>(
  function UploadArea(props, ref) {
    const {
      title,
      conditionLabel,
      buttonLabel,
      onSelectFiles,
      multiple,
      accept,
      children,
      ...rest
    } = useI18n({
      props,
      name: 'UploadArea',
    })

    // ...処理が続く

    return (
        // ...コンポーネントの中身
    )
})

また、hooksのインターフェイスに型を指定すれば、props経由で取得しないような固定値についても、型がついている状態でアクセスできます。

import { useI18n } from "/path/to/useI18n";

type FooComponentProps = {
  baz: string;
};

const FooComponent = forwardRef<HTMLDivElement, FooComponentProps>(
  (props, ref) => {
    // bar は props に存在しないが、このように型を指定することで言語定数に存在していればuseI18nの戻り値で取得することができる
    const { 
      bar // bar: string
    } = useI18n<FooComponentProps & { bar?: string }>({
      props,
      name: "FooComponent",
    });

    return <div ref={ref}>{bar}</div>;
  }
);

プロダクト側

プロダクト側からは、I18nProvider のlocale propを使って言語を切り替えることができます。
言語の切り替えはプロダクト側で行う想定です。

import { I18nProvider } from "design-system-lib";

const App = () => {
  // プロダクト側ではi18nライブラリで担ってるケースがほとんど
  const [locale, setLocale] = useState<"ja" | "en">("ja");

  // ...処理

  return (
    <I18nProvider locale={locale}>
      <ApplicationRoot />
    </I18nProvider>
  );
};

storybookでのi18n対応

プロダクト側の開発者はstorybookでコンポーネントを確認することが多いと思います。
そのため、storybookでもi18n対応をすることが望ましいです。今回はstorybookのdecoratorを使い、storybookのヘッダーで言語を切り替えを可能にしました。
また、i18n対応がされているかどうかをコンポーネントごとで表示するようにしました。

export const globalTypes = {
  i18n: {
    name: 'i18n',
    description: 'i18n settings',
    defaultValue: 'ja',
    toolbar: {
      icon: 'globe',
      items: [
        {
          value: 'ja',
          right: '🇯🇵',
          title: 'Japanese',
        },
        {
          value: 'en',
          right: '🇺🇸',
          title: 'English',
        },
      ],
      dynamicTitle: true,
    },
  },
  // その他の設定が続く(e.g. dark mode, etc...)
}

const i18nDecorator: Decorator = (Story, context) => {
  // ここでi18nの設定を取得して、context.globals.i18n にセットする
  const { i18n } = context.globals

  const [locale, setLocale] = React.useState<'ja' | 'en'>(i18n)

  const currentComponentName = context.kind.split('/').at(-1)
  const componentsName = Object.keys(ja.components)

  const isAlreadyTranslated = componentsName.includes(
    currentComponentName ?? '',
  )

  useEffect(() => {
    setLocale(i18n)
  }, [i18n])

  return (
    <ChakraProvider theme={theme}>
      <I18nProvider locale={locale}>
        <div dir={dir} id="story-wrapper" style={{ minHeight: '100vh' }}>
        <Text color={colorMode === 'light' ? 'black' : 'white'}>
          i18n対応: {isAlreadyTranslated ? '済' : '未'}
        </Text>
        <Story />
        </div>
      </I18nProvider>
    </ChakraProvider>
  )
}

このような設定をすることで、storybook上で言語を切り替えることができ、i18n対応がされているかどうかも確認できます。
以下はstorybookの画面です。UploadAreaコンポーネントがi18n対応されているかどうかを確認できます。

i18n切り替えを行うstorybookのgif。UploadAreaコンポーネントを例に、storybook上で日本語と英語を切り替えている

eslint pluginの導入

i18n対応をする際に、抜け漏れや表記ブレがないかをチェックするために、eslint pluginを作成し、導入しました。
このpluginでは以下のルールを定義しています。

  • componentsというkeyが存在するか
  • 日本語と英語で同じkeyが存在するか
  • 型定義と実装が一致しているか

このpluginを導入することで、i18n対応をする際に抜け漏れや表記ブレがないかをチェックできます。 このpluginは絶賛調整中であり、今後も改善していく予定です。

i18nの文言の運用

次にi18nの文言の運用について書いていきます。 現在enechainのデザインシステムで管理しているi18nの文言は、大きく分けて各プロダクトの説明文とコンポーネントのテキストに分かれています。
それぞれの文言はTypeScriptのファイルに定数として定義されており、それを各コンポーネントで使っています。

数がそこまで多くなためいずれもnotionで管理しており、各プロダクトの説明文はnotionのテーブルに記述されています。

一部を抜粋すると、以下のような形で定義されています。

i18nの文言を管理するnotionのテーブル

現状ではこのテーブルとTypeScriptのファイルは手動で同期しているため、今後はこの同期を自動化することが課題となっています。
文言については社内の英語が得意なメンバーにレビューをお願いして作成しました。

まとめ

今回はenechainデザインシステムでのi18n対応と運用について紹介しました。
文言の整備や運用方法、storybookでのi18n対応など、デザインシステムでのi18n対応について考えるポイントを紹介しました。
文言の同期方法やeslint pluginの整備についてはまだ課題がある状態ですが、これから整えていく予定です。

enechainでは、一緒に働く仲間を募集しています。詳しくは以下のリンクからご確認ください。