Pristupačnost

Izgradnja komponenti koje mogu koristiti svi, uključujući i osobe sa invaliditetom koje se oslanjaju na pomoćne tehnologije.

Pristupačnost (a11y) nije opcionalna funkcija—to je osnovni zahtev za moderne web komponente. Svaka komponenta mora biti upotrebljiva za sve, uključujući ljude sa vizuelnim, motoričkim, auditivnim ili kognitivnim teškoćama.

Ovaj vodič je neizdajan spisak principa i obrazaca pristupačnosti koje bi trebalo da sledite pri izradi komponenti. Nije sveobuhvatan vodič, ali bi trebalo da vam da predstavu o vrstama problema na koje treba obratiti pažnju.

Ako koristite linter sa strogim pravilima pristupačnosti kao što je Ultracite, ovakve vrste problema će verovatno biti uhvaćene automatski, ali je i dalje važno razumeti principe.

Osnovni principi

1. Semantički HTML na prvom mestu

Uvek počnite sa najprikladnijim HTML elementom. Semantički HTML pruža ugrađene funkcije pristupačnosti koje prilagođene implementacije često propuštaju.

// ❌ Don't reinvent the wheel
<div onClick={handleClick} className="button">
  Click me
</div>

// ✅ Use semantic elements
<button onClick={handleClick}>
  Click me
</button>

Semantički elementi dolaze sa odgovarajućim najavama uloga, interakcijom putem tastature, upravljanjem fokusom i učešćem u formama.

2. Navigacija tastaturom

Svaki interaktivni element mora biti dostupan putem tastature. Korisnici bi trebalo da mogu da navigiraju, aktiviraju i komuniciraju sa svim funkcionalnostima koristeći samo tastaturu.

// ✅ Complete keyboard support
function Menu() {
  const handleKeyDown = (e: React.KeyboardEvent) => {
    switch(e.key) {
      case 'ArrowDown':
        focusNextItem();
        break;
      case 'ArrowUp':
        focusPreviousItem();
        break;
      case 'Home':
        focusFirstItem();
        break;
      case 'End':
        focusLastItem();
        break;
      case 'Escape':
        closeMenu();
        break;
    }
  };

  return (
    <div role="menu" onKeyDown={handleKeyDown}>
      {/* menu items */}
    </div>
  );
}

3. Podrška za čitače ekrana

Obezbedite da sav sadržaj i interakcije budu pravilno najavljeni čitačima ekrana koristeći ARIA atribute kada je to neophodno.

// ✅ Proper ARIA labeling
<nav aria-label="Main navigation">
  <ul>
    <li><a href="/" aria-current="page">Home</a></li>
    <li><a href="/about">About</a></li>
  </ul>
</nav>

// ✅ Dynamic content announcements
<div aria-live="polite" aria-atomic="true">
  {isLoading && <span>Loading results...</span>}
  {results && <span>{results.length} results found</span>}
</div>

4. Vizuelna pristupačnost

Podržite korisnike sa oštećenjem vida kroz odgovarajući kontrast, indikatore fokusa i responzivno podešavanje veličine teksta.

/* ✅ Visible focus indicators */
button:focus-visible {
  outline: 2px solid var(--color-focus);
  outline-offset: 2px;
}

/* ✅ Sufficient color contrast (4.5:1 for normal text, 3:1 for large text) */
.text {
  color: #333; /* Against white: 12.6:1 ratio */
  background: white;
}

/* ✅ Responsive text sizing */
.text {
  font-size: 1rem; /* Respects user preferences */
}

ARIA obrasci

Razumevanje ARIA

ARIA (Accessible Rich Internet Applications) pruža semantičke informacije o elementima pomoćnim tehnologijama. Koristite ARIA da unapredite, a ne da zamenite semantički HTML.

Postoji nekoliko pravila koja treba slediti:

  1. Ne koristite ARIA ako možete koristiti semantički HTML
  2. Ne menjajte ugrađenu semantiku osim ako nije neophodno
  3. Svi interaktivni elementi moraju biti dostupni putem tastature
  4. Ne skrivajte fokusibilne elemente od pomoćnih tehnologija
  5. Svi interaktivni elementi moraju imati pristupačna imena

Uobičajeni ARIA atributi

Uloge

Definišu šta je element:

// Widget roles
<div role="button" tabIndex={0} onClick={handleClick}>
  Custom Button
</div>

// Landmark roles
<div role="navigation" aria-label="Breadcrumb">
  {/* breadcrumb items */}
</div>

// Live region roles
<div role="alert">
  Error: Invalid email address
</div>

Stanja

Opisuju trenutno stanje elementa:

// Checked state
<div
  role="checkbox"
  aria-checked={isChecked}
  tabIndex={0}
>
  Accept terms
</div>

// Expanded state
<button
  aria-expanded={isOpen}
  aria-controls="panel-1"
>
  Toggle Panel
</button>
<div id="panel-1" hidden={!isOpen}>
  Panel content
</div>

// Selected state
<li
  role="option"
  aria-selected={isSelected}
>
  Option 1
</li>

Svojstva

Pružaju dodatne informacije:

// Labels and descriptions
<input
  aria-label="Search"
  aria-describedby="search-help"
  type="search"
/>
<span id="search-help">Press Enter to search</span>

// Relationships
<button aria-controls="modal-1">Open Modal</button>
<div id="modal-1" role="dialog">{/* modal content */}</div>

// Required and invalid
<input
  aria-required="true"
  aria-invalid={hasError}
  aria-errormessage="email-error"
/>
<span id="email-error">Please enter a valid email</span>

Obrasci komponenti

Modal/Dijalog

Modalni prozori zahtevaju pažljivo upravljanje fokusom i zarobljavanje fokusa:

function Modal({ isOpen, onClose, children }) {
  const modalRef = useRef<HTMLDivElement>(null);
  const previousFocus = useRef<HTMLElement | null>(null);

  useEffect(() => {
    if (isOpen) {
      // Store current focus
      previousFocus.current = document.activeElement as HTMLElement;

      // Focus first focusable element in modal
      const firstFocusable = modalRef.current?.querySelector<HTMLElement>(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
      );
      firstFocusable?.focus();

      // Prevent body scroll
      document.body.style.overflow = 'hidden';
    } else {
      // Restore focus
      previousFocus.current?.focus();
      document.body.style.overflow = '';
    }

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

  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === 'Escape') {
      onClose();
    }

    if (e.key === 'Tab') {
      // Trap focus within modal
      const focusables = modalRef.current?.querySelectorAll<HTMLElement>(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
      );

      if (focusables && focusables.length > 0) {
        const firstFocusable = focusables[0];
        const lastFocusable = focusables[focusables.length - 1];

        if (e.shiftKey && document.activeElement === firstFocusable) {
          e.preventDefault();
          lastFocusable.focus();
        } else if (!e.shiftKey && document.activeElement === lastFocusable) {
          e.preventDefault();
          firstFocusable.focus();
        }
      }
    }
  };

  if (!isOpen) return null;

  return (
    <div
      role="dialog"
      aria-modal="true"
      aria-labelledby="modal-title"
      ref={modalRef}
      onKeyDown={handleKeyDown}
      className="modal"
    >
      <button
        onClick={onClose}
        aria-label="Close dialog"
        className="close-button"
      >
        ×
      </button>
      {children}
    </div>
  );
}

Padajući meni

Padajući meniji zahtevaju odgovarajuće ARIA atribute i navigaciju tastaturom:

function DropdownMenu({ items }) {
  const [isOpen, setIsOpen] = useState(false);
  const [selectedIndex, setSelectedIndex] = useState(-1);
  const menuRef = useRef<HTMLUListElement>(null);

  const handleKeyDown = (e: React.KeyboardEvent) => {
    switch(e.key) {
      case 'ArrowDown':
        e.preventDefault();
        if (!isOpen) {
          setIsOpen(true);
          setSelectedIndex(0);
        } else {
          setSelectedIndex(prev =>
            prev < items.length - 1 ? prev + 1 : 0
          );
        }
        break;

      case 'ArrowUp':
        e.preventDefault();
        if (isOpen) {
          setSelectedIndex(prev =>
            prev > 0 ? prev - 1 : items.length - 1
          );
        }
        break;

      case 'Enter':
      case ' ':
        e.preventDefault();
        if (!isOpen) {
          setIsOpen(true);
        } else if (selectedIndex >= 0) {
          items[selectedIndex].onClick();
          setIsOpen(false);
        }
        break;

      case 'Escape':
        setIsOpen(false);
        setSelectedIndex(-1);
        break;
    }
  };

  return (
    <div className="dropdown">
      <button
        aria-haspopup="true"
        aria-expanded={isOpen}
        aria-controls="dropdown-menu"
        onKeyDown={handleKeyDown}
        onClick={() => setIsOpen(!isOpen)}
      >
        Menu
      </button>

      {isOpen && (
        <ul
          id="dropdown-menu"
          role="menu"
          ref={menuRef}
          onKeyDown={handleKeyDown}
        >
          {items.map((item, index) => (
            <li
              key={item.id}
              role="menuitem"
              tabIndex={-1}
              aria-selected={index === selectedIndex}
              onClick={() => {
                item.onClick();
                setIsOpen(false);
              }}
            >
              {item.label}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Kartice

Interfejsi sa karticama zahtevaju specifične ARIA obrasce i navigaciju tastaturom:

function Tabs({ tabs, defaultTab = 0 }) {
  const [activeTab, setActiveTab] = useState(defaultTab);

  const handleKeyDown = (e: React.KeyboardEvent, index: number) => {
    let newIndex = index;

    switch(e.key) {
      case 'ArrowLeft':
        newIndex = index > 0 ? index - 1 : tabs.length - 1;
        break;
      case 'ArrowRight':
        newIndex = index < tabs.length - 1 ? index + 1 : 0;
        break;
      case 'Home':
        newIndex = 0;
        break;
      case 'End':
        newIndex = tabs.length - 1;
        break;
      default:
        return;
    }

    e.preventDefault();
    setActiveTab(newIndex);

    // Focus the newly selected tab
    const tabElement = document.getElementById(`tab-${newIndex}`);
    tabElement?.focus();
  };

  return (
    <div className="tabs">
      <div role="tablist" aria-label="Tabs">
        {tabs.map((tab, index) => (
          <button
            key={tab.id}
            id={`tab-${index}`}
            role="tab"
            aria-selected={activeTab === index}
            aria-controls={`panel-${index}`}
            tabIndex={activeTab === index ? 0 : -1}
            onClick={() => setActiveTab(index)}
            onKeyDown={(e) => handleKeyDown(e, index)}
          >
            {tab.label}
          </button>
        ))}
      </div>

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

Forme

Forme zahtevaju jasne oznake, poruke o greškama i povratnu validaciju:

function AccessibleForm() {
  const [errors, setErrors] = useState({});

  return (
    <form aria-label="Contact form">
      <div className="form-group">
        <label htmlFor="email">
          Email Address
          <span aria-label="required">*</span>
        </label>
        <input
          id="email"
          type="email"
          aria-required="true"
          aria-invalid={!!errors.email}
          aria-describedby={errors.email ? "email-error" : "email-help"}
        />
        <span id="email-help" className="help-text">
          We'll never share your email
        </span>
        {errors.email && (
          <span id="email-error" role="alert" className="error">
            {errors.email}
          </span>
        )}
      </div>

      <fieldset>
        <legend>Notification Preferences</legend>
        <div>
          <input
            id="notify-email"
            type="checkbox"
            name="notifications"
            value="email"
          />
          <label htmlFor="notify-email">Email notifications</label>
        </div>
        <div>
          <input
            id="notify-sms"
            type="checkbox"
            name="notifications"
            value="sms"
          />
          <label htmlFor="notify-sms">SMS notifications</label>
        </div>
      </fieldset>

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

Upravljanje fokusom

Vidljiv fokus

Prikazujte indikatore fokusa samo za navigaciju tastaturom:

/* Remove default outline */
*:focus {
  outline: none;
}

/* Show outline only for keyboard focus */
*:focus-visible {
  outline: 2px solid var(--color-focus);
  outline-offset: 2px;
}

/* Custom focus styles for specific components */
.button:focus-visible {
  box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.5);
}

Zarobljavanje fokusa

Održavajte fokus unutar određene oblasti:

function useFocusTrap(ref: React.RefObject<HTMLElement>, isActive: boolean) {
  useEffect(() => {
    if (!isActive || !ref.current) return;

    const element = ref.current;
    const focusableSelector =
      'a[href], button, textarea, input[type="text"], input[type="radio"], input[type="checkbox"], select, [tabindex]:not([tabindex="-1"])';

    const focusableElements = element.querySelectorAll<HTMLElement>(focusableSelector);
    const firstFocusable = focusableElements[0];
    const lastFocusable = focusableElements[focusableElements.length - 1];

    const handleTabKey = (e: KeyboardEvent) => {
      if (e.key !== 'Tab') return;

      if (e.shiftKey) {
        if (document.activeElement === firstFocusable) {
          e.preventDefault();
          lastFocusable?.focus();
        }
      } else {
        if (document.activeElement === lastFocusable) {
          e.preventDefault();
          firstFocusable?.focus();
        }
      }
    };

    element.addEventListener('keydown', handleTabKey);
    firstFocusable?.focus();

    return () => {
      element.removeEventListener('keydown', handleTabKey);
    };
  }, [ref, isActive]);
}

Vraćanje fokusa

Vratite fokus na odgovarajući element nakon interakcija:

function useRestoreFocus() {
  const previousFocus = useRef<HTMLElement | null>(null);

  const saveFocus = () => {
    previousFocus.current = document.activeElement as HTMLElement;
  };

  const restoreFocus = () => {
    previousFocus.current?.focus();
  };

  return { saveFocus, restoreFocus };
}

Live regije

Najavite promene dinamičkog sadržaja čitačima ekrana:

Poruke statusa

// Polite announcement (waits for screen reader to finish)
<div role="status" aria-live="polite">
  {savedMessage && "Settings saved successfully"}
</div>

// Assertive announcement (interrupts screen reader)
<div role="alert" aria-live="assertive">
  {errorMessage && `Error: ${errorMessage}`}
</div>

// Loading states
<div aria-live="polite" aria-busy={isLoading}>
  {isLoading ? "Loading..." : `${items.length} items loaded`}
</div>

Indikatori napretka

function ProgressBar({ value, max = 100 }) {
  return (
    <div
      role="progressbar"
      aria-valuenow={value}
      aria-valuemin={0}
      aria-valuemax={max}
      aria-label="Upload progress"
    >
      <div
        className="progress-fill"
        style={{ width: `${(value / max) * 100}%` }}
      />
      <span className="sr-only">
        {Math.round((value / max) * 100)}% complete
      </span>
    </div>
  );
}

Boje i kontrast

Zahtevi za kontrast

Pratite WCAG smernice za kontrast boja:

/* Normal text (< 18pt or < 14pt bold) */
.text {
  color: #595959; /* 7:1 ratio against white */
  background: white;
}

/* Large text (≥ 18pt or ≥ 14pt bold) */
.heading {
  color: #767676; /* 4.5:1 ratio against white */
  font-size: 1.5rem;
  font-weight: bold;
}

/* Non-text elements (icons, borders) */
.icon {
  color: #949494; /* 3:1 ratio against white */
}

Nezavisnost od boje

Nikada ne prenosite informacije samo bojom:

// ❌ Color only
<span className="text-red-500">Error</span>

// ✅ Color with text/icon
<span className="text-red-500">
  <ErrorIcon aria-hidden="true" />
  <span>Error: Invalid input</span>
</span>

// ✅ Multiple indicators
<input
  className={hasError ? 'border-red-500' : 'border-gray-300'}
  aria-invalid={hasError}
  aria-describedby={hasError ? 'error-message' : undefined}
/>
{hasError && (
  <span id="error-message" className="text-red-500">
    <ErrorIcon aria-hidden="true" /> This field is required
  </span>
)}

Mobilna pristupačnost

Dodirne površine

Obezbedite da su ciljne površine za dodir dovoljno velike:

/* Minimum 44x44px for iOS, 48x48dp for Android */
.button {
  min-height: 44px;
  min-width: 44px;
  padding: 12px 16px;
}

/* Add invisible touch area for small icons */
.icon-button {
  position: relative;
  padding: 8px;
}

.icon-button::before {
  content: '';
  position: absolute;
  top: -8px;
  right: -8px;
  bottom: -8px;
  left: -8px;
}

Viewport i zumiranje

Dozvolite korisnicima da zumiraju:

<!-- ✅ Allows zooming -->
<meta name="viewport" content="width=device-width, initial-scale=1">

<!-- ❌ Prevents zooming -->
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">

Uobičajene zamke

Placeholder tekst kao oznaka

Ne koristite placeholder kao jedinu oznaku:

// ❌ Placeholder disappears when typing
<input placeholder="Email address" />

// ✅ Persistent label
<label>
  Email address
  <input type="email" />
</label>

// ✅ Floating label pattern
<div className="form-field">
  <input id="email" placeholder=" " />
  <label htmlFor="email">Email address</label>
</div>

Prazni dugmići

Uvek obezbedite pristupačan tekst za dugmad sa ikonama:

// ❌ No accessible name
<button onClick={handleDelete}>
  <TrashIcon />
</button>

// ✅ Screen reader text
<button onClick={handleDelete} aria-label="Delete item">
  <TrashIcon aria-hidden="true" />
</button>

// ✅ Visually hidden text
<button onClick={handleDelete}>
  <TrashIcon aria-hidden="true" />
  <span className="sr-only">Delete item</span>
</button>

Onemogućeni elementi forme

Onemogućeni elementi nisu fokusibilni, što može zbuniti korisnike:

// ❌ User can't understand why button is disabled
<button disabled={!isValid}>
  Submit
</button>

// ✅ Use aria-disabled and explain
<button
  aria-disabled={!isValid}
  aria-describedby="submit-help"
  onClick={isValid ? handleSubmit : undefined}
  className={!isValid ? 'opacity-50 cursor-not-allowed' : ''}
>
  Submit
</button>
<span id="submit-help">
  {!isValid && 'Please fill in all required fields'}
</span>