Skip to content

Commit

Permalink
Improve Combobox accessibility (#2153)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
RobinMalfait and afercia committed Jan 24, 2023
1 parent 676de16 commit 1d94d15
Show file tree
Hide file tree
Showing 4 changed files with 66 additions and 13 deletions.
1 change: 1 addition & 0 deletions packages/@headlessui-react/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
43 changes: 34 additions & 9 deletions packages/@headlessui-react/src/components/combobox/combobox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,7 @@ type InputPropsWeControl =
| 'aria-labelledby'
| 'aria-expanded'
| 'aria-activedescendant'
| 'aria-autocomplete'
| 'onKeyDown'
| 'onChange'
| 'displayValue'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/@headlessui-vue/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
34 changes: 30 additions & 4 deletions packages/@headlessui-vue/src/components/combobox/combobox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down

0 comments on commit 1d94d15

Please sign in to comment.