From 8751ab7f8700195afb7072c484ce792822eed333 Mon Sep 17 00:00:00 2001 From: Ignat Remizov Date: Sat, 21 Feb 2026 00:17:31 +0200 Subject: [PATCH] feat(vue/popover): add `restoreFocus` opt-out for trigger refocus Add a new `Popover` prop, `restoreFocus` (default `true`), to control whether close flows automatically move focus back to the trigger. When `restoreFocus` is `false`: - outside click close no longer calls `button.focus()` for non-focusable outside targets - `close()` without an explicit focus target closes the popover without forcing focus restoration - explicit `close(element)` behavior is unchanged and still restores to the provided element Update all internal popover close paths that previously hard-focused the trigger to respect this flag. Tests: - add render-prop close test for `restoreFocus=false` - add outside-click body close test for `restoreFocus=false` - add input-trigger outside-click regression test (`PopoverButton as="input"`) to match datepicker usage Validation: - `npm test -- packages/@headlessui-vue/src/components/popover/popover.test.ts` (80 passed) --- .../src/components/popover/popover.test.ts | 83 +++++++++++++++++++ .../src/components/popover/popover.ts | 25 ++++-- 2 files changed, 100 insertions(+), 8 deletions(-) diff --git a/packages/@headlessui-vue/src/components/popover/popover.test.ts b/packages/@headlessui-vue/src/components/popover/popover.test.ts index 3a208e206e..19e872ee0a 100644 --- a/packages/@headlessui-vue/src/components/popover/popover.test.ts +++ b/packages/@headlessui-vue/src/components/popover/popover.test.ts @@ -180,6 +180,32 @@ describe('Rendering', () => { }) ) + it( + 'should expose a close function that closes the popover and does not restore focus when restoreFocus is false', + suppressConsoleLogs(async () => { + renderTemplate(html` + + Trigger + + + + + `) + + // Open the popover + await click(getPopoverButton()) + + // Ensure we can click the close button + await click(getByText('Close me')) + + // Ensure the popover is closed + assertPopoverPanel({ state: PopoverState.InvisibleUnmounted }) + + // Ensure focus did not get restored to the trigger + expect(document.activeElement).not.toBe(getPopoverButton()) + }) + ) + it( 'should expose a close function that closes the popover and restores to a specific element', suppressConsoleLogs(async () => { @@ -2188,6 +2214,63 @@ describe('Mouse interactions', () => { }) ) + it( + 'should be possible to close the popover without restoring focus when we click outside on the body element and restoreFocus is false', + suppressConsoleLogs(async () => { + renderTemplate(html` + + Trigger + Contents + + `) + + // Open popover + await click(getPopoverButton()) + + // Verify it is open + assertPopoverButton({ state: PopoverState.Visible }) + + // Click the body to close + await click(document.body) + + // Verify it is closed + assertPopoverButton({ state: PopoverState.InvisibleUnmounted }) + + // Verify focus was not restored to the trigger + expect(document.activeElement).not.toBe(getPopoverButton()) + }) + ) + + it( + 'should not restore focus to an input trigger when we click outside and restoreFocus is false', + suppressConsoleLogs(async () => { + renderTemplate(html` + + + Contents + + `) + + let input = document.querySelector('[data-trigger]') as HTMLInputElement + expect(input).not.toBeNull() + + // Open popover from the input trigger + await click(input) + + // Verify it is open + assertPopoverPanel({ state: PopoverState.Visible }) + + // Click outside to close + await click(document.body) + + // Verify it is closed + assertPopoverPanel({ state: PopoverState.InvisibleUnmounted }) + + // Verify focus was not restored to the input trigger + expect(document.activeElement).not.toBe(input) + }) + ) + it( 'should be possible to close the popover, and re-focus the button when we click outside on a non-focusable element', suppressConsoleLogs(async () => { diff --git a/packages/@headlessui-vue/src/components/popover/popover.ts b/packages/@headlessui-vue/src/components/popover/popover.ts index 564c228fbc..3b4171c20a 100644 --- a/packages/@headlessui-vue/src/components/popover/popover.ts +++ b/packages/@headlessui-vue/src/components/popover/popover.ts @@ -52,6 +52,7 @@ interface StateDefinition { panelId: Ref isPortalled: Ref + shouldRestoreFocus: Ref beforePanelSentinel: Ref afterPanelSentinel: Ref @@ -61,7 +62,8 @@ interface StateDefinition { closePopover(): void // Exposed functions - close(focusableElement: HTMLElement | Ref): void + close(focusableElement?: HTMLElement | Ref): void + focusButton(): void } let PopoverContext = Symbol('PopoverContext') as InjectionKey @@ -105,6 +107,7 @@ export let Popover = defineComponent({ inheritAttrs: false, props: { as: { type: [Object, String], default: 'div' }, + restoreFocus: { type: Boolean, default: true }, }, setup(props, { slots, attrs, expose }) { let internalPopoverRef = ref(null) @@ -159,6 +162,7 @@ export let Popover = defineComponent({ panel, button, isPortalled, + shouldRestoreFocus: computed(() => props.restoreFocus), beforePanelSentinel, afterPanelSentinel, togglePopover() { @@ -171,11 +175,16 @@ export let Popover = defineComponent({ if (popoverState.value === PopoverStates.Closed) return popoverState.value = PopoverStates.Closed }, - close(focusableElement: HTMLElement | Ref) { + focusButton() { + if (!api.shouldRestoreFocus.value) return + dom(api.button)?.focus() + }, + close(focusableElement?: HTMLElement | Ref) { api.closePopover() + if (focusableElement === undefined && !api.shouldRestoreFocus.value) return let restoreElement = (() => { - if (!focusableElement) return dom(api.button) + if (focusableElement === undefined) return dom(api.button) if (focusableElement instanceof HTMLElement) return focusableElement if (focusableElement.value instanceof HTMLElement) return dom(focusableElement) @@ -251,9 +260,9 @@ export let Popover = defineComponent({ (event, target) => { api.closePopover() - if (!isFocusableElement(target, FocusableMode.Loose)) { + if (api.shouldRestoreFocus.value && !isFocusableElement(target, FocusableMode.Loose)) { event.preventDefault() - dom(button)?.focus() + api.focusButton() } }, computed(() => popoverState.value === PopoverStates.Open) @@ -339,7 +348,7 @@ export let PopoverButton = defineComponent({ // @ts-expect-error event.target.click?.() api.closePopover() - dom(api.button)?.focus() // Re-focus the original opening Button + api.focusButton() // Re-focus the original opening Button break } } else { @@ -383,7 +392,7 @@ export let PopoverButton = defineComponent({ if (props.disabled) return if (isWithinPanel.value) { api.closePopover() - dom(api.button)?.focus() // Re-focus the original opening Button + api.focusButton() // Re-focus the original opening Button } else { event.preventDefault() event.stopPropagation() @@ -589,7 +598,7 @@ export let PopoverPanel = defineComponent({ event.preventDefault() event.stopPropagation() api.closePopover() - dom(api.button)?.focus() + api.focusButton() break } }