AgentSkillsCN

Accessibility Patterns

无障碍设计模式

SKILL.md

Accessibility Patterns

Skill Purpose: WCAG compliance implementation, screen reader support, and inclusive design patterns


Core Skill Pattern

Objective: Establish comprehensive accessibility strategy with WCAG compliance, screen reader support, and inclusive design practices.

Universal Pattern:

  1. Define accessibility standards and compliance requirements
  2. Create semantic HTML and ARIA implementation patterns
  3. Establish screen reader and keyboard navigation procedures
  4. Set up color contrast and visual accessibility validation
  5. Create accessibility testing and documentation procedures

Key Decisions (Project-Specific):

  • WCAG compliance level and target standards
  • Screen reader support strategy and testing approach
  • Keyboard navigation patterns and shortcuts
  • Color contrast requirements and validation tools
  • Accessibility testing framework and automation

Project-Specific Implementation Notes

Customize per project:

  • WCAG level based on compliance requirements (AA, AAA)
  • Screen reader support based on target user demographics
  • Keyboard navigation based on application complexity
  • Color contrast based on brand guidelines and accessibility
  • Testing depth based on criticality and resources

Example Implementation (Next.js Accessibility Pattern)

Note: This is an example pattern using WCAG 2.1 AA compliance with React/Next.js. Adapt accessibility standards and patterns based on your specific project requirements and compliance needs.

Prerequisites (Example)

  • Next.js project structure established
  • Accessibility requirements and compliance level defined
  • Screen reader testing tools configured
  • Color contrast validation tools selected

Example: Accessibility Implementation

Framework-Specific Example: This demonstrates accessibility patterns with Next.js and shadcn/ui. Adapt for your accessibility framework and component library requirements.

Accessibility Configuration

typescript
// lib/accessibility-config.ts
export interface AccessibilityConfig {
  wcagLevel: 'A' | 'AA' | 'AAA';
  language: string;
  skipNavigation: boolean;
  focusManagement: boolean;
  colorContrast: {
    normalText: number;
    largeText: number;
    interactive: number;
  };
  screenReader: {
    announcements: boolean;
    landmarks: boolean;
    descriptions: boolean;
  };
}

export const accessibilityConfig: AccessibilityConfig = {
  wcagLevel: 'AA',
  language: 'en',
  skipNavigation: true,
  focusManagement: true,
  colorContrast: {
    normalText: 4.5,
    largeText: 3.0,
    interactive: 3.0,
  },
  screenReader: {
    announcements: true,
    landmarks: true,
    descriptions: true,
  },
};

Semantic HTML Components

typescript
// components/ui/accessible-button.tsx
import React, { forwardRef } from 'react';
import { cn } from '@/lib/utils';
import { Slot } from '@radix-ui/react-slot';

interface AccessibleButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  asChild?: boolean;
  variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
  size?: 'default' | 'sm' | 'lg' | 'icon';
  'aria-label'?: string;
  'aria-describedby'?: string;
  'aria-expanded'?: boolean;
  'aria-pressed'?: boolean;
}

const AccessibleButton = forwardRef<HTMLButtonElement, AccessibleButtonProps>(
  ({ className, variant = 'default', size = 'default', asChild = false, ...props }, ref) => {
    const Comp = asChild ? Slot : 'button';
    
    return (
      <Comp
        className={cn(
          // Base styles with accessibility considerations
          'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium',
          'transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
          'disabled:pointer-events-none disabled:opacity-50',
          // High contrast focus indicators
          'focus:outline-none focus:ring-2 focus:ring-offset-2',
          {
            'bg-primary text-primary-foreground hover:bg-primary/90': variant === 'default',
            'bg-destructive text-destructive-foreground hover:bg-destructive/90': variant === 'destructive',
            'border border-input bg-background hover:bg-accent hover:text-accent-foreground': variant === 'outline',
            'bg-secondary text-secondary-foreground hover:bg-secondary/80': variant === 'secondary',
            'hover:bg-accent hover:text-accent-foreground': variant === 'ghost',
            'text-primary underline-offset-4 hover:underline': variant === 'link',
          },
          {
            'h-10 px-4 py-2': size === 'default',
            'h-9 rounded-md px-3': size === 'sm',
            'h-11 rounded-md px-8': size === 'lg',
            'h-10 w-10': size === 'icon',
          },
          className
        )}
        ref={ref}
        {...props}
      />
    );
  }
);

AccessibleButton.displayName = 'AccessibleButton';

export { AccessibleButton };

Skip Navigation Component

typescript
// components/ui/skip-navigation.tsx
import React from 'react';
import { AccessibleButton } from './accessible-button';

export function SkipNavigation() {
  return (
    <AccessibleButton
      asChild
      className="absolute left-0 top-0 -translate-x-full -translate-y-full bg-primary text-primary-foreground p-2 focus:translate-x-0 focus:translate-y-0 z-50"
      onClick={() => {
        const mainContent = document.getElementById('main-content');
        mainContent?.focus();
      }}
    >
      <a href="#main-content" className="no-underline">
        Skip to main content
      </a>
    </AccessibleButton>
  );
}

Accessible Form Components

typescript
// components/ui/accessible-input.tsx
import React, { forwardRef } from 'react';
import { cn } from '@/lib/utils';

interface AccessibleInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
  label: string;
  error?: string;
  helperText?: string;
  required?: boolean;
}

const AccessibleInput = forwardRef<HTMLInputElement, AccessibleInputProps>(
  ({ label, error, helperText, required, id, className, ...props }, ref) => {
    const inputId = id || `input-${React.useId()}`;
    const errorId = error ? `${inputId}-error` : undefined;
    const helperId = helperText ? `${inputId}-helper` : undefined;
    
    return (
      <div className="space-y-2">
        <label 
          htmlFor={inputId}
          className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
        >
          {label}
          {required && <span className="text-destructive ml-1" aria-label="required">*</span>}
        </label>
        
        <input
          id={inputId}
          ref={ref}
          className={cn(
            'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm',
            'ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium',
            'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2',
            'focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
            error && 'border-destructive focus-visible:ring-destructive',
            className
          )}
          aria-invalid={error ? 'true' : 'false'}
          aria-describedby={cn(errorId, helperId)}
          aria-required={required}
          {...props}
        />
        
        {error && (
          <p id={errorId} className="text-sm text-destructive" role="alert">
            {error}
          </p>
        )}
        
        {helperText && !error && (
          <p id={helperId} className="text-sm text-muted-foreground">
            {helperText}
          </p>
        )}
      </div>
    );
  }
);

AccessibleInput.displayName = 'AccessibleInput';

export { AccessibleInput };

Screen Reader Announcements

typescript
// lib/accessibility-announcer.ts
export class AccessibilityAnnouncer {
  private static announcementRegion: HTMLElement | null = null;
  
  static init() {
    // Create live region for screen reader announcements
    if (typeof window !== 'undefined' && !this.announcementRegion) {
      this.announcementRegion = document.createElement('div');
      this.announcementRegion.setAttribute('aria-live', 'polite');
      this.announcementRegion.setAttribute('aria-atomic', 'true');
      this.announcementRegion.className = 'sr-only';
      document.body.appendChild(this.announcementRegion);
    }
  }
  
  static announce(message: string, priority: 'polite' | 'assertive' = 'polite') {
    if (!this.announcementRegion) {
      this.init();
    }
    
    if (this.announcementRegion) {
      this.announcementRegion.setAttribute('aria-live', priority);
      this.announcementRegion.textContent = message;
      
      // Clear after announcement
      setTimeout(() => {
        if (this.announcementRegion) {
          this.announcementRegion.textContent = '';
        }
      }, 1000);
    }
  }
  
  static announcePageChange(pageTitle: string) {
    this.announce(`Navigated to ${pageTitle}`, 'assertive');
  }
  
  static announceError(error: string) {
    this.announce(`Error: ${error}`, 'assertive');
  }
  
  static announceSuccess(message: string) {
    this.announce(`Success: ${message}`, 'polite');
  }
}

Focus Management

typescript
// lib/focus-management.ts
export class FocusManager {
  static trapFocus(container: HTMLElement) {
    const focusableElements = container.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    ) as NodeListOf<HTMLElement>;
    
    const firstElement = focusableElements[0];
    const lastElement = focusableElements[focusableElements.length - 1];
    
    const handleTabKey = (e: KeyboardEvent) => {
      if (e.key === 'Tab') {
        if (e.shiftKey) {
          if (document.activeElement === firstElement) {
            lastElement.focus();
            e.preventDefault();
          }
        } else {
          if (document.activeElement === lastElement) {
            firstElement.focus();
            e.preventDefault();
          }
        }
      }
    };
    
    container.addEventListener('keydown', handleTabKey);
    
    // Return cleanup function
    return () => {
      container.removeEventListener('keydown', handleTabKey);
    };
  }
  
  static setInitialFocus(container: HTMLElement) {
    const focusableElements = container.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    ) as NodeListOf<HTMLElement>;
    
    if (focusableElements.length > 0) {
      focusableElements[0].focus();
    }
  }
  
  static restoreFocus(previousElement: HTMLElement | null) {
    if (previousElement) {
      previousElement.focus();
    }
  }
}

Color Contrast Validation

typescript
// lib/color-contrast.ts
export class ColorContrastValidator {
  static hexToRgb(hex: string): { r: number; g: number; b: number } | null {
    const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
    return result ? {
      r: parseInt(result[1], 16),
      g: parseInt(result[2], 16),
      b: parseInt(result[3], 16)
    } : null;
  }
  
  static getLuminance(r: number, g: number, b: number): number {
    const [rs, gs, bs] = [r, g, b].map(c => {
      c = c / 255;
      return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
    });
    return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
  }
  
  static getContrastRatio(color1: string, color2: string): number {
    const rgb1 = this.hexToRgb(color1);
    const rgb2 = this.hexToRgb(color2);
    
    if (!rgb1 || !rgb2) return 0;
    
    const lum1 = this.getLuminance(rgb1.r, rgb1.g, rgb1.b);
    const lum2 = this.getLuminance(rgb2.r, rgb2.g, rgb2.b);
    
    const brightest = Math.max(lum1, lum2);
    const darkest = Math.min(lum1, lum2);
    
    return (brightest + 0.05) / (darkest + 0.05);
  }
  
  static validateContrast(
    foreground: string, 
    background: string, 
    isLargeText: boolean = false
  ): { valid: boolean; ratio: number; required: number } {
    const ratio = this.getContrastRatio(foreground, background);
    const required = isLargeText ? 3.0 : 4.5;
    
    return {
      valid: ratio >= required,
      ratio: Math.round(ratio * 100) / 100,
      required
    };
  }
}

Accessibility Testing Patterns

typescript
// __tests__/accessibility/accessibility.test.tsx
import { render, screen } from '@testing-library/react';
import { AccessibleButton } from '@/components/ui/accessible-button';
import userEvent from '@testing-library/user-event';

describe('Accessibility Components', () => {
  describe('AccessibleButton', () => {
    it('should have proper ARIA attributes', () => {
      render(
        <AccessibleButton 
          aria-label="Close dialog" 
          aria-expanded={false}
        >
          ×
        </AccessibleButton>
      );
      
      const button = screen.getByRole('button');
      expect(button).toHaveAttribute('aria-label', 'Close dialog');
      expect(button).toHaveAttribute('aria-expanded', 'false');
    });
    
    it('should be keyboard accessible', async () => {
      const handleClick = jest.fn();
      render(<AccessibleButton onClick={handleClick}>Click me</AccessibleButton>);
      
      const button = screen.getByRole('button');
      button.focus();
      expect(button).toHaveFocus();
      
      await userEvent.keyboard('{Enter}');
      expect(handleClick).toHaveBeenCalled();
      
      handleClick.mockClear();
      await userEvent.keyboard(' ');
      expect(handleClick).toHaveBeenCalled();
    });
    
    it('should have visible focus indicators', () => {
      render(<AccessibleButton>Test</AccessibleButton>);
      
      const button = screen.getByRole('button');
      expect(button).toHaveClass('focus-visible:outline-none');
      expect(button).toHaveClass('focus-visible:ring-2');
    });
  });
});

Best Practices

  1. Semantic HTML - Use proper HTML elements for their intended purpose
  2. ARIA attributes - Use ARIA to enhance, not replace, semantic HTML
  3. Keyboard navigation - Ensure all interactive elements are keyboard accessible
  4. Focus management - Provide clear focus indicators and logical tab order
  5. Color contrast - Meet WCAG AA contrast ratios for text and interactive elements

Stop Conditions

STOP and escalate if:

  • Accessibility requirements unclear or incomplete
  • WCAG compliance level not defined
  • Screen reader testing strategy missing
  • Color contrast validation not implemented

Skill Version: 1.0.0