APG Patterns
English
English

Link

リソースへのインタラクティブな参照を提供するウィジェット。

デモ

WAI-ARIA APG Documentation External Link (opens in new tab) Link with onClick handler 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.tsx
import { cn } from '@/lib/utils';
import { useCallback } from 'react';

export interface LinkProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, 'onClick'> {
  /** Link destination URL */
  href?: string;
  /** Link target */
  target?: '_self' | '_blank';
  /** Click handler */
  onClick?: (event: React.MouseEvent | React.KeyboardEvent) => void;
  /** Disabled state */
  disabled?: boolean;
  /** Indicates current item in a set (e.g., current page in navigation) */
  'aria-current'?: 'page' | 'step' | 'location' | 'date' | 'time' | 'true' | boolean;
  /** Link content */
  children: React.ReactNode;
}

export const Link: React.FC<LinkProps> = ({
  href,
  target,
  onClick,
  disabled = false,
  className,
  children,
  ...spanProps
}) => {
  const navigate = useCallback(() => {
    if (!href) {
      return;
    }

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

  const handleClick = useCallback(
    (event: React.MouseEvent<HTMLSpanElement>) => {
      if (disabled) {
        event.preventDefault();
        return;
      }

      onClick?.(event);

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

  const handleKeyDown = useCallback(
    (event: React.KeyboardEvent<HTMLSpanElement>) => {
      // Ignore if composing (IME input) or already handled
      if (event.nativeEvent.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();
        }
      }
    },
    [disabled, onClick, navigate]
  );

  return (
    <span
      role="link"
      tabIndex={disabled ? -1 : 0}
      aria-disabled={disabled ? 'true' : undefined}
      className={cn('apg-link', className)}
      onClick={handleClick}
      onKeyDown={handleKeyDown}
      {...spanProps}
    >
      {children}
    </span>
  );
};

export default Link;

使い方

Example
import { Link } from './Link';

function App() {
  return (
    <div>
      {/* Basic link */}
      <Link href="https://example.com">Visit Example</Link>

      {/* Open in new tab */}
      <Link href="https://example.com" target="_blank">
        External Link
      </Link>

      {/* With onClick handler */}
      <Link onClick={(e) => console.log('Clicked', e)}>
        Interactive Link
      </Link>

      {/* Disabled link */}
      <Link href="#" disabled>
        Unavailable Link
      </Link>

      {/* With aria-label for icon links */}
      <Link href="/" aria-label="Home">
        <HomeIcon />
      </Link>
    </div>
  );
}

API

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

その他のプロパティは、内部の <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.tsx
import { render, screen } from '@testing-library/react';
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';

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

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

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

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

    it('has accessible name from aria-labelledby', () => {
      render(
        <>
          <span id="link-label">External link</span>
          <Link href="#" aria-labelledby="link-label">
            Click
          </Link>
        </>
      );
      expect(screen.getByRole('link', { name: 'External link' })).toBeInTheDocument();
    });

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

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

    it('does not have aria-disabled when not disabled', () => {
      render(<Link href="#">Active link</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 onClick={handleClick}>Click me</Link>);

      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 onClick={handleClick}>Click me</Link>);

      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 onClick={handleClick}>Click me</Link>);

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

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

    it('does not call onClick when event.defaultPrevented is true', () => {
      const handleClick = vi.fn();
      render(<Link onClick={handleClick}>Click me</Link>);

      const link = screen.getByRole('link');
      const event = new KeyboardEvent('keydown', {
        key: 'Enter',
        bubbles: true,
        cancelable: true,
      });
      event.preventDefault();

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

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

      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 onClick={handleClick} disabled>
          Disabled
        </Link>
      );

      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 onClick={handleClick} disabled>
          Disabled
        </Link>
      );

      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 href="#">Click here</Link>);

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

    it('is not focusable when disabled', async () => {
      const user = userEvent.setup();
      render(
        <>
          <button>Before</button>
          <Link href="#" disabled>
            Disabled link
          </Link>
          <button>After</button>
        </>
      );

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

    it('moves focus between multiple links with Tab', async () => {
      const user = userEvent.setup();
      render(
        <>
          <Link href="#">Link 1</Link>
          <Link href="#">Link 2</Link>
          <Link href="#">Link 3</Link>
        </>
      );

      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();
    });
  });

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

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

    it('has no axe violations with aria-label', async () => {
      const { container } = render(
        <Link href="#" aria-label="Go to homepage">

        </Link>
      );
      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 href="https://example.com">Visit</Link>);

      await user.click(screen.getByRole('link'));
      expect(window.location.href).toBe('https://example.com');
    });

    it('navigates to href on Enter key', async () => {
      const user = userEvent.setup();
      render(<Link href="https://example.com">Visit</Link>);

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

      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 href="https://example.com" target="_blank">
          External
        </Link>
      );

      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 href="https://example.com" disabled>
          Disabled
        </Link>
      );

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

    it('calls onClick and navigates to href', async () => {
      const handleClick = vi.fn();
      const user = userEvent.setup();

      render(
        <Link href="https://example.com" onClick={handleClick}>
          Visit
        </Link>
      );

      await user.click(screen.getByRole('link'));
      expect(handleClick).toHaveBeenCalledTimes(1);
      expect(window.location.href).toBe('https://example.com');
    });

    it('does not navigate when onClick prevents default', async () => {
      const handleClick = vi.fn((e: React.MouseEvent | React.KeyboardEvent) => {
        e.preventDefault();
      });
      const user = userEvent.setup();

      render(
        <Link href="https://example.com" onClick={handleClick}>
          Visit
        </Link>
      );

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

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

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

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

リソース