Hands-on

アプリケーション実装で考慮するポイント

Webサイト、モバイルアプリなどを作成する際に考慮すべき内容をまとめました。

Web Content Accessibility Guidelines (WCAG) 2.2 をベースとしています。

1. 知覚可能 (Perceivable)

コンテンツが、ユーザーが知覚できる方法で提示されているか確認しましょう。

1-1. テキストによる代替

画像や映像など、テキストではないコンテンツには、その内容を説明するテキストによる代替(代替テキスト)を提供することで、スクリーンリーダーや点字ディスプレイなどの支援技術が内容をユーザーに伝えられるようになります。

1-2. 見やすく、聞きやすいデザイン

ユーザーがコンテンツを判別できるよう、見た目や音に配慮したデザインにしましょう。

  • 色の使用
    • 色は、情報を伝える唯一の手段にしないでください。色覚特性を持つユーザーのために、色以外(テキスト、アイコン、下線など)の手段も併用しましょう。例えば、必須項目を「赤色」だけで示すのではなく、「(必須)」というテキストを追加します。
    • 参考:
  • コントラスト比の確保
    • テキスト: テキストと背景色には、最低限のコントラスト比を確保しましょう。
      • 通常テキスト: 4.5:1 以上。
      • サイズの大きなテキスト: 3:1 以上(18pt以上、または14pt以上の太字)。
    • 非テキストコンテンツ: アイコン、ボタンの輪郭、グラフの線など、情報を伝えるグラフィカルな要素も、背景とのコントラスト比を3:1以上にしましょう。ただし、コンポーネントが非アクティブな状態や、ユーザーエージェントによってスタイルが決定されている場合は除きます。
    • 参考:
  • テキストのリサイズ
    • テキストは、コンテンツや機能を損なうことなく、支援技術なしで200%までサイズ変更できるようにしましょう。
    • リフロー(Reflow): 画面を400%に拡大しても、縦スクロールのコンテンツが320CSSピクセル、横スクロールのコンテンツが256CSSピクセル内で収まり、二次元スクロール(縦横両方のスクロール)が不要になるようにしましょう。地図やゲームなど、二次元レイアウトが必須なコンテンツは例外です。
    • 参考:
  • 文字画像の使用を避ける
    • テキストは、画像ではなく、CSSなどでスタイル付けされたHTMLテキストとして提供しましょう。フォントやサイズ、色などをユーザーが自由にカスタマイズできるようになります。
    • 例外として、ロゴや、特定のフォントで表現することが不可欠な場合は文字画像を使用しても問題ありません。
    • 参考:
  • ホバーやフォーカスで表示されるコンテンツ
    • マウスホバーやキーボードフォーカスでツールチップ、サブメニューなどのコンテンツが表示される場合、以下の要件を満たしましょう。
      1. 非表示にできる: ホバーやフォーカスを外さずに、閉じるメカニズムを提供しましょう。
      2. ホバーし続けられる: マウスカーソルを移動させても、追加コンテンツが消えないようにしましょう。
      3. 表示が継続される: ユーザーが非表示にするまで、コンテンツが表示され続けるようにしましょう。
    • 参考:

2. 操作可能 (Operable)

キーボードや音声、タッチ操作など、様々な方法でコンテンツを操作できるようにしましょう。

  • キーボード操作の確保
  • 時間制限を設ける場合
    • コンテンツに時間制限を設ける場合は、ユーザーがその制限を解除、調整、または延長できるようにしましょう。例えば、フォームの入力時間がタイムアウトする前に警告を出し、時間を延長できるような機能が必要です。
    • コンテンツの自動的な動きを止める: 5秒以上動き続けるコンテンツ(カルーセルなど)は、ユーザーが一時停止、停止、または非表示にできるコントロールを提供しましょう。
    • 参考:
  • ナビゲーションと見出し
    • ページタイトル: 各ページのタイトルは、その内容や目的を説明する明確なものにしましょう。
    • ブロックスキップ: 複数のページで繰り返されるナビゲーションメニューやヘッダーなど、主要なコンテンツのブロックをスキップできるメカニズムを提供しましょう。
    • 見出しとラベル: 見出しはコンテンツの内容を、フォームのラベルはその入力項目の目的を明確に示しましょう。見出しは階層構造を正しく使い、内容を整理しましょう。
    • 参考:
  • ポインタ操作への配慮
    • ドラッグ動作: ドラッグ&ドロップ操作が必要な機能には、ドラッグなしで操作できる代替手段を提供しましょう(例:ボタン操作)。
    • ターゲットのサイズ: ボタンやリンクなどのインタラクティブな要素のサイズは、誤操作を防ぐために24 x 24 CSSピクセル以上の大きさを確保しましょう。これにより、タッチ操作のユーザーや手の震えがあるユーザーでも、簡単に操作できるようになります。
    • ラベルを含む名前: ユーザーに表示されるテキストラベルは、支援技術に公開される名前(name)に含めましょう。これにより、支援技術のユーザーがラベルと操作対象の関係性を正しく理解できます。
    • 参考:

3. 理解可能 (Understandable)

ユーザーがコンテンツやインターフェースの操作方法を理解できるようにしましょう。

  • ページの言語
    • <html>要素にlang属性を付与し、ページの主要な言語を指定しましょう。
    • コンテンツの一部で言語が変わる場合は、その部分にもlang属性を設定しましょう。これにより、スクリーンリーダーが適切な発音で読み上げることが可能になります。
    • 参考:
  • 予測可能な動作
    • フォーカス時/入力時: ユーザーが要素にフォーカスしたり、入力内容を変更したりしたときに、予期せぬページの移動やコンテキストの変化(新しいウィンドウの表示など)を自動的に起こさないようにしましょう。
    • 一貫したナビゲーション: サイト内の複数のページに共通して表示されるナビゲーションメニューは、常に同じ相対的な順序で配置しましょう。これにより、ユーザーはナビゲーションの構造を予測できます。
    • 参考:
  • 入力支援
    • エラーの特定と提案: フォームでエラーが発生した場合、どの項目がエラーなのか、そしてどのように修正すればよいかを明確なテキストで伝えましょう。
    • ラベルまたは説明: 入力フォームの各項目には、ラベルまたは説明文を提供しましょう。プレースホルダーに頼らず、<label>要素などを適切に利用することが重要です。
    • 冗長な入力項目の排除: 同一プロセス内で再入力が必要な情報(例:配送先情報と請求先情報)は、自動入力や選択肢として提供しましょう。ただし、セキュリティのために再入力が必要な場合は例外です。
    • 認証のアクセシビリティ: パスワードの記憶や転記を要求するような認知機能テストを認証プロセスに含める場合、パスワードマネージャーによる自動入力など、認知機能に依存しない代替手段を用意しましょう。
    • 参考:

4. 堅牢 (Robust)

コンテンツが、様々な環境で安定して動作するようにしましょう。

4-1. HTMLのセマンティクスとWAI-ARIAの活用

HTMLのセマンティクス(意味)を正しく使い、必要に応じてWAI-ARIAを補完することで、支援技術がコンテンツの構造や役割を正しく理解できるようになります。

  • セマンティックなHTML構造
    • ページの構造を定義する際には、headernavmainfooterarticlesectionasideなどのランドマーク要素を適切に使用しましょう。これにより、ユーザーはページ内の主要な領域へ効率的に移動できます。
    • 見出しには**h1からh6までの見出しレベル**を正しく使い、階層構造を論理的に保ちましょう。
    • フォームやリンク、ボタンなど、ユーザーが操作する要素は、formabuttonといったネイティブなHTML要素を優先的に使用しましょう。
    • 参考:
  • WAI-ARIAの活用
    • WAI-ARIAは、セマンティックな意味を持たない要素(divspanなど)に、役割(role)、状態(state)、プロパティ(property)などの情報を付与するために使用します。
    • 例えば、カスタムコンポーネント(例:タブやアコーディオン)には、role="tablist"aria-expanded="false"などの属性を付与することで、支援技術にその役割と状態を伝達できます。
    • WAI-ARIAは、ネイティブなHTML要素で解決できない場合にのみ使用し、過剰な使用は避けるようにしましょう。
    • 参考:

4-2. ステータスメッセージのアクセシビリティ

  • フォームの送信結果、読み込み状況、バリデーションエラーなど、ユーザーに状況を知らせるメッセージは、フォーカスを移動させなくても支援技術が読み取れるように実装しましょう。aria-live属性などが有効です。
  • 参考:

問題がないか確認する

コントラスト

  • Contrast
    • Figma上でコントラストをチェックできる拡張機能
  • WebAIM Contrast Checker
    • カラーコードを入力するとWCAG 2.2の達成基準を満たしているかどうかのチェックをおこなえるWebサービス

操作可能かどうか

キーボード操作やスクリーンリーダーなどを使ってコンテンツにアクセス可能になっているか実際に試して確認しましょう。

参考:

HTMLの文章構造

Linterによる開発段階でのチェック

Linterは、ソースコードの品質を自動でチェックするツールです。アクセシビリティに特化したLinter(eslint-plugin-jsx-a11yaxe-linterなど)を開発環境に導入することで、alt属性の欠落、不適切なroleの使用、見出しレベルの誤りなど、コーディング中に起こりやすいアクセシビリティの問題をリアルタイムで検知し、修正を促すことができます。

これにより、アクセシビリティの問題が後工程に持ち越されることを防ぎ、開発初期段階から品質を担保することが可能になります。またLinterだけでなく、テストフレームワーク(Playwrightなど)と組み合わせてアクセシビリティの自動テストをCI/CDに組み込むことで、より堅牢なチェック体制を構築し、アクセシビリティの改善を進めやすくなります。

たとえば今閲覧されているこのサイト https://design.pepabo.com/ では、 eslint-plugin-jsx-a11y(eslint-plugin-astro) を導入し、GitHub上でPull Requestを作成するとアクセシビリティに関するチェックが実行されています。

実装例:ヘッドレスUIの活用

ヘッドレスUIとは、UIコンポーネントからスタイル(見た目)を切り離し、機能やロジック、そしてアクセシビリティの挙動のみを提供するライブラリです。従来のUIライブラリがデザインと機能を一体で提供していたのに対し、開発者は見た目を自由にカスタマイズしながら、アクセシビリティが考慮された振る舞い(キーボード操作、WAI-ARIAの正しい利用など)を簡単に実装できます。

たとえば、ドロップダウンメニューやモーダルウィンドウ、アコーディオンといった複雑なUIを自作すると、キーボードでのフォーカス管理や適切なaria-*属性の設定など、アクセシビリティ対応に多くの手間がかかります。Radix UIやReact AriaといったヘッドレスUIライブラリは、これらの複雑なロジックをあらかじめ内包しているため、開発者はデザインに集中しつつ、高品質なアクセシビリティを確保できます。

またそこにスタイリング(CSS)における品質を担保するGMOペパボデザインシステム「Inhouse」を組み合わせることで、アクセシビリティが考慮されたコンポーネントを効率よく実装することができます。

例として、React AriaとInhouseのSassを組み合わせることでアクセシビリティが考慮されたコンポーネントを効率的に実装する方法を示します。

参考:https://github.com/pepabo/inhouse-components-web

実装の基本構造

このプロジェクトでは、以下のような構造でコンポーネントを実装しています:

  1. TSXファイル(Reactコンポーネント): React Ariaのコンポーネントを使用し、クラス名を動的に生成
  2. SCSSファイル(スタイル): Inhouseのadapter関数を使用し、クラス名に基づいてスタイルを適用

TSXで生成されたクラス名とSCSSで定義されたクラス名が対応することで、React Ariaの機能とInhouseのデザイントークンが連携します。

実装のポイント

アクセシビリティの確保

  • React Ariaが提供するアクセシビリティ機能を活用
  • キーボードナビゲーションの自動実装
  • 適切なARIA属性の設定
  • フォーカス管理の自動化

デザインシステムの一貫性

  • Inhouseのadapter関数を使用したデザイントークンの活用
  • カラー、スペーシング、タイポグラフィの統一
  • レスポンシブデザインの対応

開発効率の向上

  • 複雑なアクセシビリティロジックの実装が不要
  • デザインに集中できる環境
  • 保守性の高いコード構造

1. TextFieldコンポーネント

React AriaのTextFieldInputTextAreaFieldErrorコンポーネントを使用し、Inhouseのadapter関数でスタイリングを実装した例です。

React Ariaの活用

  • フォーカス管理
  • バリデーション状態の管理
  • エラーメッセージの表示
  • キーボードナビゲーション
import {
  TextField as AriaTextField,
  Input as AriaInput,
  TextArea as AriaTextArea,
  FieldError as AriaFieldError
} from 'react-aria-components';

const TextField: FC<Props> = (props: Props) => {
  const {
    appearance,
    color,
    size,
    state,
    width,
    isRequired,
    tag = 'input',
    value,
    ...rest
  } = props;

  // クラス名の生成:SCSSで定義されたクラス名と対応
  const wrapperClasses = ['in-textfield'];
  const innerClasses = ['_input'];

  if (typeof appearance !== 'undefined') {
    wrapperClasses.push(`-appearance-${appearance}`); // 例: -appearance-outlined
  }

  if (typeof color !== 'undefined') {
    wrapperClasses.push(`-color-${color}`); // 例: -color-neutral
  }

  if (typeof size !== 'undefined') {
    wrapperClasses.push(`-size-${size}`); // 例: -size-m
  }

  if (typeof state !== 'undefined') {
    innerClasses.push(`--${state}`); // 例: --focused, --disabled
  }

  if (typeof width !== 'undefined') {
    wrapperClasses.push(`-width-${width}`); // 例: -width-full
  }

  return (
    <AriaTextField
      value={value?.toString()}
      isDisabled={state === 'disabled'}
      isRequired={isRequired}
      className={wrapperClasses.join(' ')} // "in-textfield -appearance-outlined -size-m"
    >
      {tag === 'input' ? (
        <AriaInput
          className={innerClasses.join(' ')} // "_input --focused"
          type="text"
          {...rest}
        />
      ) : (
        <AriaTextArea
          className={innerClasses.join(' ')}
          {...rest}
        />
      )}
      <AriaFieldError className="in-validation-message -color-negative">
        {({ validationDetails }) =>
          validationDetails.valueMissing
            ? '必須項目です'
            : ''
        }
      </AriaFieldError>
    </AriaTextField>
  );
};

Inhouse adapter関数の活用

SCSSファイルでは、TSXで生成されたクラス名(in-textfield-appearance-*-size-*など)に対応するスタイルを定義しています。Inhouseのadapter関数を使用してデザイントークンに基づいたスタイリングを実装しています。

@use "@pepabo-inhouse/adapter/functions" as adapter;

// メインのスタイル定義:.in-textfieldクラスに適用
@mixin style($options: variables.$default-option) {
  position: relative;
  display: inline-block;
  box-sizing: border-box;

  // TSXで生成されるクラス名._inputに対応
  ._input {
    display: block;
    box-sizing: border-box;
    width: 100%;
    font-family: inherit;
    border: none;
    outline: 0;
    transition: adapter.get-transition-value();
    appearance: none;

    // TSXで生成されるクラス名--disabledに対応
    &.--disabled {
      opacity: adapter.get-disabled-surface-opacity();
    }
  }

  // TSXで生成されるクラス名-color-neutral、-color-negativeに対応
  @mixin -color-rule($intention: neutral) {
    ._input {
      color: adapter.get-text-color($brightness: light, $name: high_emphasis);
      background-color: adapter.get-surface-color($brightness: light, $priority: tertiary);

      &::placeholder {
        color: adapter.get-text-color($brightness: light, $name: low_emphasis);
      }
    }
  }

  // TSXで生成されるクラス名-size-s、-size-m、-size-lに対応
  @mixin -size-rule($level: m) {
    ._input {
      padding: #{(adapter.get-interactive-component-height($level: $level) * 0.5) -
        (adapter.get-line-height($level: $level, $density: normal) * 0.5)}
        adapter.get-spacing-size($level: $level);
      font-size: adapter.get-font-size($level: $level);
    }

    input._input {
      height: adapter.get-interactive-component-height($level: $level);
    }
  }

  // TSXで生成されるクラス名-appearance-outlined、-appearance-filledに対応
  @mixin -appearance-rule($appearance: outlined, $intention: neutral) {
    @if $appearance == outlined {
      ._input {
        background-color: adapter.get-surface-color($brightness: light, $priority: primary);
        border-radius: adapter.get-radius-size($level: m);
        box-shadow: inset 0 0 0 #{adapter.get-border-size($level: m)}
          #{adapter.get-field-border-color($color: $intention, $state: enabled)};

        // TSXで生成されるクラス名--focusedに対応
        &.--focused {
          outline: adapter.get-focus-ring-outline();
          outline-offset: adapter.get-focus-ring-outline-offset();
        }
      }
    }
  }
}

// エクスポート:.in-textfieldクラスを生成
@mixin export {
  .in-textfield {
    @include style;
  }
}

TSXとSCSSの連携ポイント

  • TSXでclassName="in-textfield -appearance-outlined -size-m"を生成
  • SCSSで.in-textfield.-appearance-outlined.-size-mのスタイルを定義
  • クラス名の命名規則(-appearance-*-size-*など)で両者が連携

2. Dialogコンポーネント

React AriaのDialogDialogTriggerModalModalOverlayコンポーネントを使用したモーダルダイアログの実装例です。

React Ariaの活用

  • モーダルのフォーカストラップ
  • キーボードでの閉じる操作(ESCキー)
  • 適切なARIA属性の設定
import {
  Dialog as AriaDialog,
  DialogTrigger as AriaDialogTrigger,
  Modal as AriaModal,
  ModalOverlay as AriaModalOverlay,
  Button,
  Heading
} from 'react-aria-components';

const Dialog: FC<Props> = (props: Props) => {
  const {
    title,
    children,
    footer,
    size = 'm',
    alignment = 'center',
    buttonFlow = 'row',
    triggerLabel,
    ...rest
  } = props;

  // クラス名の生成:SCSSで定義されたクラス名と対応
  const dialogClassList = ['in-dialog'];

  if (size !== 'm') {
    dialogClassList.push(`-size-${size}`); // 例: -size-l
  }
  if (alignment !== 'center') {
    dialogClassList.push(`-alignment-${alignment}`); // 例: -alignment-left
  }
  if (buttonFlow !== 'row') {
    dialogClassList.push(`-button-flow-${buttonFlow}`); // 例: -button-flow-column
  }

  const dialogClasses = dialogClassList.join(' ');

  return (
    <AriaDialogTrigger>
      <Button className="in-button -appearance-outlined">
        <span className="_body">{triggerLabel}</span>
      </Button>
      <AriaModalOverlay className="in-modal">
        {/* SCSSの.in-modalクラスに対応 */}
        <AriaModal>
          <AriaDialog
            className={dialogClasses} // "in-dialog -size-l -alignment-left"
            {...rest}
          >
            {({ close }) => (
              <>
                {/* SCSSの._headerに対応 */}
                <div className="_header">
                  {/* SCSSの._titleに対応 */}
                  <Heading
                    id="dialog-title"
                    className="_title"
                  >
                    {title}
                  </Heading>
                </div>
                {/* SCSSの._contentに対応 */}
                <div className="_content" aria-labelledby="dialog-title">
                  {children}
                </div>
                {footer && (
                  <>
                    {/* SCSSの._footerに対応 */}
                    <div
                      className="_footer"
                      role="toolbar"
                      aria-label="ダイアログのアクション"
                    >
                    {typeof footer === 'function' ? footer({ close }) : footer}
                  </div>
                  </>
                )}
              </>
            )}
          </AriaDialog>
        </AriaModal>
      </AriaModalOverlay>
    </AriaDialogTrigger>
  );
};

Inhouse adapter関数の活用

モーダルとダイアログのスタイリングにInhouseのadapter関数を活用しています。TSXで使用されるクラス名(in-modalin-dialog_header_title_content_footer)に対応するスタイルを定義しています。

@use "@pepabo-inhouse/adapter" as adapter;

// モーダルオーバーレイのスタイル:TSXのclassName="in-modal"に対応
@mixin modal-style() {
  position: fixed;
  inset: 0;
  z-index: adapter.get-major-stack-z-index(6);
  display: grid;
  place-items: center;
  background-color: adapter.get-scrim-background-color();
}

// ダイアログコンテンツのスタイル:TSXのclassName="in-dialog"に対応
@mixin dialog-style($options: variables.$default-options) {
  display: flex;
  flex-direction: column;
  gap: adapter.get-spacing-size(m);
  box-sizing: border-box;
  max-width: calc(100dvw - adapter.get-spacing-size(l) * 2);
  max-height: calc(100dvh - adapter.get-spacing-size(l) * 2);
  padding: adapter.get-spacing-size(l);
  background: adapter.get-surface-color(light, primary);
  border-radius: adapter.get-radius-size(m);
  animation: dialog-in 200ms ease-out;
  @include adapter.elevation(6);

  // TSXで生成されるクラス名-size-s、-size-m、-size-lに対応
  @each $variant-size in "s", "m", "l" {
    &.-size-#{$variant-size} {
      @if $variant-size == "s" {
        width: adapter.get-boundary-size(xs);
      } @else if $variant-size == "m" {
        width: adapter.get-boundary-size(s);
      } @else if $variant-size == "l" {
        width: adapter.get-boundary-size(m);
      }
    }
  }

  // TSXで使用されるクラス名._titleに対応
  ._title {
    @include adapter.text(l);
    margin: 0;
  }

  // TSXで使用されるクラス名._contentに対応
  ._content {
    overflow-y: auto;

    &:empty {
      display: none;
    }
  }

  // TSXで使用されるクラス名._footerに対応
  ._footer {
    display: flex;
    gap: adapter.get-spacing-size(s);
    justify-content: center;
  }
}

// エクスポート:クラスを生成
@mixin export {
  .in-modal {
    @include modal-style;
  }

  .in-dialog {
    @include dialog-style;
  }
}

TSXとSCSSの連携ポイント

  • TSXでclassName="in-modal"className="in-dialog -size-l"を生成
  • SCSSで.in-modal.in-dialog.-size-lのスタイルを定義
  • TSXのclassName="_header""_title""_content""_footer"がSCSSの子要素セレクタに対応

3. Tabコンポーネント

React AriaのTabsTabListTabTabPanelコンポーネントを使用したタブコンポーネントの実装例です。

React Ariaの活用

  • タブ間のキーボードナビゲーション(矢印キー)
  • タブパネルの適切な表示/非表示制御
  • ARIA属性の自動管理
import {
  Tabs as AriaTabs,
  TabList as AriaTabList,
  Tab as AriaTab,
  TabPanel as AriaTabPanel
} from 'react-aria-components';

// React Ariaの状態に基づいてクラス名を動的に生成
const getTabClassName = ({isSelected}: {isSelected: boolean}) => {
  return isSelected ? "_item --activated" : "_item"; // SCSSの.--activatedに対応
};

const getPanelClassName = ({isInert}: {isInert: boolean}) => {
  return !isInert ? "_panel --activated" : "_panel"; // SCSSの.--activatedに対応
};

const Tab: FC<Props> = (props: Props) => {
  const {
    tabItems,
    appearance,
    align,
    density,
    size,
    isGapless,
    ariaLabel,
    ...rest
  } = props;

  // クラス名の生成:SCSSで定義されたクラス名と対応
  const classList = ['in-tab'];

  if (typeof appearance !== 'undefined') {
    classList.push(`-appearance-${appearance}`); // 例: -appearance-outlined
  }

  if (typeof align !== 'undefined') {
    classList.push(`-align-${align}`); // 例: -align-start
  }

  if (typeof density !== 'undefined') {
    classList.push(`-density-${density}`); // 例: -density-normal
  }

  if (typeof size !== 'undefined') {
    classList.push(`-size-${size}`); // 例: -size-m
  }

  if (isGapless) {
    classList.push(`-is-gapless`);
  }

  const classes = classList.join(' ');

  return (
    <div className={classes} {...rest}>
      <AriaTabs>
        <AriaTabList className="_list" aria-label={ariaLabel}>
          {/* SCSSの._listに対応 */}
          {tabItems.map((item) => (
            <AriaTab
              key={item.id}
              id={item.id}
              className={getTabClassName} {/* 関数を渡すことで状態に応じたクラス名を生成 */}
            >
              <span className="_body">{item.label}</span>
              {/* SCSSの._bodyに対応 */}
            </AriaTab>
          ))}
        </AriaTabList>
        {tabItems.map((item) => (
          <AriaTabPanel
            key={item.id}
            id={item.id}
            className={getPanelClassName} {/* 関数を渡すことで状態に応じたクラス名を生成 */}
          >
            {item.content}
          </AriaTabPanel>
        ))}
      </AriaTabs>
    </div>
  );
};

Inhouse adapter関数の活用

タブのスタイリングにInhouseのadapter関数を活用し、一貫したデザインシステムを実現しています。TSXで生成されるクラス名に対応するスタイルを定義しています。

@use "@pepabo-inhouse/adapter" as adapter;
@use "@pepabo-inhouse/cell" as cell;

@mixin style($options: variables.$default-option) {
  // TSXで使用されるクラス名._listに対応
  ._list {
    display: flex;
    gap: adapter.get-spacing-size(xs);
    justify-content: map.get($options, align);
    box-shadow: inset 0 calc(adapter.get-border-size($level: m) * -1) 0 0
      adapter.get-border-color($brightness: light, $name: high_emphasis);
  }

  // TSXで生成されるクラス名-align-start、-align-centerに対応
  @each $align in variables.$available-align {
    &.-align-#{$align} {
      ._list {
        justify-content: $align;
      }
    }
  }

  // TSXで使用されるクラス名._itemに対応(getTabClassNameで生成)
  ._item {
    @include cell.style((density: map.get($options, density), size: map.get($options, size)));
    top: adapter.get-border-size($level: m);
    padding-inline: adapter.get-spacing-size(m);
    color: adapter.get-text-color($brightness: light, $name: high_emphasis);
    font-weight: bold;
    border-radius: adapter.get-radius-size($level: m) adapter.get-radius-size($level: m) 0 0;
    outline: none;

    // TSXのgetTabClassNameで生成されるクラス名--activatedに対応
    &:not(.--activated) {
      cursor: pointer;
    }

    // TSXで使用されるクラス名._bodyに対応
    &:focus-visible ._body {
      outline: adapter.get-focus-ring-outline();
      outline-offset: adapter.get-focus-ring-outline-offset();
    }
  }

  // TSXで使用されるクラス名._panelに対応(getPanelClassNameで生成)
  ._panel {
    width: 100%;
    overflow: auto;

    &:focus-visible {
      outline: adapter.get-focus-ring-outline();
      outline-offset: adapter.get-focus-ring-outline-offset();
    }

    // TSXのgetPanelClassNameで生成されるクラス名--activatedに対応
    &:not(.--activated) {
      display: none;
    }
  }
}

// エクスポート:.in-tabクラスを生成
@mixin export {
  .in-tab {
    @include style;
  }
}

TSXとSCSSの連携ポイント

  • TSXでclassName="in-tab -appearance-outlined -size-m"を生成
  • React Ariaの状態(isSelectedisInert)に基づいて--activatedクラスを動的に追加
  • SCSSで.in-tab.-appearance-outlined.-size-m ._item.--activatedのスタイルを定義
  • クラス名の命名規則(_list_item_body_panel)で両者が連携

4. Breadcrumbコンポーネント

React AriaのBreadcrumbsBreadcrumbLinkコンポーネントを使用したパンくずリストの実装例です。

React Ariaの活用

  • ナビゲーション構造の適切な表現
  • 現在のページの識別
  • キーボードナビゲーション
import {
  Breadcrumbs as AriaBreadcrumbs,
  Breadcrumb as AriaBreadcrumb,
  Link as AriaLink
} from 'react-aria-components';

const Breadcrumb: FC<Props> = (props: Props) => {
  const {
    options,
    size,
    density,
    overflow,
    ariaLabel,
    ...rest
  } = props;

  // クラス名の生成:SCSSで定義されたクラス名と対応
  const classList = ['in-breadcrumb'];

  if (typeof size !== 'undefined') {
    classList.push(`-size-${size}`); // 例: -size-m
  }

  if (typeof density !== 'undefined') {
    classList.push(`-density-${density}`); // 例: -density-normal
  }

  if (typeof overflow !== 'undefined') {
    classList.push(`-overflow-${overflow}`); // 例: -overflow-scroll
  }

  const classes = classList.join(' ');

  return (
    <div className={classes} {...rest}>
      {/* SCSSの._listに対応 */}
      <AriaBreadcrumbs className="_list" aria-label={ariaLabel}>
        {options.map((option) => (
          <AriaBreadcrumb key={option.value} className="_item">
            {/* SCSSの._itemに対応 */}
            {/* React AriaのisCurrent状態に基づいてクラス名を動的に生成 */}
            <AriaLink
              href={option.value}
              className={({ isCurrent }) => isCurrent ? '_current' : '_link'}
            >
              {option.label}
            </AriaLink>
          </AriaBreadcrumb>
        ))}
      </AriaBreadcrumbs>
    </div>
  );
};

Inhouse adapter関数の活用

パンくずリストのスタイリングにInhouseのadapter関数を活用しています。TSXで生成されるクラス名に対応するスタイルを定義しています。

@use "@pepabo-inhouse/adapter" as adapter;
@use "@pepabo-inhouse/cell" as cell;
@use "@pepabo-inhouse/icon" as icon;

@mixin style($options: variables.$default-option) {
  @include cell.style((density: map.get($options, density), size: map.get($options, size)));

  width: 100%;
  padding-inline: adapter.get-spacing-size($level: map.get($options, size));

  // TSXで生成されるクラス名-overflow-scrollに対応
  &.-overflow-scroll {
    overflow-x: auto;
    white-space: nowrap;
  }

  // TSXで使用されるクラス名._listに対応
  ._list {
    display: inline;
    flex-wrap: nowrap;
    margin: 0;
    padding: 0;
    list-style: none;
  }

  // TSXで使用されるクラス名._itemに対応
  ._item {
    display: inline-flex;
    align-items: center;

    // 区切り文字(Chevronアイコン)を自動挿入
    &:not(:last-child)::after {
      @include icon.style;
      content: "chevron_right";
      padding-inline: adapter.get-spacing-size($level: xs);
    }
  }

  // TSXのAriaLinkで生成されるクラス名._linkに対応(isCurrent=falseの場合)
  ._link {
    color: adapter.get-implication-color($name: interactive, $level: 700);
    text-decoration: underline;

    &:focus-visible {
      outline: adapter.get-focus-ring-outline();
    }
  }

  // TSXのAriaLinkで生成されるクラス名._currentに対応(isCurrent=trueの場合)
  ._current {
    color: adapter.get-text-color($brightness: light, $name: high_emphasis);
    text-decoration: none;
  }
}

// エクスポート:.in-breadcrumbクラスを生成
@mixin export {
  .in-breadcrumb {
    @include style;
  }
}

TSXとSCSSの連携ポイント

  • TSXでclassName="in-breadcrumb -size-m -overflow-scroll"を生成
  • React AriaのisCurrent状態に基づいて_linkまたは_currentクラスを動的に生成
  • SCSSで.in-breadcrumb.-size-m.-overflow-scroll ._linkのスタイルを定義
  • クラス名の命名規則(_list_item_link_current)で両者が連携

まとめ

この実装パターンでは、以下のポイントが重要です:

  1. クラス名の命名規則: TSXとSCSSで一貫した命名規則(in-*-appearance-*-size-*_*など)を使用
  2. 状態に基づくクラス名の動的生成: React Ariaの状態(isSelectedisCurrentなど)に基づいてクラス名を動的に生成
  3. Inhouse adapter関数の活用: デザイントークンを直接指定せず、adapter関数を通じて一貫したデザインシステムを実現
  4. アクセシビリティの自動対応: React Ariaが提供する機能により、キーボード操作やARIA属性の管理が自動化される

この組み合わせにより、アクセシビリティが考慮された、保守性の高いコンポーネントを効率的に実装できます。

ユーザーに寄り添うコンテンツ作成ガイドライン(開発者以外の方向け)

このガイドラインは、日々の業務でユーザーとコミュニケーションをとるコンテンツ(スライド、資料、SNS投稿、広告など)を作成する際に、アクセシビリティの視点を取り入れるためのものです。

コンテンツ制作に関わる方はぜひご参照ください。

1. 見た目を意識する:色の使い方と文字の読みやすさ

色だけに頼らない表現を心がける

情報は、色だけで伝えずに、形やアイコン、テキストも併用しましょう。色覚特性を持つユーザーや、白黒印刷された資料を見るユーザーにも内容が伝わります。

十分なコントラストを確保する

背景と文字の色には、はっきりとした差をつけましょう。光の反射が多い屋外や、視力が弱いユーザーでも内容が読みやすくなります。

2. 情報を正しく伝える:代替テキストと文字起こし

画像や図には説明文を添える

SNSの投稿やスライドの画像などには、その内容を説明するテキストを添えましょう。

  • 良い例: 「新商品の発表スライドです。詳細はテキストでもご確認いただけます。」
  • SNSの代替テキスト機能: InstagramやX(旧Twitter)には、画像に代替テキストを設定する機能があります。これを使うと、視覚に障害のあるユーザーも画像の内容を理解できます。
  • 参考:

動画や音声には文字起こしを添える

動画や音声コンテンツを発信する際は、内容の文字起こしやキャプションを用意しましょう。

3. 誰にでも伝わる言葉を選ぶ

専門用語や難しい言葉を避ける

専門用語や業界の略語は、一般のユーザーには伝わりにくい場合があります。できるだけ平易な言葉で説明するか、補足説明を付け加えましょう。

  • 良い例: SEOという言葉を使う場合、「検索エンジン最適化(SEO)」と補足する。
  • 参考:

リンクの目的を明確にする

リンクを貼る際は、リンク先の内容がわかるテキストにしましょう。