ARIA Accessability

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>
)