Saavutettavien komponenttien luominen ruudunlukijoille
Saavutettavien komponenttien luominen ruudunlukijoille
Kuvittele NVDA-käyttäjä, joka avaa lomakkeen sisältävän modaalin, täyttää sen ja klikkaa "Sulje". Modaali katoaa visuaalisesti — display: none CSS:ssä, kaikki näyttää kunnossa olevalta. Paitsi että elementti on yhä DOM:ssa, fokus ei ole siirtynyt mihinkään, ja ruudunlukija jatkaa juuri "suljetun" ikkunan sisällön lukemista. Käyttäjä kuulee juuri hylkäämänsä lomakkeen sisällön eikä tiedä, missä kohtaa sivua hän nyt on. Tämä ei ole keksitty esimerkki — se on yksi yleisimmistä virheistä komponenteissa, jotka on rakennettu "silmämäärin", ymmärtämättä miten saavutettavuus oikeasti toimii. Tässä artikkelissa haluan mennä sääntölistaa pidemmälle ja näyttää mekanismin niiden takana — sekä muutaman oikean komponentin, joista näet tarkalleen, missä sudenkuopat sijaitsevat.
Miten ruudunlukija oikeasti "näkee" sivun
Ennen komponenttien korjaamista kannattaa tietää, mitä ruudunlukija oikeastaan käsittelee. Se ei renderöi sivua kuten selain — se toimii saavutettavuuspuun (accessibility tree) varassa, rakenteen, jonka selain rakentaa rinnakkain DOM:n kanssa. Jokaisella solmulla tässä puussa on kolme ydinominaisuutta: rooli (onko se painike, linkki, otsikko, lomakekenttä), nimi (se, mitä käyttäjä kuulee kuvauksena) ja tila (laajennettu, valittu, poissa käytöstä).
Keskeinen seuraus: ruudunlukija ei näe CSS:ääsi. display: none ja visibility: hidden poistavat elementin saavutettavuuspuusta — se on oikea tapa piilottaa jotain. Mutta pelkkä elementin siirtäminen ruudun ulkopuolelle (position: absolute; left: -9999px) tai värin asettaminen arvoon transparent ei muuta mitään saavutettavuuspuussa — elementti on edelleen siellä ja tulee edelleen luetuksi. Juuri näin kävi yllä olevassa modaaliesimerkissä.
Toinen kulmakivi on DOM-järjestys. Selausmoodissa ruudunlukija lukee sivun siinä järjestyksessä, jossa elementit ovat puussa — riippumatta siitä, miten olet visuaalisesti järjestänyt ne käyttäen flex-direction: row-reverse tai grid-template-areas. Jos asettelusi näyttää visuaalisesti järkevältä, mutta lähdekoodin järjestys on mielivaltainen, sokea käyttäjä saa sivun "satunnaisessa järjestyksessä".
Semantiikka ensimmäisenä puolustuslinjana
Halvin tapa saada saavutettavuus kuntoon on käyttää oikeaa HTML-elementtiä sen sijaan, että keksisit sen käyttäytymisen uudelleen tyhjästä. Katso eroa:
jsx
// Huono – näyttää painikkeelta, mutta ei ole sellainenconst SaveButton = ({ onSave }) => (<div className="btn" onClick={onSave}>Tallenna muutokset</div>);
Tässä div-elementissä on vakavia puutteita, jotka eivät heti pistä silmään:
- Se ei ole fokusoitavissa – sitä ei tavoita Tab-näppäimellä.
- Se ei reagoi näppäimistöön – Enter ja välilyönti eivät tee mitään, koska Reactin
onClickkuuntelee vain hiiri-/kosketustapahtumia. - Sillä ei ole
button-roolia – ruudunlukija lukee sen tavallisena tekstinä, ilman merkkiä siitä, että sen voi "aktivoida". - Sillä ei ole poissa käytöstä -tilaa – et voi vain asettaa
disabled-attribuuttia, vaan joutuisit simuloimaan sitä käsin.
jsx
// Hyvä – saat kaiken yllä olevan ilmaiseksiconst SaveButton = ({ onSave, isSaving }) => (<button type="button" onClick={onSave} disabled={isSaving}>{isSaving ? "Tallennetaan…" : "Tallenna muutokset"}</button>);
Sama periaate pätee <nav>-elementtiin <div className="nav">:n sijaan, <label>-elementtiin, joka on sidottu kenttään htmlFor-attribuutilla sen sijaan, että placeholder teeskentelisi olevansa label, tai <ul>/<li>-elementteihin listoja varten <div>-pinon sijaan. Semanttinen HTML ei ole "siistimpää" — se kirjaimellisesti tuottaa erilaisen, rikkaamman rakenteen saavutettavuuspuuhun.
ARIA: milloin se auttaa ja milloin se haittaa
WAI-ARIA-määrittely alkaa säännöllä, joka kannattaa opetella sanatarkasti ulkoa: "No ARIA is better than Bad ARIA" — ei ARIAa on parempi kuin huono ARIA. ARIA-attribuutit eivät lisää käyttäytymistä — ne vain ohittavat sen, mitä ruudunlukija raportoi käyttäjälle. Jos lupaat jotain, mitä komponentti ei oikeasti pysty lunastamaan, olet huonommassa asemassa kuin jos et olisi lisännyt mitään.
jsx
// Huono – ARIA lupaa painikkeen, mutta mikään muu ei tue sitäconst DeleteIcon = ({ onDelete }) => (<span role="button" aria-label="Poista kohde" onClick={onDelete}>🗑</span>);
Tämä koodi kertoo ruudunlukijalle "tämä on painike" — mutta ei lisää näppäimistötukea eikä tabIndex-attribuuttia, joten näppäimistökäyttäjä ei koskaan tavoita sitä. Se on pahempi kuin ei roolia lainkaan, koska se antaa vaikutelman ominaisuuden olemassaolosta, vaikka se on fyysisesti saavuttamattomissa.
jsx
// Hyvä – käytä vain natiivia painikettaconst DeleteIcon = ({ onDelete }) => (<button type="button" onClick={onDelete} aria-label="Poista kohde"><span aria-hidden="true">🗑</span></button>);
Huomaa aria-hidden="true" emojissa — ilman sitä osa ruudunlukijoista yrittää lukea Unicode-merkin nimen ääneen ("roskakori"), mikä kuulostaa absurdilta heti sen jälkeen, kun aria-label on jo ilmoitettu. aria-label korvaa kokonaan sen, mitä käyttäjä kuulee näkyvänä sisältönä — jos elementillä on jo luettavaa tekstiä, kyseiseen tekstiin osoittava aria-labelledby on yleensä parempi valinta, jotta et ylläpidä kahta erillistä kuvausta, jotka voivat ajan myötä ajautua erilleen toisistaan.
Käytännön esimerkki: saavutettava accordion
Yksinkertainen painike ei näytä paljon. Katsotaan komponenttia, jossa tilaa ja ARIA-suhteita täytyy todella hallita tietoisesti — accordion, laajennettavien osioiden malli, joka on tuttu esimerkiksi UKK-sivuilta tai asetuspaneeleista.
jsx
import { useId, useState } from "react";const AccordionItem = ({ title, children, defaultOpen = false }) => {const [isOpen, setIsOpen] = useState(defaultOpen);const contentId = useId();return (<div className="accordion-item"><h3 className="accordion-header"><buttontype="button"className="accordion-trigger"aria-expanded={isOpen}aria-controls={contentId}onClick={() => setIsOpen((open) => !open)}>{title}<span className="accordion-icon" aria-hidden="true">{isOpen ? "−" : "+"}</span></button></h3><div id={contentId} role="region" aria-labelledby={contentId} hidden={!isOpen}>{children}</div></div>);};export default AccordionItem;
Muutamat päätökset tässä koodissa eivät ole sattumaa:
aria-expandedilmoittaa nykyisen tilan – ilman sitä ruudunlukijan käyttäjä kuulee vain "painike", eikä tiedä, onko osio auki.aria-controlsyhdistää painikkeen paneeliin, jota se ohjaa – jotkin ruudunlukijat ilmoittavat tästä suhteesta, mikä helpottaa navigointia.hidden(pelkän CSS:llä piilottamisen sijaan) takaa, että suljettu sisältö todella poistetaan saavutettavuuspuusta ja Tab-järjestyksestä, jolloin fokus ei voi joutua näkymättömään sisältöön.- Painikkeen ympäröivä
<h3>pitää otsikkohierarkian ehjänä – ruudunlukijan käyttäjät navigoivat hyvin usein otsikoiden avulla, hypäten osioiden välillä H-näppäimellä. +/−-kuvakkeessa onaria-hidden="true", koska tila kerrotaan joaria-expanded-attribuutilla – ilman tätä ruudunlukija ilmoittaisi sen kahdesti, sekavalla tavalla.
Live-regionit ja dynaamiset viestit
Toinen yleinen ongelma: jokin muuttuu sivulla ilman uudelleenlatausta, eikä ruudunlukija koskaan huomaa sitä, koska sillä ei ole syytä lukea uudelleen fragmenttia, jota käyttäjä ei parhaillaan tutki. Klassinen esimerkki on validointivirhe, joka ilmestyy dynaamisesti kentästä poistumisen jälkeen:
jsx
import { useState } from "react";const EmailField = () => {const [error, setError] = useState("");const handleBlur = (event) => {const value = event.target.value;setError(value.includes("@") ? "" : "Anna kelvollinen sähköpostiosoite.");};return (<div className="field"><label htmlFor="email">Sähköposti</label><inputid="email"type="email"aria-invalid={Boolean(error)}aria-describedby={error ? "email-error" : undefined}onBlur={handleBlur}/><span id="email-error" role="alert" className="field-error">{error}</span></div>);};export default EmailField;
role="alert" saa elementin käyttäytymään implisiittisenä aria-live="assertive"-alueena – kun sen sisältö muuttuu, ruudunlukija keskeyttää sen, mitä se parhaillaan lukee, ja ilmoittaa uuden viestin välittömästi. Tämä sopii virheille, jotka vaativat kiireellistä huomiota, mutta älä käytä sitä liikaa vähemmän kiireellisissä päivityksissä (kuten "luonnos tallennettu") – niissä aria-live="polite" sopii paremmin, koska se odottaa käyttäjän saavan nykyisen toimintonsa valmiiksi sen sijaan, että keskeyttäisi hänet. aria-describedby sitoo lisäksi virheviestin kenttään, jolloin ruudunlukija lukee sen yhdessä labelin kanssa aina, kun käyttäjä palaa kyseiseen kenttään — ei vain sillä hetkellä, kun virhe ensin ilmestyi.
Fokuksen hallinta navigoinnissa Next.js:ssä
Tämä sudenkuoppa on ominainen single-page-sovelluksille, mukaan lukien Next.js App Router. Klassisessa sivunvaihdossa (täysi uudelleenlataus) selain palauttaa fokuksen <body>-elementtiin, ja ruudunlukija ilmoittaa uuden dokumentin otsikon — käyttäjä tietää saapuneensa uudelle sivulle. Client-side-navigoinnissa mikään tästä ei tapahdu automaattisesti: fokus pysyy siinä linkissä, jota klikattiin (usein jossain navigaatiossa, uuden sisällön ulkopuolella), eikä ruudunlukija saa mitään signaalia siitä, että mikään olisi ylipäätään muuttunut.
jsx
"use client";import { usePathname } from "next/navigation";import { useEffect, useRef } from "react";const RouteAnnouncer = ({ pageTitle }) => {const pathname = usePathname();const headingRef = useRef(null);useEffect(() => {headingRef.current?.focus();}, [pathname]);return (<h1 ref={headingRef} tabIndex={-1} className="visually-focusable-heading">{pageTitle}</h1>);};export default RouteAnnouncer;
Temppu on tabIndex={-1} – normaalisti otsikot eivät ole fokusoitavissa, mutta tämä arvo antaa mahdollisuuden siirtää fokuksen niihin ohjelmallisesti (.focus()) lisäämättä niitä luonnolliseen Tab-järjestykseen. Jokaisen polunvaihdon (pathname) jälkeen fokus palaa uuden sivun otsikkoon, jolloin ruudunlukija ilmoittaa sen otsikon — aivan kuten perinteisessä uudelleenlatauksessa. Se on komponentti, joka kannattaa lisätä layoutiin kerran eikä koskaan ajatella sen jälkeen.
Miten saavutettavuutta oikeasti testataan
Automaattiset työkalut kuten axe-core tai eslint-plugin-jsx-a11y kannattaa ottaa käyttöön projektin ensimmäisestä päivästä lähtien — ne kiinni ilmeiset virheet (puuttuva alt, huono kontrasti, puuttuvat labelit) ennen kuin ne päätyvät tuotantoon. Mutta ole tietoinen niiden rajoituksista: Deque Systemsin tutkimuksen mukaan automaattiset työkalut löytävät realistisesti noin 30–40 % saavutettavuusongelmista. Loput vaativat manuaalista tarkistusta, koska kyse on merkityksestä ja kontekstista, joita kone ei pysty arvioimaan — onko lukujärjestys todella järkevä, selittääkö virheviesti todella, mitä pitää tehdä, ei todella päästä käyttäjää pakoon modaalissa olevasta fokusloukusta.
Konkreettinen, toistettava prosessi, joka löytää suurimman osan todellisista ongelmista:
- Laita hiiri pois ja käy koko kulku läpi pelkällä näppäimistöllä – Tab, Shift+Tab, Enter, välilyönti, Escape, nuolinäppäimet siellä, missä se on luonnollista (esim. valikossa). Jos et missään vaiheessa tiedä, missä fokus on, se on jo bugi.
- Kytke VoiceOver päälle (macOS: Cmd+F5) tai NVDA (Windows, ilmainen) ja käy sama kulku läpi silmät kiinni. Vain tämä paljastaa, onko järjestys, nimet ja tilat todella järkeviä ääneen kuultuna, ei vain paperilla.
- Testaa dynaamiset viestit erikseen – laukaise lomakevirhe, vaihda reittiä, avaa modaali — ja tarkista, ilmoittiko ruudunlukija todella jotain, ei vain sitä, onko elementillä oikea attribuutti koodissa.
Yhteenveto
Saavutettavat komponentit eivät ole attribuuttien tarkistuslista, joka ruuvataan kiinni lopuksi — ne ovat seurausta siitä, miten mallinnat tilaa ja rakennetta alusta lähtien. Kolme asiaa, jotka kannattaa ottaa mukaan tästä artikkelista: Ensinnäkin semanttinen HTML antaa fokusoitavuuden, näppäimistötuen ja oikean roolin ilmaiseksi — ARIAn pitäisi täydentää sitä, mitä HTML ei pysty ilmaisemaan, ei korvata sitä komponenteissa, jotka voisivat yhtä hyvin olla natiiveja elementtejä. Toiseksi tilan ja sisällön muutokset on aktiivisesti ilmoitettava — aria-live, role="alert" ja fokuksen hallinta navigoinnin jälkeen eivät ole lisäominaisuuksia, vaan edellytys sille, että dynaaminen sovellus on ylipäätään käytettävissä ilman näköä. Kolmanneksi mikään automaattinen työkalu ei korvaa oman käyttöliittymän läpikäymistä silmät kiinni — se on nopein tapa nähdä tarkalleen, missä kohtaa "saavutettava" komponenttisi oikeasti menettää käyttäjän.