APG Patterns
English
English

Link

アクティベートされたときにリソースに移動するインタラクティブな要素。

デモ

WAI-ARIA APG ドキュメント 外部リンク(新しいタブで開く) 無効化されたリンク

デモのみ表示 →

ネイティブ 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 ロール

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

この実装は教育目的で <span role="link"> を使用しています。本番環境では、ネイティブの <a href> 要素を優先してください。

WAI-ARIA プロパティ

tabindex

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

values 0 (focusable) | -1 (not focusable)
required はい (カスタム実装の場合)

aria-label

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

values string
required いいえ

aria-labelledby

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

values ID reference
required いいえ

aria-current

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

values page | step | location | date | time | true
required いいえ

WAI-ARIA ステート

aria-disabled

values true | false
required いいえ (無効時のみ)
changeTrigger 無効状態の変更
reference aria-disabled (opens in new tab)

keyboard

key action
Enter リンクをアクティブ化し、ターゲットリソースに遷移
Tab 次のフォーカス可能な要素にフォーカスを移動
Shift + Tab 前のフォーカス可能な要素にフォーカスを移動

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

アクセシブルな名前

リンクにはアクセシブルな名前が必要です。次の方法で提供できます:

  • テキストコンテンツ(推奨) - リンク内の表示テキスト
  • aria-label - リンクに対する非表示のラベルを提供
  • aria-labelledby - 外部要素をラベルとして参照

フォーカススタイル

この実装は明確なフォーカスインジケーターを提供します:

  • フォーカスリング - キーボードでフォーカスされた際に表示されるアウトライン
  • カーソルスタイル - インタラクティブであることを示すポインターカーソル
  • 無効時の外観 - 無効時は不透明度を下げ、not-allowedカーソルを表示

参考資料

ソースコード

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>

使い方

使用例
---
import Link from './Link.astro';
---

<!-- 基本的なリンク -->
<Link href="https://example.com">Example を訪問</Link>

<!-- 新しいタブで開く -->
<Link href="https://example.com" target="_blank">
  外部リンク
</Link>

<!-- 無効化されたリンク -->
<Link href="#" disabled>利用不可のリンク</Link>

<!-- カスタムイベントリスナー付き(JavaScript)-->
<Link href="#" id="interactive-link">インタラクティブリンク</Link>

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

API

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

カスタムイベント

イベント 詳細 説明
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)

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

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

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

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

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

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

test description
axe violations WCAG 2.1 AAの違反がない(jest-axeによる)
disabled axe 無効状態での違反がない
aria-label axe aria-label使用時の違反がない

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

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

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

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

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

テストツール

詳細なドキュメントは testing-strategy.md (opens in new tab) を参照してください。

Link.test.astro.ts
/**
 * Link Web Component Tests
 *
 * Note: These are unit tests for the Web Component class.
 * Full keyboard navigation and focus management tests require E2E testing
 * with Playwright due to jsdom limitations with focus events.
 */
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';

describe('Link (Web Component)', () => {
  let container: HTMLElement;

  // Web Component class extracted for testing
  class TestApgLink 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) {
        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);
    }

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

      this.activate(event);
    };

    private handleKeyDown = (event: KeyboardEvent) => {
      if (event.isComposing || event.defaultPrevented) {
        return;
      }

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

      if (event.key === 'Enter') {
        event.preventDefault();
        this.activate(event);
      }
    };

    private activate(_event: Event) {
      const href = this.dataset.href;
      const target = this.dataset.target;

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

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

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

    // Expose for testing
    get _spanElement() {
      return this.spanElement;
    }
  }

  function createLinkHTML(
    options: {
      href?: string;
      target?: '_self' | '_blank';
      disabled?: boolean;
      ariaLabel?: string;
      text?: string;
    } = {}
  ) {
    const { href = '#', target, disabled = false, ariaLabel, text = 'Click here' } = options;

    const tabindex = disabled ? '-1' : '0';
    const ariaDisabled = disabled ? 'aria-disabled="true"' : '';
    const ariaLabelAttr = ariaLabel ? `aria-label="${ariaLabel}"` : '';

    return `
      <apg-link
        class="apg-link"
        data-href="${href}"
        ${target ? `data-target="${target}"` : ''}
      >
        <span
          role="link"
          tabindex="${tabindex}"
          ${ariaDisabled}
          ${ariaLabelAttr}
        >
          ${text}
        </span>
      </apg-link>
    `;
  }

  beforeEach(() => {
    container = document.createElement('div');
    document.body.appendChild(container);

    // Register custom element if not already registered
    if (!customElements.get('apg-link')) {
      customElements.define('apg-link', TestApgLink);
    }
  });

  afterEach(() => {
    container.remove();
    vi.restoreAllMocks();
  });

  describe('Initial Rendering', () => {
    it('renders with role="link"', async () => {
      container.innerHTML = createLinkHTML();

      await new Promise((r) => requestAnimationFrame(r));

      const span = container.querySelector('span[role="link"]');
      expect(span).toBeTruthy();
    });

    it('renders with tabindex="0" by default', async () => {
      container.innerHTML = createLinkHTML();

      await new Promise((r) => requestAnimationFrame(r));

      const span = container.querySelector('span[role="link"]');
      expect(span?.getAttribute('tabindex')).toBe('0');
    });

    it('renders with tabindex="-1" when disabled', async () => {
      container.innerHTML = createLinkHTML({ disabled: true });

      await new Promise((r) => requestAnimationFrame(r));

      const span = container.querySelector('span[role="link"]');
      expect(span?.getAttribute('tabindex')).toBe('-1');
    });

    it('renders with aria-disabled="true" when disabled', async () => {
      container.innerHTML = createLinkHTML({ disabled: true });

      await new Promise((r) => requestAnimationFrame(r));

      const span = container.querySelector('span[role="link"]');
      expect(span?.getAttribute('aria-disabled')).toBe('true');
    });

    it('renders with aria-label for accessible name', async () => {
      container.innerHTML = createLinkHTML({ ariaLabel: 'Go to homepage' });

      await new Promise((r) => requestAnimationFrame(r));

      const span = container.querySelector('span[role="link"]');
      expect(span?.getAttribute('aria-label')).toBe('Go to homepage');
    });

    it('has text content as accessible name', async () => {
      container.innerHTML = createLinkHTML({ text: 'Learn more' });

      await new Promise((r) => requestAnimationFrame(r));

      const span = container.querySelector('span[role="link"]');
      expect(span?.textContent?.trim()).toBe('Learn more');
    });
  });

  describe('Click Interaction', () => {
    it('dispatches link-activate event on click', async () => {
      container.innerHTML = createLinkHTML({ href: 'https://example.com' });

      await new Promise((r) => requestAnimationFrame(r));

      const element = container.querySelector('apg-link') as HTMLElement;
      const span = container.querySelector('span[role="link"]') as HTMLElement;

      const activateHandler = vi.fn();
      element.addEventListener('link-activate', activateHandler);

      span.click();

      expect(activateHandler).toHaveBeenCalledTimes(1);
      expect(activateHandler.mock.calls[0][0].detail.href).toBe('https://example.com');
    });

    it('does not dispatch event when disabled', async () => {
      container.innerHTML = createLinkHTML({ href: 'https://example.com', disabled: true });

      await new Promise((r) => requestAnimationFrame(r));

      const element = container.querySelector('apg-link') as HTMLElement;
      const span = container.querySelector('span[role="link"]') as HTMLElement;

      const activateHandler = vi.fn();
      element.addEventListener('link-activate', activateHandler);

      span.click();

      expect(activateHandler).not.toHaveBeenCalled();
    });
  });

  describe('Keyboard Interaction', () => {
    it('dispatches link-activate event on Enter key', async () => {
      container.innerHTML = createLinkHTML({ href: 'https://example.com' });

      await new Promise((r) => requestAnimationFrame(r));

      const element = container.querySelector('apg-link') as HTMLElement;
      const span = container.querySelector('span[role="link"]') as HTMLElement;

      const activateHandler = vi.fn();
      element.addEventListener('link-activate', activateHandler);

      const enterEvent = new KeyboardEvent('keydown', {
        key: 'Enter',
        bubbles: true,
        cancelable: true,
      });
      span.dispatchEvent(enterEvent);

      expect(activateHandler).toHaveBeenCalledTimes(1);
    });

    it('does not dispatch event on Space key', async () => {
      container.innerHTML = createLinkHTML({ href: 'https://example.com' });

      await new Promise((r) => requestAnimationFrame(r));

      const element = container.querySelector('apg-link') as HTMLElement;
      const span = container.querySelector('span[role="link"]') as HTMLElement;

      const activateHandler = vi.fn();
      element.addEventListener('link-activate', activateHandler);

      const spaceEvent = new KeyboardEvent('keydown', {
        key: ' ',
        bubbles: true,
        cancelable: true,
      });
      span.dispatchEvent(spaceEvent);

      expect(activateHandler).not.toHaveBeenCalled();
    });

    it('does not dispatch event when disabled (Enter key)', async () => {
      container.innerHTML = createLinkHTML({ href: 'https://example.com', disabled: true });

      await new Promise((r) => requestAnimationFrame(r));

      const element = container.querySelector('apg-link') as HTMLElement;
      const span = container.querySelector('span[role="link"]') as HTMLElement;

      const activateHandler = vi.fn();
      element.addEventListener('link-activate', activateHandler);

      const enterEvent = new KeyboardEvent('keydown', {
        key: 'Enter',
        bubbles: true,
        cancelable: true,
      });
      span.dispatchEvent(enterEvent);

      expect(activateHandler).not.toHaveBeenCalled();
    });

    it('does not dispatch event when isComposing is true', async () => {
      container.innerHTML = createLinkHTML({ href: 'https://example.com' });

      await new Promise((r) => requestAnimationFrame(r));

      const element = container.querySelector('apg-link') as HTMLElement;
      const span = container.querySelector('span[role="link"]') as HTMLElement;

      const activateHandler = vi.fn();
      element.addEventListener('link-activate', activateHandler);

      const enterEvent = new KeyboardEvent('keydown', {
        key: 'Enter',
        bubbles: true,
        cancelable: true,
      });
      Object.defineProperty(enterEvent, 'isComposing', { value: true });
      span.dispatchEvent(enterEvent);

      expect(activateHandler).not.toHaveBeenCalled();
    });
  });

  describe('Navigation', () => {
    const originalLocation = window.location;

    beforeEach(() => {
      // @ts-expect-error - delete window.location for mocking
      delete window.location;
      // @ts-expect-error - mock window.location
      window.location = { ...originalLocation, href: '' };
    });

    afterEach(() => {
      // @ts-expect-error - restore window.location
      window.location = originalLocation;
    });

    it('navigates to href on activation', async () => {
      container.innerHTML = createLinkHTML({ href: 'https://example.com' });

      await new Promise((r) => requestAnimationFrame(r));

      const span = container.querySelector('span[role="link"]') as HTMLElement;
      span.click();

      expect(window.location.href).toBe('https://example.com');
    });

    it('opens in new tab when target="_blank"', async () => {
      const windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
      container.innerHTML = createLinkHTML({ href: 'https://example.com', target: '_blank' });

      await new Promise((r) => requestAnimationFrame(r));

      const span = container.querySelector('span[role="link"]') as HTMLElement;
      span.click();

      expect(windowOpenSpy).toHaveBeenCalledWith(
        'https://example.com',
        '_blank',
        'noopener,noreferrer'
      );
    });

    it('does not navigate when disabled', async () => {
      container.innerHTML = createLinkHTML({ href: 'https://example.com', disabled: true });

      await new Promise((r) => requestAnimationFrame(r));

      const span = container.querySelector('span[role="link"]') as HTMLElement;
      span.click();

      expect(window.location.href).toBe('');
    });
  });
});

リソース