リンク
アクティブ化されたときにリソースにナビゲートするインタラクティブ要素。
デモ
ネイティブ 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 ロール
| ロール | 対象要素 | 説明 |
|---|---|---|
link | <a href> または role=“link” を持つ要素 | 要素をハイパーリンクとして識別します。ネイティブの <a href> は暗黙的にこのロールを持ちます。 |
WAI-ARIA プロパティ
tabindex
カスタム実装では必須。ネイティブの <a href> はデフォルトでフォーカス可能。無効時は -1 に設定。
- 値
- 0 (focusable) | -1 (not focusable)
- 必須
- はい
aria-label
表示テキストがない場合にリンクの非表示ラベルを提供
- 値
- string
- 必須
- いいえ
aria-labelledby
外部要素をラベルとして参照
- 値
- ID reference
- 必須
- いいえ
aria-current
セット内の現在の項目を示す(例:ナビゲーション内の現在のページ)
- 値
- page | step | location | date | time | true
- 必須
- いいえ
WAI-ARIA ステート
aria-disabled
- 対象要素
- リンク要素
- 値
- true | false
- 必須
- いいえ
- 変更トリガー
- 無効状態の変更
キーボードサポート
| キー | アクション |
|---|---|
| Enter | リンクをアクティブ化し、ターゲットリソースに遷移 |
| Tab | 次のフォーカス可能な要素にフォーカスを移動 |
| Shift + Tab | 前のフォーカス可能な要素にフォーカスを移動 |
- この実装は教育目的で
<span role="link">を使用しています。本番環境では、ネイティブの<a href>要素を優先してください。 - ボタンとは異なり、Spaceキーはリンクをアクティブ化しません。これは link ロールと button ロールの重要な違いです。
- リンクにはテキストコンテンツ、aria-label、またはaria-labelledbyからアクセシブルな名前が必要です。
フォーカス管理
| イベント | 振る舞い |
|---|---|
ネイティブ <a href> | デフォルトでフォーカス可能 |
| カスタムリンク | tabindex="0" が必要 |
| 無効なリンク | tabindex="-1" を使用(Tabオーダーから除外) |
参考資料
ソースコード
---
/**
* APG Link Pattern - Astro Implementation
*
* An interactive element that navigates to a resource when activated.
* Uses role="link" with Web Components for enhanced interactivity.
*
* Note: This is a custom implementation for educational purposes.
* For production use, prefer native <a href> elements.
*
* @see https://www.w3.org/WAI/ARIA/apg/patterns/link/
*/
export interface Props {
/** Link destination URL */
href?: string;
/** Link target */
target?: '_self' | '_blank';
/** Whether the link is disabled */
disabled?: boolean;
/** Indicates current item in a set (e.g., current page in navigation) */
'aria-current'?: 'page' | 'step' | 'location' | 'date' | 'time' | 'true' | boolean;
/** Additional CSS class */
class?: string;
}
const {
href,
target,
disabled = false,
'aria-current': ariaCurrent,
class: className = '',
} = Astro.props;
---
<apg-link data-href={href} data-target={target}>
<span
role="link"
tabindex={disabled ? -1 : 0}
aria-disabled={disabled ? 'true' : undefined}
aria-current={ariaCurrent || undefined}
class={`apg-link ${className}`.trim()}
>
<slot />
</span>
</apg-link>
<script>
class ApgLink extends HTMLElement {
private spanElement: HTMLSpanElement | null = null;
private rafId: number | null = null;
connectedCallback() {
this.rafId = requestAnimationFrame(() => this.initialize());
}
private initialize() {
this.rafId = null;
this.spanElement = this.querySelector('span[role="link"]');
if (!this.spanElement) {
console.warn('apg-link: span element not found');
return;
}
this.spanElement.addEventListener('click', this.handleClick);
this.spanElement.addEventListener('keydown', this.handleKeyDown);
}
disconnectedCallback() {
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
this.spanElement?.removeEventListener('click', this.handleClick);
this.spanElement?.removeEventListener('keydown', this.handleKeyDown);
this.spanElement = null;
}
private isDisabled(): boolean {
return this.spanElement?.getAttribute('aria-disabled') === 'true';
}
private navigate() {
const href = this.dataset.href;
const target = this.dataset.target;
if (!href) {
return;
}
if (target === '_blank') {
window.open(href, '_blank', 'noopener,noreferrer');
} else {
window.location.href = href;
}
}
private handleClick = (event: MouseEvent) => {
if (this.isDisabled()) {
event.preventDefault();
return;
}
this.dispatchEvent(
new CustomEvent('link-activate', {
detail: { href: this.dataset.href, target: this.dataset.target },
bubbles: true,
})
);
this.navigate();
};
private handleKeyDown = (event: KeyboardEvent) => {
// Ignore if composing (IME input) or already handled
if (event.isComposing || event.defaultPrevented) {
return;
}
if (this.isDisabled()) {
return;
}
// Only Enter key activates link (NOT Space)
if (event.key === 'Enter') {
event.preventDefault();
this.dispatchEvent(
new CustomEvent('link-activate', {
detail: { href: this.dataset.href, target: this.dataset.target },
bubbles: true,
})
);
this.navigate();
}
};
}
if (!customElements.get('apg-link')) {
customElements.define('apg-link', ApgLink);
}
</script> 使い方
---
import Link from './Link.astro';
---
<!-- Basic link -->
<Link href="https://example.com">Visit Example</Link>
<!-- Open in new tab -->
<Link href="https://example.com" target="_blank">
External Link
</Link>
<!-- Disabled link -->
<Link href="#" disabled>Unavailable Link</Link>
<!-- With custom event listener (JavaScript) -->
<Link href="#" id="interactive-link">Interactive Link</Link>
<script>
document.getElementById('interactive-link')
?.addEventListener('link-activate', (e) => {
console.log('Link activated', e.detail);
});
</script> API
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
href | string | - | リンク先 URL |
target | '_self' | '_blank' | '_self' | リンクを開く場所 |
disabled | boolean | false | リンクが無効化されているかどうか |
class | string | '' | 追加の CSS クラス |
Custom Events
| イベント | Detail | 説明 |
|---|---|---|
link-activate | { href, target } | リンクがアクティベートされたとき(クリックまたは Enter)に発火 |
テスト
テストは、キーボード操作、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) を参照してください。
/**
* Link Web Component Tests
*
* Note: These are unit tests for the Web Component class.
* Full keyboard navigation and focus management tests require E2E testing
* with Playwright due to jsdom limitations with focus events.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
describe('Link (Web Component)', () => {
let container: HTMLElement;
// Web Component class extracted for testing
class TestApgLink extends HTMLElement {
private spanElement: HTMLSpanElement | null = null;
private rafId: number | null = null;
connectedCallback() {
this.rafId = requestAnimationFrame(() => this.initialize());
}
private initialize() {
this.rafId = null;
this.spanElement = this.querySelector('span[role="link"]');
if (!this.spanElement) {
return;
}
this.spanElement.addEventListener('click', this.handleClick);
this.spanElement.addEventListener('keydown', this.handleKeyDown);
}
disconnectedCallback() {
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
this.spanElement?.removeEventListener('click', this.handleClick);
this.spanElement?.removeEventListener('keydown', this.handleKeyDown);
}
private handleClick = (event: MouseEvent) => {
if (this.isDisabled()) {
event.preventDefault();
return;
}
this.activate(event);
};
private handleKeyDown = (event: KeyboardEvent) => {
if (event.isComposing || event.defaultPrevented) {
return;
}
if (this.isDisabled()) {
return;
}
if (event.key === 'Enter') {
event.preventDefault();
this.activate(event);
}
};
private activate(_event: Event) {
const href = this.dataset.href;
const target = this.dataset.target;
this.dispatchEvent(
new CustomEvent('link-activate', {
detail: { href, target },
bubbles: true,
})
);
if (href) {
if (target === '_blank') {
window.open(href, '_blank', 'noopener,noreferrer');
} else {
window.location.href = href;
}
}
}
private isDisabled(): boolean {
return this.spanElement?.getAttribute('aria-disabled') === 'true';
}
// Expose for testing
get _spanElement() {
return this.spanElement;
}
}
function createLinkHTML(
options: {
href?: string;
target?: '_self' | '_blank';
disabled?: boolean;
ariaLabel?: string;
text?: string;
} = {}
) {
const { href = '#', target, disabled = false, ariaLabel, text = 'Click here' } = options;
const tabindex = disabled ? '-1' : '0';
const ariaDisabled = disabled ? 'aria-disabled="true"' : '';
const ariaLabelAttr = ariaLabel ? `aria-label="${ariaLabel}"` : '';
return `
<apg-link
class="apg-link"
data-href="${href}"
${target ? `data-target="${target}"` : ''}
>
<span
role="link"
tabindex="${tabindex}"
${ariaDisabled}
${ariaLabelAttr}
>
${text}
</span>
</apg-link>
`;
}
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
// Register custom element if not already registered
if (!customElements.get('apg-link')) {
customElements.define('apg-link', TestApgLink);
}
});
afterEach(() => {
container.remove();
vi.restoreAllMocks();
});
describe('Initial Rendering', () => {
it('renders with role="link"', async () => {
container.innerHTML = createLinkHTML();
await new Promise((r) => requestAnimationFrame(r));
const span = container.querySelector('span[role="link"]');
expect(span).toBeTruthy();
});
it('renders with tabindex="0" by default', async () => {
container.innerHTML = createLinkHTML();
await new Promise((r) => requestAnimationFrame(r));
const span = container.querySelector('span[role="link"]');
expect(span?.getAttribute('tabindex')).toBe('0');
});
it('renders with tabindex="-1" when disabled', async () => {
container.innerHTML = createLinkHTML({ disabled: true });
await new Promise((r) => requestAnimationFrame(r));
const span = container.querySelector('span[role="link"]');
expect(span?.getAttribute('tabindex')).toBe('-1');
});
it('renders with aria-disabled="true" when disabled', async () => {
container.innerHTML = createLinkHTML({ disabled: true });
await new Promise((r) => requestAnimationFrame(r));
const span = container.querySelector('span[role="link"]');
expect(span?.getAttribute('aria-disabled')).toBe('true');
});
it('renders with aria-label for accessible name', async () => {
container.innerHTML = createLinkHTML({ ariaLabel: 'Go to homepage' });
await new Promise((r) => requestAnimationFrame(r));
const span = container.querySelector('span[role="link"]');
expect(span?.getAttribute('aria-label')).toBe('Go to homepage');
});
it('has text content as accessible name', async () => {
container.innerHTML = createLinkHTML({ text: 'Learn more' });
await new Promise((r) => requestAnimationFrame(r));
const span = container.querySelector('span[role="link"]');
expect(span?.textContent?.trim()).toBe('Learn more');
});
});
describe('Click Interaction', () => {
it('dispatches link-activate event on click', async () => {
container.innerHTML = createLinkHTML({ href: 'https://example.com' });
await new Promise((r) => requestAnimationFrame(r));
const element = container.querySelector('apg-link') as HTMLElement;
const span = container.querySelector('span[role="link"]') as HTMLElement;
const activateHandler = vi.fn();
element.addEventListener('link-activate', activateHandler);
span.click();
expect(activateHandler).toHaveBeenCalledTimes(1);
expect(activateHandler.mock.calls[0][0].detail.href).toBe('https://example.com');
});
it('does not dispatch event when disabled', async () => {
container.innerHTML = createLinkHTML({ href: 'https://example.com', disabled: true });
await new Promise((r) => requestAnimationFrame(r));
const element = container.querySelector('apg-link') as HTMLElement;
const span = container.querySelector('span[role="link"]') as HTMLElement;
const activateHandler = vi.fn();
element.addEventListener('link-activate', activateHandler);
span.click();
expect(activateHandler).not.toHaveBeenCalled();
});
});
describe('Keyboard Interaction', () => {
it('dispatches link-activate event on Enter key', async () => {
container.innerHTML = createLinkHTML({ href: 'https://example.com' });
await new Promise((r) => requestAnimationFrame(r));
const element = container.querySelector('apg-link') as HTMLElement;
const span = container.querySelector('span[role="link"]') as HTMLElement;
const activateHandler = vi.fn();
element.addEventListener('link-activate', activateHandler);
const enterEvent = new KeyboardEvent('keydown', {
key: 'Enter',
bubbles: true,
cancelable: true,
});
span.dispatchEvent(enterEvent);
expect(activateHandler).toHaveBeenCalledTimes(1);
});
it('does not dispatch event on Space key', async () => {
container.innerHTML = createLinkHTML({ href: 'https://example.com' });
await new Promise((r) => requestAnimationFrame(r));
const element = container.querySelector('apg-link') as HTMLElement;
const span = container.querySelector('span[role="link"]') as HTMLElement;
const activateHandler = vi.fn();
element.addEventListener('link-activate', activateHandler);
const spaceEvent = new KeyboardEvent('keydown', {
key: ' ',
bubbles: true,
cancelable: true,
});
span.dispatchEvent(spaceEvent);
expect(activateHandler).not.toHaveBeenCalled();
});
it('does not dispatch event when disabled (Enter key)', async () => {
container.innerHTML = createLinkHTML({ href: 'https://example.com', disabled: true });
await new Promise((r) => requestAnimationFrame(r));
const element = container.querySelector('apg-link') as HTMLElement;
const span = container.querySelector('span[role="link"]') as HTMLElement;
const activateHandler = vi.fn();
element.addEventListener('link-activate', activateHandler);
const enterEvent = new KeyboardEvent('keydown', {
key: 'Enter',
bubbles: true,
cancelable: true,
});
span.dispatchEvent(enterEvent);
expect(activateHandler).not.toHaveBeenCalled();
});
it('does not dispatch event when isComposing is true', async () => {
container.innerHTML = createLinkHTML({ href: 'https://example.com' });
await new Promise((r) => requestAnimationFrame(r));
const element = container.querySelector('apg-link') as HTMLElement;
const span = container.querySelector('span[role="link"]') as HTMLElement;
const activateHandler = vi.fn();
element.addEventListener('link-activate', activateHandler);
const enterEvent = new KeyboardEvent('keydown', {
key: 'Enter',
bubbles: true,
cancelable: true,
});
Object.defineProperty(enterEvent, 'isComposing', { value: true });
span.dispatchEvent(enterEvent);
expect(activateHandler).not.toHaveBeenCalled();
});
});
describe('Navigation', () => {
const originalLocation = window.location;
beforeEach(() => {
// @ts-expect-error - delete window.location for mocking
delete window.location;
// @ts-expect-error - mock window.location
window.location = { ...originalLocation, href: '' };
});
afterEach(() => {
// @ts-expect-error - restore window.location
window.location = originalLocation;
});
it('navigates to href on activation', async () => {
container.innerHTML = createLinkHTML({ href: 'https://example.com' });
await new Promise((r) => requestAnimationFrame(r));
const span = container.querySelector('span[role="link"]') as HTMLElement;
span.click();
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);
container.innerHTML = createLinkHTML({ href: 'https://example.com', target: '_blank' });
await new Promise((r) => requestAnimationFrame(r));
const span = container.querySelector('span[role="link"]') as HTMLElement;
span.click();
expect(windowOpenSpy).toHaveBeenCalledWith(
'https://example.com',
'_blank',
'noopener,noreferrer'
);
});
it('does not navigate when disabled', async () => {
container.innerHTML = createLinkHTML({ href: 'https://example.com', disabled: true });
await new Promise((r) => requestAnimationFrame(r));
const span = container.querySelector('span[role="link"]') as HTMLElement;
span.click();
expect(window.location.href).toBe('');
});
});
}); リソース
- 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