Improve Combobox accessibility (#2153)
* add the `aria-autocomplete` attribute * drop the `aria-activedescendant` attribute on the `Combobox.Options` component It is only required on the `Combobox.Input` component. * improve triggering VoiceOver when opening the `Combobox` We do this by mutating the `input` value for a split second to trigger a change that VoiceOver will pick up. We will also ensure to restore the value and the selection / cursor position so that the end user won't notice a difference at all. * update changelog Fixes: #2129 Co-authored-by: Andrea Fercia <a.fercia@gmail.com>
This commit is contained in:
@@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Fix false positive warning when using `<Popover.Button />` in React 17 ([#2163](https://github.com/tailwindlabs/headlessui/pull/2163))
|
||||
- Fix `failed to removeChild on Node` bug ([#2164](https://github.com/tailwindlabs/headlessui/pull/2164))
|
||||
- Don’t overwrite classes during SSR when rendering fragments ([#2173](https://github.com/tailwindlabs/headlessui/pull/2173))
|
||||
- Improve `Combobox` accessibility ([#2153](https://github.com/tailwindlabs/headlessui/pull/2153))
|
||||
|
||||
## [1.7.7] - 2022-12-16
|
||||
|
||||
|
||||
@@ -662,6 +662,7 @@ type InputPropsWeControl =
|
||||
| 'aria-labelledby'
|
||||
| 'aria-expanded'
|
||||
| 'aria-activedescendant'
|
||||
| 'aria-autocomplete'
|
||||
| 'onKeyDown'
|
||||
| 'onChange'
|
||||
| 'displayValue'
|
||||
@@ -741,6 +742,37 @@ let Input = forwardRefWithAs(function Input<
|
||||
[currentDisplayValue, data.comboboxState]
|
||||
)
|
||||
|
||||
// Trick VoiceOver in behaving a little bit better. Manually "resetting" the input makes VoiceOver
|
||||
// a bit more happy and doesn't require some changes manually first before announcing items
|
||||
// correctly. This is a bit of a hacks, but it is a workaround for a VoiceOver bug.
|
||||
//
|
||||
// TODO: VoiceOver is still relatively buggy if you start VoiceOver while the Combobox is already
|
||||
// in an open state.
|
||||
useWatch(
|
||||
([newState], [oldState]) => {
|
||||
if (newState === ComboboxState.Open && oldState === ComboboxState.Closed) {
|
||||
let input = data.inputRef.current
|
||||
if (!input) return
|
||||
|
||||
// Capture current state
|
||||
let currentValue = input.value
|
||||
let { selectionStart, selectionEnd, selectionDirection } = input
|
||||
|
||||
// Trick VoiceOver into announcing the value
|
||||
input.value = ''
|
||||
|
||||
// Rollback to original state
|
||||
input.value = currentValue
|
||||
if (selectionDirection !== null) {
|
||||
input.setSelectionRange(selectionStart, selectionEnd, selectionDirection)
|
||||
} else {
|
||||
input.setSelectionRange(selectionStart, selectionEnd)
|
||||
}
|
||||
}
|
||||
},
|
||||
[data.comboboxState]
|
||||
)
|
||||
|
||||
let isComposing = useRef(false)
|
||||
let handleCompositionStart = useEvent(() => {
|
||||
isComposing.current = true
|
||||
@@ -905,6 +937,7 @@ let Input = forwardRefWithAs(function Input<
|
||||
data.activeOptionIndex === null ? undefined : data.options[data.activeOptionIndex]?.id,
|
||||
'aria-multiselectable': data.mode === ValueMode.Multi ? true : undefined,
|
||||
'aria-labelledby': labelledby,
|
||||
'aria-autocomplete': 'list',
|
||||
defaultValue:
|
||||
props.defaultValue ??
|
||||
(data.defaultValue !== undefined
|
||||
@@ -1090,13 +1123,7 @@ let DEFAULT_OPTIONS_TAG = 'ul' as const
|
||||
interface OptionsRenderPropArg {
|
||||
open: boolean
|
||||
}
|
||||
type OptionsPropsWeControl =
|
||||
| 'aria-activedescendant'
|
||||
| 'aria-labelledby'
|
||||
| 'hold'
|
||||
| 'onKeyDown'
|
||||
| 'role'
|
||||
| 'tabIndex'
|
||||
type OptionsPropsWeControl = 'aria-labelledby' | 'hold' | 'onKeyDown' | 'role' | 'tabIndex'
|
||||
|
||||
let OptionsRenderFeatures = Features.RenderStrategy | Features.Static
|
||||
|
||||
@@ -1154,8 +1181,6 @@ let Options = forwardRefWithAs(function Options<
|
||||
[data]
|
||||
)
|
||||
let ourProps = {
|
||||
'aria-activedescendant':
|
||||
data.activeOptionIndex === null ? undefined : data.options[data.activeOptionIndex]?.id,
|
||||
'aria-labelledby': labelledby,
|
||||
role: 'listbox',
|
||||
id,
|
||||
|
||||
@@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Fix arrow key handling in `Tab` (after DOM order changes) ([#2145](https://github.com/tailwindlabs/headlessui/pull/2145))
|
||||
- Fix `Tab` key with non focusable elements in `Popover.Panel` ([#2147](https://github.com/tailwindlabs/headlessui/pull/2147))
|
||||
- Don’t overwrite classes during SSR when rendering fragments ([#2173](https://github.com/tailwindlabs/headlessui/pull/2173))
|
||||
- Improve `Combobox` accessibility ([#2153](https://github.com/tailwindlabs/headlessui/pull/2153))
|
||||
|
||||
## [1.7.7] - 2022-12-16
|
||||
|
||||
|
||||
@@ -712,6 +712,35 @@ export let ComboboxInput = defineComponent({
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Trick VoiceOver in behaving a little bit better. Manually "resetting" the input makes
|
||||
// VoiceOver a bit more happy and doesn't require some changes manually first before
|
||||
// announcing items correctly. This is a bit of a hacks, but it is a workaround for a
|
||||
// VoiceOver bug.
|
||||
//
|
||||
// TODO: VoiceOver is still relatively buggy if you start VoiceOver while the Combobox is
|
||||
// already in an open state.
|
||||
watch([api.comboboxState], ([newState], [oldState]) => {
|
||||
if (newState === ComboboxStates.Open && oldState === ComboboxStates.Closed) {
|
||||
let input = dom(api.inputRef)
|
||||
if (!input) return
|
||||
|
||||
// Capture current state
|
||||
let currentValue = input.value
|
||||
let { selectionStart, selectionEnd, selectionDirection } = input
|
||||
|
||||
// Trick VoiceOver into announcing the value
|
||||
input.value = ''
|
||||
|
||||
// Rollback to original state
|
||||
input.value = currentValue
|
||||
if (selectionDirection !== null) {
|
||||
input.setSelectionRange(selectionStart, selectionEnd, selectionDirection)
|
||||
} else {
|
||||
input.setSelectionRange(selectionStart, selectionEnd)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
let isComposing = ref(false)
|
||||
@@ -880,6 +909,7 @@ export let ComboboxInput = defineComponent({
|
||||
: api.options.value[api.activeOptionIndex.value]?.id,
|
||||
'aria-multiselectable': api.mode.value === ValueMode.Multi ? true : undefined,
|
||||
'aria-labelledby': dom(api.labelRef)?.id ?? dom(api.buttonRef)?.id,
|
||||
'aria-autocomplete': 'list',
|
||||
id,
|
||||
onCompositionstart: handleCompositionstart,
|
||||
onCompositionend: handleCompositionend,
|
||||
@@ -956,10 +986,6 @@ export let ComboboxOptions = defineComponent({
|
||||
return () => {
|
||||
let slot = { open: api.comboboxState.value === ComboboxStates.Open }
|
||||
let ourProps = {
|
||||
'aria-activedescendant':
|
||||
api.activeOptionIndex.value === null
|
||||
? undefined
|
||||
: api.options.value[api.activeOptionIndex.value]?.id,
|
||||
'aria-labelledby': dom(api.labelRef)?.id ?? dom(api.buttonRef)?.id,
|
||||
id,
|
||||
ref: api.optionsRef,
|
||||
|
||||
Reference in New Issue
Block a user