Модуль 7 · урок 6 з 10
Клавіатура і фокус
«Main user flows can be completed purely via keyboard» — критерій приймання дослівно. Це найлегша частина доступності для перевірки: сідаєш, ховаєш мишу й проходиш екран. І найчастіше на цьому все ламається.
Порядок табуляції
<button> <!-- у порядку табуляції за замовчуванням -->
<a href>
<input> <select> <textarea>
<div tabindex="0"> <!-- додати вручну -->
<div tabindex="-1"> <!-- фокус лише програмно, Tab пропускає -->
<button disabled> <!-- випадає з порядку -->
tabindex="1" та більші виривають елемент із природного порядку й ставлять
його перед усіма іншими. Один такий атрибут ламає навігацію на всій сторінці,
і причину потім важко знайти. Порядок табуляції має задаватись порядком елементів
у розмітці — це ще один аргумент за чисте дерево DOM із модуля 1.
Видимий фокус
/* ❌ класичний злочин */
*:focus { outline: none; }
/* ✅ стилізувати, а не прибирати */
:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
border-radius: 4px;
}
Це вже було в модулі 1, але повторити варто: без видимого фокуса клавіатурна навігація
перетворюється на гру наосліп. :focus-visible показує обведення лише тоді,
коли фокус прийшов із клавіатури, тож дизайнерам немає на що скаржитись.
Skip link
<a class="skip-link" href="#main">Перейти до вмісту</a>
<app-header />
<app-sidebar />
<main id="main" tabindex="-1">…</main>
.skip-link {
position: absolute;
left: -9999px;
}
.skip-link:focus {
left: 8px; top: 8px;
z-index: 100;
padding: 8px 12px;
background: var(--color-surface);
}
Без нього клавіатурний користувач щоразу проходить двадцять пунктів меню, щоб дістатись таблиці. Перший Tab на сторінці показує посилання — і один Enter переносить одразу до вмісту.
Пастка фокуса в модалці
// усе це вже вбудовано в MatDialog з модуля 6
this.dialog.open(TaskFormDialog, {
autoFocus: 'first-tabbable',
restoreFocus: true,
});
Клавіатура в таблиці
<tr [attr.tabindex]="0"
(click)="open(row)"
(keydown.enter)="open(row)"
(keydown.space)="open(row); $event.preventDefault()">
Якщо рядок клікабельний, він має бути й фокусованим. preventDefault для пробілу
обовʼязковий: інакше сторінка прокрутиться вниз замість відкриття рядка.
// у панелі інструментів — стрілки замість Tab (патерн roving tabindex)
<div role="toolbar" (keydown)="onToolbarKey($event)">
@for (action of actions; track action.id; let i = $index) {
<button [attr.tabindex]="i === activeIndex() ? 0 : -1">{{ action.label }}</button>
}
</div>
Це не обовʼязково для IDP, але корисно знати: у групах однотипних кнопок прийнято, щоб Tab заходив у групу один раз, а всередині працювали стрілки. Так навігація по панелі з десяти кнопок не забирає десять натискань Tab.
Гарячі клавіші
@HostListener('document:keydown', ['$event'])
onKey(e: KeyboardEvent): void {
// не перехоплювати, коли людина щось друкує
const tag = (e.target as HTMLElement).tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
if (e.key === '/') {
e.preventDefault();
this.searchInput()?.nativeElement.focus();
}
}
Як перевірити за пʼять хвилин
- Прибери руку з миші. Зовсім.
- Tab від початку сторінки: фокус має бути видно завжди.
- Порядок має відповідати візуальному: зліва направо, згори вниз.
- Пройди основний сценарій: знайти запис, відкрити, змінити, зберегти.
- Відкрий модалку: фокус усередині, Escape закриває, Tab не вилітає назовні.
- Після закриття фокус повернувся туди, звідки відкривали.
Якщо всі шість пунктів проходять — критерій із таблиці виконано. Це буквально пʼять хвилин роботи, а на демо виглядає переконливо: мало хто взагалі про це думає.