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カーソルを表示
参考資料
ソースコード
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; 使い方
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 ロールの重要な違いです。
テストツール
- 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/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');
});
});
}); リソース
- WAI-ARIA APG: Link パターン (opens in new tab)
- MDN: <a> element (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist