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.vue
<template>
  <span
    role="link"
    :tabindex="props.disabled ? -1 : 0"
    :aria-disabled="props.disabled ? 'true' : undefined"
    class="apg-link"
    v-bind="$attrs"
    @click="handleClick"
    @keydown="handleKeyDown"
  >
    <slot />
  </span>
</template>

<script setup lang="ts">
defineOptions({
  inheritAttrs: false,
});

export interface LinkProps {
  /** Link destination URL */
  href?: string;
  /** Link target */
  target?: '_self' | '_blank';
  /** Whether the link is disabled */
  disabled?: boolean;
  /** Callback fired when link is activated */
  onClick?: (event: MouseEvent | KeyboardEvent) => void;
}

const props = withDefaults(defineProps<LinkProps>(), {
  href: undefined,
  target: undefined,
  disabled: false,
  onClick: undefined,
});

const navigate = () => {
  if (!props.href) {
    return;
  }

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

const handleClick = (event: MouseEvent) => {
  if (props.disabled) {
    event.preventDefault();
    return;
  }

  props.onClick?.(event);

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

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

  if (props.disabled) {
    return;
  }

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

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

使い方

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

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

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

  <!-- onClick ハンドラー付き -->
  <Link @click="handleClick">インタラクティブリンク</Link>

  <!-- 無効なリンク -->
  <Link href="#" disabled>利用できないリンク</Link>
</template>

API

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

その他のすべての属性は、$attrs を介して内部の <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)

テスト 説明
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.vue.ts
import { render, screen } from '@testing-library/vue';
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.vue';

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

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

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

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

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

    it('sets aria-disabled="true" when disabled', () => {
      render(Link, {
        props: { href: '#', disabled: true },
        slots: { default: '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 },
        slots: { default: '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: '#' },
        slots: { default: '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 },
        slots: { default: '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 },
        slots: { default: '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 },
        slots: { default: '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 },
        slots: { default: '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 },
        slots: { default: '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 },
        slots: { default: '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: '#' },
        slots: { default: 'Click here' },
      });

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

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

      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({
        components: { Link },
        template: `
          <div>
            <Link href="#">Link 1</Link>
            <Link href="#">Link 2</Link>
            <Link href="#">Link 3</Link>
          </div>
        `,
      });

      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, {
        props: { href: '#' },
        slots: { default: '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 },
        slots: { default: '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: '#' },
        attrs: { 'aria-label': 'Go to homepage' },
        slots: { default: '→' },
      });
      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' },
        slots: { default: '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' },
        slots: { default: '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 },
        slots: { default: '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: '#' },
        attrs: { class: 'custom-link' },
        slots: { default: 'Styled' },
      });
      const link = screen.getByRole('link');
      expect(link).toHaveClass('custom-link');
    });

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

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

リソース