APG Patterns
English
English

Link

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

デモ

WAI-ARIA APG Documentation External 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 ロール

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.svelte
<script lang="ts">
  import type { Snippet } from 'svelte';

  interface LinkProps {
    /** 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;
    /** Click handler */
    onClick?: (event: MouseEvent | KeyboardEvent) => void;
    /** Children content (string for tests, Snippet for slots) */
    children?: string | Snippet<[]>;
    [key: string]: unknown;
  }

  let { href, target, disabled = false, onClick, children, ...restProps }: LinkProps = $props();

  function navigate() {
    if (!href) {
      return;
    }

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

  function handleClick(event: MouseEvent) {
    if (disabled) {
      event.preventDefault();
      return;
    }

    onClick?.(event);

    // Navigate only if onClick didn't prevent the event
    if (!event.defaultPrevented) {
      navigate();
    }
  }

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

    if (disabled) {
      return;
    }

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

      // Navigate only if onClick didn't prevent the event
      if (!event.defaultPrevented) {
        navigate();
      }
    }
  }
</script>

<span
  role="link"
  tabindex={disabled ? -1 : 0}
  aria-disabled={disabled ? 'true' : undefined}
  class="apg-link {restProps.class || ''}"
  onclick={handleClick}
  onkeydown={handleKeyDown}
  {...restProps}
  class:undefined={false}
  >{#if typeof children === 'string'}{children}{:else if children}{@render children()}{/if}</span
>

使い方

使用例
<script>
  import Link from './Link.svelte';

  function handleClick(event) {
    console.log('Link clicked', event);
  }
</script>

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

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

<!-- onClick ハンドラー付き -->
<Link onClick={handleClick}>Interactive Link</Link>

<!-- 無効化されたリンク -->
<Link href="#" disabled>Unavailable Link</Link>

API

プロパティ デフォルト 説明
href string - リンク先のURL
target '_self' | '_blank' '_self' リンクを開く場所
onClick (event) => void - クリック/Enterイベントハンドラー
disabled boolean false リンクが無効化されているかどうか

その他のプロパティは、restProps 経由で内部の <span> 要素に渡されます。

テスト

テストは、キーボード操作、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.svelte.ts
import { render, screen } from '@testing-library/svelte';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import Link from './Link.svelte';

describe('Link (Svelte)', () => {
  // 🔴 High Priority: APG ARIA Attributes
  describe('APG ARIA Attributes', () => {
    it('has role="link" on element', () => {
      render(Link, {
        props: { href: '#', children: 'Click here' },
      });
      expect(screen.getByRole('link')).toBeInTheDocument();
    });

    it('has tabindex="0" on element', () => {
      render(Link, {
        props: { href: '#', children: 'Click here' },
      });
      const link = screen.getByRole('link');
      expect(link).toHaveAttribute('tabindex', '0');
    });

    it('has accessible name from text content', () => {
      render(Link, {
        props: { href: '#', children: 'Learn more' },
      });
      expect(screen.getByRole('link', { name: 'Learn more' })).toBeInTheDocument();
    });

    it('has accessible name from aria-label', () => {
      render(Link, {
        props: { href: '#', 'aria-label': 'Go to homepage', children: '→' },
      });
      expect(screen.getByRole('link', { name: 'Go to homepage' })).toBeInTheDocument();
    });

    it('sets aria-disabled="true" when disabled', () => {
      render(Link, {
        props: { href: '#', disabled: true, children: 'Disabled link' },
      });
      const link = screen.getByRole('link');
      expect(link).toHaveAttribute('aria-disabled', 'true');
    });

    it('sets tabindex="-1" when disabled', () => {
      render(Link, {
        props: { href: '#', disabled: true, children: 'Disabled link' },
      });
      const link = screen.getByRole('link');
      expect(link).toHaveAttribute('tabindex', '-1');
    });

    it('does not have aria-disabled when not disabled', () => {
      render(Link, {
        props: { href: '#', children: 'Active link' },
      });
      const link = screen.getByRole('link');
      expect(link).not.toHaveAttribute('aria-disabled');
    });
  });

  // 🔴 High Priority: APG Keyboard Interaction
  describe('APG Keyboard Interaction', () => {
    it('calls onClick on Enter key', async () => {
      const handleClick = vi.fn();
      const user = userEvent.setup();
      render(Link, {
        props: { onClick: handleClick, children: 'Click me' },
      });

      const link = screen.getByRole('link');
      link.focus();
      await user.keyboard('{Enter}');

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

    it('does not call onClick on Space key', async () => {
      const handleClick = vi.fn();
      const user = userEvent.setup();
      render(Link, {
        props: { onClick: handleClick, children: 'Click me' },
      });

      const link = screen.getByRole('link');
      link.focus();
      await user.keyboard(' ');

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

    it('does not call onClick when event.isComposing is true', () => {
      const handleClick = vi.fn();
      render(Link, {
        props: { onClick: handleClick, children: 'Click me' },
      });

      const link = screen.getByRole('link');
      const event = new KeyboardEvent('keydown', {
        key: 'Enter',
        bubbles: true,
      });
      Object.defineProperty(event, 'isComposing', { value: true });

      link.dispatchEvent(event);
      expect(handleClick).not.toHaveBeenCalled();
    });

    it('calls onClick on click', async () => {
      const handleClick = vi.fn();
      const user = userEvent.setup();
      render(Link, {
        props: { onClick: handleClick, children: 'Click me' },
      });

      await user.click(screen.getByRole('link'));
      expect(handleClick).toHaveBeenCalledTimes(1);
    });

    it('does not call onClick when disabled (click)', async () => {
      const handleClick = vi.fn();
      const user = userEvent.setup();
      render(Link, {
        props: { onClick: handleClick, disabled: true, children: 'Disabled' },
      });

      await user.click(screen.getByRole('link'));
      expect(handleClick).not.toHaveBeenCalled();
    });

    it('does not call onClick when disabled (Enter key)', async () => {
      const handleClick = vi.fn();
      const user = userEvent.setup();
      render(Link, {
        props: { onClick: handleClick, disabled: true, children: 'Disabled' },
      });

      const link = screen.getByRole('link');
      link.focus();
      await user.keyboard('{Enter}');

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

  // 🔴 High Priority: Focus Management
  describe('Focus Management', () => {
    it('is focusable via Tab', async () => {
      const user = userEvent.setup();
      render(Link, {
        props: { href: '#', children: 'Click here' },
      });

      await user.tab();
      expect(screen.getByRole('link')).toHaveFocus();
    });

    it('is not focusable when disabled', async () => {
      const user = userEvent.setup();
      const container = document.createElement('div');
      document.body.appendChild(container);

      const beforeButton = document.createElement('button');
      beforeButton.textContent = 'Before';
      container.appendChild(beforeButton);

      render(Link, {
        target: container,
        props: { href: '#', disabled: true, children: 'Disabled link' },
      });

      const afterButton = document.createElement('button');
      afterButton.textContent = 'After';
      container.appendChild(afterButton);

      await user.tab();
      expect(screen.getByRole('button', { name: 'Before' })).toHaveFocus();
      await user.tab();
      expect(screen.getByRole('button', { name: 'After' })).toHaveFocus();

      document.body.removeChild(container);
    });

    it('moves focus between multiple links with Tab', async () => {
      const user = userEvent.setup();
      const container = document.createElement('div');
      document.body.appendChild(container);

      render(Link, {
        target: container,
        props: { href: '#', children: 'Link 1' },
      });
      render(Link, {
        target: container,
        props: { href: '#', children: 'Link 2' },
      });
      render(Link, {
        target: container,
        props: { href: '#', children: 'Link 3' },
      });

      await user.tab();
      expect(screen.getByRole('link', { name: 'Link 1' })).toHaveFocus();
      await user.tab();
      expect(screen.getByRole('link', { name: 'Link 2' })).toHaveFocus();
      await user.tab();
      expect(screen.getByRole('link', { name: 'Link 3' })).toHaveFocus();

      document.body.removeChild(container);
    });
  });

  // 🟡 Medium Priority: Accessibility
  describe('Accessibility', () => {
    it('has no axe violations', async () => {
      const { container } = render(Link, {
        props: { href: '#', children: 'Click here' },
      });
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations when disabled', async () => {
      const { container } = render(Link, {
        props: { href: '#', disabled: true, children: 'Disabled link' },
      });
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations with aria-label', async () => {
      const { container } = render(Link, {
        props: { href: '#', 'aria-label': 'Go to homepage', children: '→' },
      });
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });
  });

  // 🟢 Low Priority: Navigation
  describe('Navigation', () => {
    const originalLocation = window.location;

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

    afterEach(() => {
      window.location = originalLocation;
    });

    it('navigates to href on activation', async () => {
      const user = userEvent.setup();
      render(Link, {
        props: { href: 'https://example.com', children: 'Visit' },
      });

      await user.click(screen.getByRole('link'));
      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);
      const user = userEvent.setup();

      render(Link, {
        props: { href: 'https://example.com', target: '_blank', children: 'External' },
      });

      await user.click(screen.getByRole('link'));
      expect(windowOpenSpy).toHaveBeenCalledWith(
        'https://example.com',
        '_blank',
        'noopener,noreferrer'
      );

      windowOpenSpy.mockRestore();
    });

    it('does not navigate when disabled', async () => {
      const user = userEvent.setup();
      render(Link, {
        props: { href: 'https://example.com', disabled: true, children: 'Disabled' },
      });

      await user.click(screen.getByRole('link'));
      expect(window.location.href).toBe('');
    });
  });

  // 🟢 Low Priority: HTML Attribute Inheritance
  describe('HTML Attribute Inheritance', () => {
    it('applies class to element', () => {
      render(Link, {
        props: { href: '#', class: 'custom-link', children: 'Styled' },
      });
      const link = screen.getByRole('link');
      expect(link).toHaveClass('custom-link');
    });

    it('passes through data-* attributes', () => {
      render(Link, {
        props: { href: '#', 'data-testid': 'my-link', 'data-custom': 'value', children: 'Link' },
      });
      const link = screen.getByTestId('my-link');
      expect(link).toHaveAttribute('data-custom', 'value');
    });

    it('sets id attribute', () => {
      render(Link, {
        props: { href: '#', id: 'main-link', children: 'Main' },
      });
      const link = screen.getByRole('link');
      expect(link).toHaveAttribute('id', 'main-link');
    });
  });
});

リソース