001 Виды доступности
Что включает в себя доступность сайта?:
- Цветовая схема. Сложно будет разобрать определённые малоконтрастные цвета на странице, если экран нашего устройства плохой, мы пользуемся проектором или на экран падает свет
- Работа с клавиатуры. Части людей удобно пользоваться компьютером только с помощью клавиатуры / у человека нет мыши / у человека Туннельный синдром, что мешает пользоваться мышкой
- Увеличение шрифта. Кому-то удобнее увеличить размер сайта, чтобы шрифт был виднее.
- Работа со скринридером. Адаптация сайта под слабовидящих.
- Уменьшение движения. Адаптация сайта под людей, которые плохо переносят анимации на сайте. Если на ПК включена функция уменьшения движения, то и сайт должен поддерживать такой функционал.
Сайт нормально себя ведёт при сильном увеличении, поэтому четвёртый пункт уже реализован
002 Цветовая доступность
Первым делом, чтобы понять, какие проблемы могут быть связаны с цветами, можно проверить параметр Accessability через Lighthouse.
Сейчас нужно взглянуть на контрастность цветов - конкретно мы можем перейти к определённым элементам, которые имеют неподходящие цвета
Далее мы можем быстро перейти на страницу, выделить нужный элемент для изменения, тыкнуть по квадрату его цвета, раскрыть параметр подходящих контрастных цветов и выбрать тот, что нам предлагает девтулз. Таким образом мы получим актуальный цвет, который будет иметь достаточную контрастность, чтобы быть рассмотренным на странице.
Contrast ratio зависит не только от самого цвета, но и от размера шрифта. Чем больше шрифт, тем меньше ему нужен контрастный цвет, чтобы различаться на странице.
Так же нужно отметить, что мы имеем уровни контрастности:
- АА - достаточная контрастность. Используем обычно
- ААА - самый контрастный подходящий цвет. Используем, если нужно оптимизировать сайт под слабовидящих
И далее нужно просто заменить корневые цвета на странице, чтобы поменялись все нужные нам элементы
styles / global.css
:root {
--primary: #7351f5;
--green: #077b4b;
}
Данный плагин позволит посмотреть на сайт совзгляда человека с определёнными нарушениями в цветовосприятии
003 Доступность меню с клавиатуры
За доступность элементов с клавиатуры отвечает атрибут tabIndex={число}
, где число отвечает за доступность элемента для клавиатуры:
0
- стандартная доступность, где элемент встаёт в очередь с остальными элементами-1
- элемент не доступен для выбора с клавиатуры> 0
- работает какz-index
и чем выше число, тем первее выделится элемент (является не самой лучшей практикой, кроме некоторых исключений)
В меню страницы было внесено достаточно много изменений, чтобы поддерживать клавиатуру:
- была добавлена функция
openSecondLevelKey
, которая позволяет открыть список меню при нажатии на пробел или энтер - в качестве события нажатия клавиши, был использован тип
KeyboardEvent
, который был взят из реакта - далее мы указали для второго уровня меню
tabIndex={0}
, чтобы он протыкивался - повесили на этот уровень функцию
openSecondLevelKey
- в функции
buildThirdLevel
добавили третий параметр, который принимал в себя состояние открытости меню - если оно закрыто, то индекс для таба весь список принимал-1
Layout / Menu / Menu.tsx
import styles from './Menu.module.css';
import cn from 'classnames';
import { useContext, KeyboardEvent } from 'react';
import { AppContext } from '../../context/app.context';
import { FirstLevelMenuItem, PageItem } from '../../interfaces/menu.interface';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { firstLevelMenu } from '../../helpers/helpers';
import { motion } from 'framer-motion';
export const Menu = (): JSX.Element => {
const { menu, setMenu, firstCategory } = useContext(AppContext);
const router = useRouter();
const variants = {
hidden: {
marginBottom: 0,
},
visible: {
marginBottom: 20,
transition: {
when: 'beforeChildren',
staggerChildren: 0.1,
},
},
};
const variantsChildren = {
hidden: { opacity: 0, height: 0 },
visible: { opacity: 1, height: 29 },
};
const openSecondLevel = (secondCategory: string) => {
setMenu &&
setMenu(
menu.map(m => {
if (m._id.secondCategory == secondCategory) {
m.isOpened = !m.isOpened;
}
return m;
}),
);
};
// эта функция будет открывать выбранное нами с клавиатуры меню через пробел
const openSecondLevelKey = (key: KeyboardEvent, secondCategory: string) => {
if (key.code == 'Space' || key.code == 'Enter') {
// предотвращаем стандартную логику поведения при нажатии клавиши (пробел - пролистывание)
key.preventDefault();
// переиспользуем функцию раскрытия меню
openSecondLevel(secondCategory);
}
};
const buildFirstLevel = () => {
return (
<>
{firstLevelMenu.map(m => (
<div key={m.route}>
<Link href={`/${m.route}`}>
<div
className={cn(styles.firstLevel, {
[styles.firstLevelActive]: m.id == firstCategory,
})}
>
{m.icon}
<span>{m.name}</span>
</div>
</Link>
{m.id == firstCategory && buildSecondLevel(m)}
</div>
))}
</>
);
};
const buildSecondLevel = (menuItem: FirstLevelMenuItem) => {
return (
<div className={styles.secondBlock}>
{menu.map(m => {
if (m.pages.map(p => p.alias).includes(router.asPath.split('/')[2])) {
m.isOpened = true;
}
return (
<div key={m._id.secondCategory}>
<div
className={styles.secondLevel}
/* указываем табИндекс */
tabIndex={0}
/* присваиваем открытие по пробелу */
onKeyDown={(key: KeyboardEvent) =>
openSecondLevelKey(key, m._id.secondCategory)
}
onClick={() => openSecondLevel(m._id.secondCategory)}
>
{m._id.secondCategory}
</div>
{/* родительский элемент для третьего уровня меню */}
<motion.div
layout
initial={m.isOpened ? 'visible' : 'hidden'}
animate={m.isOpened ? 'visible' : 'hidden'}
variants={variants}
className={cn(styles.secondLevelBlock)}
>
{/* тут передадим парамер состояния октрытости меню */}
{buildThirdLevel(m.pages, menuItem.route, m.isOpened ?? false)}
</motion.div>
</div>
);
})}
</div>
);
};
const buildThirdLevel = (pages: PageItem[], route: string, isOpened: boolean) => {
return pages.map(p => (
<motion.div variants={variantsChildren} key={p._id}>
<Link
/* если список открыт, то по нему можно будет перейти с клавиатуры, если нет - то нельзя */
tabIndex={isOpened ? 0 : -1}
href={`/${route}/${p.alias}`}
className={cn(styles.thirdLevel, {
[styles.thirdLevelActive]: `/${route}/${p.alias}` == router.asPath,
})}
>
{p.category}
</Link>
</motion.div>
));
};
return <div className={styles.menu}>{buildFirstLevel()}</div>;
};
Далее в стилях меню нужно убрать свойство line-height
, которое искривляет аутлайн и уменьшить сам аутлайн внутрь, чтобы он не обрезался через outline-offset
Layout / Menu / Menu.module.css
.secondLevel {
margin-bottom: 10px;
cursor: pointer;
text-transform: uppercase;
color: var(--gray-dark);
font-weight: 300;
font-size: 12px;
}
.thirdLevel {
margin-bottom: 10px;
display: block;
cursor: pointer;
color: var(--gray-dark);
font-weight: 500;
font-size: 14px;
/*
уменьшаем аутлайн, чтобы он выглядел лучше
убираем line-height, чтобы аутлайн не был кривым
*/
outline-offset: -1px;
}
Далее нам нужно сделать элемент, который позволит пропустить список меню и сразу попасть на контент:
- создаём состояние
isSkipLinkDisplayed
, которое будет отслеживать скрытость кнопки перехода на контент - далее нужно получить референс на бади
bodyRef
- далее нужно реализовать функцию
skipContentAction
, которая будет осуществлять переход на нужный элемент страницы - далее реализуем в
return
вёрстке ссылку<a>
, которая и будет осуществлять переход на этот бади. Этой ссылке нужно поставить самый высокий табиндекс на странице, присвоить функции при клавиатурном нажатии (переход на контент) и на фокусе (отображение) <div>
с контентом нужно сделать кликабельным через табиндекс и в него нужно вставить референс
layout / Layout / Layout.tsx
const Layout = ({ children }: LayoutProps): JSX.Element => {
const [isSkipLinkDisplayed, setIsSkipLinkDisplayed] = useState<boolean>(false);
const bodyRef = useRef<HTMLDivElement>(null);
const skipContentAction = (key: KeyboardEvent) => {
if (key.code == 'Space' || key.code == 'Enter') {
key.preventDefault();
bodyRef.current?.focus();
}
setIsSkipLinkDisplayed(false);
};
return (
<div className={styles.wrapper}>
<a
tabIndex={0}
onFocus={() => setIsSkipLinkDisplayed(true)}
onKeyDown={skipContentAction}
href='#'
className={cn(styles.skipLink, {
[styles.displayed]: isSkipLinkDisplayed,
})}
>
Перейти сразу к содержанию
</a>
<Header className={styles.header} />
<Sidebar className={styles.sidebar} />
<div tabIndex={0} ref={bodyRef} className={styles.body}>
{children}
</div>
<Footer className={styles.footer} />
<Up />
</div>
);
};
В стилях делаем скрытие элемента skipLink
не через display: none
, так как элемент не будет табательным - его нужно скрыть через оверфлоу и уменьшение высоты. Далее нам нужно убрать аутлайн с бади, чтобы он весь не выделялся.
layout / Layout / Layout.module.css
.body {
grid-area: body;
outline: none;
}
.skipLink {
display: block;
position: fixed;
left: 100px;
top: 0;
height: 0;
overflow: hidden;
color: var(--white);
background: var(--primary);
}
.displayed {
height: auto;
}
И сразу после строки поиска мы можем перейти первым табом на эту кнопку - она появится и скроется при табе
004 Доступность форм с клавиатуры
Далее основной задачей будет реализовать добавление управления форм с клавиатуры.
- В компоненте продуктов сделаем сразу выделение всей формы, когда мы нажимаем на отзывы или при открытии формы отзывов
- Далее на карточку нужно навесить табиндекс, если та открыта
- Далее в ревьюформу закидываем состояние открытости этой формы
components / Product / Product.tsx
export const Product = motion(
forwardRef(
(
{ product, className, ...props }: ProductProps,
ref: ForwardedRef<HTMLDivElement>,
): JSX.Element => {
const [isReviewOpened, setIsReviewOpened] = useState<boolean>(false);
const reviewRef = useRef<HTMLDivElement>(null);
const scrollToReview = () => {
setIsReviewOpened(true);
reviewRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
// при скролле во вью, будем выделять саму форму
reviewRef.current?.focus();
};
const variants = {
visible: { opacity: 1, height: 'auto' },
hidden: { opacity: 0, height: 0 },
};
return (
<div ref={ref} className={className} {...props}>
{/* CODE ... */}
{/* моушн-див с карточкой */}
<motion.div
variants={variants}
initial={'hidden'}
animate={isReviewOpened ? 'visible' : 'hidden'}
>
{/* добавляем табиндекс */}
<Card
tabIndex={isReviewOpened ? 0 : -1}
ref={reviewRef}
color='blue'
className={styles.reviews}
>
{product.reviews.map(r => (
<div key={r._id}>
<Review review={r} />
<Divider />
</div>
))}
{/* прокидываем из продукта состояние открытости формы внутрь этой формы */}
<ReviewForm productId={product._id} isOpened={isReviewOpened} />
</Card>
</motion.div>
</div>
);
},
),
);
В форме обзора будем принимать из родительского компонента атрибут isOpened
, который будет отвечать за открытое состояние формы или закрытое
components / ReviewForm / ReviewForm.props.ts
import { DetailedHTMLProps, HTMLAttributes } from 'react';
export interface ReviewFormProps
extends DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement> {
productId: string;
isOpened: boolean; // из продукта прокидываем состояние открытости формы
}
- Устанавливаем
tabIndex={isOpened ? 0 : -1}
на все элементы, чтобы форма реагировала на состояние открытости и закрытости - Рейтинг уже будет использовать такую запись для реализации перелистывания и блокировки перелистывания оценки
components / ReviewForm / ReviewForm.tsx
export const ReviewForm = ({
isOpened,
productId,
className,
...props
}: ReviewFormProps): JSX.Element => {
const {
register,
control,
handleSubmit,
formState: { errors },
reset,
} = useForm<IReviewForm>();
/// CODE ...
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className={cn(styles.reviewForm, className)} {...props}>
<Input
{...register('name', { required: { value: true, message: 'Заполните имя' } })}
placeholder='Имя'
error={errors.name}
/* добавляем табиндекс */
tabIndex={isOpened ? 0 : -1}
/>
<Input
{...register('title', {
required: { value: true, message: 'Заполните заголовок' },
})}
placeholder='Заголовок отзыва'
className={styles.title}
error={errors.title}
/* добавляем табиндекс */
tabIndex={isOpened ? 0 : -1}
/>
<div className={styles.rating}>
<span>Оценка:</span>
<Controller
control={control}
name='rating'
rules={{
required: { value: true, message: 'Укажите рейтинг' },
}}
render={({ field }) => (
<Rating
isEditable
ref={field.ref}
rating={field.value}
setRating={field.onChange}
error={errors.rating}
/* прокидываем внутрь табиндекс */
tabIndex={isOpened ? 0 : -1}
/>
)}
/>
</div>
<Textarea
{...register('description', {
required: { value: true, message: 'Заполните заголовок' },
})}
placeholder='Текст отзыва'
className={styles.description}
error={errors.description}
/* добавляем табиндекс */
tabIndex={isOpened ? 0 : -1}
/>
<div className={styles.submit}>
{/* добавляем табиндекс */}
<Button appearance='primary' tabIndex={isOpened ? 0 : -1}>
Отправить
</Button>
<span className={styles.info}>
* Перед публикацией отзыв пройдет предварительную модерацию и проверку
</span>
</div>
</div>
{/* CODE ... */}
</form>
);
};
Рейтинг уже реализован более сложным образом:
- создаём массив рефов
ratingArrayRef
- далее нам нужна функция
computeFocus
, которая будет определять состояние доступности табиндекса элемента - в
useEffect
добавляем в зависимости для перестройки рейтингаtabIndex
- в функции
constructRating
мы добавим определение табиндекса и добавим спан в массив рефов - функция
handleKey
будет обрабатывать переход внутри рейтинга по стрелкам клавиатуры
components / Rating / Rating.tsx
export const Rating = forwardRef(
(
{
isEditable = false,
className,
error,
rating,
setRating,
tabIndex,
...props
}: RatingProps,
ref: ForwardedRef<HTMLDivElement>,
): JSX.Element => {
const [ratingArray, setRatingArray] = useState<JSX.Element[]>(new Array(5).fill(<></>));
// данная константа будет хранить массив спанов рейтинга (звёздочки)
const ratingArrayRef = useRef<(HTMLSpanElement | null)[]>([]);
// эта функция будет открывать и закрывать доступ для перехода через клавиатуру в определённых условиях
const computeFocus = (r: number, i: number): 0 | -1 => {
// если нельзя редактировать элемент, то возвращаем -1
if (!isEditable) {
return -1;
}
// если рейтинга нет и текущий индекс = 0, то возвращаем 0
if (!rating && i == 0) {
// если имеется переданный tabIndex, то ставим его, если нет - то 0
return tabIndex ?? 0;
}
// следующий элемент для выбора тоже оставим ему 0
if (r == i + 1) {
return tabIndex ?? 0;
}
// в остальных случаях -1
return -1;
};
useEffect(() => {
constructRating(rating);
}, [rating, tabIndex]);
const constructRating = (currentRating: number) => {
const updatedArray = ratingArray.map((r: JSX.Element, i: number) => {
return (
<span
className={cn(styles.star, {
[styles.filled]: i < currentRating,
[styles.editable]: isEditable,
})}
onMouseEnter={() => changeDisplay(i + 1)}
onMouseLeave={() => changeDisplay(rating)}
onClick={() => onClick(i + 1)}
onKeyDown={handleKey}
/* тут уже используется функция для определения доступности элемента */
tabIndex={computeFocus(rating, i)}
/* тут мы заполняем массив рефов отдельными рефами */
ref={r => ratingArrayRef.current?.push(r)}
>
<StarIcon />
</span>
);
});
setRatingArray(updatedArray);
};
const changeDisplay = (i: number) => {
if (!isEditable) {
return;
}
constructRating(i);
};
const onClick = (i: number) => {
if (!isEditable || !setRating) {
return;
}
setRating(i);
};
// теперь вместо обработки пробелов, тут будет обрабатываться переключение рейтинга через стрелки клавиатуры
const handleKey = (e: KeyboardEvent) => {
// если оценка нередактируемая или нет функции установки рейтинга
if (!isEditable || !setRating) {
return;
}
// при нажатии на срелки вправо и вверх - поднимаем рейтинг
if (e.code == 'ArrowRight' || e.code == 'ArrowUp') {
if (!rating) {
setRating(1);
} else {
e.preventDefault();
setRating(rating < 5 ? rating + 1 : 5);
}
// фокусим текущий элемент массива
ratingArrayRef.current[rating]?.focus();
}
// при нажатии на срелки вниз и влево - опускаем рейтинг
if (e.code == 'ArrowLeft' || e.code == 'ArrowDown') {
e.preventDefault();
setRating(rating > 1 ? rating - 1 : 1);
// тут -2, так как рейтинг не будет применён, когда мы его еще будем вычитать
ratingArrayRef.current[rating - 2]?.focus();
}
};
return (
<div
className={cn(className, styles.ratingWrapper, {
[styles.error]: error,
})}
{...props}
ref={ref}
> {ratingArray.map((r, i) => (
<span key={i}>{r}</span>
))}
{error && <span className={styles.errorMessage}>{error.message}</span>}
</div>
);
},
);
Сделаем звёздочки инлайн-блоковыми, чтобы аутлайн не был кривым
components / Rating / Rating.module.css
.star {
display: inline-block;
}
Итог: теперь, когда форма отзывов закрыта, мы на неё не попадаем. Мы можем спокойно переходить по элементам формы, переключать рейтинг и отправлять форму с клавиатуры.
005 Упражнение - доступность сортировки
Чтобы сортировка выбиралась с клавиатуры, нужно всего лишь поменять span
на button
components / Sort / Sort.tsx
export const Sort = ({ sort, setSort, className, ...props }: SortProps): JSX.Element => {
return (
<div className={cn(styles.sort, className)} {...props}>
<button
onClick={() => setSort(SortEnum.Rating)}
className={cn({
[styles.active]: sort == SortEnum.Rating,
})}
>
<SortIcon className={styles.sortIcon} />
По рейтингу
</button>
<button
onClick={() => setSort(SortEnum.Price)}
className={cn({
[styles.active]: sort == SortEnum.Price,
})}
>
<SortIcon className={styles.sortIcon} />
По цене
</button>
</div>
);
};
Далее тут нужно стилизовать кнопку вместо спана и вернуть стили шрифтов и бэкграунда, а так же убрать бордер
components / Sort / Sort.moduel.css
.sort button {
display: grid;
grid-template-columns: 20px 1fr;
align-items: center;
gap: 8px;
font-size: 16px;
line-height: 22px;
cursor: pointer;
border: none;
background: none;
}
.sort button:not(.active) {
grid-template-columns: 1fr;
}
Итог: и теперь рейтинг выглядит ровно так же, как и раньше
006 ARIA атрибуты
ARIA-атрибуты подразумевают под собой атрибуты, которые позволяют сформировать окружение доступное для понимания людям, которые не могут воспринимать интерфейс сайта в полной мере
Когда мы открываем сайт в браузере, он формирует сразу два отдельных элемента:
- UI из ДОМ-дерева
- Дерево доступности из ДОМ-дерева
ARIA-атрибуты делят на три группы:
Атрибут role
позволяет нам сказать скринридеру, что перед ним находится определённый элемент.
Например, вместо того, чтобы иногда писать role
, мы можем просто использовать корректные семантические теги в вёрстке, что упростит будущую разработку доступности
state
показывает состояние элемента (выделенный / не выделенный чекбокс, раскрытый / закрытый список и так далее)
Свойства в определённой мере описывают действие данного элемента
В девтулзе есть инструмент, которым мы можем отслеживать доступность элементов страницы. В окне просмотра доступности, мы можем предположить, что прочтёт скринридер. Данная кнопка будет прочтена, как:
- “Кнопка “Узнать подробнее”
А уже кнопка поиска будет прочитана ридером просто как “кнопка”, так как ей не был задан ни один из атрибутов и текста внутри неё - нет
Тут описаны все основные паттерны использования страницы, которые встречаются в вебе, с описанием атрибутов, которые нужно использовать для скринридеров: AI-ARIA-Authoring-Practices
007 Использование Screen Reader
Самый популярный скринридер гугловский и им можно попробовать воспользоваться. Его минус в отличие от маковского скринридера заключается в том, что он может просто прочитать какой-либо атрибут без наименования прочитанного атрибута
В параметрах обязательно нужно выбрать ChromeVox модификатор, который будет основным включателем всех функций
И теперь наши сочетания клавиш начинаются с shift+alt
Виды навигации по странице:
- Активные элементы - это когда мы перемещаемся через табы
- Хединги - проход скринридера по заголовкам
- Лэндмарки - проход скринридера по семантическим тегам
- Формы - навигация по элементам формы (кнопки, селекты, инпуты)
- Ссылки
- Таблицы
Тут можно выбрать вышеописанное озвучивание
008 Aria-label и aria-labelledby
Атрибут aria-label
присваивается непосредственно самому элементу в дом-дереве.
Атрибут aria-labelledby
при построении дерева возьмёт из того, который в нём указан через id
. Так же он может указывать на несколько id
, что позволит конкатенировать имя из нескольких элементов
Чтобы скринридер прочитал правильно количество курсов, можно вложить переменную в атрибут
page-components > TopPageComponent.tsx
{products && (
<Tag color='grey' size='m' aria-label={products.length + ' элементов'}>
{products.length}
</Tag>
)}
Так же добавим лейбл на стрелку, которая отправляет пользователя наверх
components / Up / Up.tsx
return (
<motion.div
className={styles.up}
animate={controls}
initial={{ opacity: 0 }}
>
<ButtonIcon appearance='primary' icon='up' aria-label="Наверх" onClick={scrollToTop} />
</motion.div>
);
Тут же мы накидываем атрибут на кнопку поиска информации
components > Search > Search.tsx
<Button
appearance='primary'
className={styles.button}
onClick={goToSearch}
aria-label={'Искать по сайту'}
>
<GlassIcon />
</Button>
Теперь скринридер будет правильно читать данные элементы страницы
Тут же, чтобы реализовать полностью функциональный выбор кнопки, напишем:
- Нужен элемент, который будет озвучиваться как “сортировка”. Для этого создадим элемент с классом
sortName
, который будет скрыт. Ему присвоимid
, чтобыaria-labelledby
читал сначала его - Далее в кнопках добавляем атрибут выбранного элемента
aria-selected
- Потом добавляем в них свои
id
, чтобы скринридер смог прочитать их самих себя - И далее в
aria-labelledby
укажем порядок прочтения элементов поid
components > Sort > Sort.tsx
export const Sort = ({ sort, setSort, className, ...props }: SortProps): JSX.Element => {
return (
<div className={cn(styles.sort, className)} {...props}>
<div className={styles.sortName} id={'sort'}>
Сортировка
</div>
<button
id={'rating'}
onClick={() => setSort(SortEnum.Rating)}
className={cn({
[styles.active]: sort == SortEnum.Rating,
})}
aria-selected={sort == SortEnum.Rating}
aria-labelledby={'sort rating'}
>
<SortIcon className={styles.sortIcon} />
По рейтингу
</button>
<button
id={'price'}
onClick={() => setSort(SortEnum.Price)}
className={cn({
[styles.active]: sort == SortEnum.Price,
})}
aria-selected={sort == SortEnum.Price}
aria-labelledby={'sort price'}
>
<SortIcon className={styles.sortIcon} />
По цене
</button>
</div>
);
};
И теперь скринридер правильно читает кнопки:
- Сортировка
- По рейтингу / По цене
- Кнопка
- Выбрана / не выбрана
009 Aria-hidden
Уже атрибут aria-hidden
позволяет нам скрыть некоторые чисто визуальные элементы от скринридера, чтобы тот не читал их
Тут мы сталкиваемся с такой сложностью, что все aria-label
атрибуты, которые мы поместим в спан или див не будут читаться, так как внутри них есть текст.
Если мы захотим, что скринридер прочитал цену как: “Цена 99999 рублей”, то у нас ничего не получится подобным способом, так как скринридер прочтёт только текст внутри спана или дива.
Чтобы обогатить нужный для чтения текст, можно создать невидимый блок, который будет просто иметь в себе уточняющий текст
styles > globals.css
.visuallyHidden {
position: absolute;
width: 0;
height: 0;
overflow: hidden;
}
Далее все элементы с priceRu
оборачиваем в спан и в эти спаны вкладываем ещё спаны, которые будут скрыты и просто будут читаться. Во вложенные спаны вкладываем уточнения для чисел на странице.
Далее на элементы с отдельным текстом цена / кредит / скидка накинем aria-hidden
для скрытия от скринридера
components > Product > Product.tsx
<div className={styles.price}>
<span>
{/* тут будет храниться спан с уточнением числа */}
<span className={'visuallyHidden'}>Цена</span>
{/* это цена */}
{priceRu(product.price)}
</span>
{product.oldPrice && (
<Tag className={styles.oldPrice} color='green'>
<span className={'visuallyHidden'}>скидка</span>
{priceRu(product.price - product.oldPrice)}
</Tag>
)}
</div>
<div className={styles.credit}>
<span>
<span className={'visuallyHidden'}>Кредит</span>
{priceRu(product.credit)}/<span className={styles.month}>мес</span>
</span>
</div>
<div className={styles.rating}>
{/* тут будет храниться спан с уточнением числа */}
<span className={'visuallyHidden'}>
{/* тут хранится текст с вычислением рейтинга */}
{'рейтинг' + (product.reviewAvg ?? product.initialRating)}
</span>
{/* тут уже находится сам рейтинг */}
<Rating rating={product.reviewAvg ?? product.initialRating} />
</div>
<div className={styles.tags}>
{product.categories.map(c => (
<Tag key={c} className={styles.category} color='ghost'>
{c}
</Tag>
))}
</div>
{/* данные дивы мы скрываем от скринридера */}
<div className={styles.priceTitle} aria-hidden={true}>
цена
</div>
<div className={styles.creditTitle} aria-hidden={true}>
кредит
</div>
Теперь у нас нормально читается цена, скрыты нижние записи, не читается скидка и читается рейтинг
Итог:
Если нужно обогатить контекст, чтобы читался дополнительный текст, то его можно обогатить через вложенный спан, который скрываем стилями Если нужно убрать из чтения элемент, то можно на него просто накинуть
aria-hidden
010 Добавление landmarks
Тут представлены все ARIA-атрибуты одобренные стандартом.
Конкретно атрибуты-landmarks позволяют нам разделить документ на определённые области, чтобы упростить навигацию по странице:
- лэндмарк
banner
- тегheader
navigation
-nav
main
-main
- и так далее
Превратим компонент поиска в форму и присвоим ей роль поиска
components > Serach > Search.tsx
return (
<form className={cn(className, styles.search)} {...props} role={'search'}>
<Input
className={styles.input}
placeholder='Поиск...'
value={search}
onChange={e => setSearch(e.target.value)}
onKeyDown={handleKeyDown}
/>
<Button
appearance='primary'
className={styles.button}
onClick={goToSearch}
aria-label={'Искать по сайту'}
>
<GlassIcon />
</Button>
</form>
);
Так же обёрнем навигацию в nav
и присвоим ей роль навигации
Layout > Menu > Menu.tsx
return (
<nav className={styles.menu} role={'navigation'}>
{buildFirstLevel()}
</nav>
);
Далее обернём тело страницы (продукты) в тег main
и присвоим роль мэин-части
И теперь у нас есть понятные и доступные поля для навигации по странице
011 Доступность форм
Атрибут aria-expanded
позволяет нам сказать, открыта ли определённая форма или закрыта
components > Product > Product.tsx
<div className={styles.actions}>
<Button appearance='primary'>Узнать подробнее</Button>
<Button
appearance='ghost'
arrow={isReviewOpened ? 'down' : 'right'}
className={styles.reviewButton}
onClick={() => setIsReviewOpened(!isReviewOpened)}
aria-expanded={isReviewOpened}
>
Читать отзывы
</Button>
</div>
Читать отзывы - кнопка свёрнута / развёрнута
Для компонентов инпута и текстэрии нужно добавить роль alert
. Данная роль скажет скринридеру, чтобы он при ошибке читал данное поле
components / Input / Input.tsx
return (
<div className={cn(className, styles.inputWrapper)}>
<input
ref={ref}
className={cn(styles.input, {
[styles.error]: error,
})}
{...props}
/>
{error && (
// тут нужно установить роль при ошибке - предупреждение
<span className={styles.errorMessage} role={'alert'}>
{error.message}
</span>
)}
</div>
);
components / Textarea / Textarea.tsx
return (
<div className={cn(styles.textareaWrapper, className)}>
<textarea
ref={ref}
className={cn(styles.textarea, {
[styles.error]: error,
})}
{...props}
/>
{error && (
// тут нужно установить роль при ошибке - предупреждение
<span className={styles.errorMessage} role={'alert'}>
{error.message}
</span>
)}
</div>
);
Так же alert
нужно закинуть в рейтинг, чтобы он тоже выдавал ошибку.
Так же рейтинг будет дополнен следующими атрибутами:
role
- роль слайдера, если редактиуремыйaria-label
в зависимости от состояние редактируемостиaria-valuenow
- текущее значениеaria-valuemin
- минимальное значениеaria-valuemax
- максимальное значениеaria-invalid
- если есть ошибка, то поле укажется, как с ошибкой
components / Rating / Rating.tsx
const constructRating = (currentRating: number) => {
const updatedArray = ratingArray.map((r: JSX.Element, i: number) => {
return (
<span
className={cn(styles.star, {
[styles.filled]: i < currentRating,
[styles.editable]: isEditable,
})}
onMouseEnter={() => changeDisplay(i + 1)}
onMouseLeave={() => changeDisplay(rating)}
onClick={() => onClick(i + 1)}
onKeyDown={handleKey}
tabIndex={computeFocus(rating, i)}
ref={r => ratingArrayRef.current?.push(r)}
// установить лейбл в зависимости от его редактируемости
aria-label={isEditable ? 'Укажите рейтинг курса' : 'Рейтинг ' + rating}
// установим роль рейтингу, если он редактируемый
role={isEditable ? 'slider' : ''}
// дальше нужно указать значения для рейтинга
aria-valuenow={rating}
aria-valuemin={1}
aria-valuemax={5}
// далее определяем условие для неверности рейтинга
aria-invalid={error ? true : false}
>
<StarIcon />
</span>
);
});
setRatingArray(updatedArray);
};
return (
<div
className={cn(className, styles.ratingWrapper, {
[styles.error]: error,
})}
{...props}
ref={ref}
> {ratingArray.map((r, i) => (
<span key={i}>{r}</span>
))}
{error && (
// тут нужно установить роль при ошибке - предупреждение
<span className={styles.errorMessage} role={'alert'}>
{error.message}
</span>
)}
</div>
);
В самой форме обзора нужно для всех полей установить атрибут aria-invalid
, который будет триггерить скринридер при ошибке.
Далее из useForm
нужно получить функцию clearErrors()
, которая очистит формы без ошибок, что позволит скринридеру не прочитывать уже исправленные формы. Пихаем эту функцию в кнопку отправки данных формы
components / ReviewForm / ReviewForm.tsx
export const ReviewForm = ({
isOpened,
productId,
className,
...props
}: ReviewFormProps): JSX.Element => {
const {
register,
control,
handleSubmit,
formState: { errors },
reset,
// данный метод позволяет очищать ошибки
clearErrors,
} = useForm<IReviewForm>();
const [isSuccess, setIsSuccess] = useState<boolean>();
const [error, setError] = useState<string>();
const onSubmit = async (formData: IReviewForm) => {
try {
const { data } = await axios.post<IReviewSentResponse>(API.review.createDemo, {
...formData,
productId,
});
if (data.message) {
setIsSuccess(true);
reset();
} else {
setError('Что-то пошло не так...');
}
} catch (e) {
setError(e.message);
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className={cn(styles.reviewForm, className)} {...props}>
<Input
{...register('name', { required: { value: true, message: 'Заполните имя' } })}
placeholder='Имя'
error={errors.name}
tabIndex={isOpened ? 0 : -1}
// если присутствует ошибка, то нужно указать, что это поле невалидно
aria-invalid={errors.name ? true : false}
/>
<Input
{...register('title', {
required: { value: true, message: 'Заполните заголовок' },
})}
placeholder='Заголовок отзыва'
className={styles.title}
error={errors.title}
tabIndex={isOpened ? 0 : -1}
// если присутствует ошибка, то нужно указать, что это поле невалидно
aria-invalid={errors.title ? true : false}
/>
<div className={styles.rating}>
<span>Оценка:</span>
<Controller
control={control}
name='rating'
rules={{
required: { value: true, message: 'Укажите рейтинг' },
}}
render={({ field }) => (
<Rating
isEditable
ref={field.ref}
rating={field.value}
setRating={field.onChange}
error={errors.rating}
tabIndex={isOpened ? 0 : -1}
// если присутствует ошибка, то нужно указать, что это поле невалидно
aria-invalid={errors.rating ? true : false}
/>
)}
/>
</div>
<Textarea
{...register('description', {
required: { value: true, message: 'Заполните заголовок' },
})}
placeholder='Текст отзыва'
className={styles.description}
error={errors.description}
tabIndex={isOpened ? 0 : -1}
// лейбл области текста
aria-label={'Текст отзыва'}
// если присутствует ошибка, то нужно указать, что это поле невалидно
aria-invalid={errors.description ? true : false}
/>
<div className={styles.submit}>
<Button
appearance='primary'
tabIndex={isOpened ? 0 : -1}
// при отправке данных будут очищаться формы
onClick={() => clearErrors()}
>
Отправить
</Button>
<span className={styles.info}>
* Перед публикацией отзыв пройдет предварительную модерацию и проверку
</span>
</div>
</div>
{isSuccess && (
<div className={cn(styles.panel, styles.success)}>
<div className={styles.successTitle}>Ваш отзыв отправлен</div>
<div>Спасибо, ваш отзыв будет опубликован после проверки.</div>
<CloseIcon className={styles.close} onClick={() => setIsSuccess(false)} />
</div>
)}
{error && (
<div className={cn(styles.panel, styles.error)}>
<CloseIcon className={styles.close} onClick={() => setError(undefined)} />
</div>
)}
</form>
);
};
Теперь скринридер читает ошибку ровно того поля, данные которого были невалидны
012 Упражнение - Доступность оповещений
Далее нужно сделать доступными для скринридера оповещения об отправке и сбое в отправке формы
components / ReviewForm / ReviewForm.tsx
{isSuccess && (
<div className={cn(styles.panel, styles.success)} role={'alert'}>
<div className={styles.successTitle}>Ваш отзыв отправлен</div>
<div>Спасибо, ваш отзыв будет опубликован после проверки.</div>
<button
className={styles.close}
onClick={() => setIsSuccess(false)}
aria-label={'Закрыть оповещение'}
>
<CloseIcon />
</button>
</div>
)}
{error && (
<div className={cn(styles.panel, styles.error)} role={'alert'}>
Что-то пошло не так. Пожалуйста, обновите страницу.
<button
className={styles.close}
onClick={() => setError(undefined)}
aria-label={'Закрыть оповещение'}
>
<CloseIcon />
</button>
</div>
)}
Теперь скринридер может прочитать само оповещение и читает кнопку закрытия оповещения
013 Доступность меню и списка
Куда важнее чем ариа-лейблы всегда является правильная семантика тегов - их всегда нужно выбирать правильные
Далее, чтобы поддержать доступность всех элементов списка меню, нужно определить всем данным элементам правильные семантические теги:
Так же нужно сказать, что тут дополнительно было добавлено состояние, которое определяет открытость или закрытость элемента меню - оно добавляет роль log
для элемента списка, что оповещает о закрытости/открытости определённого элемента второго уровня списка
components / Menu / Menu.tsx
export const Menu = (): JSX.Element => {
const { menu, setMenu, firstCategory } = useContext(AppContext);
// функция для анонсирования состояния открытости или закрытости меню
const [announce, setAnnounce] = useState<'closed' | 'opened' | undefined>();
const router = useRouter();
const variants = {
visible: {
marginBottom: 20,
transition: {
when: 'beforeChildren',
staggerChildren: 0.1,
},
},
hidden: { marginBottom: 0 },
};
const variantsChildren = {
visible: {
opacity: 1,
height: 29,
},
hidden: { opacity: 0, height: 0 },
};
const openSecondLevel = (secondCategory: string) => {
setMenu &&
setMenu(
menu.map(m => {
if (m._id.secondCategory == secondCategory) {
// установим анонс, если состояние меню поменялось
setAnnounce(m.isOpened ? 'closed' : 'opened');
m.isOpened = !m.isOpened;
}
return m;
}),
);
};
const openSecondLevelKey = (key: KeyboardEvent, secondCategory: string) => {
if (key.code == 'Space' || key.code == 'Enter') {
key.preventDefault();
openSecondLevel(secondCategory);
}
};
const buildFirstLevel = () => {
return (
// список будет реализован через ul
<ul className={styles.firstLevelList}>
{firstLevelMenu.map(m => (
// элементы списка - li
// открытость определяем через id
<li key={m.route} aria-expanded={m.id == firstCategory}>
<Link href={`/${m.route}`}>
<div
className={cn(styles.firstLevel, {
[styles.firstLevelActive]: m.id == firstCategory,
})}
>
{m.icon}
<span>{m.name}</span>
</div>
</Link>
{m.id == firstCategory && buildSecondLevel(m)}
</li>
))}
</ul>
);
};
const buildSecondLevel = (menuItem: FirstLevelMenuItem) => {
return (
// список будет реализован через ul
<ul className={styles.secondBlock}>
{menu.map(m => {
if (m.pages.map(p => p.alias).includes(router.asPath.split('/')[2])) {
m.isOpened = true;
}
return (
// а это будет элементом списка
<li key={m._id.secondCategory}>
{/* так же этот элемент является кнопкой */}
<button
onKeyDown={(key: KeyboardEvent) =>
openSecondLevelKey(key, m._id.secondCategory)
}
className={styles.secondLevel}
onClick={() => openSecondLevel(m._id.secondCategory)}
// элемент открыт, если его параметр isOpened === true
aria-expanded={m.isOpened}
>
{m._id.secondCategory}
</button>
<motion.ul
layout
variants={variants}
initial={m.isOpened ? 'visible' : 'hidden'}
animate={m.isOpened ? 'visible' : 'hidden'}
className={styles.secondLevelBlock}
>
{buildThirdLevel(m.pages, menuItem.route, m.isOpened ?? false)}
</motion.ul>
</li>
);
})}
</ul>
);
};
const buildThirdLevel = (pages: PageItem[], route: string, isOpened: boolean) => {
return pages.map(p => (
// это уже отдельный элемент списка в подчинении второго уровня меню
<motion.li key={p._id} variants={variantsChildren}>
<Link
href={`/${route}/${p.alias}`}
tabIndex={isOpened ? 0 : -1}
className={cn(styles.thirdLevel, {
[styles.thirdLevelActive]: `/${route}/${p.alias}` == router.asPath,
})}
// данный атрибут позволит показать нам на какой странице мы находимся
// когда мы перейдём на данный элемент, то услышим, что это текущая страница
aria-current={`/${route}/${p.alias}` == router.asPath ? 'page' : false}
>
{p.category}
</Link>
</motion.li>
));
};
return (
<nav className={styles.menu} role='navigation'>
{/* этот спан будет предупреждать скринридер о том, что элемент списка открыт или закрыт */}
{announce && (
<span role='log' className='visuallyHidden'>
{announce == 'opened' ? 'развернуто' : 'свернуто'}
</span>
)}
{buildFirstLevel()}
</nav>
);
};
Так же нам нужно восстановить внешний вид списка
components / Menu / Menu.module.css
.firstLevelList {
list-style: none;
padding: 0;
}
.secondLevel {
margin-bottom: 10px;
cursor: pointer;
text-transform: uppercase;
color: var(--gray-dark);
font-weight: 300;
font-size: 12px;
background: none;
border: none;
padding: 0;
}
.secondBlock {
margin-top: 15px;
margin-left: 12px;
padding: 0 0 0 32px;
border-left: 1px solid #DFDFDF;
list-style: none;
}
.secondLevelBlock {
overflow: hidden;
list-style: none;
padding: 0;
}
Так же мы можем просто установить роль вместо того, чтобы писать ul
и элементы списка li
, как мы это сделали ниже просто вписать роль list
и listitem
.
Однако такой подход не является самым правильным, так как нам нужно поддерживать клавиатурные переходы по подобным элементам
page-components / TopPageComponent / TopPageComponent.tsx
<div role={'list'}>
{sortedProducts &&
sortedProducts.map(p => (
<Product
role={'listitem'}
layout={shouldReducedMotion ? false : true}
key={p._id}
product={p}
/>
))}
</div>
И теперь каждая карточка ролей озвучивается как элемент списка. Скринридер нам прочтёт, что тут 8 элементов списка
014 Уменьшение движения
Хук useReducedMotion
получает от системы пользователя информацию о том, включено ли у него состояние уменьшенной анимации. Если true
, то анимация отключается, а если false
, то анимация остаётся та, что мы реализовали
В хедере анимация непрозрачности будет только если у пользователя не стоит функция уменьшения движения
layout / Header / Header.tsx
const shouldReducedMotion = useReducedMotion();
const variants = {
opened: {
opacity: 1,
x: 0,
transition: {
stiffness: 90,
},
},
closed: {
opacity: shouldReducedMotion ? 1 : 0,
x: '100%',
},
};
Анимация при появлении меню будет при тех же условиях. Тернарный оператор так же можно использовать при присваивании другого объекта, что позволит реализовать другую анимацию или просто её не присваивать
layout / Menu / Menu.tsx
const shouldReducedMotion = useReducedMotion();
const variants = {
hidden: {
marginBottom: 0,
},
visible: shouldReducedMotion
? {}
: {
marginBottom: 20,
transition: {
when: 'beforeChildren',
staggerChildren: 0.1,
},
},
};
const variantsChildren = {
hidden: { opacity: 0, height: 0 },
visible: { opacity: shouldReducedMotion ? 1 : 0, height: 29 },
};
Тут будет происходить анимация лейаута, если у пользователя отключена функция уменьшения движения
layout / TopPageComponent / TopPageComponent.tsx
const shouldReducedMotion = useReducedMotion();
return (
<div>
{sortedProducts &&
sortedProducts.map(p => (
<Product
layout={shouldReducedMotion ? false : true}
key={p._id}
product={p}
/>
))}
</div>
)