Skip to content

Commit

Permalink
fix(Select/CustomSelect)(a11y): make Select accessible for screen rea…
Browse files Browse the repository at this point in the history
…ders (#6087)

- close #3547 

Описание
Добавляем `aria`-аттрибуты `CustomSelect`'у, следуя паттерну [Combobox](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/).
Как для обычного режима селекта, так и для `searchable` режима, когда в инпут можно вводить текст для фильтрации опций.

 Изменения
- добавлены aria-аттрибуты для CustomSelect, следуя паттерну [Combobox](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/)
- исправлена работа `onBlur/onFocus` пропов. Начиная с react17 react больше не реагирует на dispatch кастомных событий `blur/focus` и мы должны создавать события `focusin` `focusout`.
- вместо попеременного использования `Input` и `SelectMimicry` мы теперь всегда  используем внутренний компонент `CustomSelectInput`. Это позволило избавиться от множества проблем и блокеров для поддержки доступности.
  - ушла проблема с потерей фокуса при выборе опции в режиме `searchable`
  - при фокусе на селекте в режиме `searchable` сразу доступен ввод с клавиатуры для поиска опций, причём скрин ридер правильно зачитывает информацию о селект и выбранной опции если она есть, либо плейсхолдер.
  - стал из коробки работать фокус на селекте при клике на связанный с селектом лэйбл.
  - для связи с лэйблом достаточно просто задать `id` селекта в `htmlFor` аттрибуте лэйбла. Без инпута для этого надо было бы ещё и прокидывать связь через `aria-labelledby`.
- инпут имеет свойство `autoComplete="off"`, потому что хром начинает предлагать свои варианты для заполнения инпута и мешает взаимодействовать с открытым списком.
- новый компонент (`CustomSelectInput`) был добавлен ввиду того, что невозможно было переиспользовать имеющийся `Input` или `SelectMimicry` без внесений лишних изменений. Тем не менее `CustomSelectInput` продолжает внутри себя использовать `FormField` и заимствует часть свойство, имеющихся у `Input` и `SelectMimicry`.  
  В данный момент я не собираюсь переделывать `SelectMimicry` в рамках этого PR, но не против попробовать сделать это в будущем.
- после тестирования селекта специалистом по доступности, было принято решение убрать обёртку компонентом `label` из `CustomSelect` и `NativeSelect`, потому что это заставляет скринридер зачитывать текст дважды при навигации с помощью стрелок, если у селекта выбранна опция. Не всегда, лишь в некоторых версиях Хром, но поведение стабильное.
- тем не менее мы оставили поведения подобное `label` с помощью дополнительного кода в CustomSelect. В NativeSelect дополнительного кода не требуется, потому что нативный селект растянут и лежит поверх визуальных элементов и гарантирует фокус на селекте при клике по любой области внутри компонента NativeSelect.
- исправлено прыгающее поведение фокуса на опции при навигации с клавиатуры, если на одну из опций наведён курсор.
https://github.com/VKCOM/VKUI/blob/9bae73ff784eaf7f6de096e8b7f56485f2e9cb50/packages/vkui/src/components/CustomSelect/CustomSelect.tsx#L677-L685
- в документацию по `Select/CustomSelect` добавлены разделы про доступность с рекомендацией использования label и связывания label с селект.
- обновлены примеры документации, FormItem лэйблы теперь связаны с селектами, для демонстрации предпочтительного использования.
  • Loading branch information
mendrew authored Nov 23, 2023
1 parent 9d47c34 commit ec3a56d
Show file tree
Hide file tree
Showing 13 changed files with 878 additions and 286 deletions.
332 changes: 207 additions & 125 deletions packages/vkui/src/components/CustomSelect/CustomSelect.test.tsx

Large diffs are not rendered by default.

360 changes: 228 additions & 132 deletions packages/vkui/src/components/CustomSelect/CustomSelect.tsx

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,27 +1,35 @@
import * as React from 'react';
import { Icon16Cancel } from '@vkontakte/icons';
import { stopPropagation } from '../../lib/utils';
import { HasDataAttribute } from '../../types';
import { IconButton } from '../IconButton/IconButton';

export interface CustomSelectClearButtonProps {
export interface CustomSelectClearButtonProps extends HasDataAttribute {
className?: string;
onClick(): void;
disabled?: boolean;
}

export const CustomSelectClearButton = ({ className, onClick }: CustomSelectClearButtonProps) => {
export const CustomSelectClearButton = ({
className,
onClick,
...restProps
}: CustomSelectClearButtonProps) => {
return (
<IconButton
className={className}
Component="div"
onClick={(e) => {
stopPropagation(e);
onClick();
}}
aria-label="Очистить поле"
onKeyDown={stopPropagation}
role="button"
activeMode="opacity"
hoverMode="opacity"
{...restProps}
className={className}
onClick={(e) => {
stopPropagation(e);
e.preventDefault();
onClick();
}}
>
<Icon16Cancel />
</IconButton>
Expand Down
133 changes: 133 additions & 0 deletions packages/vkui/src/components/CustomSelect/CustomSelectInput.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
.CustomSelectInput {
position: relative;
}

.CustomSelectInput__el {
position: absolute;
top: 0;
left: 0;
z-index: var(--vkui_internal--z_index_form_field_element);
width: 100%;
height: var(--vkui--size_field_height--regular);
line-height: var(--vkui--size_field_height--regular);
margin: 0;
border: 0;
border-radius: inherit;
box-sizing: border-box;
box-shadow: none;
appearance: none;
color: var(--vkui--color_text_primary);
padding: 0 12px;
background: transparent;
}

.CustomSelectInput__el--cursor-pointer {
cursor: pointer;
}

.CustomSelectInput--sizeY-compact .CustomSelectInput__el {
height: var(--vkui--size_field_height--compact);
}

@media (--sizeY-compact) {
.CustomSelectInput--sizeY-none .CustomSelectInput__el {
height: var(--vkui--size_field_height--compact);
}
}

.CustomSelectInput--hasBefore .CustomSelectInput__el {
padding-left: 0;
}

.CustomSelectInput--hasAfter .CustomSelectInput__el {
padding-right: 0;
}

.CustomSelectInput__el:disabled {
opacity: var(--vkui--opacity_disable_accessibility);
}

.CustomSelectInput__container {
z-index: var(--vkui_internal--z_index_form_field_element);
width: 100%;
max-height: 100%;
padding-right: 0;
padding-left: 12px;
color: var(--vkui--color_text_primary);
box-sizing: border-box;
overflow: hidden;
pointer-events: none;
}

.CustomSelectInput--hasBefore .CustomSelectInput__container {
padding-left: 0;
}

.CustomSelectInput--multiline .CustomSelectInput__container {
padding-top: 12px;
padding-bottom: 12px;
}

.CustomSelectInput--sizeY-compact.CustomSelectInput--multiline .CustomSelectInput__container {
padding-top: 8px;
padding-bottom: 8px;
}

@media (--sizeY-compact) {
.CustomSelectInput--sizeY-none.CustomSelectInput--multiline .CustomSelectInput__container {
padding-top: 8px;
padding-bottom: 8px;
}
}

.CustomSelectInput__input-group {
position: relative;
display: flex;
align-self: stretch;
align-items: center;
flex: 1;
overflow: hidden;
}

.CustomSelectInput--hasBefore .CustomSelectInput__input-group {
border-radius: 0;
}

.CustomSelectInput__title {
display: block;
}

.CustomSelectInput:not(.CustomSelectInput--multiline) .CustomSelectInput__title {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}

.CustomSelectInput--empty .CustomSelectInput__title {
color: var(--vkui--color_text_secondary);
}

/* Для доступности placeholder в инпуте задан, но визуально не виден, потому что
* для комфортного управления видом плейсходера мы рендерим его отдельно, так же как и лэйбл
*/
.CustomSelectInput__el::placeholder {
opacity: 0;
}

.CustomSelectInput--align-right .CustomSelectInput__title,
.CustomSelectInput--align-right .CustomSelectInput__el {
text-align: right;
}

.CustomSelectInput--align-center .CustomSelectInput__title,
.CustomSelectInput--align-center .CustomSelectInput__el {
text-align: center;
}

/**
* CMP:
* CalendarHeader
*/
:global(.vkuiInternalCalendarHeader__picker) .CustomSelectInput__container {
padding-right: 4px;
}
113 changes: 113 additions & 0 deletions packages/vkui/src/components/CustomSelect/CustomSelectInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import * as React from 'react';
import { classNames } from '@vkontakte/vkjs';
import { useAdaptivity } from '../../hooks/useAdaptivity';
import { useExternRef } from '../../hooks/useExternRef';
import { useFocusWithin } from '../../hooks/useFocusWithin';
import { SizeType } from '../../lib/adaptivity';
import { getFormFieldModeFromSelectType } from '../../lib/select';
import { HasAlign, HasRef, HasRootRef } from '../../types';
import { FormField, FormFieldProps } from '../FormField/FormField';
import type { SelectType } from '../Select/Select';
import { SelectTypography } from '../SelectTypography/SelectTypography';
import { Text } from '../Typography/Text/Text';
import styles from './CustomSelectInput.module.css';

const sizeYClassNames = {
none: styles['CustomSelectInput--sizeY-none'],
[SizeType.COMPACT]: styles['CustomSelectInput--sizeY-compact'],
};

export interface CustomSelectInputProps
extends React.InputHTMLAttributes<HTMLInputElement>,
HasRef<HTMLInputElement>,
HasRootRef<HTMLDivElement>,
HasAlign,
Omit<FormFieldProps, 'mode' | 'type'> {
selectType?: SelectType;
multiline?: boolean;
labelTextTestId?: string;
fetching?: boolean;
}

/**
* @since 5.10.0
* @private
*/
export const CustomSelectInput = ({
align = 'left',
getRef,
className,
getRootRef,
style,
before,
after,
status,
children,
placeholder,
selectType = 'default',
multiline,
disabled,
fetching,
labelTextTestId,
...restProps
}: CustomSelectInputProps) => {
const { sizeY = 'none' } = useAdaptivity();

const title = children || placeholder;
const showLabelOrPlaceholder = !Boolean(restProps.value);

const handleRootRef = useExternRef(getRootRef);
const focusWithin = useFocusWithin(handleRootRef);

return (
<FormField
Component="div"
style={style}
className={classNames(
styles['CustomSelectInput'],
align === 'right' && styles['CustomSelectInput--align-right'],
align === 'center' && styles['CustomSelectInput--align-center'],
!children && styles['CustomSelectInput--empty'],
multiline && styles['CustomSelectInput--multiline'],
sizeY !== SizeType.REGULAR && sizeYClassNames[sizeY],
before && styles['CustomSelectInput--hasBefore'],
after && styles['CustomSelectInput--hasAfter'],
className,
)}
getRootRef={handleRootRef}
before={before}
after={after}
disabled={disabled}
mode={getFormFieldModeFromSelectType(selectType)}
status={status}
>
<div className={styles['CustomSelectInput__input-group']}>
<div
className={classNames(styles['CustomSelectInput__container'], className)}
tabIndex={-1}
aria-hidden
data-testid={labelTextTestId}
>
<SelectTypography selectType={selectType} className={styles['CustomSelectInput__title']}>
{showLabelOrPlaceholder && title}
</SelectTypography>
</div>
<Text
{...restProps}
disabled={disabled && !fetching}
readOnly={restProps.readOnly || (disabled && fetching)}
Component="input"
normalize={false}
type="text"
className={classNames(
styles['CustomSelectInput__el'],
(restProps.readOnly || (showLabelOrPlaceholder && !focusWithin)) &&
styles['CustomSelectInput__el--cursor-pointer'],
)}
getRootRef={getRef}
placeholder={children ? '' : placeholder}
/>
</div>
</FormField>
);
};
Loading

0 comments on commit ec3a56d

Please sign in to comment.