11.2 נגישות מתקדמת הרצאה
נגישות מתקדמת - Advanced Accessibility¶
נגישות (a11y) היא לא רק דרישה חוקית - היא חלק בלתי נפרד מפיתוח ווב איכותי. אתר נגיש מאפשר לכל המשתמשים, כולל אנשים עם מוגבלויות, להשתמש באפליקציה שלנו. בשיעור זה נלמד לעומק כיצד לבנות אפליקציות ריאקט נגישות.
הנחיות נגישות לתוכן ווב - WCAG Guidelines¶
WCAG (Web Content Accessibility Guidelines) הוא התקן הבינלאומי לנגישות ווב. הוא מחולק לשלוש רמות:
- רמה A - דרישות בסיסיות. ללא עמידה בהן, חלק מהמשתמשים לא יוכלו להשתמש באתר כלל
- רמה AA - הרמה המומלצת. רוב החוקים והתקנות דורשים עמידה ברמה זו
- רמה AAA - הרמה הגבוהה ביותר. לא תמיד אפשרית אך מומלצת
ארבעת העקרונות של WCAG (POUR)¶
- ניתן לתפיסה - Perceivable - המידע חייב להיות מוצג בצורה שכל המשתמשים יכולים לתפוס
- ניתן להפעלה - Operable - הממשק חייב להיות ניתן להפעלה בכל דרך (עכבר, מקלדת, קול)
- ניתן להבנה - Understandable - המידע והממשק חייבים להיות ברורים
- עמיד - Robust - התוכן חייב לעבוד עם טכנולוגיות מסייעות שונות
תפקידים, מצבים ותכונות של ARIA¶
ARIA (Accessible Rich Internet Applications) מאפשר להוסיף מידע נגישות לאלמנטים ב-HTML.
תפקידים - ARIA Roles¶
// תפקיד ניווט
<nav role="navigation" aria-label="ניווט ראשי">
<ul>
<li><a href="/">דף הבית</a></li>
<li><a href="/about">אודות</a></li>
</ul>
</nav>
// תפקיד התראה
<div role="alert">
השינויים נשמרו בהצלחה
</div>
// תפקיד דיאלוג
<div role="dialog" aria-label="אישור מחיקה" aria-modal="true">
<h2>האם למחוק את הפריט?</h2>
<button>אישור</button>
<button>ביטול</button>
</div>
// תפקיד טאב
<div role="tablist" aria-label="הגדרות">
<button role="tab" aria-selected="true" aria-controls="panel1">
כללי
</button>
<button role="tab" aria-selected="false" aria-controls="panel2">
מתקדם
</button>
</div>
<div role="tabpanel" id="panel1" aria-labelledby="tab1">
תוכן הטאב הראשון
</div>
מצבים ותכונות - ARIA States and Properties¶
// aria-expanded - האם אלמנט מורחב
<button aria-expanded={isOpen} aria-controls="menu" onClick={toggle}>
תפריט
</button>
<ul id="menu" hidden={!isOpen}>
<li>אפשרות 1</li>
<li>אפשרות 2</li>
</ul>
// aria-busy - האם תוכן בטעינה
<div aria-busy={isLoading} aria-live="polite">
{isLoading ? 'טוען...' : 'התוכן נטען'}
</div>
// aria-disabled - האם אלמנט מושבת
<button aria-disabled={!isValid} onClick={isValid ? handleSubmit : undefined}>
שלח
</button>
// aria-current - מציין את הפריט הנוכחי
<nav>
<a href="/" aria-current={pathname === '/' ? 'page' : undefined}>דף הבית</a>
<a href="/about" aria-current={pathname === '/about' ? 'page' : undefined}>אודות</a>
</nav>
// aria-describedby - מקשר אלמנט לתיאור נוסף
<input
type="password"
aria-describedby="password-requirements"
/>
<p id="password-requirements">
הסיסמה חייבת להכיל לפחות 8 תווים
</p>
הכלל הראשון של ARIA¶
אם אפשר להשתמש באלמנט HTML סמנטי - השתמשו בו במקום ARIA.
// לא מומלץ - שימוש ב-ARIA כשיש אלמנט סמנטי
<div role="button" tabIndex={0} onClick={handleClick}>
לחץ כאן
</div>
// מומלץ - שימוש באלמנט סמנטי
<button onClick={handleClick}>
לחץ כאן
</button>
// לא מומלץ
<div role="navigation">...</div>
// מומלץ
<nav>...</nav>
ניהול פוקוס וניווט מקלדת - Focus Management¶
סדר פוקוס - Tab Order¶
// סדר טאב ברירת מחדל - לפי סדר ב-DOM
<button>ראשון</button>
<button>שני</button>
<button>שלישי</button>
// tabIndex={0} - מוסיף אלמנט לסדר הטאב הטבעי
<div tabIndex={0} role="button" onClick={handleClick}>
אלמנט שניתן לפוקוס
</div>
// tabIndex={-1} - ניתן לפוקוס תכנותית אבל לא בטאב
<div tabIndex={-1} ref={errorRef}>
הודעת שגיאה
</div>
// לעולם אל תשתמשו ב-tabIndex חיובי!
// tabIndex={1} - שובר את הסדר הטבעי
ניהול פוקוס בריאקט¶
import { useRef, useEffect } from 'react';
// העברת פוקוס לאלמנט ספציפי
function ErrorMessage({ message }: { message: string | null }) {
const errorRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (message) {
errorRef.current?.focus();
}
}, [message]);
if (!message) return null;
return (
<div ref={errorRef} tabIndex={-1} role="alert">
{message}
</div>
);
}
// ניהול פוקוס בניווט (Next.js)
'use client';
import { usePathname } from 'next/navigation';
import { useEffect, useRef } from 'react';
function FocusManager({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const mainRef = useRef<HTMLMainElement>(null);
useEffect(() => {
// העברת פוקוס לתחילת התוכן בכל ניווט
mainRef.current?.focus();
}, [pathname]);
return (
<main ref={mainRef} tabIndex={-1}>
{children}
</main>
);
}
ניווט מקלדת בקומפוננטות מורכבות¶
import { useState, useRef, useCallback, type KeyboardEvent } from 'react';
interface MenuItem {
id: string;
label: string;
onClick: () => void;
}
function Menu({ items }: { items: MenuItem[] }) {
const [activeIndex, setActiveIndex] = useState(0);
const menuRef = useRef<HTMLUListElement>(null);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setActiveIndex((prev) => (prev + 1) % items.length);
break;
case 'ArrowUp':
e.preventDefault();
setActiveIndex((prev) => (prev - 1 + items.length) % items.length);
break;
case 'Home':
e.preventDefault();
setActiveIndex(0);
break;
case 'End':
e.preventDefault();
setActiveIndex(items.length - 1);
break;
case 'Enter':
case ' ':
e.preventDefault();
items[activeIndex].onClick();
break;
case 'Escape':
// סגירת התפריט
break;
}
},
[items, activeIndex]
);
return (
<ul
ref={menuRef}
role="menu"
onKeyDown={handleKeyDown}
tabIndex={0}
>
{items.map((item, index) => (
<li
key={item.id}
role="menuitem"
tabIndex={index === activeIndex ? 0 : -1}
aria-current={index === activeIndex}
onClick={item.onClick}
>
{item.label}
</li>
))}
</ul>
);
}
תאימות לקוראי מסך - Screen Reader Compatibility¶
טקסט חלופי לתמונות¶
// תמונה תוכנית - מספרת מידע
<img src="/chart.png" alt="גרף מכירות: עלייה של 25% ברבעון האחרון" />
// תמונה דקורטיבית - אין צורך בטקסט חלופי
<img src="/decoration.png" alt="" role="presentation" />
// אייקון עם משמעות
<button aria-label="סגור">
<svg aria-hidden="true">...</svg>
</button>
// אייקון ליד טקסט
<button>
<svg aria-hidden="true">...</svg>
<span>שמור</span>
</button>
אזורים חיים - Live Regions¶
// עדכון שקורא המסך יקריא
function NotificationArea() {
const [message, setMessage] = useState('');
return (
<>
{/* polite - ימתין שקורא המסך יסיים את ההקראה הנוכחית */}
<div aria-live="polite" aria-atomic="true">
{message}
</div>
{/* assertive - יפסיק את ההקראה הנוכחית ויקריא מיד */}
<div aria-live="assertive" role="alert">
{errorMessage}
</div>
</>
);
}
// דוגמה מעשית - מונה עגלת קניות
function CartCounter({ count }: { count: number }) {
return (
<button aria-label={`עגלת קניות, ${count} פריטים`}>
<ShoppingCartIcon aria-hidden="true" />
<span aria-live="polite">{count}</span>
</button>
);
}
הסתרה מקוראי מסך¶
// aria-hidden - מסתיר מקוראי מסך אבל נשאר גלוי
<span aria-hidden="true">|</span> {/* מפריד ויזואלי */}
// sr-only class - גלוי רק לקוראי מסך
// (הגדרה ב-CSS)
<span className="sr-only">פתיחת תפריט</span>
/* sr-only - ויזואלית נסתר, נגיש לקוראי מסך */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
טפסים נגישים - Accessible Forms¶
תוויות ושגיאות¶
function AccessibleForm() {
const [errors, setErrors] = useState<Record<string, string>>({});
return (
<form aria-label="טופס יצירת קשר" noValidate>
{/* תווית מקושרת לשדה */}
<div>
<label htmlFor="name">שם מלא</label>
<input
id="name"
type="text"
required
aria-required="true"
aria-invalid={!!errors.name}
aria-describedby={errors.name ? 'name-error' : undefined}
/>
{errors.name && (
<p id="name-error" role="alert">
{errors.name}
</p>
)}
</div>
{/* שדה עם הנחיות */}
<div>
<label htmlFor="password">סיסמה</label>
<input
id="password"
type="password"
aria-required="true"
aria-invalid={!!errors.password}
aria-describedby="password-help password-error"
/>
<p id="password-help">
לפחות 8 תווים, אות גדולה, מספר ותו מיוחד
</p>
{errors.password && (
<p id="password-error" role="alert">
{errors.password}
</p>
)}
</div>
{/* קבוצת שדות קשורים */}
<fieldset>
<legend>שיטת תקשורת מועדפת</legend>
<div>
<input type="radio" id="contact-email" name="contact" value="email" />
<label htmlFor="contact-email">אימייל</label>
</div>
<div>
<input type="radio" id="contact-phone" name="contact" value="phone" />
<label htmlFor="contact-phone">טלפון</label>
</div>
</fieldset>
<button type="submit">שלח</button>
</form>
);
}
סיכום שגיאות¶
function FormErrorSummary({ errors }: { errors: Record<string, string> }) {
const errorEntries = Object.entries(errors);
const summaryRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (errorEntries.length > 0) {
summaryRef.current?.focus();
}
}, [errorEntries.length]);
if (errorEntries.length === 0) return null;
return (
<div ref={summaryRef} tabIndex={-1} role="alert" aria-label="שגיאות בטופס">
<h3>נמצאו {errorEntries.length} שגיאות:</h3>
<ul>
{errorEntries.map(([field, message]) => (
<li key={field}>
<a href={`#${field}`}>{message}</a>
</li>
))}
</ul>
</div>
);
}
ניגודיות צבעים ונגישות חזותית - Color Contrast and Visual Accessibility¶
יחסי ניגודיות נדרשים¶
- טקסט רגיל - יחס ניגודיות מינימלי 4.5:1 (AA) או 7:1 (AAA)
- טקסט גדול (18px+ או 14px+ bold) - יחס 3:1 (AA) או 4.5:1 (AAA)
- אלמנטים גרפיים ומשמעותיים - יחס 3:1
/* דוגמאות לניגודיות טובה */
:root {
/* טקסט על רקע לבן */
--text-primary: #1a1a2e; /* יחס 16.5:1 */
--text-secondary: #4a4a5a; /* יחס 7.2:1 */
--text-muted: #6b6b7b; /* יחס 4.6:1 - מינימום AA */
/* צבעי סטטוס נגישים */
--success: #0d7c3e; /* על לבן: 5.9:1 */
--error: #c31432; /* על לבן: 5.1:1 */
--warning: #7a5800; /* על לבן: 5.5:1 */
}
/* לא להסתמך על צבע בלבד */
.error-field {
border-color: var(--error);
border-width: 2px; /* גם שינוי רוחב */
}
.error-field::before {
content: "!"; /* גם סימון טקסטואלי */
color: var(--error);
font-weight: bold;
margin-inline-end: 4px;
}
מצב מופחת תנועה - Reduced Motion¶
/* כיבוד העדפת המשתמש לתנועה מופחתת */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* דוגמה לאנימציה מותאמת */
.card {
transition: transform 0.3s ease;
}
@media (prefers-reduced-motion: reduce) {
.card {
transition: none;
}
}
// שימוש ב-React
function useReducedMotion() {
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
useEffect(() => {
const query = window.matchMedia('(prefers-reduced-motion: reduce)');
setPrefersReducedMotion(query.matches);
const handler = (event: MediaQueryListEvent) => {
setPrefersReducedMotion(event.matches);
};
query.addEventListener('change', handler);
return () => query.removeEventListener('change', handler);
}, []);
return prefersReducedMotion;
}
בדיקת נגישות - Testing Accessibility¶
כלים אוטומטיים¶
// הפעלת axe בסביבת פיתוח בלבד
// app/layout.tsx
if (process.env.NODE_ENV === 'development') {
import('@axe-core/react').then((axe) => {
const React = require('react');
const ReactDOM = require('react-dom');
axe.default(React, ReactDOM, 1000);
// דוחות נגישות יופיעו בקונסול
});
}
# בדיקת נגישות עם Lighthouse
npx lighthouse https://localhost:3000 --only-categories=accessibility --output=html
# eslint-plugin-jsx-a11y - כללי נגישות ב-ESLint
npm install -D eslint-plugin-jsx-a11y
// .eslintrc.json
{
"plugins": ["jsx-a11y"],
"extends": ["plugin:jsx-a11y/recommended"],
"rules": {
"jsx-a11y/anchor-is-valid": "error",
"jsx-a11y/click-events-have-key-events": "error",
"jsx-a11y/no-static-element-interactions": "error",
"jsx-a11y/alt-text": "error",
"jsx-a11y/label-has-associated-control": "error"
}
}
בדיקה ידנית¶
רשימת בדיקות ידניות שכדאי לבצע:
- ניווט בכל האתר באמצעות מקלדת בלבד (Tab, Enter, Escape, חצים)
- בדיקה עם קורא מסך (VoiceOver ב-Mac, NVDA ב-Windows)
- בדיקת האתר בזום 200%
- כיבוי תמונות ובדיקה שכל המידע עדיין זמין
- בדיקה במצב ניגודיות גבוהה (High Contrast Mode)
קומפוננטות נגישות בריאקט - Accessible React Components¶
מלכודת פוקוס - Focus Trap¶
import { useEffect, useRef, type ReactNode } from 'react';
function FocusTrap({ children, active }: { children: ReactNode; active: boolean }) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!active) return;
const container = containerRef.current;
if (!container) return;
const focusableElements = container.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
// פוקוס על האלמנט הראשון
firstElement?.focus();
function handleKeyDown(e: KeyboardEvent) {
if (e.key !== 'Tab') return;
if (e.shiftKey) {
// Shift+Tab - חזרה לאחור
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement?.focus();
}
} else {
// Tab - קדימה
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement?.focus();
}
}
}
container.addEventListener('keydown', handleKeyDown);
return () => container.removeEventListener('keydown', handleKeyDown);
}, [active]);
return <div ref={containerRef}>{children}</div>;
}
דלג לתוכן - Skip Link¶
function SkipLink() {
return (
<a
href="#main-content"
className="skip-link"
>
דלג לתוכן הראשי
</a>
);
}
// בתוך ה-Layout
function Layout({ children }: { children: React.ReactNode }) {
return (
<>
<SkipLink />
<header>...</header>
<nav>...</nav>
<main id="main-content" tabIndex={-1}>
{children}
</main>
<footer>...</footer>
</>
);
}
/* Skip link - גלוי רק בפוקוס */
.skip-link {
position: absolute;
top: -40px;
left: 0;
background: #000;
color: #fff;
padding: 8px 16px;
z-index: 100;
transition: top 0.2s;
}
.skip-link:focus {
top: 0;
}
מודל נגיש¶
import { useEffect, useRef, type ReactNode } from 'react';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
children: ReactNode;
}
function AccessibleModal({ isOpen, onClose, title, children }: ModalProps) {
const previousFocusRef = useRef<HTMLElement | null>(null);
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isOpen) {
// שמירת האלמנט שהיה בפוקוס לפני פתיחת המודל
previousFocusRef.current = document.activeElement as HTMLElement;
// מניעת גלילה ברקע
document.body.style.overflow = 'hidden';
}
return () => {
document.body.style.overflow = '';
// החזרת הפוקוס לאלמנט הקודם
previousFocusRef.current?.focus();
};
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
function handleEscape(e: KeyboardEvent) {
if (e.key === 'Escape') onClose();
}
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<>
{/* שכבת רקע */}
<div
className="modal-overlay"
onClick={onClose}
aria-hidden="true"
/>
{/* המודל */}
<FocusTrap active={isOpen}>
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
<h2 id="modal-title">{title}</h2>
{children}
<button onClick={onClose} aria-label="סגור">
X
</button>
</div>
</FocusTrap>
</>
);
}
סיכום¶
בשיעור זה למדנו על:
- WCAG - שלוש רמות הנגישות (A, AA, AAA) וארבעת העקרונות (POUR)
- ARIA - תפקידים, מצבים ותכונות שמוסיפים מידע נגישות
- ניהול פוקוס - סדר טאב, העברת פוקוס, וניווט מקלדת
- קוראי מסך - טקסט חלופי, אזורים חיים, והסתרה נבונה
- טפסים נגישים - תוויות, שגיאות, וקיבוץ שדות
- ניגודיות צבעים - יחסי ניגודיות, תנועה מופחתת
- בדיקת נגישות - כלים אוטומטיים (axe, Lighthouse) ובדיקות ידניות
- קומפוננטות נגישות - מלכודת פוקוס, Skip Link, מודל נגיש
נגישות היא לא תוספת - היא חלק מפיתוח איכותי שמשרת את כל המשתמשים.