APG Patterns
English GitHub
English GitHub

Landmarks

ページの主要セクションを識別する8つのARIAランドマークロールのセット。支援技術ユーザーのナビゲーションを効率化します。

🤖 AI Implementation Guide

デモ

このデモでは8つのランドマーク領域を色分けされたボーダーで視覚化しています。各ランドマークにはロールを識別するためのラベルが表示されています。

Main Content

This section demonstrates the region landmark. A section element only becomes a region landmark when it has an accessible name via aria-labelledby or aria-label.

デモのみ表示 →

Native HTML

ネイティブHTMLセマンティック要素を優先

可能な限りネイティブHTMLセマンティック要素を使用してください。 これらはARIA属性なしでランドマークロールを提供し、支援技術によるサポートも優れています。

<header>...</header>      <!-- banner -->
<nav>...</nav>           <!-- navigation -->
<main>...</main>         <!-- main -->
<footer>...</footer>     <!-- contentinfo -->
<aside>...</aside>       <!-- complementary -->
<section aria-label="...">...</section>  <!-- region -->
HTML要素 ARIAロール 自動マッピング条件
<header> banner <body>の直接の子の場合のみ
<nav> navigation 常に
<main> main 常に
<footer> contentinfo <body>の直接の子の場合のみ
<aside> complementary 常に
<section> region aria-labelまたはaria-labelledbyがある場合のみ
<form> form aria-labelまたはaria-labelledbyがある場合のみ

注:検索機能には<form role="search">を使用してください。<search>要素はブラウザサポートが限定的です。

アクセシビリティ

WAI-ARIA ランドマークロール

ランドマークはページの主要なセクションを識別します。8つのランドマークロールがあり、支援技術ユーザーがページ構造を効率的にナビゲートできるようになります。

ロール HTML要素 説明 制約
banner <header> サイト全体のヘッダー ページに1つ、トップレベルのみ
navigation <nav> ナビゲーションリンク 複数可、2つ以上の場合はラベル必須
main <main> 主要コンテンツ ページに1つ、トップレベルのみ
contentinfo <footer> サイト全体のフッター ページに1つ、トップレベルのみ
complementary <aside> 補完的コンテンツ トップレベル推奨
region <section> 名前付きセクション aria-label/labelledby必須
search <form role="search"> 検索機能 form要素と併用
form <form> フォーム領域 aria-label/labelledby必須

WAI-ARIA プロパティ

aria-label / aria-labelledby

ランドマークにアクセシブルな名前を提供します。同じ種類のランドマークが複数存在する場合、またはregionformランドマークには必須です。

文字列(aria-label)またはID参照(aria-labelledby)
必須条件
  • 同じ種類のランドマークが複数ある場合
  • regionランドマーク(常に)
  • formランドマーク(常に)
<nav aria-label="メイン"> または <section aria-labelledby="heading-id">

キーボードサポート

ランドマークにはキーボード操作は不要です。ランドマークは構造要素であり、インタラクティブなウィジェットではありません。スクリーンリーダーは組み込みのランドマークナビゲーションを提供します:

スクリーンリーダー ナビゲーション方法
NVDA Dキー
VoiceOver ローター(Webナビゲーション)
JAWS Rキーまたはセミコロン

ベストプラクティス

  • セマンティックHTML要素を使用 - ARIAロールよりも<header><nav><main>などを優先
  • 複数のランドマークにラベルを付ける - 複数の<nav><aside>がある場合、それぞれに一意のアクセシブルな名前を付ける
  • ランドマークの数を制限 - ランドマークが多すぎる(約7以上)と支援技術ユーザーにとって煩わしくなる
  • すべてのコンテンツをランドマーク内に配置 - ランドマーク外のコンテンツは、ランドマークでナビゲートするユーザーに見落とされる可能性がある
  • トップレベルに配置 - <header><footer><body>の直接の子の場合にのみbanner/contentinfoにマップされる

参考資料

ソースコード

LandmarkDemo.tsx
import { cn } from '@/lib/utils';

export interface LandmarkDemoProps extends React.HTMLAttributes<HTMLDivElement> {
  /** Show landmark labels overlay */
  showLabels?: boolean;
}

/**
 * LandmarkDemo - Demonstrates the 8 ARIA landmark roles
 *
 * This component visualizes proper landmark structure for educational purposes.
 * It demonstrates:
 * - banner (header)
 * - navigation (nav)
 * - main
 * - contentinfo (footer)
 * - complementary (aside)
 * - region (section with label)
 * - search (form with role="search")
 * - form (form with label)
 *
 * @see https://www.w3.org/WAI/ARIA/apg/patterns/landmarks/
 */
export const LandmarkDemo: React.FC<LandmarkDemoProps> = ({
  showLabels = false,
  className,
  ...props
}) => {
  return (
    <div className={cn('apg-landmark-demo', className)} {...props}>
      {/* Banner Landmark */}
      <header className="apg-landmark-banner">
        <LandmarkLabel label="banner" visible={showLabels} />
        <div className="apg-landmark-content">
          <span className="apg-landmark-logo">Site Logo</span>
          {/* Navigation Landmark (Main) */}
          <nav aria-label="Main" className="apg-landmark-navigation">
            <LandmarkLabel label="navigation" visible={showLabels} />
            <ul className="apg-landmark-nav-list">
              <li>
                <a href="#home">Home</a>
              </li>
              <li>
                <a href="#about">About</a>
              </li>
              <li>
                <a href="#contact">Contact</a>
              </li>
            </ul>
          </nav>
        </div>
      </header>

      {/* Main Landmark */}
      <main className="apg-landmark-main">
        <LandmarkLabel label="main" visible={showLabels} />
        <div className="apg-landmark-main-content">
          {/* Region Landmark */}
          <section aria-labelledby="content-heading" className="apg-landmark-region">
            <LandmarkLabel label="region" visible={showLabels} />
            <h2 id="content-heading" className="apg-landmark-heading">
              Main Content
            </h2>
            <p>
              This section demonstrates the <code>region</code> landmark. A section element only
              becomes a region landmark when it has an accessible name via{' '}
              <code>aria-labelledby</code> or <code>aria-label</code>.
            </p>

            {/* Search Landmark */}
            <form role="search" aria-label="Site search" className="apg-landmark-search">
              <LandmarkLabel label="search" visible={showLabels} />
              <label htmlFor="search-input" className="apg-landmark-search-label">
                Search
              </label>
              <input
                type="search"
                id="search-input"
                className="apg-landmark-search-input"
                placeholder="Search..."
              />
              <button type="submit" className="apg-landmark-search-button">
                Search
              </button>
            </form>

            {/* Form Landmark */}
            <form aria-label="Contact form" className="apg-landmark-form">
              <LandmarkLabel label="form" visible={showLabels} />
              <div className="apg-landmark-form-field">
                <label htmlFor="name-input">Name</label>
                <input type="text" id="name-input" className="apg-landmark-input" />
              </div>
              <div className="apg-landmark-form-field">
                <label htmlFor="email-input">Email</label>
                <input type="email" id="email-input" className="apg-landmark-input" />
              </div>
              <button type="submit" className="apg-landmark-submit">
                Submit
              </button>
            </form>
          </section>

          {/* Complementary Landmark */}
          <aside aria-label="Related content" className="apg-landmark-complementary">
            <LandmarkLabel label="complementary" visible={showLabels} />
            <h2 className="apg-landmark-heading">Related</h2>
            <ul>
              <li>
                <a href="#related1">Related Link 1</a>
              </li>
              <li>
                <a href="#related2">Related Link 2</a>
              </li>
            </ul>
          </aside>
        </div>
      </main>

      {/* Contentinfo Landmark */}
      <footer className="apg-landmark-contentinfo">
        <LandmarkLabel label="contentinfo" visible={showLabels} />
        <div className="apg-landmark-content">
          {/* Navigation Landmark (Footer) */}
          <nav aria-label="Footer" className="apg-landmark-navigation">
            <LandmarkLabel label="navigation" visible={showLabels} />
            <ul className="apg-landmark-nav-list">
              <li>
                <a href="#privacy">Privacy</a>
              </li>
              <li>
                <a href="#terms">Terms</a>
              </li>
              <li>
                <a href="#sitemap">Sitemap</a>
              </li>
            </ul>
          </nav>
          <p className="apg-landmark-copyright">&copy; 2024 Example Site</p>
        </div>
      </footer>
    </div>
  );
};

/** Landmark label overlay component */
const LandmarkLabel: React.FC<{ label: string; visible: boolean }> = ({ label, visible }) => {
  return (
    <span className={cn('apg-landmark-label', !visible && 'hidden')} aria-hidden="true">
      {label}
    </span>
  );
};

export default LandmarkDemo;

テスト

テストでは、8つのランドマークロールがすべて存在し、適切にラベル付けされ、APGの制約に従っていることを確認します。ランドマークは静的な構造要素であるため、テストはインタラクションよりもHTML構造に焦点を当てています。

テスト戦略

ユニットテスト(Container API / Testing Library)

コンポーネントのHTML出力とランドマーク構造を検証します。ブラウザなしで正しいレンダリングを確認できます。

  • 8つのランドマークロールがすべて存在
  • セマンティックHTML要素の使用
  • 適切なラベル付け(aria-label/aria-labelledby)
  • 制約の検証(mainは1つのみ、bannerは1つのみなど)

E2Eテスト(Playwright)

実際のブラウザ環境でランドマーク構造を検証し、アクセシビリティ監査を実行します。

  • ランドマークロールが正しく公開される
  • axe-coreアクセシビリティ監査
  • フレームワーク間の一貫性

テストカテゴリ

高優先度: ランドマーク構造

テスト 説明
banner ランドマーク bannerロールを持つheader要素がある
navigation ランドマーク navigationロールを持つnav要素がある
main ランドマーク mainロールを持つmain要素がある
contentinfo ランドマーク contentinfoロールを持つfooter要素がある
complementary ランドマーク complementaryロールを持つaside要素がある
region ランドマーク aria-labelledbyを持つsection(regionロール)がある
search ランドマーク role="search"を持つformがある
form ランドマーク aria-label(formロール)を持つformがある
mainは1つのみ mainランドマークが1つだけ存在する
bannerは1つのみ bannerランドマークが1つだけ存在する
contentinfoは1つのみ contentinfoランドマークが1つだけ存在する

高優先度: ランドマークのラベル付け

テスト 説明
navigationのラベル 複数のnavigationが一意のaria-labelを持つ
regionのラベル regionがaria-labelまたはaria-labelledbyを持つ
formのラベル formランドマークがaria-labelを持つ
searchのラベル searchランドマークがaria-labelを持つ
complementaryのラベル complementaryランドマークがaria-labelを持つ

中優先度: アクセシビリティ

テスト 説明
axe-core監査 WCAG 2.1 AAの違反がない

低優先度: プロパティと構造

テスト 説明
className プロパティ カスタムclassNameがコンテナに適用される
showLabels プロパティ 有効時にランドマークラベルが表示される
セマンティックHTML header、nav、main、footer、aside、section要素を使用

チェックリスト(手動検証)

これらの推奨事項は手動または専用ツールで検証するのが最適です:

  • すべてのコンテンツがランドマーク内にある(推奨だが必須ではない)
  • ランドマークの総数が7以下(多すぎると煩雑になる)
  • スクリーンリーダーナビゲーションが正しく動作する(NVDA D キー、VoiceOver ローター)

テストツール

詳細は testing-strategy.md (opens in new tab) を参照してください。

LandmarkDemo.test.tsx
import { render, screen, within } from '@testing-library/react';
import { axe } from 'jest-axe';
import { describe, expect, it } from 'vitest';
import { LandmarkDemo } from './LandmarkDemo';

describe('LandmarkDemo', () => {
  // 🔴 High Priority: Landmark Roles
  describe('APG: Landmark Roles', () => {
    it('has banner landmark (header)', () => {
      render(<LandmarkDemo />);
      expect(screen.getByRole('banner')).toBeInTheDocument();
    });

    it('has navigation landmark (nav)', () => {
      render(<LandmarkDemo />);
      expect(screen.getAllByRole('navigation').length).toBeGreaterThanOrEqual(1);
    });

    it('has main landmark (main)', () => {
      render(<LandmarkDemo />);
      expect(screen.getByRole('main')).toBeInTheDocument();
    });

    it('has contentinfo landmark (footer)', () => {
      render(<LandmarkDemo />);
      expect(screen.getByRole('contentinfo')).toBeInTheDocument();
    });

    it('has complementary landmark (aside)', () => {
      render(<LandmarkDemo />);
      expect(screen.getByRole('complementary')).toBeInTheDocument();
    });

    it('has region landmark with label (section[aria-labelledby])', () => {
      render(<LandmarkDemo />);
      const region = screen.getByRole('region');
      expect(region).toBeInTheDocument();
      // Region must have accessible name
      expect(region).toHaveAccessibleName();
    });

    it('has search landmark (form[role="search"])', () => {
      render(<LandmarkDemo />);
      expect(screen.getByRole('search')).toBeInTheDocument();
    });

    it('has form landmark with label', () => {
      render(<LandmarkDemo />);
      const form = screen.getByRole('form');
      expect(form).toBeInTheDocument();
      // Form landmark must have accessible name
      expect(form).toHaveAccessibleName();
    });

    it('has exactly one main landmark', () => {
      render(<LandmarkDemo />);
      expect(screen.getAllByRole('main')).toHaveLength(1);
    });

    it('has exactly one banner landmark', () => {
      render(<LandmarkDemo />);
      expect(screen.getAllByRole('banner')).toHaveLength(1);
    });

    it('has exactly one contentinfo landmark', () => {
      render(<LandmarkDemo />);
      expect(screen.getAllByRole('contentinfo')).toHaveLength(1);
    });
  });

  // 🔴 High Priority: Landmark Labeling
  describe('APG: Landmark Labeling', () => {
    it('navigation landmarks have unique labels when multiple', () => {
      render(<LandmarkDemo />);
      const navs = screen.getAllByRole('navigation');
      if (navs.length > 1) {
        const labels = navs.map(
          (nav) => nav.getAttribute('aria-label') || nav.getAttribute('aria-labelledby')
        );
        const uniqueLabels = new Set(labels.filter(Boolean));
        expect(uniqueLabels.size).toBe(navs.length);
      }
    });

    it('region landmark has accessible name', () => {
      render(<LandmarkDemo />);
      const region = screen.getByRole('region');
      // Check for aria-label or aria-labelledby
      const hasLabel = region.hasAttribute('aria-label') || region.hasAttribute('aria-labelledby');
      expect(hasLabel).toBe(true);
    });

    it('form landmark has accessible name', () => {
      render(<LandmarkDemo />);
      const form = screen.getByRole('form');
      // Check for aria-label or aria-labelledby
      const hasLabel = form.hasAttribute('aria-label') || form.hasAttribute('aria-labelledby');
      expect(hasLabel).toBe(true);
    });

    it('search landmark has accessible name', () => {
      render(<LandmarkDemo />);
      const search = screen.getByRole('search');
      // Check for aria-label or aria-labelledby
      const hasLabel = search.hasAttribute('aria-label') || search.hasAttribute('aria-labelledby');
      expect(hasLabel).toBe(true);
    });

    it('complementary landmark has accessible name', () => {
      render(<LandmarkDemo />);
      const aside = screen.getByRole('complementary');
      // Check for aria-label or aria-labelledby
      const hasLabel = aside.hasAttribute('aria-label') || aside.hasAttribute('aria-labelledby');
      expect(hasLabel).toBe(true);
    });
  });

  // 🟡 Medium Priority: Accessibility
  describe('Accessibility', () => {
    it('has no axe-core violations', async () => {
      const { container } = render(<LandmarkDemo />);
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });
  });

  // 🟢 Low Priority: Props
  describe('Props', () => {
    it('applies className to container', () => {
      render(<LandmarkDemo className="custom-class" data-testid="landmark-demo" />);
      const container = screen.getByTestId('landmark-demo');
      expect(container).toHaveClass('custom-class');
    });

    it('shows landmark labels when showLabels is true', () => {
      render(<LandmarkDemo showLabels data-testid="landmark-demo" />);
      // When showLabels is true, landmark labels should be visible
      // Check for label overlay elements
      expect(screen.getByText('banner')).toBeInTheDocument();
      expect(screen.getByText('main')).toBeInTheDocument();
      expect(screen.getByText('contentinfo')).toBeInTheDocument();
    });
  });

  // 🟢 Low Priority: Semantic Structure
  describe('Semantic Structure', () => {
    it('uses semantic HTML elements', () => {
      const { container } = render(<LandmarkDemo />);
      expect(container.querySelector('header')).toBeInTheDocument();
      expect(container.querySelector('nav')).toBeInTheDocument();
      expect(container.querySelector('main')).toBeInTheDocument();
      expect(container.querySelector('footer')).toBeInTheDocument();
      expect(container.querySelector('aside')).toBeInTheDocument();
      expect(container.querySelector('section')).toBeInTheDocument();
    });

    it('main contains primary content area', () => {
      render(<LandmarkDemo />);
      const main = screen.getByRole('main');
      // Main should contain the region and complementary landmarks
      expect(within(main).getByRole('region')).toBeInTheDocument();
      expect(within(main).getByRole('complementary')).toBeInTheDocument();
    });
  });
});

リソース