APG Patterns
English
English

リンク

アクティブ化されたときにリソースにナビゲートするインタラクティブ要素。

デモ

WAI-ARIA APG DocumentationExternal Link (opens in new tab)Disabled Link

デモのみ表示 →

ネイティブ HTML

ネイティブ HTML を優先

このカスタムコンポーネントを使用する前に、ネイティブの <a href> 要素の使用を検討してください。ネイティブ要素は組み込みのアクセシビリティ、完全なブラウザ機能、SEO の利点を提供し、JavaScript なしで動作します。

<a href="https://example.com">Visit Example</a>

<!-- For new tab -->
<a href="https://example.com" target="_blank" rel="noopener noreferrer">
  External Link
</a>

カスタムの role="link" 実装は、教育目的のみ、または SPA ルーティングを使用した複雑な JavaScript 駆動のナビゲーションが必要な場合にのみ使用してください。

機能ネイティブ <a href>カスタム role="link"
Ctrl/Cmd + クリック(新しいタブ)組み込みサポートなし
右クリックコンテキストメニュー完全なメニュー制限あり
リンクアドレスのコピー組み込みサポートなし
ブックマークへのドラッグ組み込みサポートなし
SEO 認識クロール対象無視される可能性
JavaScript なしでの動作動作する動作しない
スクリーンリーダーの読み上げ自動ARIA が必要
フォーカス管理自動tabindex が必要

このカスタム実装は、APG パターンを実証するための教育目的で提供されています。本番環境では、常にネイティブの <a href> 要素を優先してください。

アクセシビリティ

WAI-ARIA ロール

ロール 対象要素 説明
link <a href> または role=“link” を持つ要素 要素をハイパーリンクとして識別します。ネイティブの <a href> は暗黙的にこのロールを持ちます。

WAI-ARIA プロパティ

tabindex

カスタム実装では必須。ネイティブの <a href> はデフォルトでフォーカス可能。無効時は -1 に設定。

値
0 (focusable) | -1 (not focusable)
必須
はい

aria-label

表示テキストがない場合にリンクの非表示ラベルを提供

値
string
必須
いいえ

aria-labelledby

外部要素をラベルとして参照

値
ID reference
必須
いいえ

aria-current

セット内の現在の項目を示す(例:ナビゲーション内の現在のページ)

値
page | step | location | date | time | true
必須
いいえ

WAI-ARIA ステート

aria-disabled

対象要素
リンク要素
値
true | false
必須
いいえ
変更トリガー
無効状態の変更

キーボードサポート

キー アクション
Enter リンクをアクティブ化し、ターゲットリソースに遷移
Tab 次のフォーカス可能な要素にフォーカスを移動
Shift + Tab 前のフォーカス可能な要素にフォーカスを移動
  • この実装は教育目的で <span role="link"> を使用しています。本番環境では、ネイティブの <a href> 要素を優先してください。
  • ボタンとは異なり、Spaceキーはリンクをアクティブ化しません。これは link ロールと button ロールの重要な違いです。
  • リンクにはテキストコンテンツ、aria-label、またはaria-labelledbyからアクセシブルな名前が必要です。

フォーカス管理

イベント 振る舞い
ネイティブ <a href> デフォルトでフォーカス可能
カスタムリンク tabindex="0" が必要
無効なリンク tabindex="-1" を使用(Tabオーダーから除外)

参考資料

ソースコード

Link.astro
---
/**
 * APG Link Pattern - Astro Implementation
 *
 * An interactive element that navigates to a resource when activated.
 * Uses role="link" with Web Components for enhanced interactivity.
 *
 * Note: This is a custom implementation for educational purposes.
 * For production use, prefer native <a href> elements.
 *
 * @see https://www.w3.org/WAI/ARIA/apg/patterns/link/
 */

export interface Props {
  /** Link destination URL */
  href?: string;
  /** Link target */
  target?: '_self' | '_blank';
  /** Whether the link is disabled */
  disabled?: boolean;
  /** Indicates current item in a set (e.g., current page in navigation) */
  'aria-current'?: 'page' | 'step' | 'location' | 'date' | 'time' | 'true' | boolean;
  /** Additional CSS class */
  class?: string;
}

const {
  href,
  target,
  disabled = false,
  'aria-current': ariaCurrent,
  class: className = '',
} = Astro.props;
---

<apg-link data-href={href} data-target={target}>
  <span
    role="link"
    tabindex={disabled ? -1 : 0}
    aria-disabled={disabled ? 'true' : undefined}
    aria-current={ariaCurrent || undefined}
    class={`apg-link ${className}`.trim()}
  >
    <slot />
  </span>
</apg-link>

<script>
  class ApgLink extends HTMLElement {
    private spanElement: HTMLSpanElement | null = null;
    private rafId: number | null = null;

    connectedCallback() {
      this.rafId = requestAnimationFrame(() => this.initialize());
    }

    private initialize() {
      this.rafId = null;
      this.spanElement = this.querySelector('span[role="link"]');

      if (!this.spanElement) {
        console.warn('apg-link: span element not found');
        return;
      }

      this.spanElement.addEventListener('click', this.handleClick);
      this.spanElement.addEventListener('keydown', this.handleKeyDown);
    }

    disconnectedCallback() {
      if (this.rafId !== null) {
        cancelAnimationFrame(this.rafId);
        this.rafId = null;
      }
      this.spanElement?.removeEventListener('click', this.handleClick);
      this.spanElement?.removeEventListener('keydown', this.handleKeyDown);
      this.spanElement = null;
    }

    private isDisabled(): boolean {
      return this.spanElement?.getAttribute('aria-disabled') === 'true';
    }

    private navigate() {
      const href = this.dataset.href;
      const target = this.dataset.target;

      if (!href) {
        return;
      }

      if (target === '_blank') {
        window.open(href, '_blank', 'noopener,noreferrer');
      } else {
        window.location.href = href;
      }
    }

    private handleClick = (event: MouseEvent) => {
      if (this.isDisabled()) {
        event.preventDefault();
        return;
      }

      this.dispatchEvent(
        new CustomEvent('link-activate', {
          detail: { href: this.dataset.href, target: this.dataset.target },
          bubbles: true,
        })
      );

      this.navigate();
    };

    private handleKeyDown = (event: KeyboardEvent) => {
      // Ignore if composing (IME input) or already handled
      if (event.isComposing || event.defaultPrevented) {
        return;
      }

      if (this.isDisabled()) {
        return;
      }

      // Only Enter key activates link (NOT Space)
      if (event.key === 'Enter') {
        event.preventDefault();

        this.dispatchEvent(
          new CustomEvent('link-activate', {
            detail: { href: this.dataset.href, target: this.dataset.target },
            bubbles: true,
          })
        );

        this.navigate();
      }
    };
  }

  if (!customElements.get('apg-link')) {
    customElements.define('apg-link', ApgLink);
  }
</script>

使い方

Example
---
import Link from './Link.astro';
---

<!-- Basic link -->
<Link href="https://example.com">Visit Example</Link>

<!-- Open in new tab -->
<Link href="https://example.com" target="_blank">
  External Link
</Link>

<!-- Disabled link -->
<Link href="#" disabled>Unavailable Link</Link>

<!-- With custom event listener (JavaScript) -->
<Link href="#" id="interactive-link">Interactive Link</Link>

<script>
  document.getElementById('interactive-link')
    ?.addEventListener('link-activate', (e) => {
      console.log('Link activated', e.detail);
    });
</script>

API

プロパティ型デフォルト説明
hrefstring-リンク先 URL
target'_self' | '_blank''_self'リンクを開く場所
disabledbooleanfalseリンクが無効化されているかどうか
classstring''追加の CSS クラス

Custom Events

イベントDetail説明
link-activate{ href, target }リンクがアクティベートされたとき(クリックまたは Enter)に発火

テスト

テストは、キーボード操作、ARIA属性、アクセシビリティ要件全体にわたってAPG準拠を検証します。Linkコンポーネントは2層のテスト戦略を採用しています。

テスト戦略

ユニットテスト (Testing Library)

フレームワーク固有のテストライブラリを使用してコンポーネントのレンダリング出力を検証します。これらのテストは正しいHTML構造とARIA属性を確認します。

  • ARIA属性(role="link"、tabindex)
  • キーボード操作(Enterキーでのアクティブ化)
  • 無効状態の処理
  • jest-axeによるアクセシビリティ検証

E2Eテスト (Playwright)

すべてのフレームワークで実際のブラウザ環境でコンポーネントの動作を検証します。これらのテストはインタラクションとフレームワーク間の一貫性をカバーします。

  • ライブブラウザでのARIA構造
  • キーボードでのアクティブ化(Enterキー)
  • クリック操作の動作
  • 無効状態のインタラクション
  • axe-coreによるアクセシビリティスキャン
  • フレームワーク間の一貫性チェック

テストカテゴリ

高優先度: APGキーボード操作 (Unit + E2E)

testdescription
Enter keyリンクをアクティブ化し、ターゲットに遷移
Space keyリンクをアクティブ化しない(リンクはEnterキーのみに応答)
IME composingIME入力中はEnterキーを無視
Tab navigationTabキーでリンク間のフォーカスを移動
Disabled Tab skip無効なリンクはTabオーダーでスキップされる

高優先度: ARIA属性 (Unit + E2E)

testdescription
role="link"要素がlinkロールを持つ
tabindex="0"要素がキーボードでフォーカス可能
aria-disabled無効時に "true" に設定
tabindex="-1"無効時にTabオーダーから除外するために設定
Accessible nameテキストコンテンツ、aria-label、またはaria-labelledbyから名前を取得

高優先度: クリック動作 (Unit + E2E)

testdescription
Click activationクリックでリンクがアクティブ化される
Disabled click無効なリンクはクリックイベントを無視
Disabled Enter無効なリンクはEnterキーを無視

中優先度: アクセシビリティ (Unit + E2E)

testdescription
axe violationsWCAG 2.1 AAの違反がない(jest-axeによる)
disabled axe無効状態での違反がない
aria-label axearia-label使用時の違反がない

低優先度: ナビゲーション & プロパティ (Unit)

testdescription
href navigationアクティブ化時にhrefに遷移
target="_blank"セキュリティオプションを含めて新しいタブで開く
classNameカスタムクラスが適用される
data-* attributesカスタムdata属性が渡される

低優先度: フレームワーク間の一貫性 (E2E)

testdescription
All frameworks have linksReact、Vue、Svelte、Astroすべてがカスタムリンク要素をレンダリング
Same link countすべてのフレームワークで同じ数のリンクをレンダリング
Consistent ARIAすべてのフレームワークで一貫したARIA構造

重要: ボタンとは異なり、リンクはEnterキーのみでアクティブ化され、Spaceキーでは動作しません。 これは link ロールと button ロールの重要な違いです。

テストツール

詳細はテスト戦略ガイドを参照してください。

Link.test.astro.ts
/**
 * Link Astro Component Tests using Container API
 *
 * These tests verify the initial server-rendered HTML of Link.astro.
 * Web Component behavior (Enter/click activation, Space not activating,
 * IME composition, navigation, opening in a new tab, disabled state and the
 * link-activate event) is covered by E2E tests in e2e/link.spec.ts.
 *
 * @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 Link from './Link.astro';

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

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

  async function renderLink(
    props: Record<string, unknown> = {},
    text = 'Click here'
  ): Promise<Document> {
    const html = await container.renderToString(Link, { props, slots: { default: text } });
    return new JSDOM(html).window.document;
  }

  // 🔴 High Priority: HTML Structure
  describe('HTML Structure', () => {
    it('renders apg-link custom element wrapper', async () => {
      const doc = await renderLink({ href: '#' });
      expect(doc.querySelector('apg-link')).not.toBeNull();
    });

    it('renders a span with role="link"', async () => {
      const doc = await renderLink({ href: '#' });
      expect(doc.querySelector('span[role="link"]')).not.toBeNull();
    });

    it('passes href to the Web Component via data-href', async () => {
      const doc = await renderLink({ href: 'https://example.com' });
      expect(doc.querySelector('apg-link')?.getAttribute('data-href')).toBe('https://example.com');
    });

    it('passes target to the Web Component via data-target', async () => {
      const doc = await renderLink({ href: 'https://example.com', target: '_blank' });
      expect(doc.querySelector('apg-link')?.getAttribute('data-target')).toBe('_blank');
    });

    it('does not set data-target when target is not provided', async () => {
      const doc = await renderLink({ href: 'https://example.com' });
      expect(doc.querySelector('apg-link')?.hasAttribute('data-target')).toBe(false);
    });
  });

  // 🔴 High Priority: Accessible Name
  describe('Accessible Name', () => {
    it('uses slot content as the text content', async () => {
      const doc = await renderLink({ href: '#' }, 'Learn more');
      expect(doc.querySelector('[role="link"]')?.textContent?.trim()).toBe('Learn more');
    });
  });

  // 🔴 High Priority: Focus Management
  describe('Focus Management', () => {
    it('has tabindex="0" by default', async () => {
      const doc = await renderLink({ href: '#' });
      expect(doc.querySelector('[role="link"]')?.getAttribute('tabindex')).toBe('0');
    });

    it('has tabindex="-1" when disabled', async () => {
      const doc = await renderLink({ href: '#', disabled: true });
      expect(doc.querySelector('[role="link"]')?.getAttribute('tabindex')).toBe('-1');
    });
  });

  // 🔴 High Priority: ARIA Attributes
  describe('ARIA Attributes', () => {
    it('has aria-disabled="true" when disabled', async () => {
      const doc = await renderLink({ href: '#', disabled: true });
      expect(doc.querySelector('[role="link"]')?.getAttribute('aria-disabled')).toBe('true');
    });

    it('does not have aria-disabled when not disabled', async () => {
      const doc = await renderLink({ href: '#' });
      expect(doc.querySelector('[role="link"]')?.hasAttribute('aria-disabled')).toBe(false);
    });

    it('has aria-current="page" when provided', async () => {
      const doc = await renderLink({ href: '#', 'aria-current': 'page' });
      expect(doc.querySelector('[role="link"]')?.getAttribute('aria-current')).toBe('page');
    });

    it('does not have aria-current when not provided', async () => {
      const doc = await renderLink({ href: '#' });
      expect(doc.querySelector('[role="link"]')?.hasAttribute('aria-current')).toBe(false);
    });
  });

  // 🟢 Low Priority: HTML Attribute Inheritance
  describe('HTML Attributes', () => {
    it('applies a custom class alongside apg-link on the span', async () => {
      const doc = await renderLink({ href: '#', class: 'custom-class' });
      expect(doc.querySelector('[role="link"]')?.className).toBe('apg-link custom-class');
    });
  });
});

リソース