Skip to content

Commit

Permalink
a11y: allow to pass axeConfig to baseline unit test (#8145)
Browse files Browse the repository at this point in the history
В функцию для юнит тестирования `baselineComponent` был добавлен параметр `a11yConfig`, который позволяет задавать конфигуракцию для `axe`, в том числе точечно отключать правила. Это позволяет не отключать сразу все правила c помощь `{a11y: false}`, а только те, которые в данные момент решить не получается и создать для решения issue/pr.

Часто для компонентов при проверке `a11y` не хватает `label/title`, для этого некоторые тесты были дополнены, чтобы `a11y` не ругался, из-за отстутвия необходимый лэйблов для инпута и т.п.

- Для Alert компонента добавлен warning если `title` не задан и `label/labelledby` не передан.
- Для baselineComponent тестов ModalPage/ModalCard добавлен `ModalPageHeader` в пример;
- Для `Alert/ModalPage/ModalCard/ModalCardBase` в документацию добавлены пункты про a11y.
  • Loading branch information
andrey-medvedev-vk authored Jan 27, 2025
1 parent f2f7055 commit 7a953d8
Show file tree
Hide file tree
Showing 20 changed files with 218 additions and 53 deletions.
13 changes: 11 additions & 2 deletions packages/vkui/src/components/Accordion/Accordion.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
import { fireEvent, render } from '@testing-library/react';
import { baselineComponent, waitCSSKeyframesAnimation } from '../../testing/utils';
import { a11yTest, baselineComponent, waitCSSKeyframesAnimation } from '../../testing/utils';
import { Accordion } from './Accordion';

describe(Accordion, () => {
a11yTest(() => (
<Accordion>
<Accordion.Summary iconPosition="before" data-testid="summary">
Title
</Accordion.Summary>
<Accordion.Content data-testid="content">Content</Accordion.Content>
</Accordion>
));

baselineComponent(Accordion.Content);
baselineComponent(Accordion.Summary, { a11y: false });
baselineComponent((props) => <Accordion.Summary {...props}>Title</Accordion.Summary>);

it('toggles on click', async () => {
const result = render(
Expand Down
26 changes: 23 additions & 3 deletions packages/vkui/src/components/Alert/Alert.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Platform } from '../../lib/platform';
import {
baselineComponent,
fakeTimers,
setNodeEnv,
userEvent,
waitCSSKeyframesAnimation,
} from '../../testing/utils';
Expand All @@ -25,9 +26,28 @@ import typographyStyles from '../Typography/Typography.module.css';
describe('Alert', () => {
fakeTimers();

baselineComponent(Alert, {
// TODO [a11y]: "ARIA dialog and alertdialog nodes should have an accessible name (aria-dialog-name)"
a11y: false,
baselineComponent((props) => <Alert {...props} title="Alert title" onClose={noop} />, {});

it('shows warning if title and area attributes are not provided', () => {
setNodeEnv('development');
const warn = jest.spyOn(console, 'warn').mockImplementation(noop);

const component = render(<Alert onClose={noop} title="Alert title" />);
expect(warn).not.toHaveBeenCalled();

component.rerender(<Alert onClose={noop} aria-label="Alert title" />);
expect(warn).not.toHaveBeenCalled();

component.rerender(<Alert onClose={noop} aria-labelledby="labelId" />);
expect(warn).not.toHaveBeenCalled();

component.rerender(<Alert onClose={noop} />);

expect(warn.mock.calls[0][0]).toBe(
'%c[VKUI/Alert] Если "title" не используется, то необходимо задать либо "aria-label", либо "aria-labelledby" (см. правило axe aria-dialog-name)',
);

setNodeEnv('test');
});

describe('closes', () => {
Expand Down
16 changes: 15 additions & 1 deletion packages/vkui/src/components/Alert/Alert.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { type UseFocusTrapProps } from '../../hooks/useFocusTrap';
import { usePlatform } from '../../hooks/usePlatform';
import { useCSSKeyframesAnimationController } from '../../lib/animation';
import { stopPropagation } from '../../lib/utils';
import { warnOnce } from '../../lib/warnOnce';
import type {
AlignType,
AnchorHTMLAttributesOnly,
Expand Down Expand Up @@ -81,6 +82,8 @@ export interface AlertProps
allowClickPropagation?: boolean;
}

const warn = warnOnce('Alert');

/**
* @see https://vkcom.github.io/VKUI/#/Alert
*/
Expand Down Expand Up @@ -149,6 +152,17 @@ export const Alert = ({

useScrollLock();

if (
process.env.NODE_ENV === 'development' &&
!title &&
!restProps['aria-label'] &&
!restProps['aria-labelledby']
) {
warn(
'Если "title" не используется, то необходимо задать либо "aria-label", либо "aria-labelledby" (см. правило axe aria-dialog-name)',
);
}

const handleClick = allowClickPropagation
? onClick
: (event: React.MouseEvent<HTMLElement>) => {
Expand All @@ -166,7 +180,6 @@ export const Alert = ({
getRootRef={getRootRef}
>
<FocusTrap
{...restProps}
{...animationHandlers}
onClick={handleClick}
getRootRef={elementRef}
Expand All @@ -183,6 +196,7 @@ export const Alert = ({
aria-modal
aria-labelledby={titleId}
aria-describedby={descriptionId}
{...restProps}
>
<div
className={classNames(
Expand Down
12 changes: 12 additions & 0 deletions packages/vkui/src/components/Alert/Readme.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
Начиная с VKUI v7 этот компонент можно объявить в любом месте приложения в пределах [`AppRoot`](#/AppRoot). Больше нет необходимости явно передавать его в свойство `popout` компоненту [`SplitLayout`](#/SplitLayout).

## Цифровая доступность (a11y)

`Alert` является модальным окном (`aria-role="dialog"`), а значит у него обязательно должно быть имя — его краткое название. Благодаря этому пользователи вспомогательных технологий знают, что это за элемент и какое у него содержимое.

Задать имя можно с помощью следующих способов:

- используя свойство `title`;
- используя свойство `aria-label`;
- используя свойство `aria-labelledby`;

## Типы кнопок

В Алертах особое внимание нужно уделить кнопкам. Всего есть три типа кнопок:
`cancel`, `destructive` и `default`.

Expand Down
9 changes: 8 additions & 1 deletion packages/vkui/src/components/CardScroll/CardScroll.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,14 @@ const setup = ({ defaultScrollLeft = 50, cardsCount = 6 }: PrepareDataParams) =>

describe('CardScroll', () => {
baselineComponent(CardScroll, {
a11y: false,
a11yConfig: {
rules: {
// TODO [a11y]: "<ul> and <ol> must only directly contain <li>, <script> or <template> elements (list)"
// https://dequeuniversity.com/rules/axe/4.5/aria-required-parent?application=axeAPI
// see https://github.com/VKCOM/VKUI/issues/8135
list: { enabled: false },
},
},
});

it('check scroll by click arrow left', async () => {
Expand Down
16 changes: 15 additions & 1 deletion packages/vkui/src/components/ChipsInput/ChipsInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,21 @@ import { baselineComponent, userEvent } from '../../testing/utils';
import { ChipsInput } from './ChipsInput';

describe(ChipsInput, () => {
baselineComponent(ChipsInput, { a11y: false });
baselineComponent(ChipsInput, {
a11yConfig: {
rules: {
// TODO: listbox не имеет label/title/labelledby
// https://dequeuniversity.com/rules/axe/4.9/aria-input-field-name?application=axeAPI
'aria-input-field-name': { enabled: false },
// TODO: combobox is not allowed as children of listbox
// https://dequeuniversity.com/rules/axe/4.9/aria-required-children?application=axeAPI
'aria-required-children': { enabled: false },
// TODO: real input has no assiciated label
// https://dequeuniversity.com/rules/axe/4.9/label?application=axeAPI
'label': { enabled: false },
},
},
});

it('check reset form event', async () => {
const onChange = jest.fn();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,38 @@ const BLUE_OPTION = { value: 'blue', label: 'Синий' };
const YELLOW_OPTION = { value: 'yellow', label: 'Жёлтый' };

describe(ChipsInputBase, () => {
baselineComponent(ChipsInputBaseTest, {
// доступность должна быть реализована в обёртках над ChipsInputBase
a11y: false,
});

const onAddChipOption = jest.fn();
const onRemoveChipOption = jest.fn();
const onClearOptions = jest.fn();

baselineComponent(
(props) => (
<ChipsInputBaseTest
onAddChipOption={onAddChipOption}
onRemoveChipOption={onRemoveChipOption}
onClear={onClearOptions}
value={[RED_OPTION]}
{...props}
/>
),
{
a11yConfig: {
rules: {
'nested-interactive': { enabled: false },
// TODO: real input has no assiciated label
// https://dequeuniversity.com/rules/axe/4.9/label?application=axeAPI
'label': { enabled: false },
// TODO: listbox не имеет label/title/labelledby
// https://dequeuniversity.com/rules/axe/4.9/aria-input-field-name?application=axeAPI
'aria-input-field-name': { enabled: false },
// TODO: combobox is not allowed as children of listbox
// https://dequeuniversity.com/rules/axe/4.9/aria-required-children?application=axeAPI
'aria-required-children': { enabled: false },
},
},
},
);

beforeEach(() => {
onAddChipOption.mockClear();
onRemoveChipOption.mockClear();
Expand Down Expand Up @@ -408,6 +431,6 @@ describe(ChipsInputBase, () => {
expect(screen.getByTestId('clear-button')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('clear-button'));

expect(onClearOptions).toBeCalledTimes(1);
expect(onClearOptions).toHaveBeenCalledTimes(1);
});
});
16 changes: 15 additions & 1 deletion packages/vkui/src/components/ChipsSelect/ChipsSelect.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,21 @@ describe('ChipsSelect', () => {
afterEach(() => {
placementStub = undefined;
});
baselineComponent(ChipsSelect, { a11y: false });
baselineComponent(ChipsSelect, {
a11yConfig: {
rules: {
// TODO: listbox не имеет label/title/labelledby
// https://dequeuniversity.com/rules/axe/4.9/aria-input-field-name?application=axeAPI
'aria-input-field-name': { enabled: false },
// TODO: combobox is not allowed as children of listbox
// https://dequeuniversity.com/rules/axe/4.9/aria-required-children?application=axeAPI
'aria-required-children': { enabled: false },
// TODO: real input has no assiciated label
// https://dequeuniversity.com/rules/axe/4.9/label?application=axeAPI
'label': { enabled: false },
},
},
});

fakeTimers();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,7 @@ import { CustomSelectInput, type CustomSelectInputProps } from './CustomSelectIn
import styles from './CustomSelectInput.module.css';

describe(CustomSelectInput, () => {
baselineComponent(CustomSelectInput, {
a11y: false,
});
baselineComponent((props) => <CustomSelectInput {...props} placeholder="Select label" />);

it.each<{ props: Partial<CustomSelectInputProps>; className: string }>([
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,13 @@ describe('CustomSelectOption', () => {
baselineComponent(
(props) => <CustomSelectOption {...props}>CustomSelectOption</CustomSelectOption>,
{
// TODO [a11y]: "Certain ARIA roles must be contained by particular parents (aria-required-parent)"
// https://dequeuniversity.com/rules/axe/4.5/aria-required-parent?application=axeAPI
a11y: false,
a11yConfig: {
rules: {
// TODO [a11y]: "Certain ARIA roles must be contained by particular parents (aria-required-parent)"
// https://dequeuniversity.com/rules/axe/4.5/aria-required-parent?application=axeAPI
'aria-required-parent': { enabled: false },
},
},
},
);

Expand Down
13 changes: 8 additions & 5 deletions packages/vkui/src/components/DateInput/DateInput.test.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { format, subDays } from 'date-fns';
import { baselineComponent, userEvent } from '../../testing/utils';
Expand Down Expand Up @@ -30,11 +31,13 @@ const convertInputsToNumbers = (inputs: HTMLElement[]) => {
};

describe('DateInput', () => {
baselineComponent(DateInput, {
// TODO [a11y]: "Elements must only use allowed ARIA attributes (aria-allowed-attr)"
// https://dequeuniversity.com/rules/axe/4.5/aria-allowed-attr?application=axeAPI
a11y: false,
});
baselineComponent((props) => (
<React.Fragment>
<label htmlFor="date-input">Date range</label>
<DateInput {...props} id="date-input" />
</React.Fragment>
));

it('should be correct input value', () => {
const onChange = jest.fn();
render(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { addDays, format } from 'date-fns';
import { baselineComponent, userEvent } from '../../testing/utils';
Expand Down Expand Up @@ -37,11 +38,12 @@ const convertInputsToNumbers = (inputs: HTMLElement[]) => {
};

describe('DateRangeInput', () => {
baselineComponent(DateRangeInput, {
// TODO [a11y]: "Elements must only use allowed ARIA attributes (aria-allowed-attr)"
// https://dequeuniversity.com/rules/axe/4.5/aria-allowed-attr?application=axeAPI
a11y: false,
});
baselineComponent((props) => (
<React.Fragment>
<label htmlFor="range-input">Date range</label>
<DateRangeInput {...props} id="range-input" />
</React.Fragment>
));

it('should be correct input value', () => {
render(
Expand Down
11 changes: 6 additions & 5 deletions packages/vkui/src/components/ModalCard/ModalCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { fireEvent, render, screen } from '@testing-library/react';
import { baselineComponent, waitCSSTransitionEnd } from '../../testing/utils';
import { Button } from '../Button/Button';
import { ConfigProvider } from '../ConfigProvider/ConfigProvider';
import { ModalPageHeader } from '../ModalPageHeader/ModalPageHeader';
import { ModalCard } from './ModalCard';
import { type ModalCardProps } from './types';

Expand All @@ -14,11 +15,11 @@ export const waitModalCardCSSTransitionEnd = async (el: HTMLElement) =>
* Большинство логики покрыто в `ModalRoot.test.tsx`
*/
describe(ModalCard, () => {
baselineComponent((p) => <ModalCard open nav="id" {...p} />, {
// TODO [a11y]: "ARIA dialog and alertdialog nodes should have an accessible name (aria-dialog-name)"
// https://dequeuniversity.com/rules/axe/4.5/aria-dialog-name?application=axeAPI
a11y: false,
});
baselineComponent((p) => (
<ModalCard open nav="id" {...p}>
<ModalPageHeader>Title</ModalPageHeader>
</ModalCard>
));

test('mount and unmount', async () => {
const result = render(<ModalCard id="host" data-testid="host" />);
Expand Down
11 changes: 11 additions & 0 deletions packages/vkui/src/components/ModalCard/Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,17 @@ const Example = () => {

# Спецификация

## Цифровая доступность (a11y)

`ModalCard` является модальным окном (`aria-role="dialog"`), а значит у него обязательно должно быть имя — его краткое название. Благодаря этому пользователи вспомогательных технологий знают, что это за элемент и какое у него содержимое.

Задать имя можно с помощью следующих способов:

- используя свойство `title`;
- используя свойство `aria-label`;
- используя свойство `aria-labelledby`;
- используя внутри компонент [ModalPageHeader](#/ModalPageHeader). Этот компонент сам свяжется с `ModalCard` через контекст c помощью `aria-labelledby`.

## Анатомия

Вёрстку и параметры `ModalCard` наследует от [ModalCardBase](#/ModalCardBase). Также используется компоновка из следующих внутренних
Expand Down
10 changes: 9 additions & 1 deletion packages/vkui/src/components/ModalCardBase/Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,15 @@

## Цифровая доступность (a11y)

Чтобы кнопка для закрытия была доступной для ассистивных технологий, мы передаем в нее скрытый визуально текст, который сможет прочитать скринридер. Чтобы заменить текст, передайте его в `dismissLabel`.
- Если`ModalCardBase` является модальным окном, то ему надо добавить аттрибут `aria-role="dialog"`. Также у него обязательно должно быть имя — его краткое название. Благодаря этому пользователи вспомогательных технологий знают, что это за элемент и какое у него содержимое.

Задать имя можно с помощью следующих способов:

- используя свойство `title`;
- используя свойство `aria-label`;
- используя свойство `aria-labelledby`;

- Чтобы кнопка для закрытия была доступной для ассистивных технологий, мы передаем в нее скрытый визуально текст, который сможет прочитать скринридер. Чтобы заменить текст, передайте его в `dismissLabel`.

```jsx { "props": { "layout": false, "iframe": false } }
<div style={{ margin: 20 }}>
Expand Down
Loading

0 comments on commit 7a953d8

Please sign in to comment.