Пошук уроків, статей та іншого контенту
Дослідите focus, blur, focusin і focusout для створення доступної навігації та валідації форм.
Фокус — це стан елемента, який зараз отримує введення з клавіатури або програмні команди.
У браузері фокус можуть отримувати:
текстові поля;
кнопки;
посилання;
прапорці та перемикачі;
елементи з відповідним значенням tabindex;
інші інтерактивні елементи.
Поточний елемент із фокусом можна отримати через document.activeElement:
console.log(document.activeElement);Користувач зазвичай переміщується між елементами клавішею
TabShift + TabФокус важливий не лише для обробки подій, а й для доступності. Користувачі, які не використовують мишу, повинні розуміти, де саме вони перебувають на сторінці.
focus і blurПодія focus виникає, коли елемент отримує фокус:
const input = document.querySelector('#name');
input.addEventListener('focus', () => {
console.log('Поле отримало фокус');
});Подія blur виникає, коли елемент втрачає фокус:
input.addEventListener('blur', () => {
console.log('Поле втратило фокус');
});Особливості цих подій:
focus і blur не спливають до батьківських елементів;
події виникають безпосередньо на елементі, який отримав або втратив фокус;
focus і blur не можна скасувати за допомогою preventDefault().
Наприклад, цей обробник не спрацює під час фокусування поля:
const form = document.querySelector('form');
form.addEventListener('focus', () => {
console.log('Фокус усередині форми');
});Причина — focus не спливає від поля до форми.
focusin і focusoutfocusin і focusout схожі на focus і blur, але вони спливають.
focusin виникає, коли елемент отримує фокус;
focusout виникає, коли елемент втрачає фокус.
Тому їх зручно використовувати для делегування подій:
const form = document.querySelector('form');
form.addEventListener('focusin', (event) => {
console.log('Фокус отримав:', event.target);
});
form.addEventListener('focusout', (event) => {
console.log('Фокус втратив:', event.target);
});Якщо користувач фокусується на input, подія спочатку виникає на самому input, а потім спливає до form.
| Подія | Коли виникає | Спливає | |---|---|---| | focus | Елемент отримав фокус | Ні | | blur | Елемент втратив фокус | Ні | | focusin | Елемент отримав фокус | Так | | focusout | Елемент втратив фокус | Так |
Якщо потрібно слухати фокус на конкретному елементі, підійдуть focus і blur.
Якщо потрібно слухати фокус на багатьох дочірніх елементах через одного батьківського обробника, зручніші focusin і focusout.
Делегування особливо корисне для форм зі значною кількістю полів або для динамічно доданих елементів.
const form = document.querySelector('#profile-form');
form.addEventListener('focusin', (event) => {
const field = event.target.closest('input, textarea, select');
if (!field) {
return;
}
field.classList.add('is-focused');
});
form.addEventListener('focusout', (event) => {
const field = event.target.closest('input, textarea, select');
if (!field) {
return;
}
field.classList.remove('is-focused');
});Обробники встановлено лише на формі, але вони реагують на події її дочірніх полів.
Для перевірки конкретного типу елемента можна використовувати matches:
form.addEventListener('focusin', (event) => {
if (!event.target.matches('[data-validate]')) {
return;
}
event.target.classList.add('is-focused');
});relatedTargetУ подій фокуса є властивість relatedTarget. Вона містить елемент, з якого фокус перейшов або на який він переходить.
Наприклад, під час focusout:
document.addEventListener('focusout', (event) => {
console.log('Фокус залишив:', event.target);
console.log('Фокус переходить до:', event.relatedTarget);
});Під час focusin:
document.addEventListener('focusin', (event) => {
console.log('Фокус отримав:', event.target);
console.log('Фокус прийшов від:', event.relatedTarget);
});relatedTarget може дорівнювати null, наприклад коли фокус залишає сторінку або переходить до іншого вікна.
Цю властивість можна використовувати, щоб визначити, чи залишив користувач весь компонент, а не просто перемістився між його дочірніми елементами:
const menu = document.querySelector('.menu');
menu.addEventListener('focusout', (event) => {
if (!menu.contains(event.relatedTarget)) {
menu.classList.remove('is-active');
}
});У цьому прикладі клас видаляється лише тоді, коли фокус переходить за межі меню.
Браузер автоматично додає фокусованим елементам стандартний індикатор. Не варто без необхідності прибирати його:
/* Небажано */
button:focus {
outline: none;
}Якщо потрібен власний стиль, індикатор потрібно замінити помітним варіантом:
button:focus-visible,
a:focus-visible,
input:focus-visible {
outline: 3px solid #1565c0;
outline-offset: 3px;
} :focus-visible дає змогу показувати індикатор переважно тоді, коли фокус отримано клавіатурою. Водночас важливо перевірити поведінку інтерфейсу з клавіатурою, а не покладатися лише на мишу.
tabindexЗвичайні інтерактивні елементи вже беруть участь у навігації клавішею Tab.
Атрибут tabindex="0" додає нефокусованому за замовчуванням елементу участь у природному порядку навігації:
<div tabindex="0">Інтерактивний блок</div>Проте додавати tabindex до div лише заради обробки фокуса зазвичай не варто. Кнопка або посилання вже мають правильну семантику та клавіатурну поведінку:
<button type="button">Відкрити налаштування</button>tabindex="-1" дозволяє сфокусувати елемент програмно, але прибирає його з послідовності переходу клавішею Tab:
const heading = document.querySelector('#settings-heading');
heading.focus();Це може бути корисно, коли після навігації потрібно перемістити фокус до заголовка нового розділу.
Перевірку поля часто виконують під час focusout. Так користувач отримує повідомлення після того, як завершив введення в поле.
Важливо:
поле повинно мати видимий <label>;
помилка повинна бути пов’язана з полем через aria-describedby;
стан помилки варто позначати через aria-invalid;
перевірка під час втрати фокуса не повинна бути єдиною перевіркою — форму також потрібно перевіряти під час надсилання.
Нижче наведено повний приклад форми та навігації.
<!DOCTYPE html>
<html lang="uk">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Події фокуса</title>
<style>
:root {
font-family: system-ui, sans-serif;
color: #17202a;
background: #f4f6f8;
}
body {
max-width: 720px;
margin: 0 auto;
padding: 2rem 1rem;
}
nav {
margin-bottom: 2rem;
}
nav ul {
display: flex;
gap: 1rem;
padding: 0;
list-style: none;
}
nav a {
color: #075985;
}
nav a.is-focused {
text-decoration: underline;
text-decoration-thickness: 3px;
}
:focus-visible {
outline: 3px solid #d97706;
outline-offset: 3px;
}
form {
display: grid;
gap: 1rem;
padding: 1.5rem;
background: white;
border: 1px solid #d1d5db;
border-radius: 8px;
}
.field {
display: grid;
gap: 0.35rem;
}
input {
max-width: 320px;
padding: 0.6rem;
border: 2px solid #9ca3af;
border-radius: 4px;
font: inherit;
}
input.is-focused {
border-color: #2563eb;
}
input[aria-invalid="true"] {
border-color: #b91c1c;
}
.error {
min-height: 1.25rem;
color: #b91c1c;
font-size: 0.9rem;
}
.status {
min-height: 1.5rem;
font-weight: 600;
}
</style>
</head>
<body>
<nav class="menu" aria-label="Основна навігація">
<ul>
<li><a href="#profile">Профіль</a></li>
<li><a href="#security">Безпека</a></li>
<li><a href="#help">Допомога</a></li>
</ul>
</nav>
<main>
<h1 id="profile">Налаштування профілю</h1>
<form id="profile-form" novalidate>
<div class="field">
<label for="email">Електронна пошта</label>
<input
id="email"
name="email"
type="email"
required
autocomplete="email"
aria-describedby="email-error"
data-validate
>
<div id="email-error" class="error" aria-live="polite"></div>
</div>
<div class="field">
<label for="password">Пароль</label>
<input
id="password"
name="password"
type="password"
minlength="8"
required
autocomplete="new-password"
aria-describedby="password-error"
data-validate
>
<div id="password-error" class="error" aria-live="polite"></div>
</div>
<button type="submit">Зберегти зміни</button>
<p id="form-status" class="status" role="status" aria-live="polite"></p>
</form>
<section id="security" tabindex="-1">
<h2>Безпека</h2>
<p>Використовуйте довгий унікальний пароль.</p>
</section>
<section id="help" tabindex="-1">
<h2>Допомога</h2>
<p>Заповніть поля форми та натисніть кнопку збереження.</p>
</section>
</main>
<script>
const menu = document.querySelector('.menu');
const form = document.querySelector('#profile-form');
const fields = [...form.querySelectorAll('[data-validate]')];
const status = document.querySelector('#form-status');
function getErrorMessage(field) {
if (field.validity.valueMissing) {
return 'Це поле обов’язкове.';
}
if (field.validity.typeMismatch) {
return 'Введіть коректну електронну адресу.';
}
if (field.validity.tooShort) {
return `Мінімальна довжина — ${field.minLength} символів.`;
}
return '';
}
function validateField(field) {
const error = document.querySelector(`#${field.getAttribute('aria-describedby')}`);
const message = getErrorMessage(field);
const isValid = message === '';
field.setAttribute('aria-invalid', String(!isValid));
error.textContent = message;
return isValid;
}
menu.addEventListener('focusin', (event) => {
const link = event.target.closest('a');
if (!link) {
return;
}
link.classList.add('is-focused');
});
menu.addEventListener('focusout', (event) => {
const link = event.target.closest('a');
if (!link) {
return;
}
link.classList.remove('is-focused');
});
form.addEventListener('focusin', (event) => {
const field = event.target.closest('[data-validate]');
if (!field) {
return;
}
field.classList.add('is-focused');
});
form.addEventListener('focusout', (event) => {
const field = event.target.closest('[data-validate]');
if (!field) {
return;
}
field.classList.remove('is-focused');
validateField(field);
});
form.addEventListener('submit', (event) => {
event.preventDefault();
const isFormValid = fields.every(validateField);
if (!isFormValid) {
const firstInvalidField = fields.find((field) => !field.validity.valid);
if (firstInvalidField) {
firstInvalidField.focus();
}
status.textContent = 'Виправте помилки у формі.';
return;
}
status.textContent = 'Зміни успішно збережено.';
});
</script>
</body>
</html>У прикладі:
focusin додає полю клас is-focused.
focusout видаляє цей клас і запускає валідацію.
Повідомлення про помилку записується в елемент, указаний у aria-describedby.
aria-invalid="true" повідомляє допоміжним технологіям про помилкове значення.
Під час надсилання перевіряються всі поля.
Якщо є помилка, фокус переходить до першого неправильного поля.
Навігація використовує звичайні посилання, тому працює з клавіатурою без додаткової логіки.
focusout від перевірки під час inputПодія input виникає після кожної зміни значення:
input.addEventListener('input', () => {
console.log('Значення змінено');
});Подія focusout виникає, коли користувач залишає поле.
Перевірка на кожен input корисна для деяких сценаріїв, але може створювати несприятливий досвід:
помилка з’являється до завершення введення;
повідомлення постійно змінюються;
користувач отримує зайві оголошення від програм читання з екрана.
Поширений підхід:
під час першого введення не показувати помилку;
після focusout показати помилку, якщо значення неправильне;
після першої невдалої перевірки оновлювати повідомлення під час input.
Фокус можна встановити методом focus():
const searchInput = document.querySelector('#search');
searchInput.focus();Це корисно:
після відкриття діалогового вікна;
після переходу на новий розділ;
після невдалого надсилання форми;
коли потрібно повернути користувача до елемента, який потребує уваги.
Однак не слід переміщувати фокус без зрозумілої причини. Неочікувана зміна фокуса дезорієнтує користувача, особливо під час роботи з клавіатурою або програмою читання з екрана.
Після переходу до розділу можна сфокусувати його заголовок або сам розділ:
<section id="results" tabindex="-1">
<h2>Результати пошуку</h2>
</section>const results = document.querySelector('#results');
results.focus();tabindex="-1" дозволяє викликати focus(), але не додає розділ до звичайного порядку переходу клавішею Tab.
Для focus і blur можна використати фазу захоплення:
const container = document.querySelector('.container');
container.addEventListener(
'focus',
(event) => {
console.log('Фокус у дочірньому елементі:', event.target);
},
true
);
container.addEventListener(
'blur',
(event) => {
console.log('Фокус залишив дочірній елемент:', event.target);
},
true
);Третій аргумент true вмикає прослуховування під час фази захоплення. У такому режимі обробник батьківського елемента може реагувати на focus і blur дочірніх елементів, хоча ці події не спливають.
У більшості випадків для делегування простіше використати focusin і focusout.
*:focus {
outline: none;
}Такий стиль ускладнює навігацію для користувачів клавіатури. Якщо стандартний індикатор не підходить, його потрібно замінити контрастним стилем.
focus на батьківському елементі без захопленняform.addEventListener('focus', handler);Цей обробник не реагує на фокус дочірніх полів, оскільки focus не спливає. Використовуйте focusin або фазу захоплення.
blurКористувач може натиснути кнопку надсилання, не залишивши деякі поля звичайним способом. Тому валідація під час втрати фокуса не замінює перевірку під час submit.
Повідомлення про помилку, яке просто виведено поруч із полем, може бути незрозумілим для допоміжних технологій. Використовуйте aria-describedby:
<input id="email" aria-describedby="email-error">
<p id="email-error"></p>tabindex для виправлення семантикиНе варто перетворювати div на кнопку лише за допомогою tabindex. Такий елемент усе одно не матиме повної поведінки кнопки, наприклад підтримки клавіші Enter або пробілу. Для дій використовуйте <button>, а для переходів — <a>.
focus()Примусове переміщення фокуса під час кожної зміни стану інтерфейсу може переривати роботу користувача. Використовуйте focus() лише тоді, коли нове положення фокуса логічно випливає з дії користувача.
focus виникає під час отримання фокуса, а blur — під час його втрати.
focus і blur не спливають.
focusin і focusout спливають, тому підходять для делегування.
relatedTarget показує попередній або наступний елемент фокуса.
Фокусна навігація повинна бути доступною з клавіатури.
Не прибирайте індикатор фокуса без якісної заміни.
Валідацію зручно запускати на focusout, але форму потрібно перевіряти і під час submit.
Помилки форми слід пов’язувати з полями через aria-describedby та позначати aria-invalid.
focus() корисний для логічного переміщення користувача, але його не слід застосовувати без потреби.