mirror of
https://github.com/wshobson/agents.git
synced 2026-03-18 09:37:15 +00:00
style: format all files with prettier
This commit is contained in:
@@ -21,30 +21,35 @@ Master accessibility implementation to create inclusive experiences that work fo
|
||||
## Core Capabilities
|
||||
|
||||
### 1. WCAG 2.2 Guidelines
|
||||
|
||||
- Perceivable: Content must be presentable in different ways
|
||||
- Operable: Interface must be navigable with keyboard and assistive tech
|
||||
- Understandable: Content and operation must be clear
|
||||
- Robust: Content must work with current and future assistive technologies
|
||||
|
||||
### 2. ARIA Patterns
|
||||
|
||||
- Roles: Define element purpose (button, dialog, navigation)
|
||||
- States: Indicate current condition (expanded, selected, disabled)
|
||||
- Properties: Describe relationships and additional info (labelledby, describedby)
|
||||
- Live regions: Announce dynamic content changes
|
||||
|
||||
### 3. Keyboard Navigation
|
||||
|
||||
- Focus order and tab sequence
|
||||
- Focus indicators and visible focus states
|
||||
- Keyboard shortcuts and hotkeys
|
||||
- Focus trapping for modals and dialogs
|
||||
|
||||
### 4. Screen Reader Support
|
||||
|
||||
- Semantic HTML structure
|
||||
- Alternative text for images
|
||||
- Proper heading hierarchy
|
||||
- Skip links and landmarks
|
||||
|
||||
### 5. Mobile Accessibility
|
||||
|
||||
- Touch target sizing (44x44dp minimum)
|
||||
- VoiceOver and TalkBack compatibility
|
||||
- Gesture alternatives
|
||||
@@ -54,18 +59,18 @@ Master accessibility implementation to create inclusive experiences that work fo
|
||||
|
||||
### WCAG 2.2 Success Criteria Checklist
|
||||
|
||||
| Level | Criterion | Description |
|
||||
|-------|-----------|-------------|
|
||||
| A | 1.1.1 | Non-text content has text alternatives |
|
||||
| A | 1.3.1 | Info and relationships programmatically determinable |
|
||||
| A | 2.1.1 | All functionality keyboard accessible |
|
||||
| A | 2.4.1 | Skip to main content mechanism |
|
||||
| AA | 1.4.3 | Contrast ratio 4.5:1 (text), 3:1 (large text) |
|
||||
| AA | 1.4.11 | Non-text contrast 3:1 |
|
||||
| AA | 2.4.7 | Focus visible |
|
||||
| AA | 2.5.8 | Target size minimum 24x24px (NEW in 2.2) |
|
||||
| AAA | 1.4.6 | Enhanced contrast 7:1 |
|
||||
| AAA | 2.5.5 | Target size minimum 44x44px |
|
||||
| Level | Criterion | Description |
|
||||
| ----- | --------- | ---------------------------------------------------- |
|
||||
| A | 1.1.1 | Non-text content has text alternatives |
|
||||
| A | 1.3.1 | Info and relationships programmatically determinable |
|
||||
| A | 2.1.1 | All functionality keyboard accessible |
|
||||
| A | 2.4.1 | Skip to main content mechanism |
|
||||
| AA | 1.4.3 | Contrast ratio 4.5:1 (text), 3:1 (large text) |
|
||||
| AA | 1.4.11 | Non-text contrast 3:1 |
|
||||
| AA | 2.4.7 | Focus visible |
|
||||
| AA | 2.5.8 | Target size minimum 24x24px (NEW in 2.2) |
|
||||
| AAA | 1.4.6 | Enhanced contrast 7:1 |
|
||||
| AAA | 2.5.5 | Target size minimum 44x44px |
|
||||
|
||||
## Key Patterns
|
||||
|
||||
@@ -73,13 +78,13 @@ Master accessibility implementation to create inclusive experiences that work fo
|
||||
|
||||
```tsx
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary';
|
||||
variant?: "primary" | "secondary";
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
function AccessibleButton({
|
||||
children,
|
||||
variant = 'primary',
|
||||
variant = "primary",
|
||||
isLoading = false,
|
||||
disabled,
|
||||
...props
|
||||
@@ -94,11 +99,11 @@ function AccessibleButton({
|
||||
aria-disabled={disabled || isLoading}
|
||||
className={cn(
|
||||
// Visible focus ring
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
|
||||
// Minimum touch target size (44x44px)
|
||||
'min-h-[44px] min-w-[44px]',
|
||||
variant === 'primary' && 'bg-primary text-primary-foreground',
|
||||
(disabled || isLoading) && 'opacity-50 cursor-not-allowed'
|
||||
"min-h-[44px] min-w-[44px]",
|
||||
variant === "primary" && "bg-primary text-primary-foreground",
|
||||
(disabled || isLoading) && "opacity-50 cursor-not-allowed",
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -118,8 +123,8 @@ function AccessibleButton({
|
||||
### Pattern 2: Accessible Modal Dialog
|
||||
|
||||
```tsx
|
||||
import * as React from 'react';
|
||||
import { FocusTrap } from '@headlessui/react';
|
||||
import * as React from "react";
|
||||
import { FocusTrap } from "@headlessui/react";
|
||||
|
||||
interface DialogProps {
|
||||
isOpen: boolean;
|
||||
@@ -135,21 +140,21 @@ function AccessibleDialog({ isOpen, onClose, title, children }: DialogProps) {
|
||||
// Close on Escape key
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isOpen) {
|
||||
if (e.key === "Escape" && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Prevent body scroll when open
|
||||
React.useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.body.style.overflow = "hidden";
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
@@ -227,7 +232,9 @@ function AccessibleForm() {
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="email" className="block font-medium">
|
||||
Email address
|
||||
<span aria-hidden="true" className="text-destructive ml-1">*</span>
|
||||
<span aria-hidden="true" className="text-destructive ml-1">
|
||||
*
|
||||
</span>
|
||||
<span className="sr-only">(required)</span>
|
||||
</label>
|
||||
<input
|
||||
@@ -237,10 +244,10 @@ function AccessibleForm() {
|
||||
required
|
||||
aria-required="true"
|
||||
aria-invalid={!!errors.email}
|
||||
aria-describedby={errors.email ? 'email-error' : 'email-hint'}
|
||||
aria-describedby={errors.email ? "email-error" : "email-hint"}
|
||||
className={cn(
|
||||
'w-full px-3 py-2 border rounded-md',
|
||||
errors.email && 'border-destructive'
|
||||
"w-full px-3 py-2 border rounded-md",
|
||||
errors.email && "border-destructive",
|
||||
)}
|
||||
/>
|
||||
{errors.email ? (
|
||||
@@ -271,10 +278,10 @@ function SkipLink() {
|
||||
href="#main-content"
|
||||
className={cn(
|
||||
// Hidden by default, visible on focus
|
||||
'sr-only focus:not-sr-only',
|
||||
'focus:absolute focus:top-4 focus:left-4 focus:z-50',
|
||||
'focus:bg-background focus:px-4 focus:py-2 focus:rounded-md',
|
||||
'focus:ring-2 focus:ring-primary'
|
||||
"sr-only focus:not-sr-only",
|
||||
"focus:absolute focus:top-4 focus:left-4 focus:z-50",
|
||||
"focus:bg-background focus:px-4 focus:py-2 focus:rounded-md",
|
||||
"focus:ring-2 focus:ring-primary",
|
||||
)}
|
||||
>
|
||||
Skip to main content
|
||||
@@ -302,12 +309,15 @@ function Layout({ children }) {
|
||||
|
||||
```tsx
|
||||
function useAnnounce() {
|
||||
const [message, setMessage] = React.useState('');
|
||||
const [message, setMessage] = React.useState("");
|
||||
|
||||
const announce = React.useCallback((text: string, priority: 'polite' | 'assertive' = 'polite') => {
|
||||
setMessage(''); // Clear first to ensure re-announcement
|
||||
setTimeout(() => setMessage(text), 100);
|
||||
}, []);
|
||||
const announce = React.useCallback(
|
||||
(text: string, priority: "polite" | "assertive" = "polite") => {
|
||||
setMessage(""); // Clear first to ensure re-announcement
|
||||
setTimeout(() => setMessage(text), 100);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const Announcer = () => (
|
||||
<div
|
||||
|
||||
@@ -73,7 +73,7 @@ function Accordion({ items }) {
|
||||
onClick={() => setOpenIndex(isOpen ? -1 : index)}
|
||||
>
|
||||
{item.title}
|
||||
<span aria-hidden="true">{isOpen ? '−' : '+'}</span>
|
||||
<span aria-hidden="true">{isOpen ? "−" : "+"}</span>
|
||||
</button>
|
||||
</h3>
|
||||
<div
|
||||
@@ -103,16 +103,16 @@ function Tabs({ tabs }) {
|
||||
let newIndex = index;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowRight':
|
||||
case "ArrowRight":
|
||||
newIndex = (index + 1) % tabs.length;
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
case "ArrowLeft":
|
||||
newIndex = (index - 1 + tabs.length) % tabs.length;
|
||||
break;
|
||||
case 'Home':
|
||||
case "Home":
|
||||
newIndex = 0;
|
||||
break;
|
||||
case 'End':
|
||||
case "End":
|
||||
newIndex = tabs.length - 1;
|
||||
break;
|
||||
default:
|
||||
@@ -172,7 +172,7 @@ function MenuButton({ label, items }) {
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
if (!isOpen) {
|
||||
setIsOpen(true);
|
||||
@@ -181,16 +181,16 @@ function MenuButton({ label, items }) {
|
||||
setActiveIndex((prev) => Math.min(prev + 1, items.length - 1));
|
||||
}
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
setActiveIndex((prev) => Math.max(prev - 1, 0));
|
||||
break;
|
||||
case 'Escape':
|
||||
case "Escape":
|
||||
setIsOpen(false);
|
||||
buttonRef.current?.focus();
|
||||
break;
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
case "Enter":
|
||||
case " ":
|
||||
if (isOpen && activeIndex >= 0) {
|
||||
e.preventDefault();
|
||||
items[activeIndex].onClick();
|
||||
@@ -253,36 +253,36 @@ function MenuButton({ label, items }) {
|
||||
|
||||
```tsx
|
||||
function Combobox({ options, onSelect, placeholder }) {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const inputRef = useRef(null);
|
||||
const listboxId = useId();
|
||||
|
||||
const filteredOptions = options.filter((opt) =>
|
||||
opt.toLowerCase().includes(inputValue.toLowerCase())
|
||||
opt.toLowerCase().includes(inputValue.toLowerCase()),
|
||||
);
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
setIsOpen(true);
|
||||
setActiveIndex((prev) =>
|
||||
Math.min(prev + 1, filteredOptions.length - 1)
|
||||
Math.min(prev + 1, filteredOptions.length - 1),
|
||||
);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
setActiveIndex((prev) => Math.max(prev - 1, 0));
|
||||
break;
|
||||
case 'Enter':
|
||||
case "Enter":
|
||||
if (activeIndex >= 0) {
|
||||
e.preventDefault();
|
||||
selectOption(filteredOptions[activeIndex]);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
case "Escape":
|
||||
setIsOpen(false);
|
||||
setActiveIndex(-1);
|
||||
break;
|
||||
@@ -396,16 +396,16 @@ function Toolbar({ items }) {
|
||||
let newIndex = activeIndex;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowRight':
|
||||
case "ArrowRight":
|
||||
newIndex = (activeIndex + 1) % items.length;
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
case "ArrowLeft":
|
||||
newIndex = (activeIndex - 1 + items.length) % items.length;
|
||||
break;
|
||||
case 'Home':
|
||||
case "Home":
|
||||
newIndex = 0;
|
||||
break;
|
||||
case 'End':
|
||||
case "End":
|
||||
newIndex = items.length - 1;
|
||||
break;
|
||||
default:
|
||||
@@ -414,7 +414,7 @@ function Toolbar({ items }) {
|
||||
|
||||
e.preventDefault();
|
||||
setActiveIndex(newIndex);
|
||||
toolbarRef.current?.querySelectorAll('button')[newIndex]?.focus();
|
||||
toolbarRef.current?.querySelectorAll("button")[newIndex]?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -34,12 +34,11 @@ Mobile accessibility ensures apps work for users with disabilities on iOS and An
|
||||
// Ensure adequate spacing between touch targets
|
||||
function ButtonGroup({ buttons }) {
|
||||
return (
|
||||
<div className="flex gap-3"> {/* 12px minimum gap */}
|
||||
<div className="flex gap-3">
|
||||
{" "}
|
||||
{/* 12px minimum gap */}
|
||||
{buttons.map((btn) => (
|
||||
<button
|
||||
key={btn.id}
|
||||
className="min-w-[44px] min-h-[44px] px-4 py-2"
|
||||
>
|
||||
<button key={btn.id} className="min-w-[44px] min-h-[44px] px-4 py-2">
|
||||
{btn.label}
|
||||
</button>
|
||||
))}
|
||||
@@ -66,7 +65,7 @@ function IconButton({ icon, label, onClick }) {
|
||||
### React Native Accessibility Props
|
||||
|
||||
```tsx
|
||||
import { View, Text, TouchableOpacity, AccessibilityInfo } from 'react-native';
|
||||
import { View, Text, TouchableOpacity, AccessibilityInfo } from "react-native";
|
||||
|
||||
// Basic accessible button
|
||||
function AccessibleButton({ onPress, title, hint }) {
|
||||
@@ -91,15 +90,15 @@ function ProductCard({ product }) {
|
||||
accessibilityLabel={`${product.name}, ${product.price}, ${product.rating} stars`}
|
||||
accessibilityRole="button"
|
||||
accessibilityActions={[
|
||||
{ name: 'activate', label: 'View details' },
|
||||
{ name: 'addToCart', label: 'Add to cart' },
|
||||
{ name: "activate", label: "View details" },
|
||||
{ name: "addToCart", label: "Add to cart" },
|
||||
]}
|
||||
onAccessibilityAction={(event) => {
|
||||
switch (event.nativeEvent.actionName) {
|
||||
case 'addToCart':
|
||||
case "addToCart":
|
||||
addToCart(product);
|
||||
break;
|
||||
case 'activate':
|
||||
case "activate":
|
||||
viewDetails(product);
|
||||
break;
|
||||
}
|
||||
@@ -356,11 +355,9 @@ function SwipeableCard({ item, onDelete }) {
|
||||
return (
|
||||
<View
|
||||
accessible={true}
|
||||
accessibilityActions={[
|
||||
{ name: 'delete', label: 'Delete item' },
|
||||
]}
|
||||
accessibilityActions={[{ name: "delete", label: "Delete item" }]}
|
||||
onAccessibilityAction={(event) => {
|
||||
if (event.nativeEvent.actionName === 'delete') {
|
||||
if (event.nativeEvent.actionName === "delete") {
|
||||
onDelete(item);
|
||||
}
|
||||
}}
|
||||
@@ -382,7 +379,7 @@ function SwipeableCard({ item, onDelete }) {
|
||||
<TouchableOpacity
|
||||
accessibilityLabel={`Delete ${item.title}`}
|
||||
onPress={() => onDelete(item)}
|
||||
style={{ position: 'absolute', right: 0 }}
|
||||
style={{ position: "absolute", right: 0 }}
|
||||
>
|
||||
<Text>Delete</Text>
|
||||
</TouchableOpacity>
|
||||
@@ -395,7 +392,7 @@ function SwipeableCard({ item, onDelete }) {
|
||||
|
||||
```tsx
|
||||
// Respect reduced motion preference
|
||||
import { AccessibilityInfo } from 'react-native';
|
||||
import { AccessibilityInfo } from "react-native";
|
||||
|
||||
function AnimatedComponent() {
|
||||
const [reduceMotion, setReduceMotion] = useState(false);
|
||||
@@ -404,8 +401,8 @@ function AnimatedComponent() {
|
||||
AccessibilityInfo.isReduceMotionEnabled().then(setReduceMotion);
|
||||
|
||||
const subscription = AccessibilityInfo.addEventListener(
|
||||
'reduceMotionChanged',
|
||||
setReduceMotion
|
||||
"reduceMotionChanged",
|
||||
setReduceMotion,
|
||||
);
|
||||
|
||||
return () => subscription.remove();
|
||||
@@ -414,9 +411,7 @@ function AnimatedComponent() {
|
||||
return (
|
||||
<Animated.View
|
||||
style={{
|
||||
transform: reduceMotion
|
||||
? []
|
||||
: [{ translateX: animatedValue }],
|
||||
transform: reduceMotion ? [] : [{ translateX: animatedValue }],
|
||||
opacity: reduceMotion ? 1 : animatedOpacity,
|
||||
}}
|
||||
>
|
||||
@@ -503,6 +498,7 @@ const scaledFontSize = (size: number) => {
|
||||
|
||||
```markdown
|
||||
## VoiceOver (iOS) Testing
|
||||
|
||||
- [ ] All interactive elements have labels
|
||||
- [ ] Swipe navigation covers all content in logical order
|
||||
- [ ] Custom actions available for complex interactions
|
||||
@@ -511,6 +507,7 @@ const scaledFontSize = (size: number) => {
|
||||
- [ ] Images have appropriate descriptions or are hidden
|
||||
|
||||
## TalkBack (Android) Testing
|
||||
|
||||
- [ ] Focus order is logical
|
||||
- [ ] Touch exploration works correctly
|
||||
- [ ] Custom actions available
|
||||
@@ -519,12 +516,14 @@ const scaledFontSize = (size: number) => {
|
||||
- [ ] Grouped content read together
|
||||
|
||||
## Motor Accessibility
|
||||
|
||||
- [ ] Touch targets at least 44x44 points
|
||||
- [ ] Adequate spacing between targets (8dp minimum)
|
||||
- [ ] Alternatives to complex gestures
|
||||
- [ ] No time-limited interactions
|
||||
|
||||
## Visual Accessibility
|
||||
|
||||
- [ ] Text scales to 200% without loss
|
||||
- [ ] Content visible in high contrast mode
|
||||
- [ ] Color not sole indicator
|
||||
|
||||
@@ -259,7 +259,7 @@ function Tooltip({ content, children }) {
|
||||
<div
|
||||
role="tooltip"
|
||||
// Dismissible: user can close without moving pointer
|
||||
onKeyDown={(e) => e.key === 'Escape' && setIsVisible(false)}
|
||||
onKeyDown={(e) => e.key === "Escape" && setIsVisible(false)}
|
||||
// Hoverable: content stays visible when pointer moves to it
|
||||
onMouseEnter={() => setIsVisible(true)}
|
||||
onMouseLeave={() => setIsVisible(false)}
|
||||
@@ -292,7 +292,7 @@ function CustomButton({ onClick, children }) {
|
||||
tabIndex={0}
|
||||
onClick={onClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
@@ -331,10 +331,10 @@ function Modal({ isOpen, onClose, children }) {
|
||||
// Allow Escape to close
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
@@ -357,12 +357,18 @@ function Modal({ isOpen, onClose, children }) {
|
||||
```tsx
|
||||
// Skip links
|
||||
<body>
|
||||
<a href="#main" className="skip-link">Skip to main content</a>
|
||||
<a href="#nav" className="skip-link">Skip to navigation</a>
|
||||
<a href="#main" className="skip-link">
|
||||
Skip to main content
|
||||
</a>
|
||||
<a href="#nav" className="skip-link">
|
||||
Skip to navigation
|
||||
</a>
|
||||
|
||||
<header>...</header>
|
||||
|
||||
<nav id="nav" aria-label="Main">...</nav>
|
||||
<nav id="nav" aria-label="Main">
|
||||
...
|
||||
</nav>
|
||||
|
||||
<main id="main" tabIndex={-1}>
|
||||
{/* Main content */}
|
||||
@@ -455,8 +461,12 @@ Content and interface must be understandable.
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>...</head>
|
||||
<body>...</body>
|
||||
<head>
|
||||
...
|
||||
</head>
|
||||
<body>
|
||||
...
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
@@ -571,7 +581,7 @@ function CustomCheckbox({ checked, onChange, label }) {
|
||||
aria-label={label}
|
||||
onClick={() => onChange(!checked)}
|
||||
>
|
||||
{checked ? '✓' : '○'} {label}
|
||||
{checked ? "✓" : "○"} {label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -587,8 +597,8 @@ function CustomSlider({ value, min, max, label, onChange }) {
|
||||
aria-label={label}
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'ArrowRight') onChange(Math.min(value + 1, max));
|
||||
if (e.key === 'ArrowLeft') onChange(Math.max(value - 1, min));
|
||||
if (e.key === "ArrowRight") onChange(Math.min(value + 1, max));
|
||||
if (e.key === "ArrowLeft") onChange(Math.max(value - 1, min));
|
||||
}}
|
||||
>
|
||||
<div style={{ width: `${((value - min) / (max - min)) * 100}%` }} />
|
||||
@@ -601,6 +611,7 @@ function CustomSlider({ value, min, max, label, onChange }) {
|
||||
|
||||
```markdown
|
||||
## Keyboard Testing
|
||||
|
||||
- [ ] All interactive elements focusable with Tab
|
||||
- [ ] Focus order matches visual order
|
||||
- [ ] Focus indicator always visible
|
||||
@@ -609,6 +620,7 @@ function CustomSlider({ value, min, max, label, onChange }) {
|
||||
- [ ] Enter/Space activates buttons and links
|
||||
|
||||
## Screen Reader Testing
|
||||
|
||||
- [ ] All images have alt text
|
||||
- [ ] Form inputs have labels
|
||||
- [ ] Headings in logical order
|
||||
@@ -617,6 +629,7 @@ function CustomSlider({ value, min, max, label, onChange }) {
|
||||
- [ ] Error messages announced
|
||||
|
||||
## Visual Testing
|
||||
|
||||
- [ ] Text contrast at least 4.5:1
|
||||
- [ ] UI component contrast at least 3:1
|
||||
- [ ] Works at 200% zoom
|
||||
|
||||
Reference in New Issue
Block a user