לדלג לתוכן

11.2 - נגישות מתקדמת - פתרון

פתרון - נגישות מתקדמת

פתרון תרגיל 1 - טופס הרשמה נגיש

'use client';

import { useState, useRef, useEffect, type FormEvent } from 'react';

interface FormErrors {
  name?: string;
  email?: string;
  password?: string;
  confirmPassword?: string;
  gender?: string;
  terms?: string;
}

function AccessibleRegistrationForm() {
  const [errors, setErrors] = useState<FormErrors>({});
  const [submitted, setSubmitted] = useState(false);
  const errorSummaryRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (Object.keys(errors).length > 0 && submitted) {
      errorSummaryRef.current?.focus();
    }
  }, [errors, submitted]);

  function validate(formData: FormData): FormErrors {
    const newErrors: FormErrors = {};
    const name = formData.get('name') as string;
    const email = formData.get('email') as string;
    const password = formData.get('password') as string;
    const confirmPassword = formData.get('confirmPassword') as string;
    const gender = formData.get('gender') as string;
    const terms = formData.get('terms');

    if (!name || name.length < 2) {
      newErrors.name = 'Full name must contain at least 2 characters';
    }
    if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
      newErrors.email = 'Invalid email address';
    }
    if (!password || password.length < 8) {
      newErrors.password = 'Password must contain at least 8 characters';
    }
    if (password !== confirmPassword) {
      newErrors.confirmPassword = 'Passwords do not match';
    }
    if (!gender) {
      newErrors.gender = 'Please select a gender';
    }
    if (!terms) {
      newErrors.terms = 'You must accept the terms of use';
    }

    return newErrors;
  }

  function handleSubmit(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    setSubmitted(true);
    const formData = new FormData(e.currentTarget);
    const newErrors = validate(formData);
    setErrors(newErrors);

    if (Object.keys(newErrors).length === 0) {
      // Submit the form
      console.log('Form is valid!');
    }
  }

  const errorEntries = Object.entries(errors) as [keyof FormErrors, string][];

  return (
    <form onSubmit={handleSubmit} aria-label="Registration form" noValidate>
      {/* Error summary */}
      {errorEntries.length > 0 && submitted && (
        <div
          ref={errorSummaryRef}
          tabIndex={-1}
          role="alert"
          aria-label="Form errors"
          className="error-summary"
        >
          <h2>Found {errorEntries.length} errors:</h2>
          <ul>
            {errorEntries.map(([field, message]) => (
              <li key={field}>
                <a href={`#${field}`}>{message}</a>
              </li>
            ))}
          </ul>
        </div>
      )}

      {/* Full name */}
      <div>
        <label htmlFor="name">Full name</label>
        <input
          id="name"
          name="name"
          type="text"
          aria-required="true"
          aria-invalid={!!errors.name}
          aria-describedby={errors.name ? 'name-error' : undefined}
        />
        {errors.name && (
          <p id="name-error" className="error" role="alert">
            {errors.name}
          </p>
        )}
      </div>

      {/* Email */}
      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          name="email"
          type="email"
          aria-required="true"
          aria-invalid={!!errors.email}
          aria-describedby={errors.email ? 'email-error' : undefined}
        />
        {errors.email && (
          <p id="email-error" className="error" role="alert">
            {errors.email}
          </p>
        )}
      </div>

      {/* Password */}
      <div>
        <label htmlFor="password">Password</label>
        <input
          id="password"
          name="password"
          type="password"
          aria-required="true"
          aria-invalid={!!errors.password}
          aria-describedby="password-help password-error"
        />
        <p id="password-help" className="help-text">
          At least 8 characters, including an uppercase letter, a number, and a special character
        </p>
        {errors.password && (
          <p id="password-error" className="error" role="alert">
            {errors.password}
          </p>
        )}
      </div>

      {/* Confirm password */}
      <div>
        <label htmlFor="confirmPassword">Confirm password</label>
        <input
          id="confirmPassword"
          name="confirmPassword"
          type="password"
          aria-required="true"
          aria-invalid={!!errors.confirmPassword}
          aria-describedby={
            errors.confirmPassword ? 'confirm-error' : undefined
          }
        />
        {errors.confirmPassword && (
          <p id="confirm-error" className="error" role="alert">
            {errors.confirmPassword}
          </p>
        )}
      </div>

      {/* Gender */}
      <fieldset aria-invalid={!!errors.gender}>
        <legend>Gender</legend>
        <div>
          <input type="radio" id="gender-male" name="gender" value="male" />
          <label htmlFor="gender-male">Male</label>
        </div>
        <div>
          <input type="radio" id="gender-female" name="gender" value="female" />
          <label htmlFor="gender-female">Female</label>
        </div>
        <div>
          <input type="radio" id="gender-other" name="gender" value="other" />
          <label htmlFor="gender-other">Other</label>
        </div>
        {errors.gender && (
          <p className="error" role="alert">{errors.gender}</p>
        )}
      </fieldset>

      {/* Terms of use */}
      <div>
        <input
          type="checkbox"
          id="terms"
          name="terms"
          aria-invalid={!!errors.terms}
          aria-describedby={errors.terms ? 'terms-error' : undefined}
        />
        <label htmlFor="terms">
          I agree to the <a href="/terms">terms of use</a>
        </label>
        {errors.terms && (
          <p id="terms-error" className="error" role="alert">
            {errors.terms}
          </p>
        )}
      </div>

      <button type="submit">Register</button>
    </form>
  );
}

export default AccessibleRegistrationForm;

פתרון תרגיל 2 - מודל נגיש עם מלכודת פוקוס

'use client';

import { useEffect, useRef, useCallback, type ReactNode } from 'react';

// Focus trap component
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 getFocusableElements = () =>
      container.querySelectorAll<HTMLElement>(
        'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
      );

    const elements = getFocusableElements();
    const first = elements[0];
    const last = elements[elements.length - 1];

    first?.focus();

    function handleKeyDown(e: KeyboardEvent) {
      if (e.key !== 'Tab') return;

      const currentElements = getFocusableElements();
      const firstEl = currentElements[0];
      const lastEl = currentElements[currentElements.length - 1];

      if (e.shiftKey && document.activeElement === firstEl) {
        e.preventDefault();
        lastEl?.focus();
      } else if (!e.shiftKey && document.activeElement === lastEl) {
        e.preventDefault();
        firstEl?.focus();
      }
    }

    container.addEventListener('keydown', handleKeyDown);
    return () => container.removeEventListener('keydown', handleKeyDown);
  }, [active]);

  return <div ref={containerRef}>{children}</div>;
}

// Accessible modal component
interface ModalProps {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: ReactNode;
}

function AccessibleModal({ isOpen, onClose, title, children }: ModalProps) {
  const previousFocusRef = useRef<HTMLElement | null>(null);

  useEffect(() => {
    if (isOpen) {
      previousFocusRef.current = document.activeElement as HTMLElement;
      document.body.style.overflow = 'hidden';
    }

    return () => {
      document.body.style.overflow = '';
      previousFocusRef.current?.focus();
    };
  }, [isOpen]);

  const handleEscape = useCallback(
    (e: KeyboardEvent) => {
      if (e.key === 'Escape') onClose();
    },
    [onClose]
  );

  useEffect(() => {
    if (!isOpen) return;
    document.addEventListener('keydown', handleEscape);
    return () => document.removeEventListener('keydown', handleEscape);
  }, [isOpen, handleEscape]);

  if (!isOpen) return null;

  return (
    <>
      {/* Background overlay */}
      <div
        className="modal-overlay"
        onClick={onClose}
        aria-hidden="true"
        style={{
          position: 'fixed',
          inset: 0,
          backgroundColor: 'rgba(0, 0, 0, 0.5)',
          zIndex: 999,
        }}
      />

      {/* The modal */}
      <FocusTrap active={isOpen}>
        <div
          role="dialog"
          aria-modal="true"
          aria-labelledby="modal-title"
          style={{
            position: 'fixed',
            top: '50%',
            left: '50%',
            transform: 'translate(-50%, -50%)',
            backgroundColor: 'white',
            padding: '24px',
            borderRadius: '8px',
            zIndex: 1000,
            minWidth: '300px',
          }}
        >
          <h2 id="modal-title">{title}</h2>
          {children}
          <button onClick={onClose} aria-label="Close window">
            X
          </button>
        </div>
      </FocusTrap>
    </>
  );
}

// Usage example - delete confirmation modal
function DeleteConfirmation() {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <>
      <button onClick={() => setIsOpen(true)}>Delete item</button>

      <AccessibleModal
        isOpen={isOpen}
        onClose={() => setIsOpen(false)}
        title="Confirm deletion"
      >
        <p>Are you sure you want to delete this item? This action cannot be undone.</p>
        <div style={{ display: 'flex', gap: '8px', marginTop: '16px' }}>
          <button
            onClick={() => {
              // Perform deletion
              setIsOpen(false);
            }}
          >
            Confirm deletion
          </button>
          <button onClick={() => setIsOpen(false)}>Cancel</button>
        </div>
      </AccessibleModal>
    </>
  );
}

import { useState } from 'react';

export { AccessibleModal, FocusTrap, DeleteConfirmation };

פתרון תרגיל 3 - component טאבים נגיש

'use client';

import { useState, useRef, useCallback, type KeyboardEvent, type ReactNode } from 'react';

interface TabItem {
  id: string;
  label: string;
  content: ReactNode;
}

function AccessibleTabs({ tabs }: { tabs: TabItem[] }) {
  const [activeTab, setActiveTab] = useState(0);
  const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);

  const handleKeyDown = useCallback(
    (e: KeyboardEvent) => {
      let newIndex = activeTab;

      switch (e.key) {
        case 'ArrowRight':
          e.preventDefault();
          newIndex = (activeTab + 1) % tabs.length;
          break;
        case 'ArrowLeft':
          e.preventDefault();
          newIndex = (activeTab - 1 + tabs.length) % tabs.length;
          break;
        case 'Home':
          e.preventDefault();
          newIndex = 0;
          break;
        case 'End':
          e.preventDefault();
          newIndex = tabs.length - 1;
          break;
        default:
          return;
      }

      setActiveTab(newIndex);
      tabRefs.current[newIndex]?.focus();
    },
    [activeTab, tabs.length]
  );

  return (
    <div>
      {/* Tab list */}
      <div role="tablist" aria-label="Tabs" onKeyDown={handleKeyDown}>
        {tabs.map((tab, index) => (
          <button
            key={tab.id}
            ref={(el) => { tabRefs.current[index] = el; }}
            role="tab"
            id={`tab-${tab.id}`}
            aria-selected={activeTab === index}
            aria-controls={`panel-${tab.id}`}
            tabIndex={activeTab === index ? 0 : -1}
            onClick={() => setActiveTab(index)}
            style={{
              padding: '8px 16px',
              border: 'none',
              borderBottom: activeTab === index ? '2px solid blue' : '2px solid transparent',
              backgroundColor: 'transparent',
              cursor: 'pointer',
              fontWeight: activeTab === index ? 'bold' : 'normal',
            }}
          >
            {tab.label}
          </button>
        ))}
      </div>

      {/* Panels */}
      {tabs.map((tab, index) => (
        <div
          key={tab.id}
          role="tabpanel"
          id={`panel-${tab.id}`}
          aria-labelledby={`tab-${tab.id}`}
          hidden={activeTab !== index}
          tabIndex={0}
          style={{ padding: '16px' }}
        >
          {tab.content}
        </div>
      ))}
    </div>
  );
}

// Usage example
function TabsExample() {
  const tabs: TabItem[] = [
    { id: 'general', label: 'General', content: <p>General settings</p> },
    { id: 'security', label: 'Security', content: <p>Security settings</p> },
    { id: 'notifications', label: 'Notifications', content: <p>Notification settings</p> },
  ];

  return <AccessibleTabs tabs={tabs} />;
}

export { AccessibleTabs, TabsExample };

// components/SkipLink.tsx
function SkipLink() {
  return (
    <a
      href="#main-content"
      style={{
        position: 'absolute',
        top: '-40px',
        left: 0,
        background: '#000',
        color: '#fff',
        padding: '8px 16px',
        zIndex: 100,
        transition: 'top 0.2s',
      }}
      onFocus={(e) => {
        (e.target as HTMLElement).style.top = '0';
      }}
      onBlur={(e) => {
        (e.target as HTMLElement).style.top = '-40px';
      }}
    >
      Skip to main content
    </a>
  );
}
// components/BackToTop.tsx
'use client';

import { useCallback } from 'react';

function BackToTop() {
  const scrollToTop = useCallback(() => {
    window.scrollTo({ top: 0, behavior: 'smooth' });
    // Move focus to the top of the page
    const skipLink = document.querySelector<HTMLAnchorElement>('a[href="#main-content"]');
    skipLink?.focus();
  }, []);

  return (
    <button onClick={scrollToTop} aria-label="Back to top of page">
      Back to top
    </button>
  );
}
// components/Layout.tsx
'use client';

import { usePathname } from 'next/navigation';
import { useEffect, useRef } from 'react';

function AccessibleLayout({ children }: { children: React.ReactNode }) {
  const pathname = usePathname();
  const mainRef = useRef<HTMLElement>(null);

  // Move focus to main on page navigation
  useEffect(() => {
    mainRef.current?.focus();
  }, [pathname]);

  return (
    <>
      <SkipLink />

      <header role="banner">
        <h1>My Site</h1>
        <nav aria-label="Main navigation">
          <ul>
            <li><a href="/">Home</a></li>
            <li><a href="/products">Products</a></li>
            <li><a href="/about">About</a></li>
            <li><a href="/contact">Contact</a></li>
          </ul>
        </nav>
      </header>

      <div style={{ display: 'flex' }}>
        <main id="main-content" ref={mainRef} tabIndex={-1}>
          {children}
        </main>

        <aside aria-label="Side content">
          <nav aria-label="Secondary navigation">
            <h2>Categories</h2>
            <ul>
              <li><a href="/cat1">Category 1</a></li>
              <li><a href="/cat2">Category 2</a></li>
            </ul>
          </nav>
        </aside>
      </div>

      <footer role="contentinfo">
        <p>All rights reserved</p>
        <BackToTop />
      </footer>
    </>
  );
}

export default AccessibleLayout;

הסבר: ה-Layout כולל את כל אזורי ה-landmark הנדרשים. ה-Skip Link מופיע רק בפוקוס. שני אזורי הניווט מובדלים באמצעות aria-label. בניווט בין דפים, הפוקוס עובר אוטומטית ל-main.


פתרון תרגיל 5 - רשימת משימות נגישה

'use client';

import { useState, useRef, useCallback, type KeyboardEvent } from 'react';

interface Todo {
  id: string;
  text: string;
  completed: boolean;
}

function AccessibleTodoList() {
  const [todos, setTodos] = useState<Todo[]>([]);
  const [newTodo, setNewTodo] = useState('');
  const [announcement, setAnnouncement] = useState('');
  const inputRef = useRef<HTMLInputElement>(null);
  const listRef = useRef<HTMLUListElement>(null);

  const addTodo = useCallback(() => {
    if (!newTodo.trim()) return;

    const todo: Todo = {
      id: crypto.randomUUID(),
      text: newTodo.trim(),
      completed: false,
    };

    setTodos((prev) => [...prev, todo]);
    setNewTodo('');
    setAnnouncement(`Task "${todo.text}" added`);
    inputRef.current?.focus();
  }, [newTodo]);

  const toggleTodo = useCallback((id: string) => {
    setTodos((prev) =>
      prev.map((todo) => {
        if (todo.id === id) {
          const updated = { ...todo, completed: !todo.completed };
          setAnnouncement(
            updated.completed
              ? `Task "${todo.text}" marked as completed`
              : `Task "${todo.text}" marked as not completed`
          );
          return updated;
        }
        return todo;
      })
    );
  }, []);

  const deleteTodo = useCallback(
    (id: string, index: number) => {
      const todo = todos.find((t) => t.id === id);
      setTodos((prev) => prev.filter((t) => t.id !== id));
      setAnnouncement(`Task "${todo?.text}" deleted`);

      // Move focus to the next item, the previous one, or the input field
      requestAnimationFrame(() => {
        const items = listRef.current?.querySelectorAll<HTMLElement>('[role="listitem"]');
        if (items && items.length > 0) {
          const focusIndex = Math.min(index, items.length - 1);
          items[focusIndex]?.focus();
        } else {
          inputRef.current?.focus();
        }
      });
    },
    [todos]
  );

  const handleItemKeyDown = useCallback(
    (e: KeyboardEvent, id: string, index: number) => {
      if (e.key === 'Delete' || e.key === 'Backspace') {
        e.preventDefault();
        deleteTodo(id, index);
      } else if (e.key === ' ') {
        e.preventDefault();
        toggleTodo(id);
      }
    },
    [deleteTodo, toggleTodo]
  );

  const completedCount = todos.filter((t) => t.completed).length;

  return (
    <div>
      <h2>Task list</h2>

      {/* Announcements for screen reader */}
      <div aria-live="polite" aria-atomic="true" className="sr-only">
        {announcement}
      </div>

      {/* Add a task */}
      <div role="search">
        <label htmlFor="new-todo">Add a new task</label>
        <input
          ref={inputRef}
          id="new-todo"
          type="text"
          value={newTodo}
          onChange={(e) => setNewTodo(e.target.value)}
          onKeyDown={(e) => {
            if (e.key === 'Enter') addTodo();
          }}
          aria-describedby="todo-instructions"
        />
        <button onClick={addTodo}>Add</button>
        <p id="todo-instructions" className="sr-only">
          Type a task name and press Enter or the Add button
        </p>
      </div>

      {/* Task counter */}
      <p aria-live="polite">
        {completedCount} out of {todos.length} tasks completed
      </p>

      {/* The task list */}
      <ul ref={listRef} role="list" aria-label="Task list">
        {todos.map((todo, index) => (
          <li
            key={todo.id}
            role="listitem"
            tabIndex={0}
            onKeyDown={(e) => handleItemKeyDown(e, todo.id, index)}
            aria-label={`${todo.text}${todo.completed ? ', completed' : ''}`}
          >
            <input
              type="checkbox"
              id={`todo-${todo.id}`}
              checked={todo.completed}
              onChange={() => toggleTodo(todo.id)}
              aria-checked={todo.completed}
            />
            <label
              htmlFor={`todo-${todo.id}`}
              style={{
                textDecoration: todo.completed ? 'line-through' : 'none',
              }}
            >
              {todo.text}
            </label>
            <button
              onClick={() => deleteTodo(todo.id, index)}
              aria-label={`Delete task: ${todo.text}`}
            >
              Delete
            </button>
          </li>
        ))}
      </ul>

      {todos.length === 0 && <p>No tasks in the list</p>}
    </div>
  );
}

export default AccessibleTodoList;

פתרון תרגיל 6 - בדיקת נגישות

א. הגדרת eslint-plugin-jsx-a11y:

npm install -D eslint-plugin-jsx-a11y
// .eslintrc.json
{
  "plugins": ["jsx-a11y"],
  "extends": ["plugin:jsx-a11y/strict"],
  "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",
    "jsx-a11y/no-autofocus": "warn",
    "jsx-a11y/no-noninteractive-element-interactions": "error",
    "jsx-a11y/role-has-required-aria-props": "error",
    "jsx-a11y/interactive-supports-focus": "error"
  }
}

ב. בדיקת axe בסביבת פיתוח:

// app/AxeProvider.tsx
'use client';

import { useEffect } from 'react';

function AxeProvider({ children }: { children: React.ReactNode }) {
  useEffect(() => {
    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, {
          rules: [
            { id: 'color-contrast', enabled: true },
            { id: 'label', enabled: true },
          ],
        });
      });
    }
  }, []);

  return <>{children}</>;
}

export default AxeProvider;

ג. בדיקת jest-axe:

npm install -D jest-axe @testing-library/react @testing-library/jest-dom
// __tests__/accessibility.test.tsx
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';

expect.extend(toHaveNoViolations);

describe('accessibility tests', () => {
  it('registration form has no accessibility issues', async () => {
    const { container } = render(<AccessibleRegistrationForm />);
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });

  it('modal has no accessibility issues', async () => {
    const { container } = render(
      <AccessibleModal isOpen={true} onClose={() => {}} title="Test">
        <p>Modal content</p>
        <button>Confirm</button>
      </AccessibleModal>
    );
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });

  it('tabs have no accessibility issues', async () => {
    const tabs = [
      { id: '1', label: 'Tab 1', content: <p>Content 1</p> },
      { id: '2', label: 'Tab 2', content: <p>Content 2</p> },
    ];
    const { container } = render(<AccessibleTabs tabs={tabs} />);
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });

  it('task list has no accessibility issues', async () => {
    const { container } = render(<AccessibleTodoList />);
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });
});

ד. רשימת בדיקה ידנית:

  1. ניווט בכל האתר באמצעות מקלדת בלבד (Tab, Shift+Tab, Enter, Space, Escape, חצים)
  2. בדיקה שיש אינדיקציית פוקוס ברורה וגלויה על כל אלמנט אינטראקטיבי
  3. בדיקה עם קורא מסך (VoiceOver) שכל התוכן נקרא בסדר הגיוני
  4. בדיקה שכל התמונות המשמעותיות כוללות טקסט חלופי מתאר
  5. בדיקה שהאתר ניתן לשימוש בזום 200% ללא אובדן תוכן או פונקציונליות
  6. בדיקת ניגודיות צבעים בכל הטקסטים והאלמנטים המשמעותיים (יחס 4.5:1 לפחות)
  7. בדיקה שהודעות שגיאה מוכרזות לקוראי מסך (aria-live או role="alert")
  8. בדיקה שטפסים כוללים labels מקושרים, שגיאות ברורות, והנחיות נגישות
  9. בדיקה שמודלים וחלונות קופצים כוללים מלכודת פוקוס וסגירה ב-Escape
  10. בדיקה שסרטונים ואודיו כוללים כתוביות או תמלול

תשובות לשאלות

1. ההבדל בין aria-label, aria-labelledby ו-aria-describedby:

  • aria-label - מספק תווית טקסטואלית ישירה לאלמנט. משמש כשאין תווית גלויה. דוגמה: <button aria-label="סגור">X</button>
  • aria-labelledby - מצביע על אלמנט אחר שמשמש כתווית (באמצעות ה-id שלו). משמש כשהתווית כבר קיימת בדף. דוגמה: <div aria-labelledby="section-title"> כאשר יש <h2 id="section-title">כותרת</h2>
  • aria-describedby - מצביע על אלמנט שמספק תיאור נוסף (מעבר לתווית). משמש להנחיות, שגיאות או מידע משלים. דוגמה: שדה סיסמה עם aria-describedby="password-help" שמצביע על טקסט עם דרישות הסיסמה

2. למה לא tabIndex חיובי:

tabIndex חיובי (1, 2, 3...) שובר את סדר הטאב הטבעי של ה-DOM. אלמנטים עם tabIndex חיובי מקבלים פוקוס לפני כל שאר האלמנטים, מה שיוצר חוויה מבלבלת ולא צפויה. קשה מאוד לתחזק את הסדר כשמוסיפים או מסירים אלמנטים. השימוש ב-0 (סדר טבעי) ו-1- (רק תכנותי) מספיק לכל המקרים.

3. aria-live polite מול assertive:

  • polite - קורא המסך ימתין שיסיים את ההקראה הנוכחית לפני שיקריא את העדכון. מתאים לעדכונים שאינם דחופים. דוגמה: "הפריט נוסף לעגלת הקניות"
  • assertive - קורא המסך יפסיק את ההקראה הנוכחית ויקריא את העדכון מיד. מתאים להודעות דחופות. דוגמה: "שגיאה: החיבור לשרת נותק"

4. הכלל הראשון של ARIA:

הכלל אומר: אם אפשר להשתמש באלמנט HTML סמנטי, אל תשתמש ב-ARIA. אלמנטים סמנטיים (button, nav, input) כבר כוללים תפקידים, states והתנהגויות מובנות. שימוש ב-ARIA מוסיף מורכבות ויכול לגרום לטעויות. למשל, <button> כבר תומך בפוקוס, Enter, Space ומוכרז כ-"כפתור", בעוד ש-<div role="button"> דורש הוספה ידנית של כל אלה.

5. מלכודת פוקוס (Focus Trap):

מלכודת פוקוס מגבילה את ניווט ה-Tab כך שהפוקוס נשאר בתוך אלמנט מסוים ולא יוצא ממנו. כשמגיעים לאלמנט האחרון ולוחצים Tab, הפוקוס חוזר לראשון (ולהיפך עם Shift+Tab). היא חשובה כי בלעדיה, משתמשי מקלדת עלולים "ללכת לאיבוד" מאחורי מודל פתוח. משתמשים בה במודלים (dialogs), תפריטי dropdown, ומגירות צד (drawers) - כל מקרה שבו יש שכבת UI שמכסה את שאר הדף.