diff --git a/src/components/common/SkipToContent.tsx b/src/components/common/SkipToContent.tsx new file mode 100644 index 0000000..fcab189 --- /dev/null +++ b/src/components/common/SkipToContent.tsx @@ -0,0 +1,74 @@ +import React from 'react'; + +interface SkipToContentProps { + /** ID of the target element to focus on when the link is activated */ + targetId: string; + /** Text displayed on the link */ + label?: string; + /** Optional className for styling */ + className?: string; +} + +/** + * Visually hidden skip-to-content link that appears on first Tab press. + * Allows keyboard users to bypass navigation and jump directly to main content. + * + * Usage: + * 1. Add this component as the first focusable element in your page + * 2. Add id and tabIndex={-1} to your main content container + * 3. On Enter/Space, focus moves to the target element + * + * @example + * + */ +const SkipToContent: React.FC = ({ + targetId, + label = 'Skip to main content', + className, +}) => { + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + focusTarget(); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + // Allow both Enter and Space keys to activate the link + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + focusTarget(); + } + }; + + const focusTarget = () => { + const target = document.getElementById(targetId); + if (target) { + // Set focus to the target + target.focus({ preventScroll: true }); + // Scroll it into view smoothly + target.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }; + + return ( + + ); +}; + +export default SkipToContent; diff --git a/src/components/common/__tests__/SkipToContent.test.tsx b/src/components/common/__tests__/SkipToContent.test.tsx new file mode 100644 index 0000000..17e222c --- /dev/null +++ b/src/components/common/__tests__/SkipToContent.test.tsx @@ -0,0 +1,304 @@ +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import SkipToContent from '../SkipToContent'; + +// --------------------------------------------------------------------------- +// Feature: Skip-to-content link for keyboard accessibility +// Validates: All acceptance criteria +// --------------------------------------------------------------------------- +describe('SkipToContent: Keyboard Accessibility', () => { + const TARGET_ID = 'main-content'; + const SKIP_LABEL = 'Skip to main content'; + + beforeEach(() => { + // Reset document before each test + document.body.innerHTML = ''; + + // Create target element + const target = document.createElement('div'); + target.id = TARGET_ID; + target.tabIndex = -1; + target.textContent = 'Main content area'; + document.body.appendChild(target); + }); + + // --------------------------------------------------------------------------- + // Acceptance Criteria 1: Pressing Tab once reveals the skip link + // --------------------------------------------------------------------------- + describe('AC1: Pressing Tab reveals skip link', () => { + it('skip link is initially visually hidden', () => { + const { container } = render( + + ); + const link = container.querySelector('a'); + expect(link).toBeInTheDocument(); + + // Check that it's positioned off-screen initially + // Get computed style to verify off-screen positioning + window.getComputedStyle(link!); + // The link should have left: -100% or be absolutely positioned off-screen + expect(link).toHaveClass('absolute', '-left-full'); + }); + + it('skip link becomes visible when focused via Tab key', async () => { + const user = userEvent.setup(); + const { container } = render( + + ); + const link = container.querySelector('a') as HTMLElement; + + // Initially off-screen + expect(link).toHaveClass('-left-full'); + + // Tab to the link + await user.tab(); + + // Should be focused + expect(link).toHaveFocus(); + + // Should be visible (focus class applied) + expect(link).toHaveClass('focus:left-4'); + }); + + it('skip link is the first focusable element when Tab is pressed', async () => { + const user = userEvent.setup(); + const { container } = render( + + ); + const link = container.querySelector('a'); + + // Tab once - should focus the skip link + await user.tab(); + + expect(link).toHaveFocus(); + }); + }); + + // --------------------------------------------------------------------------- + // Acceptance Criteria 2: Activating skip link moves focus to main content + // --------------------------------------------------------------------------- + describe('AC2: Activating skip link moves focus to main content', () => { + it('pressing Enter activates the skip link and moves focus', async () => { + const user = userEvent.setup(); + const { container } = render( + + ); + const link = container.querySelector('a') as HTMLElement; + const target = document.getElementById(TARGET_ID) as HTMLElement; + + // Tab to skip link + await user.tab(); + expect(link).toHaveFocus(); + + // Press Enter + await user.keyboard('{Enter}'); + + // Focus should move to target + expect(target).toHaveFocus(); + }); + + it('pressing Space activates the skip link and moves focus', async () => { + const user = userEvent.setup(); + const { container } = render( + + ); + const link = container.querySelector('a') as HTMLElement; + const target = document.getElementById(TARGET_ID) as HTMLElement; + + // Tab to skip link + await user.tab(); + expect(link).toHaveFocus(); + + // Press Space + await user.keyboard(' '); + + // Focus should move to target + expect(target).toHaveFocus(); + }); + + it('clicking skip link moves focus to target', async () => { + const user = userEvent.setup(); + const { container } = render( + + ); + const link = container.querySelector('a') as HTMLElement; + const target = document.getElementById(TARGET_ID) as HTMLElement; + + // Click the link + await user.click(link); + + // Focus should move to target + expect(target).toHaveFocus(); + }); + }); + + // --------------------------------------------------------------------------- + // Acceptance Criteria 3: Link is invisible to mouse users + // --------------------------------------------------------------------------- + describe('AC3: Link is invisible to mouse users', () => { + it('skip link is not visible without keyboard focus', () => { + const { container } = render( + + ); + const link = container.querySelector('a') as HTMLElement; + + // Should be positioned off-screen + expect(link).toHaveClass('-left-full'); + + // Get computed style to verify off-screen positioning + const computedStyle = window.getComputedStyle(link); + // The left position should keep it off-screen (either -100% or absolute positioning) + expect(computedStyle.position).toBe('absolute'); + }); + + it('mouse hover does not reveal the skip link', async () => { + const user = userEvent.setup(); + const { container } = render( + + ); + const link = container.querySelector('a') as HTMLElement; + + // Hover over the link + await user.pointer({ keys: '[MouseOver]', target: link }); + + // Should still be off-screen (not visible) + expect(link).not.toHaveFocus(); + expect(link).toHaveClass('-left-full'); + }); + }); + + // --------------------------------------------------------------------------- + // Feature: Functional focus management + // --------------------------------------------------------------------------- + describe('Focus management', () => { + it('scrolls target into view when skip link is activated', async () => { + const user = userEvent.setup(); + const scrollIntoViewMock = vi.fn(); + + const target = document.getElementById(TARGET_ID) as HTMLElement; + target.scrollIntoView = scrollIntoViewMock; + + render( + + ); + + + // Tab to skip link and press Enter + await user.tab(); + await user.keyboard('{Enter}'); + + // scrollIntoView should have been called + expect(scrollIntoViewMock).toHaveBeenCalledWith({ + behavior: 'smooth', + block: 'start', + }); + }); + + it('renders with default label when not provided', () => { + const { container } = render(); + const link = container.querySelector('a'); + expect(link).toHaveTextContent('Skip to main content'); + }); + + it('renders with custom label when provided', () => { + const customLabel = 'Jump to creators'; + const { container } = render( + + ); + const link = container.querySelector('a'); + expect(link).toHaveTextContent(customLabel); + }); + + it('accepts custom className prop', () => { + const customClass = 'custom-skip-link'; + const { container } = render( + + ); + const link = container.querySelector('a'); + expect(link).toHaveClass(customClass); + }); + }); + + // --------------------------------------------------------------------------- + // Edge cases + // --------------------------------------------------------------------------- + describe('Edge cases', () => { + it('gracefully handles missing target element', async () => { + const user = userEvent.setup(); + const { container } = render( + + ); + const link = container.querySelector('a') as HTMLElement; + + // Tab to skip link + await user.tab(); + expect(link).toHaveFocus(); + + // Try to activate - should not throw error + await user.keyboard('{Enter}'); + expect(link).toHaveFocus(); // Focus stays on link since target doesn't exist + }); + + it('prevents default link behavior when activated', async () => { + const user = userEvent.setup(); + const { container } = render( + + ); + const link = container.querySelector('a') as HTMLElement; + + const preventDefaultSpy = vi.fn(); + link.addEventListener('click', preventDefaultSpy); + + // Tab and click + await user.tab(); + await user.keyboard('{Enter}'); + + // Default behavior should be prevented (no navigation) + expect(link.href).toBe(`${window.location.href}#${TARGET_ID}`); + }); + }); + + // --------------------------------------------------------------------------- + // Integration: LandingPage skip-to-content workflow + // --------------------------------------------------------------------------- + describe('LandingPage integration pattern', () => { + it('works as first focusable element with tabIndex=-1 target', async () => { + const user = userEvent.setup(); + + // Simulate LandingPage structure + const { container } = render( + <> + +
+ Creator list content +
+ + ); + + const skipLink = container.querySelector('a') as HTMLElement; + const creatorList = document.getElementById( + 'main-creator-list' + ) as HTMLElement; + + // Tab once - skip link focused + await user.tab(); + expect(skipLink).toHaveFocus(); + + // Press Enter - focus moves to creator list + await user.keyboard('{Enter}'); + expect(creatorList).toHaveFocus(); + expect(skipLink).not.toHaveFocus(); + }); + }); +}); diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx index c2f7453..86e0ca4 100644 --- a/src/pages/LandingPage.tsx +++ b/src/pages/LandingPage.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { courseService, type Course } from '@/services/course.service'; +import SkipToContent from '@/components/common/SkipToContent'; import { cn } from '@/lib/utils'; import SearchBar from '@/components/common/SearchBar'; import StickyFilterBar from '@/components/common/StickyFilterBar'; @@ -487,11 +488,12 @@ function LandingPage() { }; return ( - // #306: the outer wrapper is just a decorative shell; the actual - // landmark structure is a top-level
sibling of the
- // below, so screen-reader landmark navigation lands directly on the - // marketplace content rather than on the brand banner.
+ + {/* #306: the outer wrapper is just a decorative shell; the actual + landmark structure is a top-level
sibling of the
+ below, so screen-reader landmark navigation lands directly on the + marketplace content rather than on the brand banner. */}
@@ -574,7 +576,7 @@ function LandingPage() { - +