diff --git a/packages/react-aria/src/interactions/useFocusVisible.ts b/packages/react-aria/src/interactions/useFocusVisible.ts index 9e992f5334f..e1d0ac089dd 100644 --- a/packages/react-aria/src/interactions/useFocusVisible.ts +++ b/packages/react-aria/src/interactions/useFocusVisible.ts @@ -110,17 +110,29 @@ function handleClickEvent(e: MouseEvent) { } function handleFocusEvent(e: FocusEvent) { + if (ignoreFocusEvent) { + return; + } + + let target = getEventTarget(e); + let ownerWindow = getOwnerWindow(target); + let ownerDocument = getOwnerDocument(target); + + // When the window regains focus, the browser restores focus to the element that was focused + // before, firing a focus event the user did not initiate. handleWindowBlur sets + // hasBlurredWindowRecently so restored focus doesn't switch to virtual modality below, but + // Safari fires the window/element focus pair twice when returning to a tab or app and the first + // element focus event clears the flag, so re-arm it whenever the window itself is focused. + // Like handleWindowBlur, this intentionally doesn't check isTrusted. + if (target === ownerWindow) { + hasBlurredWindowRecently = true; + return; + } + // Firefox fires two extra focus events when the user first clicks into an iframe: // first on the window, then on the document. We ignore these events so they don't // cause keyboard focus rings to appear. - let ownerWindow = getOwnerWindow(getEventTarget(e)); - let ownerDocument = getOwnerDocument(getEventTarget(e)); - if ( - getEventTarget(e) === ownerWindow || - getEventTarget(e) === ownerDocument || - ignoreFocusEvent || - !e.isTrusted - ) { + if (target === ownerDocument || !e.isTrusted) { return; } diff --git a/packages/react-aria/test/interactions/useFocusVisible.test.js b/packages/react-aria/test/interactions/useFocusVisible.test.js index d469996ee35..96855cc80c0 100644 --- a/packages/react-aria/test/interactions/useFocusVisible.test.js +++ b/packages/react-aria/test/interactions/useFocusVisible.test.js @@ -75,6 +75,45 @@ function toggleBrowserTabs(win = window) { fireEvent(lastActiveElement, new Event('focus')); } +// Focus the element the way the browser restores focus. useFocusVisible ignores untrusted focus +// events and jsdom only marks its own as trusted, so go through jsdom's focus() rather than +// fireEvent, using the unpatched copy saved by setupGlobalFocusEvents so it isn't recorded as a +// programmatic focus. jsdom won't dispatch focus again for the element that already has it, hence +// the blur first — useFocusVisible doesn't observe element blur, since its blur listener is on the +// window without capture and blur doesn't bubble. +function restoreFocus(element, win = window) { + const nativeFocus = hasSetupGlobalListeners.get(win).focus; + element.blur(); + nativeFocus.call(element); +} + +function toggleBrowserTabsSafari(win = window) { + // Safari fires the window/element focus pair twice when returning to a tab or app, and + // visibilitychange fires after all of the focus events. + // See https://github.com/adobe/react-spectrum/issues/7468 + const lastActiveElement = win.document.activeElement; + // leave tab + fireEvent(lastActiveElement, new Event('blur')); + fireEvent(win, new Event('blur')); + Object.defineProperty(win.document, 'visibilityState', { + value: 'hidden', + writable: true + }); + Object.defineProperty(win.document, 'hidden', {value: true, writable: true}); + fireEvent(win.document, new Event('visibilitychange')); + // return to tab + fireEvent(win, new Event('focus', {target: win})); + restoreFocus(lastActiveElement, win); + fireEvent(win, new Event('focus', {target: win})); + restoreFocus(lastActiveElement, win); + Object.defineProperty(win.document, 'visibilityState', { + value: 'visible', + writable: true + }); + Object.defineProperty(win.document, 'hidden', {value: false, writable: true}); + fireEvent(win.document, new Event('visibilitychange')); +} + function toggleBrowserWindow(win = window) { fireEvent(win, new Event('blur', {target: win})); fireEvent(win, new Event('focus', {target: win})); @@ -111,6 +150,27 @@ describe('useFocusVisible', function () { expect(el.textContent).toBe('example'); }); + it('returns positive isFocusVisible result after toggling browser tabs in Safari after keyboard navigation', async function () { + render(); + await user.tab(); + let el = screen.getByText('example-focusVisible'); + + toggleBrowserTabsSafari(); + + expect(el.textContent).toBe('example-focusVisible'); + }); + + it('returns negative isFocusVisible result after toggling browser tabs in Safari without prior keyboard navigation', async function () { + render(); + await user.tab(); + let el = screen.getByText('example-focusVisible'); + + await user.click(el); + toggleBrowserTabsSafari(); + + expect(el.textContent).toBe('example'); + }); + it('returns positive isFocusVisible result after toggling browser window after keyboard navigation', async function () { render(); await user.tab();