APG Patterns
日本語 GitHub
日本語 GitHub

Link

An interactive element that navigates to a resource when activated.

🤖 AI Implementation Guide

Demo

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

Open demo only →

Native 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.

Accessibility Features

WAI-ARIA Role

Role Element Description
link <a href> or element with role="link" Identifies the element as a hyperlink. Native <a href> has this role implicitly.

This implementation uses <span role="link"> for educational purposes. For production use, prefer native <a href> elements.

WAI-ARIA Properties

tabindex

Makes the custom link element focusable via keyboard navigation.

Values 0 (focusable) | -1 (not focusable)
Required Yes (for custom implementations)
Native HTML <a href> is focusable by default
Disabled State Set to -1 to remove from tab order

aria-disabled

Indicates the link is not interactive and cannot be activated.

Values true | false (or omitted)
Required No (only when disabled)
Effect Prevents activation via click or Enter key

aria-current (Optional)

Indicates the current item within a set (e.g., current page in navigation).

Values page | step | location | date | time | true
Required No
Use Case Navigation menus, breadcrumbs, pagination

Keyboard Support

Key Action
Enter Activate the link and navigate to the target resource
Tab Move focus to the next focusable element
Shift + Tab Move focus to the previous focusable element

Important: Unlike buttons, the Space key does not activate links. This is a key distinction between the link and button roles.

Accessible Naming

Links must have an accessible name. This can be provided through:

  • Text content (recommended) - The visible text inside the link
  • aria-label - Provides an invisible label for the link
  • aria-labelledby - References an external element as the label

Focus Styles

This implementation provides clear focus indicators:

  • Focus ring - Visible outline when focused via keyboard
  • Cursor style - Pointer cursor to indicate interactivity
  • Disabled appearance - Reduced opacity and not-allowed cursor when disabled

References

Source Code

Link.astro
---
/**
 * 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;
  /** Additional CSS class */
  class?: string;
}

const { href, target, disabled = false, class: className = '' } = Astro.props;
---

<apg-link data-href={href} data-target={target}>
  <span
    role="link"
    tabindex={disabled ? -1 : 0}
    aria-disabled={disabled ? 'true' : 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>

Usage

Example
---
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

Prop Type Default Description
href string - Link destination URL
target '_self' | '_blank' '_self' Where to open the link
disabled boolean false Whether the link is disabled
class string '' Additional CSS classes

Custom Events

Event Detail Description
link-activate { href, target } Fired when link is activated (click or Enter)

Testing

Tests verify APG compliance across keyboard interaction, ARIA attributes, and accessibility requirements.

Test Categories

High Priority: APG Keyboard Interaction

Test Description
Enter key Activates the link and navigates to target
Space key Does NOT activate the link (links only respond to Enter)
IME composing Ignores Enter key during IME input
Tab navigation Tab moves focus between links
Disabled Tab skip Disabled links are skipped in Tab order

Important: Unlike buttons, links are activated only by the Enter key, not Space. This is a key distinction between the link and button roles.

High Priority: ARIA Attributes

Test Description
role="link" Element has link role
tabindex="0" Element is focusable via keyboard
aria-disabled Set to "true" when disabled
tabindex="-1" Set when disabled to remove from Tab order
Accessible name Name from text content, aria-label, or aria-labelledby

High Priority: Click Behavior

Test Description
Click activation Click activates the link
Disabled click Disabled links ignore click events
Disabled Enter Disabled links ignore Enter key

Medium Priority: Accessibility

Test Description
axe violations No WCAG 2.1 AA violations (via jest-axe)
disabled axe No violations in disabled state
aria-label axe No violations with aria-label

Low Priority: Navigation & Props

Test Description
href navigation Navigates to href on activation
target="_blank" Opens in new tab with security options
className Custom classes are applied
data-* attributes Custom data attributes are passed through

Testing Tools

See testing-strategy.md (opens in new tab) for full documentation.

Link.test.astro.ts
/**
 * 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('');
    });
  });
});

Resources