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:
- Ne koristite ARIA ako možete koristiti semantički HTML
- Ne menjajte ugrađenu semantiku osim ako nije neophodno
- Svi interaktivni elementi moraju biti dostupni putem tastature
- Ne skrivajte fokusibilne elemente od pomoćnih tehnologija
- 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>