# Доступность

> Клавиатурный контракт, стек оверлеев, живой регион и правила контраста — что библиотека гарантирует и чем это держится.

- Page: https://granularity.tech/ru/docs/accessibility
- Last reviewed: 2026-10-15

Доступность здесь не свойство отдельных компонентов, а контракт: одинаковые
правила во всех, отклонения — оговорены, а держат их тесты, а не намерения.
Компоненты реализуют паттерны [WAI-ARIA APG](https://www.w3.org/WAI/ARIA/apg/patterns/);
там, где поведение отличается, для отличия есть причина.

## Клавиатура: четыре правила на всю библиотеку

:::steps
1. **Tab ходит между виджетами, стрелки — внутри.** Составной виджет — группа радиокнопок, дерево, вкладки, сегменты — занимает **одну** остановку в таб-порядке; внутрь попадают стрелками (roving tabindex).
2. **Esc закрывает верхнее.** Все закрываемые оверлеи участвуют в одном стеке: Esc адресуется последнему открытому слою и не проваливается к нижним. Список, открытый внутри модалки, по Esc закрывает себя, а следующий Esc — модалку.
3. **Home и End — к краям.** В списках и диапазонах: меню, списки выбора, дерево, вкладки, радиогруппа, слайдер, рейтинг, числовое поле. Их забывают чаще всего — и у самой библиотеки есть исключения: `GrImageViewer` листает только стрелками, а взятая строка `GrSortableList` двигается только стрелками.
4. **Tab внутри открытой панели закрывает её.** `GrDropdown`, `GrSelect`, `GrAutocomplete` отдают фокус дальше по документу — пользователь не оказывается заперт в панели, которую не видит.
:::

`Enter` и `Space` **не** взаимозаменяемы, и это правило нативных контролов, а не
вкусовщина:

| Элемент | Space | Enter |
|---|---|---|
| Кнопка, сегмент, заголовок аккордеона | активирует | активирует |
| Чекбокс | переключает | нет — нативный чекбокс сабмитит форму |
| Радиокнопка | выбирает | выбирает |
| Поле ввода в форме | пробел | сабмит формы |

Строка контракта — клавиши и паттерн APG — показывается на странице компонента
в [каталоге](/ru/components) у тех, для кого она есть в `docs/keyboard.md`
библиотеки (пока не у всех); полная таблица с оговорками — по ссылке оттуда.

## Фокус и оверлеи

Модальный слой собран из шести примитивов — стек, ловушка фокуса, `inert`,
блокировка прокрутки, портал, присутствие в DOM, — и собран **в одном месте**, а
не в каждом компоненте. Причина прагматична: пропуск любого из инвариантов даёт
не поломку, а тихий дефект доступности.

- **Ловушка держит фокус, только пока слой верхний.** Иначе нижнее окно ворует
  фокус у открытого поверх, а `inert` гасит собственную панель.
- **Возврат фокуса принадлежит стеку, а не ловушке.** Две системы возврата
  подрались бы; поэтому у ловушки `restoreFocus` выключен.
- **Ловушка живёт без sentinel-узлов.** Подход с двумя focus-guard кнопками по
  краям кладёт интерактивные элементы внутрь контейнера с ARIA-ролью, и axe
  ловит это как `nested-interactive`.
- **Пока слой доигрывает уход, он не адресован указателю.** Иначе клик в эти
  полторы десятых секунды достаётся исчезающему слою и пропадает бесследно.

Блокировка прокрутки — общий замок со счётчиком ссылок: несколько открытых
оверлеев снимают её независимо от порядка закрытия, а ширина исчезнувшего
скроллбара компенсируется, чтобы контент не дёргался.

## Объявления для скринридера

Событие, о котором надо сказать, а показать негде, объявляет общий живой регион:

```ts
import { useAnnouncer } from '@feugene/granularity'

const { announce } = useAnnouncer()

announce('Link copied')
announce('Could not save', { politeness: 'assertive' })
```

Граница проходит по природе текста. **Состояние** виджета — «загружается»,
«ничего не найдено», «осталось 12 символов» — живёт своим регионом внутри
виджета, рядом с содержимым. **Событие** — «тег удалён», «изображение 2 из 5» —
уходит объявителю: своего места в разметке у него нет.

:::callout{type="warning"}
`assertive` прерывает текущую речь и уместен только там, где пропустить нельзя:
отказ операции, потеря соединения. Ассертивный поток объявлений превращает
страницу в неработающую — пользователь не успевает дослушать ни одно.
:::

## Контраст

Правило одно и жёсткое: **насыщенный тон не используется как цвет текста**.
Каждому тону найдётся тема, где он проваливает AA на обычной подложке; парные
роли `-text` держат AA везде. Подробности и таблица порогов — на странице
[темизации](/ru/docs/theming).

Проверяется это не глазами: один гейт ловит `text-[var(--gr-<тон>)]` в
исходниках, второй пересчитывает контраст по токенам обеих тем — чтобы список
запрещённых сочетаний не протух при первой же перекраске.

## Уменьшенное движение

Системная настройка уважается целиком, а не по компонентам: один блок в базовом
слое гасит длительности и задержки всех переходов и анимаций. Он покрывает и
утилиты, и обёртки оверлеев, и покомпонентные `@keyframes`, и код, которого ещё
нет, — поэтому новый компонент получает поведение бесплатно.

Там, где режим меняет не скорость, а сам сценарий — например, автопрокрутку
стоит не ускорить, а не запускать, — компонент читает `matchMedia` сам.

## Чем это проверяется

| Уровень | Что проверяет |
|---|---|
| Юнит-тесты в jsdom | Обработчики, роли, состояния, объявления |
| Живой браузер | Клавиатуру и всё, чему нужен layout: в jsdom нет ни перемещения фокуса по `Tab`, ни активации по `Enter`, ни `ResizeObserver` |
| Гейты пакета | Контраст токенов, `prefers-reduced-motion`, `nested-interactive`, отдельные инварианты оверлеев |

Отдельный слой — сам этот портал: axe проходит каждый шаблон его страниц в
обеих темах, а сверху — проверка клавиатурной доступности и требования WCAG 2.2
к отражению и размеру целей. Формальный статус соответствия — на странице
[декларации о доступности](/ru/legal/accessibility-statement).

## Чего библиотека не даёт

**RTL не поддерживается.** Компоненты размечены физическими направлениями, и
`dir="rtl"` библиотека в целом не учитывает: документ справа налево получит
зеркальные отступы, смещения и панели. Исключения точечные — маски прокрутки у
`GrTabs` и `GrCarousel` и направление свайпа в `GrCarousel` читают `dir`, — и
поддержкой они не являются. Это осознанное решение линии `1.x` — переход на
логические свойства меняет вид каждого компонента и относится к мажорному
релизу, а не к патчу.
