Skip to content

Commit

Permalink
fix(CustomSelect): a11y allow NVDA to read selected option (#7235)
Browse files Browse the repository at this point in the history
Оказалось, что `NVDA` не зачитывает элементы расположенные рядом с `input`, если фокус есть на `input`, в отличии от `VoiceOver`.

Есть несколько решений.
1. Отказаться от использования `input` как основного элемента `CustomSelect` c ролью `combobox`.
  Не хотелось бы от него отказываться, чтобы продолжал работать нативный фокус на `CustomSelect` при клике на связанный с ним `label`.
> [!NOTE]
> Но если проблемы продолжатся, и это будет причиной, то стоит пожертвовать нативным фокусом.
2. Переделать `input` так, чтобы он всегда хранил `label` выбранной опции, тогда `NVDA` сможет его зачитывать.
  Тут проблема в том, что` option.label `может быть не только строкой, но и react-компонентом, поэтому нельзя `option.label` выбранной в данный момент опции, просто так передать в `input`.\.
  https://github.com/VKCOM/VKUI/blob/1662eeae1a24e36f6f9f0c0331b16bd414d895cd/packages/vkui/src/components/CustomSelect/CustomSelect.tsx#L126
Про такой вариант даже немножка в доке про [combobox](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/combobox_role) на MDN сказано
    > If the combobox element is an [<input>](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input) element, the value of the combobox is the input's value. Otherwise, the value of the combobox comes from its descendant elements.

Изменения
В качестве решения выбран второй вариант с передачей в `input.value` `option.label`. Для того, что мы могли передать туда и текстовое представление react-компонента используем утилитарную функцию [getTextFromChildren](https://github.com/VKCOM/VKUI/blob/1662eeae1a24e36f6f9f0c0331b16bd414d895cd/packages/vkui/src/lib/children.ts#L19)
Чтобы минимизировать различия с дизайном, в том числе когда `option.label` это реакт-компонент, мы продолжаем input рендерить скрыто, с `opacity: 0`, а поверх рендерим контейнер для option.label, где спокойно может лежать react-компонент.

В обычном режиме `CustomSelect` (не `searchable`) `input` не виден даже при фокусе.
В режиме `searchable` мы продолжаем рендерить контейнер поверх `input`, но при фокусе на `CustomSelect` (а значит на `input`) мы `input` показываем, чтобы пользователь мог взаимодействовать с ним для ввода текста и поиска опций.
Если в `CustomSelect` уже выбрана какая-то опция, при фокусе мы оставляем текст инпута на месте, чтобы пользователи скринридера могли прочитать выбранное в данный момент значение.

- Отрефакторил классы `CustomSelectInput` чтобы было понятнее к чему они относятся.
- Убрал из CustomSelect отдельный скрытый текст лэйбла, добавленный ранее для скринридеров, так как он никак не помогает `NVDA` и его теперь заменяет значение `value` у `CustomSelectInput`.
- На `blur` мы устанавливаем значение` input.value` равным `option.label`, если есть выбранная опция, либо ''.
- Также стараемся реагировать на изменения значения `select.value`, чтобы `input.value` всегда обновлялось в соответствии с текущей выбранной опцией. Это особенно актуально, когда `CustomSelect` `value` устанавливается снаружи.
- Изменился способ передачи в `CustomSelectInput` значения `label` текущей выбранной опции, раньше label передавался как `children` и было не понятно что в этом свойстве хранится, пока не посмотришь на то, как `CustomSelectInput` используется внутри `CustomSelect`. Теперь`label` мы передаем в свойстве `selectedOptionLabel`
- Убрал `aria-owns` из `combobox` так как аттрибут устарел и мешает `NVDA` в `Firefox` правильно зачитывать опции (c `aria-owns` вместо названия опции зачитывается "секция")
  • Loading branch information
mendrew authored and actions-user committed Aug 20, 2024
1 parent cdca250 commit 13258fe
Show file tree
Hide file tree
Showing 8 changed files with 417 additions and 234 deletions.
133 changes: 132 additions & 1 deletion packages/vkui/src/components/CustomSelect/CustomSelect.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
import * as React from 'react';
import { Meta, StoryObj } from '@storybook/react';
import { fn } from '@storybook/test';
import { Icon24User } from '@vkontakte/icons';
import { CanvasFullLayout, DisableCartesianParam } from '../../storybook/constants';
import { cities } from '../../testing/mock';
import { cities, getRandomUsers } from '../../testing/mock';
import { Avatar } from '../Avatar/Avatar';
import { CustomSelectOption } from '../CustomSelectOption/CustomSelectOption';
import { Div } from '../Div/Div';
import { FormItem } from '../FormItem/FormItem';
import { FormLayoutGroup } from '../FormLayoutGroup/FormLayoutGroup';
import { Header } from '../Header/Header';
import { CustomSelect, SelectProps } from './CustomSelect';

const story: Meta<SelectProps> = {
Expand All @@ -21,3 +29,126 @@ export const Playground: Story = {
options: cities,
},
};

function getUsers(usersArray: ReturnType<typeof getRandomUsers>) {
return usersArray.map((user) => ({
label: user.name,
value: `${user.id}`,
avatar: user.photo_100,
description: user.screen_name,
}));
}

export const QAPlayground: Story = {
render: function Render() {
const selectTypes = [
{
label: 'default',
value: 'default',
},
{
label: 'plain',
value: 'plain',
},
{
label: 'accent',
value: 'accent',
},
];

const [selectType, setSelectType] = React.useState<undefined | SelectProps['selectType']>(
undefined,
);
const users = [...getUsers(getRandomUsers(10))];
return (
<Div style={{ minWidth: '500px' }}>
<Header Component="h1">Custom Select на десктопе</Header>
<Header>Базовые примеры использования</Header>

<FormLayoutGroup mode="horizontal">
<FormItem
top="Администратор"
htmlFor="administrator-select-id"
style={{ flexGrow: 1, flexShrink: 1 }}
>
<CustomSelect
id="administrator-select-id"
placeholder="Не выбран"
options={users}
defaultValue={users[2].value}
selectType={selectType}
allowClearButton
/>
</FormItem>

<FormItem
top="Вид выпадающего списка"
htmlFor="select-type-select-id"
style={{ flexBasis: '200px', flexGrow: 0 }}
>
<CustomSelect
id="select-type-select-id"
value={selectType}
placeholder="Не задан"
options={selectTypes}
defaultValue={selectTypes[0].value}
onChange={(e) => setSelectType(e.target.value as SelectProps['selectType'])}
renderOption={({ option, ...restProps }) => (
<CustomSelectOption {...restProps} description={`"${option.value}"`} />
)}
/>
</FormItem>
</FormLayoutGroup>

<FormItem
top="Администратор"
bottom="Кастомный дизайн элементов списка"
htmlFor="administrator-select-id-2"
>
<CustomSelect
id="administrator-select-id-2"
placeholder="Не выбран"
options={users}
renderOption={({ option, ...restProps }) => (
<CustomSelectOption
{...restProps}
before={<Avatar size={24} src={option.avatar} />}
description={option.description}
/>
)}
/>
</FormItem>

<FormItem
top="Администратор"
htmlFor="administrator-select-id-3"
bottom="Ползунок скроллбара по умолчанию скрыт"
>
<CustomSelect
id="administrator-select-id-3"
placeholder="Не выбран"
options={users}
selectType={selectType}
autoHideScrollbar
/>
</FormItem>

<Header>Поиск</Header>
<FormItem
top="Администратор"
htmlFor="administrator-select-searchable-id-3"
bottom="Поиск по списку"
>
<CustomSelect
before={<Icon24User />}
placeholder="Введите имя пользователя"
searchable
id="administrator-select-searchable-id-3"
options={users}
allowClearButton
/>
</FormItem>
</Div>
);
},
};
Loading

0 comments on commit 13258fe

Please sign in to comment.