APG Patterns
English GitHub
English GitHub

Link

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

🤖 AI Implementation Guide

デモ

WAI-ARIA APG Documentation External Link (opens in new tab) Link with onClick handler Disabled Link

デモのみ表示 →

ネイティブ 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.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;
  /** 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準拠を検証します。

テストカテゴリ

高優先度: APGキーボード操作

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

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

高優先度: ARIA属性

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

高優先度: クリック動作

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

中優先度: アクセシビリティ

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

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

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

テストツール

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

リソース