diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md index 1ef024bd08..738e1d1175 100644 --- a/packages/@headlessui-react/CHANGELOG.md +++ b/packages/@headlessui-react/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fix false positive warning when using `` 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 diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx index 3162bc7efb..1d3c9625a2 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx @@ -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, diff --git a/packages/@headlessui-vue/CHANGELOG.md b/packages/@headlessui-vue/CHANGELOG.md index f7078d4704..17a5016465 100644 --- a/packages/@headlessui-vue/CHANGELOG.md +++ b/packages/@headlessui-vue/CHANGELOG.md @@ -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 diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.ts b/packages/@headlessui-vue/src/components/combobox/combobox.ts index e8dff6e025..be47a8df78 100644 --- a/packages/@headlessui-vue/src/components/combobox/combobox.ts +++ b/packages/@headlessui-vue/src/components/combobox/combobox.ts @@ -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,