diff --git a/jest.config.cjs b/jest.config.cjs index 629d9f4d..6b083813 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -21,7 +21,6 @@ const customJestConfig = { '/node_modules/(?!.*(wagmi|viem|@wagmi|@viem|@walletconnect|@metamask|@coinbase|@radix-ui|@storybook))' ], testPathIgnorePatterns: [ - '/src/components/responsive/__tests__/ResponsiveContainer.test.tsx', '/src/lib/__tests__/mobile-optimizer.test.ts', '/src/lib/__tests__/verify-performance-monitoring.ts' ], diff --git a/src/components/responsive/ResponsiveContainer.tsx b/src/components/responsive/ResponsiveContainer.tsx index 0e1f61e7..6d5201d8 100644 --- a/src/components/responsive/ResponsiveContainer.tsx +++ b/src/components/responsive/ResponsiveContainer.tsx @@ -1,15 +1,27 @@ /** * ResponsiveContainer Component - * + * * A container component with responsive padding that scales with viewport size. * Provides consistent padding across all pages with fluid scaling between breakpoints. - * + * + * Accessibility (WCAG 2.1): + * - Supports polymorphic `as` prop so consumers can render semantic HTML elements + * (main, section, article, aside, header, footer, nav) instead of a plain
. + * - Accepts `aria-label`, `aria-labelledby`, and `aria-describedby` to give + * screen readers a meaningful context for landmark / sectioning elements. + * - Accepts `role` so consumers can assign explicit ARIA landmark roles when a + * native semantic element is not appropriate. + * - Forwards all standard HTML div attributes so keyboard-navigation props + * (tabIndex, onKeyDown, etc.) flow through without extra wrapping. + * - The `data-testid` attribute defaults to `"responsive-container"` to make + * the element easily targetable in automated accessibility tests. + * * Features: * - Responsive padding: 16px (mobile) → 24px (tablet) → 32px (desktop) * - Fluid scaling between breakpoints using CSS clamp() * - Uses Viewport Provider for viewport detection * - Supports custom className for additional styling - * + * * Requirements: 3.1, 3.2, 8.5 */ @@ -19,27 +31,91 @@ import React from 'react'; import { useViewport } from '@/providers/ViewportProvider'; import { cn } from '@/lib/utils'; -export interface ResponsiveContainerProps { +// --------------------------------------------------------------------------- +// Allowed semantic element types for the polymorphic `as` prop +// --------------------------------------------------------------------------- +type AllowedElement = + | 'div' + | 'main' + | 'section' + | 'article' + | 'aside' + | 'header' + | 'footer' + | 'nav'; + +export interface ResponsiveContainerProps + extends React.HTMLAttributes { children: React.ReactNode; className?: string; + + /** + * Render as a semantic HTML element instead of a plain
. + * Defaults to "div". + * + * @example + * + * ... + * + */ + as?: AllowedElement; + + /** + * ARIA role override. Useful when `as="div"` but a landmark role is needed. + * Prefer using the `as` prop with a native semantic element where possible, + * as that provides better screen-reader compatibility. + */ + role?: React.AriaRole; + + /** + * Accessible label for this container region. + * Required (or `aria-labelledby`) when the container is rendered as a + * landmark element (main, nav, section, aside, etc.) so that screen readers + * can distinguish multiple regions of the same type. + */ + 'aria-label'?: string; + + /** References the id of an element that labels this container region. */ + 'aria-labelledby'?: string; + + /** References the id of an element that describes this container region. */ + 'aria-describedby'?: string; + + /** data-testid for automated testing. Defaults to "responsive-container". */ + 'data-testid'?: string; } /** * ResponsiveContainer provides consistent, viewport-aware padding - * + * * Padding scales: * - Mobile (<768px): 16px * - Tablet (768-1024px): 24px (fluid scaling) * - Desktop (≥1024px): 32px - * - * @example + * + * @example Basic usage * ```tsx * *

Page Content

*

This content has responsive padding

*
* ``` - * + * + * @example Semantic landmark — accessible main content region + * ```tsx + * + *

Welcome

+ *
+ * ``` + * + * @example Navigation region + * ```tsx + * + * Home + * Properties + * + * ``` + * * @example With custom className * ```tsx * @@ -50,11 +126,13 @@ export interface ResponsiveContainerProps { export const ResponsiveContainer: React.FC = ({ children, className, + as: Element = 'div', + 'data-testid': testId = 'responsive-container', + ...rest }) => { const { category } = useViewport(); // Calculate padding based on viewport category - // Using inline styles for precise control, but could also use Tailwind classes const getPadding = (): string => { switch (category) { case 'mobile': @@ -69,7 +147,7 @@ export const ResponsiveContainer: React.FC = ({ }; return ( -
= ({ maxWidth: '100%', boxSizing: 'border-box', }} + data-testid={testId} + {...rest} > {children} -
+ ); }; +// --------------------------------------------------------------------------- +// Fluid variant +// --------------------------------------------------------------------------- + +export interface ResponsiveContainerFluidProps + extends React.HTMLAttributes { + children: React.ReactNode; + className?: string; + + /** + * Render as a semantic HTML element. Defaults to "div". + */ + as?: AllowedElement; + + role?: React.AriaRole; + 'aria-label'?: string; + 'aria-labelledby'?: string; + 'aria-describedby'?: string; + + /** data-testid for automated testing. Defaults to "responsive-container-fluid". */ + 'data-testid'?: string; +} + /** - * Alternative implementation using CSS clamp() for fluid scaling - * This version provides smooth scaling between breakpoints + * Alternative implementation using CSS clamp() for fluid scaling. + * This version provides smooth scaling between breakpoints. + * + * @example + * ```tsx + * + *

Section Heading

+ *

Content with fluid responsive padding

+ *
+ * ``` */ -export const ResponsiveContainerFluid: React.FC = ({ +export const ResponsiveContainerFluid: React.FC< + ResponsiveContainerFluidProps +> = ({ children, className, + as: Element = 'div', + 'data-testid': testId = 'responsive-container-fluid', + ...rest }) => { return ( -
{children} -
+ ); }; diff --git a/src/components/responsive/ResponsiveContainerExample.tsx b/src/components/responsive/ResponsiveContainerExample.tsx index c5c05a1c..88fa553e 100644 --- a/src/components/responsive/ResponsiveContainerExample.tsx +++ b/src/components/responsive/ResponsiveContainerExample.tsx @@ -1,7 +1,8 @@ /** * ResponsiveContainer Usage Examples - * - * Demonstrates various use cases for the ResponsiveContainer component + * + * Demonstrates various use cases for the ResponsiveContainer component, + * including accessibility best-practices (semantic elements, ARIA labels). */ 'use client'; @@ -29,8 +30,14 @@ export function BasicContainerExample() { */ export function StyledContainerExample() { return ( - -

Property Details

+ +

+ Property Details +

Location: San Francisco, CA

Price: $1,200,000

@@ -46,17 +53,33 @@ export function StyledContainerExample() { */ export function NestedContainersExample() { return ( - -

Dashboard

- + +

+ Dashboard +

+
- -

Statistics

+ +

Statistics

Total Properties: 42

- - -

Recent Activity

+ + +

+ Recent Activity +

Last updated: 5 minutes ago

@@ -69,11 +92,18 @@ export function NestedContainersExample() { */ export function FluidContainerExample() { return ( - -

Fluid Padding Container

+ +

+ Fluid Padding Container +

This container uses CSS clamp() for smooth, fluid padding that scales - continuously with the viewport width. Resize your browser to see the effect! + continuously with the viewport width. Resize your browser to see the + effect!

Padding scales from 16px to 32px using: clamp(16px, 4vw, 32px) @@ -83,44 +113,52 @@ export function FluidContainerExample() { } /** - * Full page layout example + * Full page layout example — uses semantic landmark elements throughout */ export function PageLayoutExample() { return (

- {/* Header */} - -
-

PropChain

-
+
- {/* Main content */} - -
-

Featured Properties

-
- {[1, 2, 3].map((i) => ( -
-
-

Property {i}

-

$500,000

-
- ))} -
-
+ {/* Main content landmark */} + +

Featured Properties

+
+ {[1, 2, 3].map((i) => ( +
+
+ ))} +
- {/* Footer */} - -
+ {/* Footer landmark */} + +

© 2024 PropChain. All rights reserved.

-
+
); @@ -133,9 +171,15 @@ export function ComparisonExample() { return (
-

Step-based Container (Default)

- -

+

+ Step-based Container (Default) +

+ +

This container uses step-based padding that changes at breakpoints: 16px (mobile) → 24px (tablet) → 32px (desktop)

@@ -144,10 +188,14 @@ export function ComparisonExample() {

Fluid Container

- -

- This container uses fluid padding that scales smoothly with viewport width - using CSS clamp(16px, 4vw, 32px) + +

+ This container uses fluid padding that scales smoothly with viewport + width using CSS clamp(16px, 4vw, 32px)

@@ -168,35 +216,48 @@ export default function ResponsiveContainerDemo() {

-
+
-

Basic Usage

+

+ Basic Usage +

-
+
-

Styled Container

+

+ Styled Container +

-
+
-

Fluid vs Step-based

+

+ Fluid vs Step-based +

-
+
-

Nested Containers

+

+ Nested Containers +

-
+
+ +

+ Fluid Container +

+
diff --git a/src/components/responsive/__tests__/ResponsiveContainer.test.tsx b/src/components/responsive/__tests__/ResponsiveContainer.test.tsx new file mode 100644 index 00000000..f32e3633 --- /dev/null +++ b/src/components/responsive/__tests__/ResponsiveContainer.test.tsx @@ -0,0 +1,549 @@ +/** + * Accessibility tests for ResponsiveContainer and ResponsiveContainerFluid + * + * Issue #341 — WCAG 2.1 review covering: + * - Semantic HTML / landmark elements via the `as` prop + * - ARIA attributes: aria-label, aria-labelledby, aria-describedby, role + * - Screen reader label propagation + * - Keyboard navigation passthrough (tabIndex, onKeyDown) + * - data-testid defaults + * - No console.log calls in production code + * - Responsive padding per viewport category + * - Fluid variant behaviour + */ + +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; + +import { + ResponsiveContainer, + ResponsiveContainerFluid, + type ResponsiveContainerProps, +} from '../ResponsiveContainer'; + +// --------------------------------------------------------------------------- +// Mock ViewportProvider so we control the viewport category in tests +// --------------------------------------------------------------------------- +const mockUseViewport = jest.fn(); + +jest.mock('@/providers/ViewportProvider', () => ({ + useViewport: () => mockUseViewport(), +})); + +// --------------------------------------------------------------------------- +// Helper — render with a controlled viewport category +// --------------------------------------------------------------------------- +function renderWithViewport( + ui: React.ReactElement, + category: 'mobile' | 'tablet' | 'desktop' = 'mobile' +) { + mockUseViewport.mockReturnValue({ category }); + return render(ui); +} + +// --------------------------------------------------------------------------- +// ResponsiveContainer — default rendering +// --------------------------------------------------------------------------- + +describe('ResponsiveContainer — default rendering', () => { + beforeEach(() => { + mockUseViewport.mockReturnValue({ category: 'mobile' }); + }); + + it('renders children', () => { + render(Hello world); + expect(screen.getByText('Hello world')).toBeInTheDocument(); + }); + + it('renders as a
by default', () => { + const { container } = render( + Content + ); + expect(container.firstChild?.nodeName).toBe('DIV'); + }); + + it('has data-testid="responsive-container" by default', () => { + render(Content); + expect(screen.getByTestId('responsive-container')).toBeInTheDocument(); + }); + + it('accepts a custom data-testid', () => { + render( + Content + ); + expect(screen.getByTestId('my-container')).toBeInTheDocument(); + }); + + it('merges additional className with base class', () => { + render( + Content + ); + const el = screen.getByTestId('responsive-container'); + expect(el).toHaveClass('responsive-container'); + expect(el).toHaveClass('bg-gray-100'); + }); +}); + +// --------------------------------------------------------------------------- +// ResponsiveContainer — responsive padding +// --------------------------------------------------------------------------- + +describe('ResponsiveContainer — responsive padding', () => { + it('applies 16px padding on mobile', () => { + renderWithViewport( + Content, + 'mobile' + ); + expect(screen.getByTestId('responsive-container')).toHaveStyle({ + padding: '16px', + }); + }); + + it('applies 24px padding on tablet', () => { + renderWithViewport( + Content, + 'tablet' + ); + expect(screen.getByTestId('responsive-container')).toHaveStyle({ + padding: '24px', + }); + }); + + it('applies 32px padding on desktop', () => { + renderWithViewport( + Content, + 'desktop' + ); + expect(screen.getByTestId('responsive-container')).toHaveStyle({ + padding: '32px', + }); + }); + + it('falls back to 16px for unknown viewport category', () => { + mockUseViewport.mockReturnValue({ category: 'unknown' }); + render(Content); + expect(screen.getByTestId('responsive-container')).toHaveStyle({ + padding: '16px', + }); + }); + + it('sets maxWidth: 100% and boxSizing: border-box', () => { + renderWithViewport( + Content, + 'desktop' + ); + const el = screen.getByTestId('responsive-container'); + expect(el).toHaveStyle({ maxWidth: '100%', boxSizing: 'border-box' }); + }); +}); + +// --------------------------------------------------------------------------- +// ResponsiveContainer — polymorphic `as` prop (WCAG: semantic landmarks) +// --------------------------------------------------------------------------- + +describe('ResponsiveContainer — polymorphic `as` prop', () => { + beforeEach(() => { + mockUseViewport.mockReturnValue({ category: 'desktop' }); + }); + + const semanticElements: Array< + 'div' | 'main' | 'section' | 'article' | 'aside' | 'header' | 'footer' | 'nav' + > = ['div', 'main', 'section', 'article', 'aside', 'header', 'footer', 'nav']; + + semanticElements.forEach((tag) => { + it(`renders as <${tag}> when as="${tag}"`, () => { + const { container } = render( + Content + ); + expect(container.firstChild?.nodeName).toBe(tag.toUpperCase()); + }); + }); + + it('renders a
landmark accessible to screen readers', () => { + render( + + Content + + ); + expect(screen.getByRole('main', { name: 'Main content' })).toBeInTheDocument(); + }); + + it('renders a