Съдържание на курса
Урок 10 — Форми и валидация
От Dorian Chávez · основател на Hábil и архитект на интеграции ·
Време: две сесии от около 90 минути. Разпределение, което работи: първата, „Защо, преди как“ и от 10.1 до 10.4 (онова, което браузърът вече валидира, validity и setCustomValidity, :user-invalid и грешката, написана там, където се чете), с отворените им фигури в браузъра; втората, цялата 10.5, която сглобява таблото в седем стъпки, всяка от които се проверява преди следващата, и после „Грешката, която ще видиш“ и упражненията. Всяка сесия завършва с нещо, което можеш да отвориш и да изпробваш.
Какво изграждаш: формата за добавяне на услуги в revisor и филтрите за търсене сред тях
Какво научаваш: валидацията, която браузърът вече носи, :user-invalid и как да кажеш една грешка така, че екранният четец да я обяви
След урока ще можеш да
- Избираш
typeна едно поле и атрибутите (required,minlength,pattern,min,max,step), които карат браузъра да валидира без ред JavaScript. - Четеш кое правило нарушава едно поле с
validityи пишеш собствено правило съсsetCustomValidity, без да оставяш полето невалидно завинаги. - Обясняваш разликата между
:invalidи:user-invalidи защо една форма не бива да се отваря в червено. - Показваш грешка като текст в страницата, свързана с полето си чрез
aria-describedbyиaria-invalid, така че екранният четец да я обяви. - Пишеш филтър като чиста функция и го свързваш с поле за търсене и със селектор.
- Казваш, с пример, защо валидирането в браузъра не е сигурност.
Защо, преди как
До предишния урок revisor само гледа: носи файл с услуги, чертае го и изчислява колко са налични и колко се бавят средно. Да гледаш е половината от работата на онзи, който оперира набор от услуги. Другата половина е да действаш: да регистрираш такава, която току-що се е родила, и бързо да намериш онази, която създава проблеми, когато списъкът престане да се побира на един екран.
И двете неща са форми. Формата е мястото на страницата, където човек предава данни на програмата, и това я прави различна от всичко, което си правил досега. Услугите в JSON файла си ги написал ти или ги е написала програма, която познаваш. Онова, което някой напише в текстово поле, го е написал човек, който бърза, с телефона в едната ръка, който може да остави полето празно, да сложи име, което вече съществува, да напише „хиляда“ там, където се очаква число, или да постави текст от двеста знака. В урок 7 научи, че на данните отвън не се има доверие, когато се чертаят (textContent, никога innerHTML). В урок 8 научи да гледаш response.ok, преди да приемеш един отговор за добър, а в 9, че една заявка може да се провали по няколко начина и че всяка повреда се превежда на изречение, което човекът разбира. Този урок добавя третия входен път: онова, което пише човекът.
Има добра новина и тя е причината този урок да е кратък откъм JavaScript: браузърът вече знае да валидира форми. Знае го от години. С няколко атрибута на HTML —които вече познаваш от урок 2, защото са част от семантиката на полетата— браузърът не позволява да се изпрати непълна форма, съобщава какво липсва и мести фокуса към грешното поле. Лошата новина е онази, която заема втората половина на урока: онова, което браузърът показва сам, е балонче, което изчезва за секунди, не може да се стилизира, е на езика на браузъра и не винаги стига до онзи, който използва екранен четец. Табло, което всеки може да използва, се нуждае грешката да остане написана в страницата, до полето, където може да се препрочете.
Затова редът на урока е същият, който си струва да следваш винаги: първо онова, което браузърът прави безплатно, после правилният момент да се покаже грешката и накрая текстът на грешката. Ако започнеш с писане на JavaScript, накрая пренаписваш, по-зле, онова, което вече е било готово.
Състоянието на таблото в края на урок 9
Този урок тръгва от конкретно табло и е по-добре да се каже с всички букви, отколкото да те оставя да го предполагаш. В края на урок 9 revisor има тези части:
| Част | Какво прави | От кой урок идва |
|---|---|---|
index.html |
скелетът: заглавна част с „Última revisión“ (Последна проверка), обобщението, и в секцията с услуги зоните за известия (#notice и #error-notice, налични от самото начало), бутона „Reintentar“ (Опитай отново) и зона с данни с лентата с контроли —търсачката и радио бутоните „Mostrar“ (Покажи), още без действие, „Revisar ahora“ (Провери сега) и „Ordenar“ (Подреди)—, таблицата с caption и подробностите |
2, 4, 5, 7, 8 и 9 |
css/styles.css |
слоеве, променливи за цветове, значки за състояние (status-available, status-down), подредбата, таблицата, видимия фокус, класа .visually-hidden и известията |
3, 4, 5, 7 и 9 |
js/stats.js |
countByStatus, averageResponseMs и summarize, чисти функции върху масива с услуги |
6 |
js/load.js |
loadServices(url, timeoutMs): fetch с времеви лимит, проверява response.ok и хвърля Error със съобщение, което човек може да прочете |
8 и 9 |
js/state.js |
състоянието (phase, services, errorMessage, checkedAt, sortByTime, selected) и единствените функции, които го променят |
7, 8 и 9 |
js/view.js |
render(state, elements): чертае трите ситуации (зарежда се, грешка, празно), обобщението, часа и таблицата, винаги с textContent |
7, 8 и 9 |
js/main.js |
събира частите: зарежда, слуша събития, променя състоянието и чертае наново; приема ?case=empty, ?case=error, ?case=invalid и ?case=timeout, за да се види всяка ситуация |
8 и 9 |
data/services.json и data/services-empty.json |
петте примерни услуги с ключовете id, name, status (available или down) и responseMs; и празен списък |
6 и 8 |
С това табло, отворено на http://127.0.0.1:8000/09-cuando-algo-falla/panel/ (сървърът се стартира от папката programas/ на хранилището на курса, изтеглено на компютъра ти, както в урок 9; за да може ?case=timeout наистина да изчерпи времето, стартирай го с python3 09-cuando-algo-falla/slow-server.py, както там), обобщението казва 5, 4 от 5, 1 и 465 ms, а таблицата има пет реда, всеки с бутона си „Ver detalle“ (Виж подробности).
Онова, което таблото още няма: никакъв начин да се добави услуга, и търсачка и група радио бутони, които са в страницата от урок 2, без да правят нищо. Урок 2 го предупреди: „Todavía no filtra ni busca nada: eso llega más adelante“ (Още не филтрира, нито търси: това идва по-нататък). Днес идва: изграждаш формата за добавяне и правиш търсачката и радио бутоните да филтрират. В този урок файловете са в 10-formularios-validacion/panel/, което е таблото от урок 9 плюс новото.
Понятията
В този урок има три нови идеи и се опират една на друга: валидацията, която носи браузърът (10.1 и 10.2), моментът, в който е удобно да се покаже грешката (10.3), и грешката, написана там, където се чете (10.4). Раздел 10.5 събира трите в таблото.
Преди да започнем, една инструкция за навик за всички уроци оттук нататък: предскажи, преди да изпълниш. Всеки път, когато видиш програма, преди да я отвориш, запиши в дневника какво мислиш, че ще се случи. Да познаеш е приятно, но да сгрешиш учи повече: разликата между онова, което си предсказал, и онова, което е станало, е точно онова, което ти остава да разбереш.
10.1 Браузърът вече знае да валидира
Да започнем с конкретното. Страницата по-долу е форма за заявяване на известие, когато една услуга падне: имейл, името на услугата и колко трябва да се бави, за да се счита за проблем. Прочети я и предскажи: какво става, ако натиснеш „Pedir aviso“ (Поискай известие) с всичко празно? А ако напишеш dorian@ в имейла?
Програмата в края е кратка и прави само едно нещо: когато формата се изпрати, записва онова, което е било въведено. Използва нова част, new FormData(form). Спомни си от „Да решаваш, да повтаряш и да сигнализираш за грешка“ в урок 6, че new произвежда нов обект по калъп; този калъп, FormData, чете всички полета на формата по атрибута им name, а data.get("email") връща онова, което е написано в полето, което се казва email. Ще го видиш отново, по-подробно, в таблото.
<!-- fig10_01.html -->
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Avisarme cuando caiga un servicio</title>
<link rel="icon" href="data:,">
</head>
<body>
<main>
<h1>Avisarme cuando caiga un servicio</h1>
<p>Los campos marcados con * son obligatorios.</p>
<form id="alert-form">
<p>
<label for="email">Correo *</label><br>
<input id="email" name="email" type="email" required autocomplete="email">
</p>
<p>
<label for="service">Servicio *</label><br>
<input id="service" name="service" type="text" required minlength="2">
</p>
<p>
<label for="threshold">Avisar si responde en más de (ms)</label><br>
<input id="threshold" name="threshold" type="number" min="100" max="60000" step="100" value="1000">
</p>
<button type="submit">Pedir aviso</button>
</form>
<p id="result" role="status"></p>
</main>
<script type="module">
const form = document.getElementById("alert-form");
const result = document.getElementById("result");
form.addEventListener("submit", (event) => {
event.preventDefault();
const data = new FormData(form);
result.textContent =
`Aviso pedido: ${data.get("email")} recibirá un correo si ` +
`${data.get("service")} tarda más de ${data.get("threshold")} ms.`;
});
</script>
</body>
</html>
Отвори я с локалния сървър, който вече познаваш (стартиран от папката programas/ на хранилището; тази страница е в programas/10-formularios-validacion/, на http://127.0.0.1:8000/10-formularios-validacion/fig10_01.html) и натисни бутона, без да пишеш нищо. Без ред JavaScript, който да валидира, браузърът не изпраща формата, премества фокуса към имейла и показва балонче, което казва, че полето трябва да се попълни. Напиши dorian@ и натисни отново: браузърът казва, че адресът е непълен, и го казва с точно изречение (Chrome на испански: „Ingresa texto después del signo "@"…“; друг браузър използва други думи). С ana@ejemplo.mx и име на услуга формата се изпраща и малката програма по-долу записва резултата.
Страницата, когато се зареди, показва само това:
Avisarme cuando caiga un servicio
Los campos marcados con * son obligatorios.
Correo *
Servicio *
Avisar si responde en más de (ms)
Pedir aviso
Всичко това излезе от четири атрибута. Да ги разгледаме един по един, защото са кутията с инструменти на валидацията и си струва да знаеш какво обещава всеки.
required казва, че полето не може да е празно. Най-използваният и най-простият: в текстово поле означава „поне един знак“, в отметка „отметната“, а в <select> „избрана е опция и тя не е тази за покана“. Този последен случай има точно правило, което изненадва: ако първата опция има value="" (като „Elige un estado“, Избери състояние, във формата на таблото), спецификацията на HTML я нарича опция за покана (placeholder label option) и, ако е избраната, полето се счита за празно. Обърни внимание, че правилото е за първата опция, не за всяка празна стойност: при подготовката на този урок беше проверено в Chrome, че втора опция с value="" оставя <select required> валиден. Това е стандартният начин да се сложи опция за покана, без някой да може да я изпрати случайно, и затова е винаги първа.
type не само променя клавиатурата, която се появява на телефона: също валидира. type="email" отхвърля онова, което не прилича на адрес, type="url" онова, което не прилича на URL, а type="number" онова, което не е число. Една честна бележка за email: браузърът приема a@b, защото според спецификацията адрес без точка в домейна е валиден (съществуват във вътрешни мрежи). Валидира формата, не това, че имейлът съществува. За да разбереш дали съществува, има само една проверка: да изпратиш съобщение и да чакаш някой да го отвори.
minlength и maxlength ограничават дължината. maxlength пречи да се пише над границата, мълчаливо, и затова има лоша слава: човекът пише и не става нищо, без обяснение. Използвай го, когато границата е реална (поле в базата с този размер) и съобщавай колко остава. minlength, напротив, се проверява само когато човекът редактира полето. Ако HTML носи начална стойност, която вече нарушава minlength, браузърът не я отбелязва; при подготовката на този урок беше проверено с value="ab" и minlength="5": флагът tooShort остава изгасен, докато някой не пише.
Необходима скоба: регулярните изрази. Атрибутът, който следва, pattern, получава регулярен израз, а по-нататък таблото ще използва друг в JavaScript. Регулярният израз е шаблон, записан в мини-език, който описва семейство от текстове: вместо да каже „текстът е Pagos“, казва „текстът започва с буква и после носи каквото и да е“. Повечето знаци важат за себе си (a е буквата a), а няколко имат специално значение. Онези, които ще видиш в този урок, са тези:
| Част | Означава |
|---|---|
. |
какъвто и да е знак |
\S |
знак, който не е интервал (главното S отрича \s, „интервал“) |
\w |
буква без ударение, цифра или долна черта |
* |
предходното, нула или повече пъти |
+ |
предходното, едно или повече пъти |
? |
предходното е по желание: нула или един път |
( … ) |
групира няколко части, така че *, + или ? да се прилагат към цялата група |
[ … ] |
един какъвто и да е знак от тези между квадратните скоби |
^ и $ |
началото и краят на текста |
Един решен пример, този от формата на таблото: \S(.*\S)?. Чете се отляво надясно: един знак, който не е интервал; после, по желание, група, образувана от „каквото и да е“ и още един знак, който не е интервал. Казано с думи: започва и свършва с нещо, което не е интервал, а по средата може да има каквото и да е, включително интервали. Провери го със случаи: Pagos отговаря; A отговаря (незадължителната група не се появява); Mis pagos отговаря (интервалът е по средата); Pagos и Pagos не отговарят, нито празният текст. Тези шест случая бяха проверени в двигателя на JavaScript така, както ги компилира браузърът. В JavaScript регулярният израз се пише между наклонени черти, /…/, а след последната черта идват флаговете му, букви, които променят как се прилага: g (всички съвпадения, не само първото) и u или v (да разбира добре знаците на Unicode, като буквите с ударение). Ръководството на MDN за регулярните изрази има пълния списък; за този урок таблицата стига.
pattern получава регулярен израз, на който цялата стойност трябва да отговаря (изразът е закотвен: като че ли носи ^ в началото и $ в края). Във формата на таблото pattern="\S(.*\S)?" казва „започва и свършва с нещо, което не е интервал“. Две уловки: първата е, че сегашните браузъри компилират шаблона в строг режим (флагът v), в който самотно тире вътре в набор, като в [\w-]+, е синтактична грешка. Невалидният шаблон не предупреждава шумно: браузърът го игнорира и полето остава без правило, със съобщение само в конзолата. Втората уловка е нейният близнак: прекалено хитър шаблон отхвърля реални данни, като фамилия с апостроф или име с ударение. Шаблонът не е акт на вяра, а правило, което някой трябва да поддържа.
min, max и step служат за числовите полета (и за датите). min="0" max="60000" задава диапазона. step задава решетката от валидни стойности и тук има подробност с тежест: решетката се брои от min. С min="0" step="100" валидни са 0, 100, 200…, така че 1050 е невалидно, макар да е в диапазона.
Бележка за type="number", защото решението не е очевидно. Числовото поле валидира само, отхвърля онова, което не е число, и предлага стрелки за увеличаване и намаляване. „Число“, внимание, не е „само цифри“: приема също знак, десетични знаци (ако step ги позволява) и дори научна нотация; при подготовката на този урок беше проверено в Chrome 154, че 1e2 е валидна стойност и е равно на 100. Освен това тези стрелки се задействат, без да искаш, с колелото на мишката, а онзи, който използва екранен четец, не намира текстово поле, а „бутон за число“, който увеличава и намалява: неговата подразбираща се роля е spinbutton, според MDN. Дизайнерската система на британското правителство, която е изследвала темата с реални потребители, препоръчва type="text" с inputmode="numeric" за числа, които не се увеличават (така го аргументира GOV.UK Design System с изследването си), а документацията на MDN за type="number" прави същата уговорка за пощенски кодове или карти. Времето за отговор на revisor наистина е количество с диапазон („колко милисекунди“), така че използваме type="number" с min и max; ако полето ти беше номер на фиш, щеше да използваш текст с inputmode.
И още един атрибут, който не валидира, но принадлежи на тази тема: autocomplete. Казва на браузъра (и на онзи, който те чете с помощна технология) какъв вид данна иска полето. autocomplete="email" кара браузъра да предложи запазения имейл, а критерият 1.3.5 на указанията за достъпност (WCAG 2.2) изисква полетата, които събират лични данни, да декларират предназначението си по начин, който една машина разбира. Полетата на revisor (името на услуга, състояние, милисекунди) не са лични данни, така че там не съответства стойност от списъка; затова формата на таблото носи autocomplete="off": не искаме браузърът да предлага имена на услуги, които някой е писал на друго място. Списъкът с позволени стойности е в документацията на MDN за autocomplete.
Две неща, които валидацията на браузъра не е
Не е сигурност. Тя е удобство. MDN го казва без заобикалки в своето ръководство за валидация на форми: валидацията от страна на клиента не бива да се счита за изчерпателна мярка за сигурност, защото е много лесно да се прескочи. Който иска да изпрати невалидна стойност, не използва твоята форма: отваря инструментите на браузъра, маха атрибута required с едно щракване или изобщо не използва браузър и праща заявката с инструмент от командния ред. Валидацията на клиента съществува, за да греши по-малко честният човек. Валидацията, която защитава, е онази, която повтаря сървърът, и ще трябва да я напишеш там, когато таблото ти има сървър (в нашия курс по TypeScript таблото получава сървър и тогава е моментът).
В този курс таблото няма сървър: онова, което добавиш, живее в паметта на страницата и изчезва при презареждане. Това е ограничение, което се казва направо, а не недоглеждане; но навикът важи: всяка данна, която влиза, се валидира на границата, откъдето влиза.
Не е единственият механизъм. novalidate на <form> изключва автоматичната валидация при изпращане, а formnovalidate на бутон я изключва за този бутон (полезно при „Запази чернова“). Изключването ѝ не деактивира API-то за валидация, което ще видим по-нататък: формата продължава да може да пита всяко поле дали е валидно. В таблото няма да използваме novalidate: искаме браузърът да продължи да блокира изпращането и ще променим само как се показва съобщението.
Една последна подробност за изпращането: поле с disabled остава извън всичко. Не се валидира, не се брои за checkValidity() и не се изпраща с формата. При подготовката на този урок беше проверено с празно поле required и disabled: willValidate е лъжа, validity.valid е истина и полето не се появява във FormData. Таблото се възползва: ако услугата е паднала, няма смисъл да се иска времето ѝ за отговор, така че това поле се деактивира и престава да е задължително само.
10.2 Да четем състоянието на едно поле: validity и setCustomValidity
Родната валидация има две половини. Атрибутите я декларират; API-то за валидация на ограниченията (Constraint Validation API, описано в MDN и в спецификацията на HTML) ти позволява да питаш и да добавяш правила от JavaScript.
Всяко поле има свойство validity (ValidityState): обект с по един флаг за всяко правило, което може да се наруши. Онези, които ще използваш:
| Флаг | Светва, когато |
|---|---|
valueMissing |
полето е required и е празно |
typeMismatch |
стойността няма формата на type (имейл, URL) |
patternMismatch |
стойността не отговаря на pattern |
tooShort |
човекът е написал по-малко от minlength |
rangeUnderflow / rangeOverflow |
числото е под min или над max |
stepMismatch |
числото не попада в решетката на step |
badInput |
човекът е написал нещо, което полето не може да преобразува (в type="number", самотно -) |
customError |
кодът ти е извикал setCustomValidity със съобщение |
valid |
нито едно от предишните |
Страницата, която следва, обхожда тези флагове с вид цикъл, който още не си използвал: for…in. Вече познаваш for…of от урок 6, в „Да решаваш, да повтаряш и да сигнализираш за грешка“: обхожда стойностите на един списък. for (const flag in objeto) обхожда нещо друго: имената на свойствата на един обект, по едно на оборот, като текст. С { valueMissing: true, tooShort: false }, flag би бил "valueMissing" в първия оборот и "tooShort" във втория. За да прочетеш стойността на свойство, чието име е запазено в променлива, се използват квадратни скоби: control.validity[flag] е control.validity.valueMissing, когато flag е "valueMissing". Техническа подробност, която тук работи за теб: for…in посещава и свойствата, които обектът наследява от калъпа си, а флаговете на validity живеят точно там, в калъпа ValidityState; затова цикълът ги намира всичките (MDN: for…in). В обект, написан от теб, където наследените свойства не ни интересуват, се обхожда с Object.keys или се филтрира с Object.hasOwn, както в урок 9.
Преди да прочетеш страницата, която следва, предскажи: ако напишеш -5 в числово поле с min="0" и step="100", кои флагове светват? (Един отговор е вероятен; два е верният.)
<!-- fig10_02.html -->
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Las banderas de validity</title>
<link rel="icon" href="data:,">
<style>
input { font: inherit; padding: 0.5rem; }
pre { background: #f4f6f8; padding: 0.75rem; }
</style>
</head>
<body>
<main>
<h1>Las banderas de validity</h1>
<p>
<label for="time">Tiempo de respuesta (ms)</label><br>
<input id="time" type="number" required min="0" max="60000" step="100">
</p>
<p>
<label for="name">Nombre (no puede ser «admin»)</label><br>
<input id="name" type="text" required minlength="3">
</p>
<h2>Banderas activas</h2>
<pre id="report" role="status"></pre>
</main>
<script type="module">
const time = document.getElementById("time");
const name = document.getElementById("name");
const report = document.getElementById("report");
function activeFlags(control) {
const flags = [];
for (const flag in control.validity) {
if (control.validity[flag] === true && flag !== "valid") {
flags.push(flag);
}
}
return flags.length === 0 ? "ninguna (valid: true)" : flags.join(", ");
}
function validateName() {
// Sin la rama que limpia, el campo se quedaría inválido para siempre.
name.setCustomValidity(
name.value.toLowerCase() === "admin" ? "Ese nombre está reservado." : "",
);
}
function show() {
report.textContent =
`tiempo -> ${activeFlags(time)}\n` + `nombre -> ${activeFlags(name)}`;
}
for (const control of [time, name]) {
control.addEventListener("input", () => {
validateName();
show();
});
}
show();
</script>
</body>
</html>
Страницата обхожда свойствата на validity и изброява онези, които са true. Когато я отвориш, двете празни полета нарушават своя required:
Las banderas de validity
Tiempo de respuesta (ms)
Nombre (no puede ser «admin»)
Banderas activas
tiempo -> valueMissing
nombre -> valueMissing
Напиши -5 във времето и ще видиш rangeUnderflow, stepMismatch: то е под min и освен това пада извън решетката през 100, броейки от 0. Затова казах, че един отговор е вероятен, а два е верният: няколко флага могат да светят едновременно и затова една програма, която показва съобщения, трябва да реши кое е първо. С 1050 се вдига само stepMismatch; със 70000, rangeOverflow. В името, да напишеш ad включва tooShort, а да напишеш admin изгася този флаг и включва customError.
Този последен е собственото правило. setCustomValidity("texto") казва на полето „смятай се за невалидно, и това е причината“; браузърът го използва и като текст на балончето си. Носи уловка, която причинява класическа грешка: непразно съобщение оставя полето невалидно завинаги. Браузърът не знае кога правилото ти вече е изпълнено; ти трябва да извикаш setCustomValidity(""), щом стойността е правилна. В страницата функцията validateName го прави с един-единствен израз (тернарен): ако името е admin, слага съобщението, а ако не, слага празния низ. Да махнеш този втори клон е най-лесната за допускане грешка и най-трудната за разбиране отвън, защото полето „изглежда добре“, а формата не се изпраща.
Една полезна подробност, преди да продължим: два метода позволяват да се пита за цялата форма. checkValidity() връща true или false и задейства събитието invalid във всяко невалидно поле, без да показва нищо. reportValidity() прави същото и освен това показва балончето на браузъра. Когато изпратиш форма с бутона, браузърът извиква вместо теб втория.
10.3 :user-invalid: да покажем грешката, когато му е времето
Има въпрос на дизайна, който изглежда въпрос на вкус, а е на уважение: кога се боядисва едно поле в червено? Наивният отговор е „когато е невалидно“. Проблемът е, че току-що заредено поле required е невалидно от първия миг: празно е. Ако боядисаш в червено всичко невалидно, човекът отваря формата и намира цяла стена от грешки, преди да е направил каквото и да е. Като че ли касиер те мъмри, че още не си дошъл с парите.
Псевдокласът :invalid прави точно това: съвпада с всяко поле, което нарушава правило, от зареждането на страницата. Неговият наследник :user-invalid (MDN, наличен във всички браузъри от ноември 2023 г.) съвпада само когато човекът вече е взаимодействал: когато е променил полето и е излязъл от него, или когато е опитал да изпрати формата. Това е правилото, което вече описва онова, което си искал от самото начало.
Страницата, която следва, използва три малки части, за да покаже онова, което браузърът знае. elemento.matches(":user-invalid") пита дали елементът отговаря на този CSS селектор в този момент и връща true или false. setTimeout(show), без време, иска „изпълни show, щом свърши онова, което се случва“, за да се прочете състоянието след като браузърът го е обновил. А true в края на addEventListener слуша във фазата на улавяне; причината се обяснява в 10.4, със събитието invalid.
Предскажи, преди да отвориш страницата: има две задължителни и празни полета, едното стилизирано с :invalid, а другото с :user-invalid. Кое изглежда червено при зареждане?
<!-- fig10_03.html -->
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>:invalid contra :user-invalid</title>
<link rel="icon" href="data:,">
<style>
input { font: inherit; padding: 0.5rem; border: 3px solid #8a949e; }
#old-field:invalid { border-color: #b3261e; }
#new-field:user-invalid { border-color: #b3261e; }
pre { background: #f4f6f8; padding: 0.75rem; overflow-wrap: anywhere; white-space: pre-wrap; }
</style>
</head>
<body>
<main>
<h1>:invalid contra :user-invalid</h1>
<form id="form">
<p>
<label for="old-field">Con :invalid (rojo desde que abres la página)</label><br>
<input id="old-field" required>
</p>
<p>
<label for="new-field">Con :user-invalid (rojo cuando la persona ya intervino)</label><br>
<input id="new-field" required>
</p>
<button type="submit">Enviar</button>
</form>
<h2>Lo que el navegador sabe</h2>
<pre id="report" role="status"></pre>
</main>
<script type="module">
const form = document.getElementById("form");
const newField = document.getElementById("new-field");
const report = document.getElementById("report");
function show() {
report.textContent =
`valueMissing: ${newField.validity.valueMissing}\n` +
`:invalid: ${newField.matches(":invalid")}\n` +
`:user-invalid: ${newField.matches(":user-invalid")}`;
}
form.addEventListener("submit", (event) => event.preventDefault());
for (const type of ["input", "focusout", "invalid"]) {
form.addEventListener(type, () => setTimeout(show), true);
}
show();
</script>
</body>
</html>
Когато я заредиш, отчетът по-долу казва какво браузърът знае за второто поле:
:invalid contra :user-invalid
Con :invalid (rojo desde que abres la página)
Con :user-invalid (rojo cuando la persona ya intervino)
Enviar
Lo que el navegador sabe
valueMissing: true
:invalid: true
:user-invalid: false
Първото поле вече е червено, а второто не е, макар и двете да са еднакво празни. Последният ред го прави видимо: :invalid е истина, а :user-invalid е лъжа. Сега направи това, в този ред:
- Щракни върху второто поле, напиши една буква, изтрий я и натисни Tab. Отчетът се променя:
:user-invalidстава истина и полето става червено. - Презареди и натисни „Enviar“ (Изпрати), без да докосваш нищо: двете изглеждат червени и отчетът също отбелязва
:user-invalidкато истина.
Ето разлика между браузърите, която заслужава да се каже, защото може да те обърка при сравнение. Ако щракнеш върху празно поле и излезеш от него, без да напишеш нищо, Chrome 154 оставя :user-invalid на лъжа, но Firefox 155 го прави истина. И двете неща бяха проверени при подготовката на този урок; в Safari не беше изпробвано. Спецификацията оставя пространство в това какво се брои за „взаимодействие“ и практическото следствие е правило за дизайн: не разчитай на :user-invalid за полетата, които човекът е прескочил. За тях сигурният момент е изпращането, което във всички браузъри отбелязва всичко.
Стилът на таблото използва двете неща едновременно и това ще видиш в css/styles.css: .field :user-invalid, .field [aria-invalid="true"] { … }. Първата част боядисва онова, което браузърът реши да отбележи; втората онова, което отбелязва нашият код. Не е излишък, а покритие: по който и да е от двата пътя полето се вижда с грешка.
И едно напомняне за достъпността, което върви с цвета: червеното не е съобщение. Поле с червена рамка и нищо повече е невидимо за онзи, който не различава червеното, и нямо за онзи, който използва екранен четец. Цветът придружава; текстът информира. Това е, което следва.
10.4 Грешката, написана там, където се чете
Балончето на браузъра изпълнява функция, но има четири граници, които имат значение. Изчезва за няколко секунди, така че онзи, който се нуждае от повече време за четене, вече го няма. Не може да се стилизира. Излиза на езика на браузъра и с думите на производителя (при подготовката на този урок Chrome на испански каза „Ingresa texto después del signo "@". La dirección "dorian@" está incompleta.“; в друг браузър или език би било друго изречение). И с екранен четец обявяването му зависи от всяка комбинация от браузър и четец. Указанията за достъпност го предупреждават в документа, който обяснява критерия 3.3.1, Идентифициране на грешки (ниво A): грешката трябва да се идентифицира и опише в текст, а самият този документ препоръчва да не се разчита само на родната валидация заради границите ѝ при уголемяване на екрана, постоянството на съобщението и множествените грешки.
Решението се побира в четири части и ще ги видиш заедно в минимална страница, преди да ги използваме в таблото.
Първа част: съобщението е абзац от страницата, който съществува от самото начало (празен) и се запълва, когато има грешка. Живее до полето и се пише с textContent.
Втора част: полето сочи към съобщението си с aria-describedby. Този атрибут (MDN) казва на помощната технология: „текстът на този елемент описва това поле“. Етикетът (<label>) дава името на полето и се чете първи; описанието се чете след това. Когато фокусът падне върху полето, четецът казва нещо като „Correo, поле за редактиране, Escribe tu correo“. Приема няколко идентификатора, разделени с интервал: в таблото всяко поле сочи към помощта си („Al menos 2 caracteres“, поне 2 знака) и към грешката си. Проверено е в дървото за достъпност на Chrome: описанието на полето за името излезе като „Al menos 2 caracteres. No puede repetirse. Escribe el nombre del servicio.“.
Трета част: aria-invalid="true" отбелязва полето като невалидно (MDN). Така четецът може да каже „невалидно“ при стигане. MDN уточнява кога да се слага: след опит за изпращане или за валидиране, никога от зареждането на страницата в празно поле, защото би било същата червена стена както преди, но изговорено.
Четвърта част: фокусът се отвежда към първото поле с грешка. Това е, което браузърът правеше с балончето си, и когато го изключиш, трябва да се прави на ръка. Който навигира с клавиатура, пада точно там, където трябва да поправи, а който използва екранен четец, чува веднага името, грешката и състоянието. Това е и най-голямата помощ за критерия да се оперира всичко с клавиатура (2.1.1, ниво A).
Как се изключва балончето, без да се изключва валидацията? Със събитието invalid. Всеки път, когато браузърът проверява поле и го намери невалидно, задейства invalid върху него (MDN), и ако кодът ти извика preventDefault(), браузърът не показва балончето си. Две уловки: събитието не се изкачва по дървото (не изплува), така че слушател, поставен на <form>, го получава само ако го регистрираш във фазата на улавяне (третият аргумент true); проверено е с обикновен слушател във формата и не получи нищо. И тъй като си отменил събитието, браузърът също не мести фокуса: правиш го ти.
Предскажи какво ще направи тази страница с празното поле и с dorian (без знака @):
<!-- fig10_04.html -->
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Un campo, un error que se lee</title>
<link rel="icon" href="data:,">
<style>
input { font: inherit; padding: 0.5rem; border: 2px solid #8a949e; }
[aria-invalid="true"] { border-color: #b3261e; }
.error { color: #b3261e; font-weight: 600; min-height: 1.5rem; margin: 0.25rem 0; }
</style>
</head>
<body>
<main>
<h1>Un campo, un error que se lee</h1>
<form id="form">
<p>
<label for="email">Correo *</label><br>
<input id="email" name="email" type="email" required autocomplete="email"
aria-describedby="email-error">
</p>
<p id="email-error" class="error"></p>
<button type="submit">Pedir aviso</button>
</form>
<p id="result" role="status"></p>
</main>
<script type="module">
const form = document.getElementById("form");
const email = document.getElementById("email");
const error = document.getElementById("email-error");
const result = document.getElementById("result");
function message() {
if (email.validity.valueMissing) return "Escribe tu correo.";
if (email.validity.typeMismatch) return "Falta algo: un correo se ve así, nombre@dominio.mx.";
return "";
}
email.addEventListener("invalid", (event) => {
event.preventDefault();
error.textContent = message();
email.setAttribute("aria-invalid", "true");
email.focus();
});
email.addEventListener("input", () => {
if (!email.hasAttribute("aria-invalid")) {
return;
}
error.textContent = message();
if (email.validity.valid) {
email.removeAttribute("aria-invalid");
}
});
form.addEventListener("submit", (event) => {
event.preventDefault();
result.textContent = `Aviso pedido para ${email.value}.`;
});
</script>
</body>
</html>
Когато се зареди, показва само формата:
Un campo, un error que se lee
Correo *
Pedir aviso
Натисни бутона с празното поле: появява се „Escribe tu correo.“ (Напиши имейла си.), полето става червено и фокусът пада върху него. Напиши dorian и, докато пишеш, съобщението се обновява на „Falta algo: un correo se ve así, nombre@dominio.mx.“ (Нещо липсва: имейлът изглежда така, име@домейн.mx.); когато стойността стане валидна, съобщението и отметката изчезват. Забележи три решения в кода. Съобщението зависи от флага (valueMissing или typeMismatch), не от текста на браузъра, и затова е на твоя език и с твоя глас. Обновяването, докато пише, става само ако полето вече е било отбелязано, за да не се мъмри онзи, който още не е свършил. И съобщението казва какво да се направи, не само какво не е наред: 3.3.3, Предложение при грешки, когато предложението се знае.
Известията за състояние: role="status" и role="alert"
Липсва една част: съобщенията, които не принадлежат на поле. „Услугата е добавена“, „има 3 полета с грешка“, „показват се 2 от 5 услуги“. Който гледа екрана, ги вижда; който използва екранен четец, се нуждае те да бъдат обявени, без да се мести фокусът. Това изисква критерият 4.1.3, Съобщения за състояние (ниво AA). Решава се с жив регион: елемент, чието съдържание, когато се промени, четецът го чете, без никой да го посети.
Има два и разликата е в спешността. С role="status" (MDN) известието е учтиво: четецът изчаква да свърши четенето на онова, което е четял. С role="alert" (MDN) прекъсва. MDN моли вторият да се използва внимателно, защото прекъсването е неудобство, резервирано за онова, което не може да чака (прекъсната връзка, не „5 резултата“). В таблото #notice (зарежда се или празно), #count и #form-result са status, а #error-notice (услугата не можа да се зареди) е alert.
И правилото, което най-често се нарушава, което MDN поставя на първо място: контейнерът трябва да съществува в страницата, преди съдържанието му да се промени. Ако създадеш абзаца с текста му едновременно, много четци не го обявяват, защото онова, което наблюдават, е промяна в рамките на вече познат регион. Затова трите елемента са в HTML, празни, от самото начало, и затова стиловият лист никога не ги изключва с display: none, докато са празни: това би ги извадило от дървото за достъпност, както беше измерено в урок 9.
Една граница, която се казва направо: онова, което можа да се провери при подготовката на този урок, е състоянието, което браузърът предава на помощната технология (дървото за достъпност с описанията и състоянието invalid, и че живите региони променят текста си в правилния момент). Онова, което не беше направено, е да се чуе изходът на истински екранен четец. Когато свършиш урока, направи тази проверка ти, с четеца, който носи системата ти; тя е единствената, която се брои.
10.5 В таблото: да добавяме и да филтрираме
Сега всичко заедно. Таблото получава две неща: търсачката и радио бутоните от урок 2 да филтрират таблицата и формата за добавяне на услуга. Да започнем с решенията, преди кода.
Контролите вече бяха там. Полето „Buscar servicio“ (Търси услуга) и групата „Mostrar“ с радио бутоните Todos (Всички), Disponibles (Налични) и Caídos (Паднали) са в страницата от урок 2, с етикетите си и своя <fieldset>. Не е нужно да измисляш търсачка: достатъчно е да ги слушаш. Това е печалбата от това, че си написал HTML по смисъла му от самото начало.
Филтърът е чиста функция. filterServices(services, filter) получава масива и условията и връща филтрирания масив, без да пипа страницата. Това е същият стил като js/stats.js и затова може да се тества без екран, както направи със състоянието в урок 7. Нормализира текста, преди да сравни (маха ударенията и главните букви), за да може catalo да намери Catálogo: normalize("NFD") (MDN) отделя всяка буква от ударението ѝ, а един регулярен израз изтрива знаците.
Състоянието расте с едно поле. state.filter пази текста и избраното състояние; visible(state) първо филтрира и после подрежда. Не се филтрира, като се пипат редове от DOM: филтрира се, като се чертае наново от данните, както научи в урок 7.
Броят се обявява със закъснение. Таблицата се обновява при всеки клавиш, но известието „Mostrando 3 de 5 servicios.“ (Показват се 3 от 5 услуги.) изчаква 400 ms от последния клавиш. Без това изчакване екранният четец би обявявал „Mostrando 4… Mostrando 3… Mostrando 1…“ буква по буква и онова, което е трябвало да помага, пречи. Това е setTimeout (MDN), който се отменя и се нагласява наново при всеки клавиш.
Формата се възползва от онова, което вече има. Атрибутите правят валидацията; програмата само променя мястото, където се показва съобщението. Името трябва да е уникално: това е правило, което браузърът не познава, така че върви със setCustomValidity и почистването му. Времето за отговор се деактивира, ако услугата е паднала. Всяко поле винаги резервира реда на съобщението си (min-height), за да не подскача страницата, когато някой сгреши; в урок 11 ще видиш, че тези скокове се измерват.
Формата служи и при празен списък. С ?case=empty таблото казва „No hay servicios que revisar.“ и няма таблица. Там формата остава видима: това е начинът да се регистрира първата. И там е „Reintentar“, което урок 9 показва при празния списък, защото „Revisar ahora“ живее в зоната с данните, скрита в този случай. Затова зоната на формата зависи от фазата ("ready"), а не от това, че има редове.
След добавяне се съобщава и се връща към първото поле. Съобщението казва колко услуги има сега и, ако сегашният филтър скрива новата, казва и това: без него онзи, който филтрира по „Caídos“ и добави налична, би видял, че „не стана нищо“.
Новите файлове са js/filters.js и js/form.js (този превежда флаговете на validity на изречения и чете формата). Променят се js/state.js, js/view.js, js/main.js, index.html и css/styles.css. Остават както ги остави урок 9: js/load.js, js/stats.js, data/services.json и data/services-empty.json.
Ще го изградиш в седем стъпки, от чистите части към онези, които пипат страницата. Всяка стъпка има една и съща форма: първо какво прави файлът и защо, после кодът му, и накрая как проверяваш, че е излязъл добре, преди да минеш към следващата. Редът не е прищявка: до стъпка 6 таблото продължава да работи точно както в урок 9, защото всяка нова част се добавя, без още никой да я използва, и така всяка грешка, която се появи, е от последната стъпка, която си направил. Само стъпка 7 свързва всичко. Проверките с конзолата използват await import("./js/archivo.js"), което зарежда модул от конзолата на инструментите на браузъра с отворено табло и ти позволява да извикваш функциите му на ръка.
Стъпка 1 — js/filters.js: кои услуги минават филтъра
Какво прави и защо. Решава кои услуги отговарят на търсения текст и на избраното състояние. Това е най-краткият файл на урока и онзи, който най-много си струва да се разбере, защото не знае нищо за страницата: получава масив и връща друг. Има две функции. normalize(text) оставя един текст готов за сравнение: normalize("NFD") отделя всяка буква от ударението ѝ („á“ става „a“ плюс знак за ударение), а после .replace(/\p{Diacritic}/gu, "") изтрива знаците. Този /\p{Diacritic}/gu е регулярен израз като тези от скобата в 10.1: \p{Diacritic} означава „всеки знак, който Unicode класифицира като диакритичен“, тоест ударенията и тремата; флагът g кара да се изтрият всички, а не само първият, а u е онзи, който позволява да се напише \p{…}. След това toLowerCase() превръща всичко в малки букви, а trim() маха интервалите по краищата. Втората функция, filterServices, използва filter от урок 6 с двойно условие: нормализираното име включва (includes) търсения текст и състоянието е избраното или е избрано „Todos“.
// panel/js/filters.js
// Decide qué servicios pasan el filtro. Son funciones puras: no tocan el documento.
// Quita acentos y mayúsculas para que "catalo" encuentre "Catálogo".
export function normalize(text) {
return text
.normalize("NFD")
.replace(/\p{Diacritic}/gu, "")
.toLowerCase()
.trim();
}
// filter = { text: "", status: "all" | "available" | "down" }
export function filterServices(services, filter) {
const wanted = normalize(filter.text);
return services.filter((service) => {
const nameMatches = normalize(service.name).includes(wanted);
const statusMatches = filter.status === "all" || service.status === filter.status;
return nameMatches && statusMatches;
});
}
Как проверяваш. С отворено табло (още изглежда както в урок 9) напиши в конзолата const f = await import("./js/filters.js") и после f.normalize(" CATÁLOGO "). Трябва да отговори "catalogo": без ударение, без главни букви и без интервали. Така беше проверено в Chrome 154.
Стъпка 2 — js/state.js: състоянието се учи да филтрира
Какво прави и защо. Това е състоянието от урок 9 с едно поле повече и три нови функции. Новото е filter, changeFilter, nameExists, addService и това, че visible филтрира, преди да подреди. Три инструмента на JavaScript се появяват тук за първи път. Object.assign(destino, cambios) копира в destino всяко свойство на cambios и оставя непокътнати останалите: ако state.filter е { text: "cat", status: "all" } и дойде { status: "down" }, резултатът е { text: "cat", status: "down" }. Затова changeFilter може да получи само онова, което се е променило, дошло от търсачката или от радио бутоните. some, от урок 6, отговаря дали поне една услуга отговаря на условието, което е точно въпросът „това име вече съществува ли?“. А push добавя елемент в края на масив; тук масивът наистина се променя, защото да добавиш услуга е точно да промениш списъка.
// panel/js/state.js
// El estado del panel y las únicas formas de cambiarlo.
// No toca el documento: por eso se puede probar sin pantalla.
import { filterServices, normalize } from "./filters.js";
export function createState() {
return {
phase: "loading", // "loading" | "error" | "ready"
services: [],
errorMessage: null, // texto para la persona, solo en la fase "error"
checkedAt: null, // cuándo llegaron los datos por última vez (un Date), o null
sortByTime: false, // false = en el orden original; true = del más rápido al más lento
selected: null, // el id del servicio elegido, o null
filter: { text: "", status: "all" },
};
}
export function startLoading(state) {
state.phase = "loading";
state.errorMessage = null;
}
export function loadSucceeded(state, services, checkedAt) {
state.phase = "ready";
state.services = services;
state.checkedAt = checkedAt;
state.selected = null;
}
export function loadFailed(state, message) {
state.phase = "error";
state.errorMessage = message;
}
// Lo que la pantalla debe mostrar: "empty" no es una fase que se guarde, se DEDUCE.
// Una lista vacía que llegó bien es un resultado válido, no un error.
export function situation(state) {
if (state.phase === "ready" && state.services.length === 0) return "empty";
return state.phase;
}
export function toggleSort(state) {
state.sortByTime = !state.sortByTime;
}
// Elegir el que ya estaba elegido lo deselecciona.
export function select(state, id) {
state.selected = state.selected === id ? null : id;
}
// changes = { text } o { status }: solo se pisa lo que llega.
export function changeFilter(state, changes) {
Object.assign(state.filter, changes);
}
export function nameExists(state, name) {
const wanted = normalize(name);
return state.services.some((service) => normalize(service.name) === wanted);
}
export function addService(state, service) {
state.services.push(service);
}
// Lo que se debe mostrar, calculado a partir del estado cada vez: primero se filtra,
// luego se ordena. filter y toSorted devuelven copias: el arreglo de los datos no se toca.
export function visible(state) {
const filtered = filterServices(state.services, state.filter);
if (!state.sortByTime) {
return filtered;
}
// Los que no tienen medida (null) van al final.
return filtered.toSorted((a, b) => (a.responseMs ?? Infinity) - (b.responseMs ?? Infinity));
}
export function selectedService(state) {
return state.services.find((service) => service.id === state.selected) ?? null;
}
Как проверяваш. Презареди таблото: трябва да изглежда същото като в урок 9, с пет реда и обобщението на 5, 4 от 5, 1 и 465 ms. Изглежда, че не е станало нищо, и това е, което се търси: филтърът стартира на „Todos“ и с празен текст, така че пропуска всички, а още никой не го променя. Ако видиш нещо различно, грешката е в този файл. Основната проверка е стъпка 3.
Стъпка 3 — Тестът без екран
Какво прави и защо. Преди да пипнем страницата, един тест, който не се нуждае от екран, като този от урок 7. Предскажи колко реда ще кажат ok:
<!-- panel/filters-test.html -->
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="data:,">
<title>Prueba de los filtros (sin dibujar nada del panel)</title>
</head>
<body>
<h1>Prueba de los filtros</h1>
<pre id="output"></pre>
<script type="module">
import { normalize, filterServices } from "./js/filters.js";
import { createState, loadSucceeded, changeFilter, nameExists, addService, toggleSort, visible } from "./js/state.js";
const services = [
{ id: "catalog", name: "Catálogo", status: "available", responseMs: 120 },
{ id: "payments", name: "Pagos", status: "available", responseMs: 480 },
{ id: "inventory", name: "Inventario", status: "down", responseMs: null },
{ id: "notifications", name: "Notificaciones", status: "available", responseMs: 310 },
{ id: "search", name: "Búsqueda", status: "available", responseMs: 950 },
];
const lines = [];
function check(description, condition) {
lines.push(`${condition ? "ok " : "FALLA "} ${description}`);
}
const names = (list) => list.map((service) => service.name).join(", ");
const all = (text) => ({ text, status: "all" });
check("normalize quita acentos y mayúsculas", normalize(" CATÁLOGO ") === "catalogo");
check("sin filtro pasan todos", filterServices(services, all("")).length === 5);
check("«catalo» encuentra Catálogo", names(filterServices(services, all("catalo"))) === "Catálogo");
check("«BUSQUEDA» encuentra Búsqueda: ni la «ú» ni las mayúsculas importan", names(filterServices(services, all("BUSQUEDA"))) === "Búsqueda");
check("el estado «down» deja solo Inventario", names(filterServices(services, { text: "", status: "down" })) === "Inventario");
check("texto y estado se combinan: «o» y «available» deja tres", names(filterServices(services, { text: "o", status: "available" })) === "Catálogo, Pagos, Notificaciones");
check("filtrar no cambia el arreglo original", services.length === 5);
const state = createState();
loadSucceeded(state, services, new Date());
check("nameExists ignora mayúsculas y acentos", nameExists(state, "catalogo") === true);
check("nameExists dice que no a un nombre nuevo", nameExists(state, "Facturas") === false);
changeFilter(state, { status: "down" });
addService(state, { id: "billing", name: "Facturas", status: "available", responseMs: 230 });
check("un servicio nuevo que no cumple el filtro se agrega pero no se ve", state.services.length === 6 && names(visible(state)) === "Inventario");
changeFilter(state, { text: "o", status: "all" });
toggleSort(state);
check("el orden se aplica después del filtro: con «o», del más rápido al más lento", names(visible(state)) === "Catálogo, Notificaciones, Pagos, Inventario");
document.querySelector("#output").textContent = lines.join("\n");
</script>
</body>
</html>
Отвори я в …/10-formularios-validacion/panel/filters-test.html и сравни с онова, което си предсказал:
Prueba de los filtros
ok normalize quita acentos y mayúsculas
ok sin filtro pasan todos
ok «catalo» encuentra Catálogo
ok «BUSQUEDA» encuentra Búsqueda: ni la «ú» ni las mayúsculas importan
ok el estado «down» deja solo Inventario
ok texto y estado se combinan: «o» y «available» deja tres
ok filtrar no cambia el arreglo original
ok nameExists ignora mayúsculas y acentos
ok nameExists dice que no a un nombre nuevo
ok un servicio nuevo que no cumple el filtro se agrega pero no se ve
ok el orden se aplica después del filtro: con «o», del más rápido al más lento
Обърни внимание на проверката на комбинацията: с „o“ биха минали само четири (също и Inventario), а само с „available“ други четири (също и Búsqueda); трите, които излизат, доказват, че се прилагат двете условия едновременно. Тест, чийто резултат би бил същият с едно-единствено условие, не би доказал комбинацията.
Стъпка 4 — index.html: контролите, които липсваха
Какво прави и защо. Спрямо урок 9 се променят малко неща: <fieldset> на радио бутоните получава id, за да се слуша; появяват се известието за броя, #count, и id на кутията на таблицата, #table-zone, за да се скрива, когато никоя услуга не минава филтъра; секцията с услуги получава класа layout-tall, който се обяснява със стиловете; и в края на <main> идва новата секция, „Agregar un servicio“ (Добави услуга). Обърни внимание на връзката на всяко поле с помощта му и с грешката му чрез aria-describedby, на елементите с role="status" (които съществуват празни от самото начало) и на това, че зоната на формата започва скрита:
<!-- panel/index.html -->
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Revisor de servicios</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="css/styles.css">
<script type="module" src="js/main.js"></script>
</head>
<body>
<header class="page-header">
<h1>Revisor de servicios</h1>
<p>Última revisión: <span id="checked-at">todavía no</span></p>
<nav>
<p>Ir a: <a href="#summary">Resumen</a> o <a href="#services">Servicios</a></p>
</nav>
</header>
<main class="layout">
<section id="summary">
<h2>Resumen</h2>
<dl class="summary">
<div>
<dt>Servicios revisados</dt>
<dd id="summary-total"></dd>
</div>
<div>
<dt>Disponibles</dt>
<dd id="summary-available"></dd>
</div>
<div>
<dt>Caídos</dt>
<dd id="summary-down"></dd>
</div>
<div>
<dt>Respuesta promedio</dt>
<dd id="summary-average"></dd>
</div>
</dl>
</section>
<section id="services" class="layout-tall">
<h2>Servicios</h2>
<!-- Las dos zonas de aviso existen desde el principio: así los lectores de pantalla
las vigilan antes de que aparezca el primer mensaje. -->
<p id="notice" class="notice" role="status">Cargando servicios…</p>
<p id="error-notice" class="notice notice-error" role="alert"></p>
<button type="button" id="retry" hidden>Reintentar</button>
<div id="data-zone" hidden>
<div class="controls">
<p class="field">
<label for="search">Buscar servicio</label>
<input type="search" id="search" name="search">
</p>
<fieldset id="status-filter">
<legend>Mostrar</legend>
<label><input type="radio" name="filter" value="all" checked> Todos</label>
<label><input type="radio" name="filter" value="available"> Disponibles</label>
<label><input type="radio" name="filter" value="down"> Caídos</label>
</fieldset>
<p><button type="button" id="check-now">Revisar ahora</button></p>
<p><button type="button" id="sort" aria-pressed="false">Ordenar por tiempo de respuesta</button></p>
</div>
<p id="count" class="notice" role="status"></p>
<div class="table-scroll" id="table-zone" role="region" aria-labelledby="table-caption" tabindex="0">
<table>
<caption id="table-caption">Estado de los servicios en la última revisión</caption>
<thead>
<tr>
<th scope="col">Servicio</th>
<th scope="col">Estado</th>
<th scope="col" class="number">Tiempo de respuesta</th>
<th scope="col">Acción</th>
</tr>
</thead>
<tbody id="services-body"></tbody>
</table>
</div>
<p id="detail" role="status"></p>
</div>
</section>
<section id="add-zone" aria-labelledby="add-title" hidden>
<h2 id="add-title">Agregar un servicio</h2>
<p>Los campos marcados con * son obligatorios.</p>
<form id="add-form" class="form-grid" autocomplete="off">
<div class="field">
<label for="new-name">Nombre *</label>
<input id="new-name" name="name" type="text" required minlength="2"
pattern="\S(.*\S)?" aria-describedby="new-name-hint new-name-error">
<p id="new-name-hint" class="hint">Al menos 2 caracteres. No puede repetirse.</p>
<p id="new-name-error" class="field-error"></p>
</div>
<div class="field">
<label for="new-status">Estado *</label>
<select id="new-status" name="status" required aria-describedby="new-status-error">
<option value="">Elige un estado</option>
<option value="available">Disponible</option>
<option value="down">Caído</option>
</select>
<p id="new-status-error" class="field-error"></p>
</div>
<div class="field">
<label for="new-response-ms">Tiempo de respuesta (ms) *</label>
<input id="new-response-ms" name="responseMs" type="number" required min="0" max="60000" step="1"
aria-describedby="new-response-ms-hint new-response-ms-error">
<p id="new-response-ms-hint" class="hint">De 0 a 60 000. Si el servicio está caído, no se pide.</p>
<p id="new-response-ms-error" class="field-error"></p>
</div>
<button type="submit">Agregar servicio</button>
<p id="form-result" class="notice" role="status"></p>
</form>
</section>
</main>
<footer>
<p>Datos de ejemplo del archivo data/services.json.</p>
</footer>
</body>
</html>
Как проверяваш. Презареди: таблото продължава да показва същото, а новата форма не се появява. Правилно е: секцията започва с hidden, а изгледът от урок 9 не знае, че съществува, така че никой не я показва. В конзолата document.querySelector("#add-zone").hidden трябва да върне true.
Стъпка 5 — css/styles.css: формата и подредбата ѝ
Какво прави и защо. Още един блок в края на css/styles.css, който отново отваря слоя components. Три неща заслужават поглед. Първото е правилото на .layout-tall: на широк екран двуколонната решетка от урок 5 би сложила новата секция на следващия ред, под таблицата, с огромна дупка до нея; ако секцията с услугите заема два реда (grid-row: span 2), формата се качва в лявата колона, точно под обобщението. Второто е селекторът за грешка, .field :user-invalid, .field [aria-invalid="true"]: носи .field отпред, защото без него би тежал по-малко от .field input, правилото, което дава рамката на всички полета, и би загубил. Това е специфичността от урок 3, която си върши работата, а изходът е да напишеш селектора, който съответства, не !important. Третото е редът, резервиран за грешката. И нещо, което го няма: никакво правило, за да се побира таблицата на 320 px. Не е нужно, защото таблицата се превърта в кутията си от урок 5; измерването на 320 px дава 320 във всичките седем стъпки на обхода, с който приключва стъпка 7.
@layer components {
/* ---- Lección 10: el formulario para agregar un servicio ---- */
/* En una pantalla ancha, la sección de servicios ocupa los dos renglones de la derecha
y el formulario sube a la columna izquierda, justo debajo del resumen. */
@media (width >= 64em) {
.layout-tall {
grid-row: span 2;
}
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(14rem, 100%), 1fr));
gap: var(--space-3);
align-items: start;
}
/* El botón y el aviso del formulario van en su propio renglón. */
.form-grid > button,
.form-grid > .notice {
grid-column: 1 / -1;
justify-self: start;
}
select {
font: inherit;
}
/* Los campos del formulario se ven como el de búsqueda de la Lección 3. */
.field input,
.field select {
min-height: 2.5rem;
padding: var(--space-1) var(--space-2);
border: 1px solid var(--color-control);
border-radius: var(--radius);
background: var(--color-surface);
color: inherit;
}
.field input:disabled {
background: var(--color-page);
}
.hint {
margin: 0;
color: var(--color-muted);
font-size: 0.875rem;
}
/* Cada campo reserva siempre la línea de su error: si el mensaje apareciera empujando
todo lo de abajo, la página daría un salto cada vez que alguien se equivoca
(y los saltos se miden: Lección 11). */
.field-error {
min-height: 1.5rem;
margin: 0;
color: var(--color-down-text);
font-weight: 600;
}
/* El error se ve cuando el navegador sabe que la persona intervino (:user-invalid)
o cuando nuestro código lo marcó (aria-invalid). Con .field delante, el selector
pesa más que «.field input» y gana sin !important. */
.field :user-invalid,
.field [aria-invalid="true"] {
border-color: var(--color-down-text);
background: var(--color-down-bg);
}
}
Как проверяваш. С прозорец над 1024 px ширина (64em) напиши в конзолата getComputedStyle(document.querySelector("#services")).gridRowStart. Трябва да отговори "span 2"; в по-тесен прозорец "auto", защото правилото живее вътре в @media.
Стъпка 6 — js/form.js: от флагове към изречения
Какво прави и защо. Превежда флаговете на validity на изречения. Всяко поле има своя таблица със съобщения. Търсенето обхожда флаговете в реда, в който са написани (затова, с -5, излиза „No puede ser negativo.“ (Не може да е отрицателно.), а не съобщението за решетката), и ако няма собствено изречение, използва текста на браузъра като последно средство. readService сглобява услугата от формата със същите ключове като в data/services.json и ѝ дава нов id с crypto.randomUUID(), която генерира идентификатор, който не се повтаря: id от урок 6 е онова, което идентифицира една услуга, и такава, която се ражда във формата, също се нуждае от свое. (Тази функция съществува само в сигурни страници: сервирани по https или от твоята собствена машина, като 127.0.0.1.) Две части от записа, които вече познаваш: цикълът for (const flag in forControl) обхожда имената на свойствата на таблицата със съобщения, както for…in на фигура 10.2, а MESSAGES[control.id] ?? {} използва нулевото обединяване от урок 6, за да получи поле без собствена таблица празен обект вместо undefined.
// panel/js/form.js
// Traduce lo que el navegador sabe de un campo a una frase para la persona, y lee el
// formulario. No dibuja nada: recibe un control y devuelve texto.
// Para cada campo, qué decir según la regla que incumple (las banderas de `validity`).
const MESSAGES = {
"new-name": {
valueMissing: "Escribe el nombre del servicio.",
tooShort: "Usa al menos 2 caracteres.",
patternMismatch: "Sin espacios al inicio ni al final.",
},
"new-status": {
valueMissing: "Elige el estado del servicio.",
},
"new-response-ms": {
valueMissing: "Escribe el tiempo en milisegundos.",
badInput: "Escribe un número, como 250.",
rangeUnderflow: "No puede ser negativo.",
rangeOverflow: "El máximo es 60 000 ms.",
stepMismatch: "Escribe un número entero.",
},
};
// Devuelve el texto del error de un control, o "" si el control es válido.
// El orden de las banderas en MESSAGES es el orden de prioridad: varias pueden
// estar encendidas a la vez y se dice una sola, la primera.
export function errorMessage(control) {
if (!control.willValidate || control.validity.valid) {
return "";
}
if (control.validity.customError) {
return control.validationMessage; // el texto que puso setCustomValidity
}
const forControl = MESSAGES[control.id] ?? {};
for (const flag in forControl) {
if (control.validity[flag]) {
return forControl[flag];
}
}
return control.validationMessage; // último recurso: el texto del navegador
}
// Lee un formulario ya validado y arma el servicio con las mismas claves que data/services.json.
// Un campo desactivado no viaja en FormData: un servicio caído queda con responseMs en null.
export function readService(form) {
const data = new FormData(form);
const time = data.get("responseMs");
return {
id: crypto.randomUUID(), // un identificador nuevo, que no choca con ninguno
name: data.get("name").trim(),
status: data.get("status"),
responseMs: time === null ? null : Number(time),
};
}
Как проверяваш. Сега, когато HTML от стъпка 4 вече има полетата, напиши в конзолата const form = await import("./js/form.js") и после form.errorMessage(document.querySelector("#new-name")). Трябва да отговори "Escribe el nombre del servicio." (Напиши името на услугата.): полето е празно и е required, а функцията е превела флага valueMissing на изречението от таблицата си. Това, че секцията е скрита, не променя нищо, защото hidden не изважда едно поле от валидацията; disabled го прави.
Стъпка 7 — js/view.js и js/main.js, заедно
Какво прави и защо. Това е стъпката, която свързва всичко, и затова са два файла, които вървят заедно. В изгледа renderCount е нова и се експортира отделно, за да може main.js да я забави; render също решава кога се вижда зоната на формата и кога таблицата (без редове се скрива).
А js/main.js расте с два блока в края, филтрите и формата. Преди да го прочетеш, ето решенията, които ще намериш в него, всяко с причината си:
- Слушателят на радио бутоните е на
<fieldset>, а не на всеки радио бутон: събитиетоchangeсе изкачва по дървото, катоclickот урок 7, така че един-единствен обслужва трите. - В слушателя на
invalidусловиетоevent.target === form.querySelector(":invalid")е вярно само за първото невалидно поле по реда на документа. Затова фокусът и общото известие стават веднъж, дори браузърът да задейства събитието три пъти. - В слушателя на
inputпървият ред изпразва известието на формата: ако човек поправя и продължава да пише, „No se agregó: hay 3 campos con error“ (Не беше добавена: има 3 полета с грешка) вече не е вярно и не бива да остава. - В слушателя на
focusoutусловието гледа:user-invalidили собствената отметка. Това е моментът да се покаже грешката на поле, което човекът редактира и напуска. Използваfocusout, а неblur, защотоfocusoutсе изкачва по дървото (MDN), и един-единствен слушател във формата обслужва трите полета. - В този на
submit,FormData(MDN) чете формата по атрибутаnameна всяко поле, а деактивираните полета не влизат. Затова паднала услуга не носиresponseMsиreadServiceзапазваnull, коетоjs/stats.jsи таблицата вече разбират.
Едно предупреждение за реда: не презареждай между двата файла. Новият изглед очаква елементи (addZone, tableZone, count), които само новият main.js му предава. Ако запазиш view.js, презаредиш и още имаш main.js от урок 9, таблицата остава празна, а конзолата на Chrome казва Cannot set properties of undefined (setting 'hidden'): изгледът се е опитал да скрие зона, която никой не му е подал. Така беше проверено при подготовката на урока; ако ти се случи, не е грешка в твоя изглед: липсва следващият файл.
Първо изгледът:
// panel/js/view.js
// Dibuja el estado en el documento, con sus tres situaciones: cargando, error y vacío.
// Todo texto de los datos entra con textContent.
import { summarize } from "./stats.js";
import { situation, visible, selectedService } from "./state.js";
// Lista cerrada de estados que el panel conoce. Un valor que no esté aquí no llega a una clase.
const LABELS = { available: "Disponible", down: "Caído" };
// La fecha y la hora de la última revisión, escritas para una persona y a la manera de México.
const TIME_FORMAT = new Intl.DateTimeFormat("es-MX", { dateStyle: "long", timeStyle: "short" });
function label(status) {
return Object.hasOwn(LABELS, status) ? LABELS[status] : "Desconocido";
}
function createRow(service, isSelected) {
const row = document.createElement("tr");
if (isSelected) row.classList.add("selected");
const nameCell = document.createElement("th");
nameCell.scope = "row";
nameCell.textContent = service.name;
const statusCell = document.createElement("td");
const badge = document.createElement("span");
badge.className = Object.hasOwn(LABELS, service.status) ? `status status-${service.status}` : "status";
badge.textContent = label(service.status);
statusCell.append(badge);
const timeCell = document.createElement("td");
timeCell.className = "number";
timeCell.textContent = service.responseMs === null ? "sin respuesta" : `${service.responseMs} ms`;
const actionCell = document.createElement("td");
const button = document.createElement("button");
button.type = "button";
button.dataset.id = service.id;
button.setAttribute("aria-pressed", String(isSelected));
const hint = document.createElement("span");
hint.className = "visually-hidden";
hint.textContent = ` de ${service.name}`;
button.append("Ver detalle", hint);
actionCell.append(button);
row.append(nameCell, statusCell, timeCell, actionCell);
return row;
}
function describe(service) {
const time = service.responseMs === null ? "sin respuesta" : `responde en ${service.responseMs} ms`;
return `${service.name}: ${label(service.status).toLowerCase()}, ${time}.`;
}
function renderCheckedAt(checkedAt, target) {
if (checkedAt === null) return;
const time = document.createElement("time");
time.dateTime = checkedAt.toISOString();
time.textContent = TIME_FORMAT.format(checkedAt);
target.replaceChildren(time);
}
function renderSummary(services, elements) {
const summary = summarize(services);
elements.total.textContent = String(services.length);
elements.available.textContent = `${summary.available} de ${services.length}`;
elements.down.textContent = String(summary.down);
elements.average.textContent = summary.averageMs === null ? "sin datos" : `${summary.averageMs} ms`;
}
// El aviso de cuántos servicios se ven. Vive aparte porque a veces se anuncia con retraso (ver main.js).
export function renderCount(state, elements) {
const total = state.services.length;
const shown = visible(state).length;
elements.count.textContent = shown === 0
? "Ningún servicio coincide con el filtro."
: `Mostrando ${shown} de ${total} ${total === 1 ? "servicio" : "servicios"}.`;
}
// elements = { notice, errorNotice, retry, dataZone, addZone, tableZone, count, checkedAt,
// total, available, down, average, body, detail, sortButton }
// options.count = false deja el aviso del conteo como estaba (main.js lo actualiza después).
export function render(state, elements, options = {}) {
const current = situation(state);
// Un solo lugar decide qué se ve. Los textos de aviso son nuestros, no vienen de fuera.
elements.notice.textContent =
current === "loading" ? "Cargando servicios…" :
current === "empty" ? "No hay servicios que revisar." : "";
elements.errorNotice.textContent = current === "error" ? state.errorMessage : "";
// Tras un error o una lista vacía hay que poder pedir otra vez, y «Revisar ahora» vive
// en la zona de datos, que en esos dos casos está oculta: por eso se muestra «Reintentar».
elements.retry.hidden = current !== "error" && current !== "empty";
elements.dataZone.hidden = current !== "ready";
// El formulario también sirve cuando la lista llegó vacía: ahí se da de alta el primero.
elements.addZone.hidden = state.phase !== "ready";
renderCheckedAt(state.checkedAt, elements.checkedAt);
// Cargando o con error no hay cifras que mostrar; vacío sí: cero servicios es un resultado.
if (current === "loading" || current === "error") {
for (const cell of [elements.total, elements.available, elements.down, elements.average]) {
cell.textContent = "";
}
} else {
renderSummary(state.services, elements);
}
if (current !== "ready") {
elements.body.replaceChildren();
elements.detail.textContent = "";
return;
}
elements.sortButton.setAttribute("aria-pressed", String(state.sortByTime));
const rows = visible(state).map((service) => createRow(service, service.id === state.selected));
elements.body.replaceChildren(...rows);
elements.tableZone.hidden = rows.length === 0;
if (options.count !== false) {
renderCount(state, elements);
}
const chosen = selectedService(state);
elements.detail.textContent = chosen === null ? "Elige un servicio para ver su detalle." : describe(chosen);
}
После логиката. Прочети js/main.js отгоре надолу: първо онова, което вече имаше (зареждане, подредба, избор) и после филтрите и формата, с коментар, който обяснява защо на всеки блок:
// panel/js/main.js
// Junta las piezas: pide los datos, guarda el resultado en el estado y dibuja.
import { loadServices } from "./load.js";
import {
createState, startLoading, loadSucceeded, loadFailed, toggleSort, select,
changeFilter, nameExists, addService, visible, situation,
} from "./state.js";
import { render, renderCount } from "./view.js";
import { errorMessage, readService } from "./form.js";
// Casos de prueba para ver cada situación sin romper nada: index.html?case=empty
// Es una lista cerrada: el texto de la dirección elige UNA de estas opciones, nunca se usa como dirección.
const CASES = {
normal: { url: "data/services.json", timeoutMs: 3000 },
empty: { url: "data/services-empty.json", timeoutMs: 3000 },
error: { url: "data/missing.json", timeoutMs: 3000 },
invalid: { url: "index.html", timeoutMs: 3000 },
// ?delay=5000 solo lo entiende slow-server.py, que tarda 5 s en contestar; el límite son 3 s.
timeout: { url: "data/services.json?delay=5000", timeoutMs: 3000 },
};
const requested = new URLSearchParams(location.search).get("case");
const current = Object.hasOwn(CASES, requested) ? CASES[requested] : CASES.normal;
const elements = {
notice: document.querySelector("#notice"),
errorNotice: document.querySelector("#error-notice"),
retry: document.querySelector("#retry"),
dataZone: document.querySelector("#data-zone"),
addZone: document.querySelector("#add-zone"),
tableZone: document.querySelector("#table-zone"),
count: document.querySelector("#count"),
checkedAt: document.querySelector("#checked-at"),
total: document.querySelector("#summary-total"),
available: document.querySelector("#summary-available"),
down: document.querySelector("#summary-down"),
average: document.querySelector("#summary-average"),
body: document.querySelector("#services-body"),
detail: document.querySelector("#detail"),
sortButton: document.querySelector("#sort"),
checkNow: document.querySelector("#check-now"),
};
const state = createState();
async function load() {
// El botón que pidió la carga se oculta mientras carga, y con él se va el foco:
// se anota para devolverlo al terminar.
const fromButton = document.activeElement === elements.checkNow || document.activeElement === elements.retry;
startLoading(state);
render(state, elements);
try {
loadSucceeded(state, await loadServices(current.url, current.timeoutMs), new Date());
} catch (error) {
loadFailed(state, error.message);
}
render(state, elements);
if (fromButton) {
(situation(state) === "ready" ? elements.checkNow : elements.retry).focus();
}
}
// Cambiar el estado y dibujar. Al volver a dibujar las filas, el botón que tenía el foco
// desaparece y aparece otro igual: hay que devolverle el foco a quien lo tenía.
function update(change) {
const focusedId = document.activeElement?.dataset?.id;
change();
render(state, elements);
if (focusedId !== undefined) {
elements.body.querySelector(`button[data-id="${CSS.escape(focusedId)}"]`)?.focus();
}
}
elements.sortButton.addEventListener("click", () => update(() => toggleSort(state)));
elements.checkNow.addEventListener("click", load);
elements.retry.addEventListener("click", load);
// Delegación: un solo oyente en el <tbody> atiende los botones de todas las filas.
elements.body.addEventListener("click", (event) => {
const button = event.target.closest("button[data-id]");
if (button === null) return;
update(() => select(state, button.dataset.id));
});
// ---- filtros ----
// La tabla se redibuja con cada tecla, pero el aviso del conteo espera a que la persona deje de
// escribir: un lector de pantalla no debe leer "Mostrando 4", "Mostrando 3", "Mostrando 1" letra por letra.
let timer;
function announceCountLater() {
clearTimeout(timer);
timer = setTimeout(() => renderCount(state, elements), 400);
}
document.querySelector("#search").addEventListener("input", (event) => {
changeFilter(state, { text: event.target.value });
render(state, elements, { count: false });
announceCountLater();
});
// Un solo oyente para los tres radios: el evento change sube hasta el <fieldset>.
document.querySelector("#status-filter").addEventListener("change", (event) => {
changeFilter(state, { status: event.target.value });
render(state, elements);
});
// ---- formulario: agregar un servicio ----
const form = document.querySelector("#add-form");
const nameField = document.querySelector("#new-name");
const statusField = document.querySelector("#new-status");
const timeField = document.querySelector("#new-response-ms");
const formResult = document.querySelector("#form-result");
// Escribe (o borra) el texto del error de un campo y marca el campo para los lectores de pantalla.
function paint(control) {
const text = errorMessage(control);
document.querySelector(`#${control.id}-error`).textContent = text;
if (text === "") {
control.removeAttribute("aria-invalid");
} else {
control.setAttribute("aria-invalid", "true");
}
}
// Un servicio caído no tiene tiempo de respuesta: se desactiva el campo (y deja de validarse).
function syncTime() {
const down = statusField.value === "down";
timeField.disabled = down;
if (down) {
timeField.value = "";
paint(timeField);
}
}
// La regla que el navegador no conoce: el nombre no puede repetirse. SIEMPRE con su rama que limpia.
function validateUniqueName() {
nameField.setCustomValidity(
nameExists(state, nameField.value) ? "Ya existe un servicio con ese nombre." : "",
);
}
// El navegador dispara "invalid" en cada campo inválido cuando se intenta enviar. Cancelarlo apaga su
// burbuja; en su lugar escribimos el mensaje en la página, donde se queda y un lector de pantalla lo lee.
// "invalid" no sube por el árbol: por eso se escucha en la fase de captura (el tercer argumento).
form.addEventListener("invalid", (event) => {
event.preventDefault();
paint(event.target);
if (event.target === form.querySelector(":invalid")) {
const howMany = form.querySelectorAll(":invalid").length;
formResult.textContent = howMany === 1
? "No se agregó: hay 1 campo con error."
: `No se agregó: hay ${howMany} campos con error.`;
event.target.focus();
}
}, true);
form.addEventListener("input", (event) => {
const control = event.target;
formResult.textContent = "";
if (control === nameField) validateUniqueName();
if (control === statusField) syncTime();
if (control.getAttribute("aria-invalid") === "true") paint(control);
});
form.addEventListener("focusout", (event) => {
const control = event.target;
if (control.matches(":user-invalid") || control.hasAttribute("aria-invalid")) paint(control);
});
form.addEventListener("submit", (event) => {
event.preventDefault();
const service = readService(form);
addService(state, service);
form.reset();
syncTime();
render(state, elements);
const hidden = visible(state).includes(service) ? "" : " El filtro actual lo oculta.";
formResult.textContent =
`Servicio «${service.name}» agregado. Ahora hay ${state.services.length}.${hidden}`;
nameField.focus();
});
load();
Как проверяваш. Изпробвай сега таблото, със стартиран сървър от папката programas/ на хранилището, на http://127.0.0.1:8000/10-formularios-validacion/panel/. Тези стъпки бяха проверени в Chrome 154 с прозорец на 320 px ширина:
- Напиши
cataloв „Buscar servicio“: остава един ред и, половин секунда по-късно, известието казва „Mostrando 1 de 5 servicios.“. - Отбележи „Caídos“ в групата „Mostrar“, без да изтриваш текста: никой ред не съвпада, таблицата се скрива и известието казва „Ningún servicio coincide con el filtro.“ (Никоя услуга не отговаря на филтъра.). Изтрий текста и остава само
Inventario. - Отбележи отново „Todos“ и натисни „Agregar servicio“ (Добави услуга) с всичко празно: фокусът пада върху „Nombre“ (Име), появяват се три червени съобщения и известието казва „No se agregó: hay 3 campos con error.“.
- Напиши
catalogo, избери „Disponible“ и напиши100: когато натиснеш „Agregar servicio“, името казва „Ya existe un servicio con ese nombre.“ (Вече съществува услуга с това име.) (нито главните букви, нито ударението имат значение). - Смени името на
facturasи времето на-5: съобщението казва „No puede ser negativo.“. Поправи го на230: съобщението изчезва, докато пишеш, и при изпращане известието казва „Servicio «facturas» agregado. Ahora hay 6.“ (Услугата „facturas“ е добавена. Сега има 6.). Обобщението става 6 проверени услуги, 5 от 6 налични, 1 паднала и 418 ms средно време за отговор. - Избери „Caído“ като състояние: полето за времето се деактивира и се изпразва. Напиши ново име и изпрати: услугата се добавя без време, а в таблицата се появява „sin respuesta“.
- Отвори
?case=empty: появяват се „No hay servicios que revisar.“, „Reintentar“ и формата. Добави услуга: таблицата се появява с един ред, а „Reintentar“ се скрива, защото „Revisar ahora“ отново е на показ.
Конзолата, през целия обход, остава празна.
Грешката, която ще видиш
Съобщението е от конзолата и се появява, когато изпратиш форма, в която има задължително поле, което човекът не може да види. Страницата го предизвиква нарочно: второто поле е required, но е скрито с display: none.
<!-- fig10_05.html -->
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Un campo obligatorio que no se puede ver</title>
<link rel="icon" href="data:,">
</head>
<body>
<main>
<h1>Un campo obligatorio que no se puede ver</h1>
<form id="form">
<p>
<label for="email">Correo *</label>
<input id="email" name="email" type="email" required>
</p>
<p>
<label for="note">Nota *</label>
<input id="note" name="note" type="text" required style="display: none">
</p>
<button type="submit">Enviar</button>
</form>
</main>
</body>
</html>
Отвори я, напиши валиден имейл и натисни „Enviar“. Не се случва нищо видимо: формата не се изпраща и няма балонче. В конзолата се появява:
An invalid form control with name='note' is not focusable.
Браузърът се е опитал да направи обичайното: да намери първото невалидно поле и да насочи фокуса към него, за да покаже съобщението. Но скрито поле не може да получи фокус, така че няма къде да се покаже, а изпращането остава блокирано, без човекът да знае защо. Текстът на съобщението е на браузъра (Chrome 154; в друг браузър изречението се променя) и във формата си name='note' носи атрибута name на виновното поле, което ти казва кое да търсиш.
Има три поправки, по ред на предпочитание. Ако полето не е нужно, махни required, докато е скрито. Ако е нужно да съществува, но да не се вижда, деактивирай го с disabled (остава извън валидацията, както видя с времето в таблото). А ако трябва да се вижда, щом се активира, покажи го, преди да се опиташ да изпратиш. Онова, което не се поправя, е да заглушиш съобщението: то е симптом, че някой е оставил капан във формата.
Какво се прави погрешно
Да третираш валидацията на клиента като защита. Как изглежда: „формата вече валидира имейла, значи е готово“. Цена: всеки я прескача с две щраквания в инструментите на браузъра или като изпрати заявката без браузър. Ако таблото ти има сървър, правилото се повтаря там; ако няма, данната не бива да е опасна сама по себе си, и затова таблото я чертае с textContent.
Да боядисваш :invalid от самото начало. Как изглежда: формата се отваря в червено. Цена: онзи, който идва, се чувства смъмрен, преди да е започнал, а онзи, който използва екранен четец, чува списък от грешки в полета, които още не е видял. Използвай :user-invalid или собствената отметка след първия опит.
Да сложиш съобщение в setCustomValidity и да не го почистиш. Как изглежда: поле, което „изглежда добре“ и не позволява да се изпрати. Цена: това е най-трудната за дебъгване грешка в урока, защото няма никакво съобщение за грешка от програмата. Всеки клон, който слага съобщение, се нуждае от свой клон, който го маха; таблото го прави с един ред с условен израз.
pattern с невалиден синтаксис. Как изглежда: pattern="[\w-]+" и правилото никога не се прилага. Цена: конзолата на Chrome казва „Pattern attribute value [\w-]+ is not a valid regular expression“, но формата не показва нищо, така че правилото изчезва мълчаливо. Екранирай тирето ([\w\-]+) и, преди всичко, изпробвай със стойност, която би трябвало да се провали.
Грешката само в цвят, само в балончето или само в placeholder. Как изглежда: поле с червена рамка и нищо повече, или подсказка вътре в полето в бледосиво, която изчезва, когато пишеш. Цена: губиш онзи, който не различава цветовете, онзи, който използва екранен четец, и онзи, който се нуждае да препрочете. Етикетът е етикет (<label>), грешката е текст, и двете живеят извън полето.
type="number" за онова, което не е количество. Как изглежда: поле за номер на фиш, за телефон или за пощенски код със стрелки нагоре и надолу. Цена: стойността се променя с колелото на мишката, без да искаш, губят се нулите отляво и екранният четец обявява бутон за увеличаване и намаляване (spinbutton) там, където няма количество за увеличаване или намаляване. За такива данни, текст с inputmode="numeric".
Да обявяваш всеки клавиш. Как изглежда: известието на филтъра се променя при всяка буква. Цена: екранният четец се превръща в картечница и онзи, който го използва, се губи. Изчакай човекът да свърши да пише, както прави таблото със своите 400 ms.
Да деактивираш бутона за изпращане, докато всичко не стане валидно. Как изглежда: сив бутон без обяснение. Цена: онзи, който не вижда деактивирания бутон, не знае защо не напредва и няма никакво съобщение, което да го обяснява. Остави бутона активен и обясни какво липсва в момента на опита.
Упражнения
Упражнение 1 — Предскажи флаговете
Без да отваряш нищо, запиши кои флагове на validity светват във всеки случай, и после провери с fig10_02.html: (а) празно поле с required; (б) 250 в поле с min="0" max="60000" step="100"; (в) -100 в същото поле; (г) 60050 в същото поле.
Упражнение 2 — Собствено правило в таблото
Добави към формата на таблото второ правило за името: не може да бъде нито admin, нито test (без значение главните букви). Трябва да използва setCustomValidity, да почисти съобщението, когато името е правилно, и да покаже собствен текст, който казва какво да се направи, не само какво не е наред. Провери, че полето отново е валидно, когато го поправиш.
Упражнение 3 — Изчистване на филтрите
Добави бутон „Limpiar filtros“ (Изчисти филтрите) към лентата с контроли. Когато го натиснеш, трябва да: изпразни текста, отбележи отново „Todos“, начертае наново таблицата и върне фокуса на полето за търсене. Помисли какво се случва с региона role="status", ако текстът на броя не се промени.
Решения
Упражнение 1. (а) valueMissing. (б) stepMismatch: 250 е в диапазона, но не е кратно на 100, броейки от 0. (в) rangeUnderflow; освен това -100 е кратно на 100, така че stepMismatch не светва. (г) rangeOverflow и stepMismatch, защото 60050 също пада извън решетката. Ако си сгрешил в (в), урокът е в това, че step и min не са независими: решетката се мери от min, а една стойност може да е извън диапазона, без да е извън решетката.
Упражнение 2. Промени validateUniqueName в js/main.js, така че да избира между три случая и да почиства в последния:
const RESERVED = ["admin", "test"];
function validateUniqueName() {
const value = nameField.value.trim().toLowerCase();
if (RESERVED.includes(value)) {
nameField.setCustomValidity(`«${value}» está reservado. Elige un nombre que describa el servicio.`);
} else if (nameExists(state, nameField.value)) {
nameField.setCustomValidity("Ya existe un servicio con ese nombre.");
} else {
nameField.setCustomValidity("");
}
}
Последният клон е онзи, който не позволява на полето да остане невалидно завинаги. Тъй като errorMessage вече чете validationMessage, когато има customError, не е нужно да пипаш нищо друго. Провери случая, в който пишеш admin, изпращаш (появява се съобщението) и после поправяш на admin2: съобщението изчезва, докато пишеш.
Упражнение 3. В index.html, в лентата с контроли, до „Revisar ahora“, един <p><button type="button" id="clear-filters">Limpiar filtros</button></p>. В js/main.js:
document.querySelector("#clear-filters").addEventListener("click", () => {
changeFilter(state, { text: "", status: "all" });
document.querySelector("#search").value = "";
document.querySelector('input[name="filter"][value="all"]').checked = true;
render(state, elements);
document.querySelector("#search").focus();
});
Броят се чертае наново сам, защото render() извиква renderCount(). Но има уловка: ако текстът на известието вече е бил „Mostrando 5 de 5 servicios.“, съдържанието не се променя и екранните четци обикновено не го повтарят. Това е добър случай, в който да не се обяви е правилно: няма нищо ново за казване (един жив регион обявява промени, не повторения). Ако искаш да обявиш самото действие, напиши „Filtros limpiados.“ (Филтрите са изчистени.) в известието на формата или в собствен регион.
Как разбирам, че съм успял
Проверките са измерими. Стартирай сървъра от папката programas/ на изтегленото хранилище и отвори таблото:
cd programas
python3 -m http.server 8000 --bind 127.0.0.1
Отвори http://127.0.0.1:8000/10-formularios-validacion/panel/.
- Клавиатура: само с Tab, Shift+Tab, Enter и стрелките стигаш до търсачката, до радио бутоните, до „Revisar ahora“, до бутона за подреждане, до бутоните „Ver detalle“, до трите полета и до бутона за добавяне; контурът на фокуса се вижда във всеки; и добавяш услуга, без да докосваш мишката.
- Конзола: в раздела Конзола няма никаква грешка, нито предупреждение след презареждане и след като изминеш седемте стъпки на обхода, с който приключва раздел 10.5.
- 320 px: в режима на устройство на инструментите, при ширина 320 px не се появява хоризонтална лента за превъртане. В конзолата
document.documentElement.scrollWidth <= document.documentElement.clientWidthвръщаtrue. - Грешките се четат: с празната форма, след натискане на „Agregar servicio“,
document.querySelector("#new-name").getAttribute("aria-invalid")връща"true"иdocument.querySelector("#new-name-error").textContentвръща изречение. - Филтърът: с текст
catalo,document.querySelectorAll("#services-body tr").lengthвръща1. - Деактивираното поле остава извън: с избрано „Caído“,
document.querySelector("#new-response-ms").disabledвръщаtrue. - Без екран:
filters-test.htmlпоказва единайсет реда и всички започват сok. - Екранен четец: включи този, който носи системата ти (в Linux Mint, Orca) и повтори стъпка 3 от раздел 10.5. Трябва да каже името на полето, описанието му и че не е валидно.
- Фигурите:
fig10_05.htmlоставя в конзолата съобщението „An invalid form control… is not focusable“, а останалите не оставят никаква грешка.
И като заключение, три въпроса от предишни уроци; отговори, без да гледаш, и после провери:
- В урок 2: кой елемент на HTML дава име на едно поле и защо
placeholderне го заменя? - В урок 7: защо
js/view.jsизползваtextContent, а неinnerHTML, дори данните да идват от собствения ти сървър? - В урок 8: какво връща
fetchпри 404 и какво трябва да се провери, за да не се третира като данни?
Запиши в дневника онова, на което не си могъл да отговориш. Този списък е твоят преговор за утре.
За допълнително четене
- Валидация на форми от страна на клиента, в MDN: пълното ръководство, с API-то за валидация и примерите със собствени съобщения.
- 3.3.1 Идентифициране на грешки, в указанията WCAG 2.2: какво изисква критерият и кои техники го изпълняват.
:user-invalid, в MDN: кога съвпада и кога не.- Текстови полета, в дизайнерската система на британското правителство: най-внимателното ръководство за
type="number",inputmode,autocompleteиmaxlength.
Предпочитате имейл? Пишете ни на hola@habil.mx