APG Patterns
English GitHub
English GitHub

Link

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

🤖 AI Implementation Guide

デモ

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

デモのみ表示 →

ネイティブ HTML

Use Native HTML First

Before using this custom component, consider using native <a href> elements. They provide built-in accessibility, full browser functionality, SEO benefits, and work without 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>

Use custom role="link" implementations only for educational purposes or when you need complex JavaScript-driven navigation with SPA routing.

Feature Native <a href> Custom role="link"
Ctrl/Cmd + Click (new tab) Built-in Not supported
Right-click context menu Full menu Limited
Copy link address Built-in Not supported
Drag to bookmarks Built-in Not supported
SEO recognition Crawled May be ignored
Works without JavaScript Yes No
Screen reader announcement Automatic Requires ARIA
Focus management Automatic Requires tabindex

This custom implementation is provided for educational purposes to demonstrate APG patterns. In production, always prefer native <a href> elements.

アクセシビリティ

WAI-ARIA ロール

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

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

WAI-ARIA プロパティ

tabindex

カスタムリンク要素をキーボードナビゲーションでフォーカス可能にします。

0 (フォーカス可能) | -1 (フォーカス不可)
必須 はい (カスタム実装の場合)
ネイティブHTML <a href> はデフォルトでフォーカス可能
無効状態 Tabオーダーから除外するには -1 に設定

aria-disabled

リンクがインタラクティブでなく、アクティブ化できないことを示します。

true | false (または省略)
必須 いいえ (無効時のみ)
効果 クリックまたはEnterキーによるアクティブ化を防止

aria-current (オプション)

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

page | step | location | date | time | true
必須 いいえ
ユースケース ナビゲーションメニュー、パンくずリスト、ページネーション

キーボードサポート

キー アクション
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;
  /** Additional CSS class */
  class?: string;
}

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

<apg-link data-href={href} data-target={target}>
  <span
    role="link"
    tabindex={disabled ? -1 : 0}
    aria-disabled={disabled ? 'true' : 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)

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

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

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

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

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

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

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

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

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

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

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

テスト 説明
すべてのフレームワークにリンクがある React、Vue、Svelte、Astroすべてがカスタムリンク要素をレンダリング
同じリンク数 すべてのフレームワークで同じ数のリンクをレンダリング
一貫したARIA すべてのフレームワークで一貫したARIA構造

テストツール

詳細なドキュメントは 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('');
    });
  });
});

リソース