From 43a25785d55b76bff65d5ee96af6cb12acac6961 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 24 Jan 2023 17:29:38 +0100 Subject: [PATCH 1/4] add the `aria-autocomplete` attribute --- packages/@headlessui-react/src/components/combobox/combobox.tsx | 2 ++ packages/@headlessui-vue/src/components/combobox/combobox.ts | 1 + 2 files changed, 3 insertions(+) diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx index 3162bc7ef..7ff6897ee 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' @@ -905,6 +906,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 diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.ts b/packages/@headlessui-vue/src/components/combobox/combobox.ts index e8dff6e02..a55951c7c 100644 --- a/packages/@headlessui-vue/src/components/combobox/combobox.ts +++ b/packages/@headlessui-vue/src/components/combobox/combobox.ts @@ -880,6 +880,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, From 03be85565a956234fc8f4787285b58ddde3f5473 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 24 Jan 2023 17:30:08 +0100 Subject: [PATCH 2/4] drop the `aria-activedescendant` attribute on the `Combobox.Options` component It is only required on the `Combobox.Input` component. --- .../src/components/combobox/combobox.tsx | 10 +--------- .../src/components/combobox/combobox.ts | 4 ---- 2 files changed, 1 insertion(+), 13 deletions(-) diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx index 7ff6897ee..a57cba634 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx @@ -1092,13 +1092,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 @@ -1156,8 +1150,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/src/components/combobox/combobox.ts b/packages/@headlessui-vue/src/components/combobox/combobox.ts index a55951c7c..b60bfaa65 100644 --- a/packages/@headlessui-vue/src/components/combobox/combobox.ts +++ b/packages/@headlessui-vue/src/components/combobox/combobox.ts @@ -957,10 +957,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, From 89b3917c74f6b23a31a68b87261dab5b94a7c8f8 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 24 Jan 2023 17:31:09 +0100 Subject: [PATCH 3/4] 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. --- .../src/components/combobox/combobox.tsx | 31 +++++++++++++++++++ .../src/components/combobox/combobox.ts | 29 +++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx index a57cba634..1d3c9625a 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx @@ -742,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 diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.ts b/packages/@headlessui-vue/src/components/combobox/combobox.ts index b60bfaa65..be47a8df7 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) From a5e97d3f8529ddf6f7f475fc7f01fa3f8f889f44 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 24 Jan 2023 17:37:49 +0100 Subject: [PATCH 4/4] update changelog --- packages/@headlessui-react/CHANGELOG.md | 1 + packages/@headlessui-vue/CHANGELOG.md | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/@headlessui-react/CHANGELOG.md b/packages/@headlessui-react/CHANGELOG.md index 1ef024bd0..738e1d117 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-vue/CHANGELOG.md b/packages/@headlessui-vue/CHANGELOG.md index f7078d470..17a501646 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