From 3751c0a50493225a068ca53275000803b69484e0 Mon Sep 17 00:00:00 2001 From: Marvell69 Date: Thu, 28 May 2026 14:33:05 +0100 Subject: [PATCH 1/2] Add SkipToContent component for improved keyboard accessibility --- src/components/common/SkipToContent.tsx | 74 +++++ .../common/__tests__/SkipToContent.test.tsx | 303 ++++++++++++++++++ src/pages/LandingPage.tsx | 5 +- 3 files changed, 380 insertions(+), 2 deletions(-) create mode 100644 src/components/common/SkipToContent.tsx create mode 100644 src/components/common/__tests__/SkipToContent.test.tsx 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..dc71bc4 --- /dev/null +++ b/src/components/common/__tests__/SkipToContent.test.tsx @@ -0,0 +1,303 @@ +import { render, screen } 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 + const style = 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; + + const { container } = render( + + ); + const link = container.querySelector('a') as HTMLElement; + + // 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 37cff2b..5c16fd1 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 SearchBar from '@/components/common/SearchBar'; import StickyFilterBar from '@/components/common/StickyFilterBar'; import CreatorCard from '@/components/common/CreatorCard'; @@ -392,6 +393,7 @@ function LandingPage() { return (
+
@@ -467,8 +469,7 @@ function LandingPage() { - - title="Explore creators" supportingText="Discover creator profiles and marketplace listings." className="mb-7" From 11626fa946e1b1e805b08426242b1e36ad4ffd4d Mon Sep 17 00:00:00 2001 From: Marvell69 Date: Thu, 28 May 2026 18:51:33 +0100 Subject: [PATCH 2/2] feat: add accessibility skip-to-content feature and implement on LandingPage --- .../common/__tests__/SkipToContent.test.tsx | 9 +++++---- src/pages/LandingPage.tsx | 12 ++++++------ 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/components/common/__tests__/SkipToContent.test.tsx b/src/components/common/__tests__/SkipToContent.test.tsx index dc71bc4..17e222c 100644 --- a/src/components/common/__tests__/SkipToContent.test.tsx +++ b/src/components/common/__tests__/SkipToContent.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react'; +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'; @@ -35,7 +35,8 @@ describe('SkipToContent: Keyboard Accessibility', () => { expect(link).toBeInTheDocument(); // Check that it's positioned off-screen initially - const style = window.getComputedStyle(link!); + // 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'); }); @@ -178,10 +179,10 @@ describe('SkipToContent: Keyboard Accessibility', () => { const target = document.getElementById(TARGET_ID) as HTMLElement; target.scrollIntoView = scrollIntoViewMock; - const { container } = render( + render( ); - const link = container.querySelector('a') as HTMLElement; + // Tab to skip link and press Enter await user.tab(); diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx index 3453fc2..1c72734 100644 --- a/src/pages/LandingPage.tsx +++ b/src/pages/LandingPage.tsx @@ -490,13 +490,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. */}
@@ -577,6 +576,7 @@ function LandingPage() { +