Link
アクティブ化されたときにリソースにナビゲートするインタラクティブ要素。
🤖 AI Implementation Guideデモ
ネイティブ 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カーソルを表示
参考資料
ソースコード
<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構造 |
テストツール
- Vitest (opens in new tab) - ユニットテストランナー
- Testing Library (opens in new tab) - フレームワーク別テストユーティリティ(React、Vue、Svelte)
- Playwright (opens in new tab) - E2Eテスト用ブラウザ自動化
- axe-core/playwright (opens in new tab) - E2Eでの自動アクセシビリティテスト
詳細なドキュメントは testing-strategy.md (opens in new tab) を参照してください。
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');
});
});
}); リソース
- WAI-ARIA APG: Link パターン (opens in new tab)
- MDN: <a> 要素 (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist