🎨

アクセシビリティ改善を促すコンポーネント作り(テーマカラー編)

2024/10/21に公開

実装概要

アクセシビリティを改善するために、テーマカラーの切り替え機能(コントラスト調整可)、キーボード操作、音声読み上げ機能を実装しました。
本記事内では主に テーマカラーの切り替え機能 を実装し、コントラストの調整などを簡略化できるコンポーネント作りを紹介します。

開発環境

  • Typescript
  • Vite
  • React
  • SCSS など

サンプルコード

https://codesandbox.io/p/sandbox/accessibility-l4ljgs

開発環境のディレクトリ構造

  • src
    • accessibility
      • // 本記事ではaccessibility配下は扱いません
      • EyeTrackingProvider
      • FocusableProvider
      • SpeechProvider
    • assets
    • components
      • AnchorLink
      • Button
      • Checkbox
      • Header
      • SideMenu
      • Text
      • TextInput
    • theme
      • ThemeProvider
    • App.tsx
    • main.tsx
  • vite.config.ts

アプリケーション層コンポーネント

基本的なレイアウトと必要最低限な各コンポーネントをApp.tsxに実装しました。

App.tsxをレンダリングした画面

App.tsx
import React, { useState } from "react";
import styles from "./index.module.scss";
import FocusableProvider from "./accessibility/FocusableProvider";
import SpeechProvider from "./accessibility/SpeechProvider";
import ThemeProvider from "./theme/ThemeProvider";
import Header from "./components/Header";
import SideMenu from "./components/SideMenu";
import Checkbox from "./components/Checkbox";
import TextInput from "./components/TextInput";
import Button from "./components/Button";
import AnchorLink from "./components/AnchorLink";
import Text from "./components/Text";

const App: React.FC = () => {
  const [checkboxChecked, setCheckboxChecked] = useState(false);
  const [textInputValue, setTextInputValue] = useState("");
  const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    console.log("Form submitted");
    console.log("Checkbox checked:", checkboxChecked);
    console.log("Text input value:", textInputValue);
  };

  return (
    <>
      <SpeechProvider>
        <FocusableProvider role="main">
          <ThemeProvider theme={"blue"} darkMode={false}>
            <Header />
            <div className={styles.Wrapper}>
              <SideMenu />
              <main className={styles.Main}>
                <div className={styles.Inner}>
                  <Text>
                    サンプルテキスト
                    <br />
                    サンプルテキスト
                    <br />
                    サンプルテキスト
                    <br />
                    サンプルテキスト
                    <br />
                    サンプルテキスト
                    <br />
                    サンプルテキスト
                    <br />
                    サンプルテキスト
                    <br />
                    サンプルテキスト
                    <br />
                    サンプルテキスト
                    <br />
                    サンプルテキスト
                  </Text>
                  <form className={styles.Form} onSubmit={handleSubmit}>
                    <Checkbox
                      id="example-checkbox"
                      label="Example Checkbox"
                      checked={checkboxChecked}
                      onChange={setCheckboxChecked}
                      tabIndex={0}
                    />
                    <TextInput
                      id="example-text-input"
                      label="氏名"
                      value={textInputValue}
                      onChange={setTextInputValue}
                      placeholder="Enter some text"
                      tabIndex={0}
                    />
                    <Button type="submit" tabIndex={0}>
                      Submit
                    </Button>
                  </form>
                  <AnchorLink
                    href="https://example.com"
                    text="Example Link"
                    ariaLabel="Go to example.com"
                    tabIndex={0}
                  />
                </div>
              </main>
            </div>
          </ThemeProvider>
        </FocusableProvider>
      </SpeechProvider>
    </>
  );
};

export default App;

ThemeProviderの使用例

ThemeProviderは外部コンポーネント化しているので、以下のようにApp.tsxなどでテーマカラーを実施したい箇所の子要素をThemeProviderで内包すると使用可能になります。

App.tsx
<ThemeProvider theme={"blue"} darkMode={false}>
    <Header />
    <div className={styles.Wrapper}>
      <SideMenu />
      <main className={styles.Main}>
        ...etc
      </main>
    </div>
</ThemeProvider>

これでThemeProviderの型に合ったthemedarkModeの値を渡すことで、Webページ内の配色を切り替えることが簡単になります。
ここでは未実装ですが、呼び出した親コンポーネント内にuseStateなどでthemeとdarkModeを定義することで、動的に配色を切り替えることも可能です。

theme: 'blue'でApp.tsxがレンダリングされた画面
theme={'blue'}の画面


theme={'orange'}の画面


theme={'blue'}&darkMode={true}の画面


theme={'orange'}&darkMode={true}の画面

ThemeProviderにテーマカラー切り替えとダークモード機能を実装する

ThemeProviderコンポーネントにテーマカラーとダークモードの有無を切り替えられる機能を実装します。
基本的に各サービスブランドに指定された配色がテーマとして登録される想定ですが、汎用性を求めて各UIパーツごとのカラーも個別にPropsで切り替えられるようにしています。
Propsで変更できる要素はThemeContextPropsに明記しています。

ThemeProvider.tsx
import React, { useState, createContext, useContext, ReactNode } from "react";

interface ThemeContextProps {
  // 各サービスブランドで指定されたテーマ名を渡せるようにする
  theme?: "blue" | "green" | "orange";
  // ダークモードの有無
  darkMode?: boolean;
  // 各UIパーツのカラー
  primaryColor?: string;
  accentColor?: string;
  surfaceColor?: string;
  bgColor?: string;
  textColor?: string;
  onBgTextColor?: string;
}

interface ThemeProvider extends ThemeContextProps {
  children: ReactNode || string;
}

const ThemeContext = createContext<ThemeContextProps>({
  theme: undefined,
  darkMode: undefined,
  primaryColor: undefined,
  accentColor: undefined,
  surfaceColor: "#fff",
  bgColor: "#eee",
  textColor: "#fefefe",
  onBgTextColor: "#fff",
});

const ThemeProvider: React.FC<ThemeProvider> = ({
  theme,
  darkMode,
  primaryColor,
  accentColor,
  surfaceColor,
  bgColor,
  textColor,
  onBgTextColor,
  children,
}) => {
  // 初期描画時に表示するカラーを設定
  const initialColor = () => {
    // テーマカラー(各サービスブランドなどで指定されている配色を定義)
    if (theme === "blue") {
      primaryColor = "#5b91ba";
      accentColor = "#f07167";
    } else if (theme === "green") {
      primaryColor = "#0b9319";
      accentColor = "#f07167";
    } else if (theme === "orange") {
      primaryColor = "#ed8a49";
      accentColor = "#f07167";
    }

    // ダークモードの有無
    if (darkMode) {
      surfaceColor = "#333";
      bgColor = "#222";
      textColor = "#fefefe";
      onBgTextColor = "#fefefe";
    } else {
      surfaceColor = "#fff";
      bgColor = "#eee";
      textColor = "#333";
      onBgTextColor = "#fff";
    }

    return {
      primaryColor,
      accentColor,
      surfaceColor,
      bgColor,
      textColor,
      onBgTextColor,
    };
  };
  let rootStyles = {};
  const [color, setColor] = useState(initialColor());

  if (darkMode) {
    rootStyles = {
      "--primaryColor": `color-mix(in srgb, ${color.primaryColor} 40%, black)`,
      "--primaryColorLight": `color-mix(in srgb, ${color.primaryColor} 70%, white)`,
      "--primaryColorDark": `color-mix(in srgb, ${color.primaryColor} 70%, black)`,
      "--accentColor": `color-mix(in srgb, ${color.accentColor} 40%, black)`,
      "--accentColorLight": `color-mix(in srgb, ${color.accentColor} 70%, white)`,
      "--accentColorDark": `color-mix(in srgb, ${color.accentColor} 70%, black)`,
      "--surfaceColor": `color-mix(in srgb, ${color.surfaceColor} 80%, black)`,
      "--surfaceColorLight": `color-mix(in srgb, ${color.surfaceColor} 70%, whtie)`,
      "--surfaceColorDark": `color-mix(in srgb, ${color.surfaceColor} 70%, black)`,
      "--bgColor": `color-mix(in srgb, ${color.bgColor} 80%, black)`,
      "--bgColorLight": `color-mix(in srgb, ${color.bgColor} 70%, white)`,
      "--bgColorDark": `color-mix(in srgb, ${color.bgColor} 70%, black)`,
      "--textColor": color.textColor,
      "--onBgTextColor": color.onBgTextColor,
    };
  } else {
    rootStyles = {
      "--primaryColor": color.primaryColor,
      "--primaryColorLight": `color-mix(in srgb, ${color.primaryColor} 70%, white)`,
      "--primaryColorDark": `color-mix(in srgb, ${color.primaryColor} 70%, black)`,
      "--accentColor": color.accentColor,
      "--accentColorLight": `color-mix(in srgb, ${color.accentColor} 70%, white)`,
      "--accentColorDark": `color-mix(in srgb, ${color.accentColor} 70%, black)`,
      "--surfaceColor": color.surfaceColor,
      "--surfaceColorLight": `color-mix(in srgb, ${color.surfaceColor} 70%, whtie)`,
      "--surfaceColorDark": `color-mix(in srgb, ${color.surfaceColor} 70%, black)`,
      "--bgColor": color.bgColor,
      "--bgColorLight": `color-mix(in srgb, ${color.bgColor} 70%, white)`,
      "--bgColorDark": `color-mix(in srgb, ${color.bgColor} 70%, black)`,
      "--textColor": color.textColor,
      "--onBgTextColor": color.onBgTextColor,
    };
  }

  return (
    <div style={rootStyles} data-theme={theme || "none"}>
      <ThemeContext.Provider
        value={{
          theme,
          primaryColor,
          accentColor,
          surfaceColor,
          bgColor,
          textColor,
        }}
      >
        {children}
      </ThemeContext.Provider>
    </div>
  );
};

export default ThemeProvider;

コード解説

初期化時の処理

useStateで定義したcolorにinitialColorという関数を指定する。
initialColor内では、事前に定義したtheme名に紐付いたprimaryColorやaccentColorが変数に代入され、その後darkModeの有無で共通要素(テキストや背景色など)のカラーが代入されます。

サンプルコードでは未実装ですが、useStateのsetColorから動的にカラーを切り替えることも可能です。

ThemeProvider.tsx
const initialColor = () => {
    // テーマカラー(各サービスブランドなどで指定されている配色を定義)
    if (theme === "blue") {
      primaryColor = "#5b91ba";
      accentColor = "#f07167";
    } else if (theme === "green") {
      primaryColor = "#0b9319";
      accentColor = "#f07167";
    } else if (theme === "orange") {
      primaryColor = "#ed8a49";
      accentColor = "#f07167";
    }

    // ダークモードの有無
    if (darkMode) {
      surfaceColor = "#333";
      bgColor = "#222";
      textColor = "#fefefe";
      onBgTextColor = "#fefefe";
    } else {
      surfaceColor = "#fff";
      bgColor = "#eee";
      textColor = "#333";
      onBgTextColor = "#fff";
    }

    return {
      primaryColor,
      accentColor,
      surfaceColor,
      bgColor,
      textColor,
      onBgTextColor,
    };
  };
  let rootStyles = {};
  const [color, setColor] = useState(initialColor());

色彩調整

その後routeStyleという変数内にCSS変数名color-mixで色彩を調整した配色が代入されます。

配色は各サービスごとのデザイン仕様や、コントラストの調整などで白黒どちらかの配色を何%混ぜるかを調整することを想定しています。
UIで使用しているカラーパターンが多いほどrootStyles内の項目も多くなってしまいますが、一度定義しまえばカラーパターンを使いまわせるので外部ファイル化してしまっても良い部分となります。
darkModeだと基本的にテキストや背景色などの共通要素のカラーは白黒反転することが多いので、コントラストを調整して色味を決めると良いです。

ThemeProvider.tsx
let rootStyles = {};
const [color, setColor] = useState(initialColor());

if (darkMode) {
    rootStyles = {
      "--primaryColor": `color-mix(in srgb, ${color.primaryColor} 40%, black)`,
      "--primaryColorLight": `color-mix(in srgb, ${color.primaryColor} 70%, white)`,
      "--primaryColorDark": `color-mix(in srgb, ${color.primaryColor} 70%, black)`,
      "--accentColor": `color-mix(in srgb, ${color.accentColor} 40%, black)`,
      "--accentColorLight": `color-mix(in srgb, ${color.accentColor} 70%, white)`,
      "--accentColorDark": `color-mix(in srgb, ${color.accentColor} 70%, black)`,
      "--surfaceColor": `color-mix(in srgb, ${color.surfaceColor} 80%, black)`,
      "--surfaceColorLight": `color-mix(in srgb, ${color.surfaceColor} 70%, whtie)`,
      "--surfaceColorDark": `color-mix(in srgb, ${color.surfaceColor} 70%, black)`,
      "--bgColor": `color-mix(in srgb, ${color.bgColor} 80%, black)`,
      "--bgColorLight": `color-mix(in srgb, ${color.bgColor} 70%, white)`,
      "--bgColorDark": `color-mix(in srgb, ${color.bgColor} 70%, black)`,
      "--textColor": color.textColor,
      "--onBgTextColor": color.onBgTextColor,
    };
} else {
    rootStyles = {
      "--primaryColor": color.primaryColor,
      "--primaryColorLight": `color-mix(in srgb, ${color.primaryColor} 70%, white)`,
      "--primaryColorDark": `color-mix(in srgb, ${color.primaryColor} 70%, black)`,
      "--accentColor": color.accentColor,
      "--accentColorLight": `color-mix(in srgb, ${color.accentColor} 70%, white)`,
      "--accentColorDark": `color-mix(in srgb, ${color.accentColor} 70%, black)`,
      "--surfaceColor": color.surfaceColor,
      "--surfaceColorLight": `color-mix(in srgb, ${color.surfaceColor} 70%, whtie)`,
      "--surfaceColorDark": `color-mix(in srgb, ${color.surfaceColor} 70%, black)`,
      "--bgColor": color.bgColor,
      "--bgColorLight": `color-mix(in srgb, ${color.bgColor} 70%, white)`,
      "--bgColorDark": `color-mix(in srgb, ${color.bgColor} 70%, black)`,
      "--textColor": color.textColor,
      "--onBgTextColor": color.onBgTextColor,
    };
}

テンプレート側の実装

配色関連のPropsはThemeContext.Providervalue要素に渡し、子要素に描画したい値をchildrenとして定義しておきます。
このコードではReactで実装しているが、Vueを使用している場合は<slot />などを使用することで同様の実装が可能です。

ThemeProvider.tsx
return (
    <div style={rootStyles} data-theme={theme || "none"}>
      <ThemeContext.Provider
        value={{
          theme,
          primaryColor,
          accentColor,
          surfaceColor,
          bgColor,
          textColor,
        }}
      >
        {children}
      </ThemeContext.Provider>
    </div>
);

まとめ

ThemeProviderのようなコンポーネントを作成しておくと Webサービスが複数あって、一定の配色規則を守りながらサービスごとに配色が変更したい 場合に一つのコンポーネントで配色を調整することができます。
サービス間でのデザインルールやブランドカラーの使用ルールなどがまとめられるので、UIの実装に一定の規則を設けることができます。
もしテーマカラーのような単位で配色を定義していなくても、Propsで各UIパーツの配色を個別で渡すこともできます。

ダークモードの実装も今回はThemeProviderのPropsのみで操作できるように実装しましたが、応用としてCSSのprefers-color-schemeなども併用して指定することも可能です。
これによりPCなどのデバイス側のシステム設定に準じた表示が可能になります。

次項は 音声読み上げ機能 コンポーネントの実装について書きます。

レバテック開発部

Discussion