-
Notifications
You must be signed in to change notification settings - Fork 184
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
fix(Select/CustomSelect)(a11y): make Select accessible for screen rea…
…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
Showing
13 changed files
with
878 additions
and
286 deletions.
There are no files selected for viewing
332 changes: 207 additions & 125 deletions
332
packages/vkui/src/components/CustomSelect/CustomSelect.test.tsx
Large diffs are not rendered by default.
Oops, something went wrong.
360 changes: 228 additions & 132 deletions
360
packages/vkui/src/components/CustomSelect/CustomSelect.tsx
Large diffs are not rendered by default.
Oops, something went wrong.
22 changes: 15 additions & 7 deletions
22
packages/vkui/src/components/CustomSelect/CustomSelectClearButton.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
133 changes: 133 additions & 0 deletions
133
packages/vkui/src/components/CustomSelect/CustomSelectInput.module.css
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
113
packages/vkui/src/components/CustomSelect/CustomSelectInput.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
}; |
Oops, something went wrong.