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

Usage

Example
<script setup>
import Link from './Link.vue';
</script>

<template>
  <!-- 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 @click="handleClick">Interactive Link</Link>

  <!-- Disabled link -->
  <Link href="#" disabled>Unavailable Link</Link>
</template>

API

Prop Type Default Description
href string - Link destination URL
target '_self' | '_blank' '_self' Where to open the link
onClick (event) => void - Click/Enter event handler
disabled boolean false Whether the link is disabled

All other attributes are passed to the underlying <span> element via $attrs.

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.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');
    });
  });
});

Resources