diff --git a/packages/@headlessui-react/src/components/combobox/combobox.tsx b/packages/@headlessui-react/src/components/combobox/combobox.tsx index 7971322b05..7563dc3ac2 100644 --- a/packages/@headlessui-react/src/components/combobox/combobox.tsx +++ b/packages/@headlessui-react/src/components/combobox/combobox.tsx @@ -1729,6 +1729,12 @@ function OptionFn< if (disabled || data.virtual?.disabled(value)) return event.preventDefault() select() + if (data.mode === ValueMode.Single) { + requestAnimationFrame(() => actions.closeCombobox()) + } + }) + + let handleFocus = useEvent(() => { // We want to make sure that we don't accidentally trigger the virtual keyboard. // // This would happen if the input is focused, the options are open, you select an option (which @@ -1745,12 +1751,6 @@ function OptionFn< requestAnimationFrame(() => data.inputRef.current?.focus({ preventScroll: true })) } - if (data.mode === ValueMode.Single) { - requestAnimationFrame(() => actions.closeCombobox()) - } - }) - - let handleFocus = useEvent(() => { if (disabled || data.virtual?.disabled(value)) { return actions.goToOption(Focus.Nothing) } @@ -1787,7 +1787,7 @@ function OptionFn< id, ref: optionRef, role: 'option', - tabIndex: disabled === true ? undefined : -1, + tabIndex: -1, 'aria-disabled': disabled === true ? true : undefined, // According to the WAI-ARIA best practices, we should use aria-checked for // multi-select,but Voice-Over disagrees. So we use aria-checked instead for diff --git a/packages/@headlessui-react/src/test-utils/interactions.ts b/packages/@headlessui-react/src/test-utils/interactions.ts index 7107eae57a..55757f2096 100644 --- a/packages/@headlessui-react/src/test-utils/interactions.ts +++ b/packages/@headlessui-react/src/test-utils/interactions.ts @@ -252,7 +252,7 @@ export async function rawClick( if (!cancelled) { let next: HTMLElement | null = element as HTMLElement | null while (next !== null) { - if (next.matches(focusableSelector)) { + if (next.matches(focusableSelectorWithNegativeTabindex)) { next.focus() break } @@ -463,9 +463,7 @@ function focusNext(event: Partial) { return innerFocusNext() } -// Credit: -// - https://stackoverflow.com/a/30753870 -let focusableSelector = [ +let _focusableSelector = [ '[contentEditable=true]', '[tabindex]', 'a[href]', @@ -476,6 +474,10 @@ let focusableSelector = [ 'select:not([disabled])', 'textarea:not([disabled])', ] + +// Credit: +// - https://stackoverflow.com/a/30753870 +let focusableSelector = _focusableSelector .map( process.env.NODE_ENV === 'test' ? // TODO: Remove this once JSDOM fixes the issue where an element that is @@ -486,6 +488,14 @@ let focusableSelector = [ ) .join(',') +let focusableSelectorWithNegativeTabindex = _focusableSelector + .map( + process.env.NODE_ENV === 'test' + ? (selector) => `${selector}:not([style*='display: none'])` + : (selector) => selector + ) + .join(',') + function getFocusableElements(container = document.body) { if (!container) return [] return Array.from(container.querySelectorAll(focusableSelector)) diff --git a/packages/@headlessui-vue/src/components/combobox/combobox.ts b/packages/@headlessui-vue/src/components/combobox/combobox.ts index e3e4cc39d8..d9f9dcc6d3 100644 --- a/packages/@headlessui-vue/src/components/combobox/combobox.ts +++ b/packages/@headlessui-vue/src/components/combobox/combobox.ts @@ -1462,6 +1462,12 @@ export let ComboboxOption = defineComponent({ if (props.disabled || api.virtual.value?.disabled(props.value)) return event.preventDefault() api.selectOption(id) + if (api.mode.value === ValueMode.Single) { + requestAnimationFrame(() => api.closeCombobox()) + } + } + + function handleFocus() { // We want to make sure that we don't accidentally trigger the virtual keyboard. // // This would happen if the input is focused, the options are open, you select an option @@ -1478,12 +1484,6 @@ export let ComboboxOption = defineComponent({ requestAnimationFrame(() => dom(api.inputRef)?.focus({ preventScroll: true })) } - if (api.mode.value === ValueMode.Single) { - requestAnimationFrame(() => api.closeCombobox()) - } - } - - function handleFocus() { if (props.disabled || api.virtual.value?.disabled(props.value)) { return api.goToOption(Focus.Nothing) } @@ -1520,7 +1520,7 @@ export let ComboboxOption = defineComponent({ id, ref: internalOptionRef, role: 'option', - tabIndex: disabled === true ? undefined : -1, + tabIndex: -1, 'aria-disabled': disabled === true ? true : undefined, // According to the WAI-ARIA best practices, we should use aria-checked for // multi-select,but Voice-Over disagrees. So we use aria-selected instead for diff --git a/packages/@headlessui-vue/src/test-utils/interactions.ts b/packages/@headlessui-vue/src/test-utils/interactions.ts index 58418680e9..e76b4d3f63 100644 --- a/packages/@headlessui-vue/src/test-utils/interactions.ts +++ b/packages/@headlessui-vue/src/test-utils/interactions.ts @@ -243,7 +243,7 @@ export async function click( if (!cancelled) { let next: HTMLElement | null = element as HTMLElement | null while (next !== null) { - if (next.matches(focusableSelector)) { + if (next.matches(focusableSelectorWithNegativeTabindex)) { next.focus() break } @@ -456,9 +456,7 @@ function focusNext(event: Partial) { return innerFocusNext() } -// Credit: -// - https://stackoverflow.com/a/30753870 -let focusableSelector = [ +let _focusableSelector = [ '[contentEditable=true]', '[tabindex]', 'a[href]', @@ -469,6 +467,10 @@ let focusableSelector = [ 'select:not([disabled])', 'textarea:not([disabled])', ] + +// Credit: +// - https://stackoverflow.com/a/30753870 +let focusableSelector = _focusableSelector .map( process.env.NODE_ENV === 'test' ? // TODO: Remove this once JSDOM fixes the issue where an element that is @@ -479,6 +481,14 @@ let focusableSelector = [ ) .join(',') +let focusableSelectorWithNegativeTabindex = _focusableSelector + .map( + process.env.NODE_ENV === 'test' + ? (selector) => `${selector}:not([style*='display: none'])` + : (selector) => selector + ) + .join(',') + function getFocusableElements(container = document.body) { if (!container) return [] return Array.from(container.querySelectorAll(focusableSelector)) diff --git a/packages/@headlessui-vue/src/utils/render.ts b/packages/@headlessui-vue/src/utils/render.ts index 9d4d8cd0a0..da30b3dfec 100644 --- a/packages/@headlessui-vue/src/utils/render.ts +++ b/packages/@headlessui-vue/src/utils/render.ts @@ -218,16 +218,14 @@ function mergeProps(...listOfProps: Record[]) { } } - // Do not attach any event handlers when there is a `disabled` or `aria-disabled` prop set. + // Ensure event listeners are not called if `disabled` or `aria-disabled` is true if (target.disabled || target['aria-disabled']) { - return Object.assign( - target, - // Set all event listeners that we collected to `undefined`. This is - // important because of the `cloneElement` from above, which merges the - // existing and new props, they don't just override therefore we have to - // explicitly nullify them. - Object.fromEntries(Object.keys(eventHandlers).map((eventName) => [eventName, undefined])) - ) + for (let eventName in eventHandlers) { + // Prevent default events for `onClick`, `onMouseDown`, `onKeyDown`, etc. + if (/^(on(?:Click|Pointer|Mouse|Key)(?:Down|Up|Press)?)$/.test(eventName)) { + eventHandlers[eventName] = [(e: any) => e?.preventDefault?.()] + } + } } // Merge event handlers