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.astro
---
/**
 * LandmarkDemo - Demonstrates the 8 ARIA landmark roles
 *
 * This component visualizes proper landmark structure for educational purposes.
 * Since landmarks are static structural elements, no JavaScript is required.
 *
 * @see https://www.w3.org/WAI/ARIA/apg/patterns/landmarks/
 */

interface Props {
  /** Show landmark labels overlay */
  showLabels?: boolean;
  class?: string;
}

const { showLabels = false, class: className } = Astro.props;
---

<div class:list={['apg-landmark-demo', className]}>
  <!-- Banner Landmark -->
  <header class="apg-landmark-banner">
    <span class:list={['apg-landmark-label', !showLabels && 'hidden']} aria-hidden="true"
      >banner</span
    >
    <div class="apg-landmark-content">
      <span class="apg-landmark-logo">Site Logo</span>
      <!-- Navigation Landmark (Main) -->
      <nav aria-label="Main" class="apg-landmark-navigation">
        <span class:list={['apg-landmark-label', !showLabels && 'hidden']} aria-hidden="true"
          >navigation</span
        >
        <ul class="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 class="apg-landmark-main">
    <span class:list={['apg-landmark-label', !showLabels && 'hidden']} aria-hidden="true">main</span
    >
    <div class="apg-landmark-main-content">
      <!-- Region Landmark -->
      <section aria-labelledby="content-heading" class="apg-landmark-region">
        <span class:list={['apg-landmark-label', !showLabels && 'hidden']} aria-hidden="true"
          >region</span
        >
        <h2 id="content-heading" class="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" class="apg-landmark-search">
          <span class:list={['apg-landmark-label', !showLabels && 'hidden']} aria-hidden="true"
            >search</span
          >
          <label for="search-input" class="apg-landmark-search-label">Search</label>
          <input
            type="search"
            id="search-input"
            class="apg-landmark-search-input"
            placeholder="Search..."
          />
          <button type="submit" class="apg-landmark-search-button">Search</button>
        </form>

        <!-- Form Landmark -->
        <form aria-label="Contact form" class="apg-landmark-form">
          <span class:list={['apg-landmark-label', !showLabels && 'hidden']} aria-hidden="true"
            >form</span
          >
          <div class="apg-landmark-form-field">
            <label for="name-input">Name</label>
            <input type="text" id="name-input" class="apg-landmark-input" />
          </div>
          <div class="apg-landmark-form-field">
            <label for="email-input">Email</label>
            <input type="email" id="email-input" class="apg-landmark-input" />
          </div>
          <button type="submit" class="apg-landmark-submit">Submit</button>
        </form>
      </section>

      <!-- Complementary Landmark -->
      <aside aria-label="Related content" class="apg-landmark-complementary">
        <span class:list={['apg-landmark-label', !showLabels && 'hidden']} aria-hidden="true"
          >complementary</span
        >
        <h2 class="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 class="apg-landmark-contentinfo">
    <span class:list={['apg-landmark-label', !showLabels && 'hidden']} aria-hidden="true"
      >contentinfo</span
    >
    <div class="apg-landmark-content">
      <!-- Navigation Landmark (Footer) -->
      <nav aria-label="Footer" class="apg-landmark-navigation">
        <span class:list={['apg-landmark-label', !showLabels && 'hidden']} aria-hidden="true"
          >navigation</span
        >
        <ul class="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 class="apg-landmark-copyright">&copy; 2024 Example Site</p>
    </div>
  </footer>
</div>

テスト

テストでは、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.astro.ts
/**
 * LandmarkDemo Astro Component Tests using Container API
 *
 * These tests verify the LandmarkDemo.astro component output using Astro's Container API.
 * This ensures the component renders correct HTML structure and landmark elements.
 *
 * Note: Landmarks are static structural elements without JavaScript interaction,
 * so Container API tests are sufficient for most verification.
 *
 * @see https://docs.astro.build/en/reference/container-reference/
 */
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { describe, it, expect, beforeEach } from 'vitest';
import { JSDOM } from 'jsdom';
import LandmarkDemo from './LandmarkDemo.astro';

describe('LandmarkDemo (Astro Container API)', () => {
  let container: AstroContainer;

  beforeEach(async () => {
    container = await AstroContainer.create();
  });

  // Helper to render and parse HTML
  async function renderLandmarkDemo(
    props: {
      showLabels?: boolean;
      class?: string;
    } = {}
  ): Promise<Document> {
    const html = await container.renderToString(LandmarkDemo, { props });
    const dom = new JSDOM(html);
    return dom.window.document;
  }

  // 🔴 High Priority: Landmark Roles
  describe('Landmark Roles', () => {
    it('renders header element (banner landmark)', async () => {
      const doc = await renderLandmarkDemo();
      const header = doc.querySelector('header');
      expect(header).not.toBeNull();
    });

    it('renders nav element (navigation landmark)', async () => {
      const doc = await renderLandmarkDemo();
      const navs = doc.querySelectorAll('nav');
      expect(navs.length).toBeGreaterThanOrEqual(1);
    });

    it('renders main element (main landmark)', async () => {
      const doc = await renderLandmarkDemo();
      const main = doc.querySelector('main');
      expect(main).not.toBeNull();
    });

    it('renders footer element (contentinfo landmark)', async () => {
      const doc = await renderLandmarkDemo();
      const footer = doc.querySelector('footer');
      expect(footer).not.toBeNull();
    });

    it('renders aside element (complementary landmark)', async () => {
      const doc = await renderLandmarkDemo();
      const aside = doc.querySelector('aside');
      expect(aside).not.toBeNull();
    });

    it('renders section with aria-labelledby (region landmark)', async () => {
      const doc = await renderLandmarkDemo();
      const section = doc.querySelector('section[aria-labelledby]');
      expect(section).not.toBeNull();
    });

    it('renders form with role="search" (search landmark)', async () => {
      const doc = await renderLandmarkDemo();
      const search = doc.querySelector('form[role="search"]');
      expect(search).not.toBeNull();
    });

    it('renders form with aria-label (form landmark)', async () => {
      const doc = await renderLandmarkDemo();
      // Find form that has aria-label but not role="search"
      const forms = doc.querySelectorAll('form[aria-label]');
      const formLandmark = Array.from(forms).find((form) => form.getAttribute('role') !== 'search');
      expect(formLandmark).not.toBeNull();
    });

    it('renders exactly one main element', async () => {
      const doc = await renderLandmarkDemo();
      const mains = doc.querySelectorAll('main');
      expect(mains.length).toBe(1);
    });

    it('renders exactly one header at top level', async () => {
      const doc = await renderLandmarkDemo();
      // Header should not be inside article, aside, main, nav, section
      const headers = doc.querySelectorAll('header');
      // Check that there's one header that's not nested in other landmarks
      const topLevelHeaders = Array.from(headers).filter((header) => {
        const parent = header.parentElement;
        if (!parent) return true;
        const parentTag = parent.tagName.toLowerCase();
        return !['article', 'aside', 'main', 'nav', 'section'].includes(parentTag);
      });
      expect(topLevelHeaders.length).toBe(1);
    });

    it('renders exactly one footer at top level', async () => {
      const doc = await renderLandmarkDemo();
      const footers = doc.querySelectorAll('footer');
      // Check that there's one footer that's not nested in other landmarks
      const topLevelFooters = Array.from(footers).filter((footer) => {
        const parent = footer.parentElement;
        if (!parent) return true;
        const parentTag = parent.tagName.toLowerCase();
        return !['article', 'aside', 'main', 'nav', 'section'].includes(parentTag);
      });
      expect(topLevelFooters.length).toBe(1);
    });
  });

  // 🔴 High Priority: Landmark Labeling
  describe('Landmark Labeling', () => {
    it('navigation elements have aria-label when multiple', async () => {
      const doc = await renderLandmarkDemo();
      const navs = doc.querySelectorAll('nav');
      if (navs.length > 1) {
        const labels = Array.from(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('section element has aria-labelledby attribute', async () => {
      const doc = await renderLandmarkDemo();
      const section = doc.querySelector('section[aria-labelledby]');
      expect(section).not.toBeNull();
      const labelId = section?.getAttribute('aria-labelledby');
      expect(labelId).toBeTruthy();
      // Check that the referenced element exists
      const labelElement = doc.getElementById(labelId!);
      expect(labelElement).not.toBeNull();
    });

    it('search form has aria-label attribute', async () => {
      const doc = await renderLandmarkDemo();
      const search = doc.querySelector('form[role="search"]');
      expect(search?.hasAttribute('aria-label')).toBe(true);
    });

    it('form landmark has aria-label attribute', async () => {
      const doc = await renderLandmarkDemo();
      const forms = doc.querySelectorAll('form[aria-label]');
      const formLandmark = Array.from(forms).find((form) => form.getAttribute('role') !== 'search');
      expect(formLandmark).not.toBeNull();
      expect(formLandmark?.getAttribute('aria-label')).toBeTruthy();
    });

    it('aside element has aria-label attribute', async () => {
      const doc = await renderLandmarkDemo();
      const aside = doc.querySelector('aside');
      expect(aside?.hasAttribute('aria-label')).toBe(true);
    });
  });

  // 🟢 Low Priority: Props
  describe('Props', () => {
    it('applies custom class to container', async () => {
      const doc = await renderLandmarkDemo({ class: 'custom-class' });
      const container = doc.querySelector('.apg-landmark-demo');
      expect(container?.classList.contains('custom-class')).toBe(true);
    });

    it('shows landmark labels when showLabels is true', async () => {
      const doc = await renderLandmarkDemo({ showLabels: true });
      // Check for label overlay elements
      const labels = doc.querySelectorAll('.apg-landmark-label');
      expect(labels.length).toBeGreaterThan(0);
    });

    it('hides landmark labels by default', async () => {
      const doc = await renderLandmarkDemo({ showLabels: false });
      const labels = doc.querySelectorAll('.apg-landmark-label');
      // Labels should either not exist or have hidden class
      Array.from(labels).forEach((label) => {
        expect(label.classList.contains('hidden')).toBe(true);
      });
    });
  });

  // 🟢 Low Priority: Semantic Structure
  describe('Semantic Structure', () => {
    it('main contains region landmark', async () => {
      const doc = await renderLandmarkDemo();
      const main = doc.querySelector('main');
      const regionInMain = main?.querySelector('section[aria-labelledby]');
      expect(regionInMain).not.toBeNull();
    });

    it('main contains complementary landmark', async () => {
      const doc = await renderLandmarkDemo();
      const main = doc.querySelector('main');
      const asideInMain = main?.querySelector('aside');
      expect(asideInMain).not.toBeNull();
    });

    it('header contains navigation', async () => {
      const doc = await renderLandmarkDemo();
      const header = doc.querySelector('header');
      const navInHeader = header?.querySelector('nav');
      expect(navInHeader).not.toBeNull();
    });

    it('footer contains navigation', async () => {
      const doc = await renderLandmarkDemo();
      const footer = doc.querySelector('footer');
      const navInFooter = footer?.querySelector('nav');
      expect(navInFooter).not.toBeNull();
    });
  });

  // CSS Classes
  describe('CSS Classes', () => {
    it('has apg-landmark-demo class on container', async () => {
      const doc = await renderLandmarkDemo();
      const container = doc.querySelector('.apg-landmark-demo');
      expect(container).not.toBeNull();
    });

    it('landmarks have appropriate CSS classes', async () => {
      const doc = await renderLandmarkDemo();
      expect(doc.querySelector('.apg-landmark-banner')).not.toBeNull();
      expect(doc.querySelector('.apg-landmark-main')).not.toBeNull();
      expect(doc.querySelector('.apg-landmark-contentinfo')).not.toBeNull();
    });
  });
});

リソース