diff --git a/packages/react-aria/src/interactions/useFocus.ts b/packages/react-aria/src/interactions/useFocus.ts index 596ef28e33f..3ea8c7a1f2e 100644 --- a/packages/react-aria/src/interactions/useFocus.ts +++ b/packages/react-aria/src/interactions/useFocus.ts @@ -19,7 +19,7 @@ import {DOMAttributes, FocusableElement, FocusEvents} from '@react-types/shared' import {FocusEvent, useCallback} from 'react'; import {getActiveElement, getEventTarget} from '../utils/shadowdom/DOMFunctions'; import {getOwnerDocument} from '../utils/domHelpers'; -import {useSyntheticBlurEvent} from './utils'; +import {shouldIgnoreTransientFocusChange, useSyntheticBlurEvent} from './utils'; export interface FocusProps extends FocusEvents { /** Whether the focus events should be disabled. */ @@ -47,7 +47,10 @@ export function useFocus( onBlurProp(e); } - if (onFocusChange) { + if ( + onFocusChange && + !shouldIgnoreTransientFocusChange(e.relatedTarget as Element, e.currentTarget as Element) + ) { onFocusChange(false); } @@ -72,7 +75,12 @@ export function useFocus( onFocusProp(e); } - if (onFocusChange) { + let isTransientPivot = shouldIgnoreTransientFocusChange( + e.relatedTarget as Element, + e.currentTarget as Element + ); + + if (onFocusChange && !isTransientPivot) { onFocusChange(true); } diff --git a/packages/react-aria/src/interactions/useFocusWithin.ts b/packages/react-aria/src/interactions/useFocusWithin.ts index 0250b3bd3e8..a911a512718 100644 --- a/packages/react-aria/src/interactions/useFocusWithin.ts +++ b/packages/react-aria/src/interactions/useFocusWithin.ts @@ -15,7 +15,12 @@ // NOTICE file in the root directory of this source tree. // See https://github.com/facebook/react/tree/cc7c1aece46a6b69b41958d731e0fd27c94bfc6c/packages/react-interactions -import {createSyntheticEvent, setEventTarget, useSyntheticBlurEvent} from './utils'; +import { + createSyntheticEvent, + setEventTarget, + shouldIgnoreTransientFocusChange, + useSyntheticBlurEvent +} from './utils'; import {DOMAttributes} from '@react-types/shared'; import {FocusEvent, useCallback, useRef} from 'react'; import {getActiveElement, getEventTarget, nodeContains} from '../utils/shadowdom/DOMFunctions'; @@ -63,6 +68,12 @@ export function useFocusWithin(props: FocusWithinProps): FocusWithinResult { state.current.isFocusWithin && !nodeContains(e.currentTarget as Element, e.relatedTarget as Element) ) { + if ( + shouldIgnoreTransientFocusChange(e.relatedTarget as Element, e.currentTarget as Element) + ) { + return; + } + state.current.isFocusWithin = false; removeAllGlobalListeners(); @@ -92,10 +103,19 @@ export function useFocusWithin(props: FocusWithinProps): FocusWithinResult { const ownerDocument = getOwnerDocument(eventTarget); const activeElement = getActiveElement(ownerDocument); if (!state.current.isFocusWithin && activeElement === eventTarget) { + let isTransientPivot = shouldIgnoreTransientFocusChange( + e.relatedTarget as Element, + e.currentTarget as Element + ); + if (onFocusWithin) { onFocusWithin(e); } + if (isTransientPivot) { + return; + } + if (onFocusWithinChange) { onFocusWithinChange(true); } diff --git a/packages/react-aria/src/interactions/utils.ts b/packages/react-aria/src/interactions/utils.ts index ede93c0ea50..51f0f66239d 100644 --- a/packages/react-aria/src/interactions/utils.ts +++ b/packages/react-aria/src/interactions/utils.ts @@ -12,7 +12,7 @@ import {FocusableElement} from '@react-types/shared'; import {focusWithoutScrolling} from '../utils/focusWithoutScrolling'; -import {getActiveElement, getEventTarget} from '../utils/shadowdom/DOMFunctions'; +import {getActiveElement, getEventTarget, nodeContains} from '../utils/shadowdom/DOMFunctions'; import {getOwnerWindow} from '../utils/domHelpers'; import {isFocusable} from '../utils/isFocusable'; import {FocusEvent as ReactFocusEvent, SyntheticEvent, useCallback, useRef} from 'react'; @@ -111,6 +111,71 @@ export function useSyntheticBlurEvent( } export let ignoreFocusEvent = false; +export let ignoreTransientFocus = false; + +let transientFocusRoot: HTMLElement | null = null; +let endTransientFocusCleanup: (() => void) | null = null; + +/** + * Suppresses focus ring and focus-visible updates while focus briefly moves to a + * collection root during Shift+Tab exit pivot. + */ +export function beginTransientCollectionFocus(root: HTMLElement): void { + endTransientCollectionFocus(); + + ignoreTransientFocus = true; + ignoreFocusEvent = true; + transientFocusRoot = root; + + let window = getOwnerWindow(root); + let onFocusIn = (e: FocusEvent) => { + let target = getEventTarget(e) as Element; + if (transientFocusRoot && !nodeContains(transientFocusRoot, target)) { + endTransientCollectionFocus(); + } + }; + + window.addEventListener('focusin', onFocusIn, true); + + let raf = requestAnimationFrame(() => { + if (ignoreTransientFocus) { + endTransientCollectionFocus(); + } + }); + + endTransientFocusCleanup = () => { + window.removeEventListener('focusin', onFocusIn, true); + cancelAnimationFrame(raf); + endTransientFocusCleanup = null; + }; +} + +export function endTransientCollectionFocus(): void { + endTransientFocusCleanup?.(); + ignoreTransientFocus = false; + ignoreFocusEvent = false; + transientFocusRoot = null; +} + +/** + * Returns true when the collection root receives focus from a descendant during + * a Shift+Tab exit pivot. Focus ring updates on the root should be suppressed, + * but descendant blur events should still update focus state normally. + */ +export function shouldIgnoreTransientFocusChange( + relatedTarget: Element | null, + currentTarget: Element +): boolean { + if (!ignoreTransientFocus || !transientFocusRoot) { + return false; + } + + if (currentTarget === transientFocusRoot) { + return relatedTarget !== null && nodeContains(transientFocusRoot, relatedTarget); + } + + return false; +} /** * This function prevents the next focus event fired on `target`, without using diff --git a/packages/react-aria/src/selection/useSelectableCollection.ts b/packages/react-aria/src/selection/useSelectableCollection.ts index 3b3bb8413db..fa4795b50cf 100644 --- a/packages/react-aria/src/selection/useSelectableCollection.ts +++ b/packages/react-aria/src/selection/useSelectableCollection.ts @@ -10,6 +10,8 @@ * governing permissions and limitations under the License. */ +import {beginTransientCollectionFocus} from '../interactions/utils'; + import {CLEAR_FOCUS_EVENT, FOCUS_EVENT} from '../utils/constants'; import {dispatchVirtualFocus, moveVirtualFocus} from '../focus/virtualFocus'; @@ -401,6 +403,7 @@ export function useSelectableCollection( let shiftTab = () => { if (!allowsTabNavigation && ref.current) { + beginTransientCollectionFocus(ref.current); ref.current.focus(); } return {shouldContinuePropagation: true, shouldPreventDefault: false}; diff --git a/packages/react-aria/test/interactions/transientFocus.test.js b/packages/react-aria/test/interactions/transientFocus.test.js new file mode 100644 index 00000000000..4edd1a0cdf1 --- /dev/null +++ b/packages/react-aria/test/interactions/transientFocus.test.js @@ -0,0 +1,71 @@ +/* + * Copyright 2020 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { + beginTransientCollectionFocus, + endTransientCollectionFocus, + ignoreFocusEvent, + ignoreTransientFocus, + shouldIgnoreTransientFocusChange +} from '../../src/interactions/utils'; + +describe('transient collection focus', () => { + let root; + let child; + let outside; + + beforeEach(() => { + outside = document.createElement('button'); + root = document.createElement('div'); + child = document.createElement('button'); + root.tabIndex = 0; + root.appendChild(child); + document.body.appendChild(outside); + document.body.appendChild(root); + }); + + afterEach(() => { + endTransientCollectionFocus(); + document.body.removeChild(outside); + document.body.removeChild(root); + }); + + it('shouldIgnoreTransientFocusChange when collection root receives focus from descendant', () => { + beginTransientCollectionFocus(root); + expect(shouldIgnoreTransientFocusChange(child, root)).toBe(true); + }); + + it('should not ignore blur when focus moves from descendant to root', () => { + beginTransientCollectionFocus(root); + expect(shouldIgnoreTransientFocusChange(root, child)).toBe(false); + }); + + it('should not ignore focus changes when focus moves from root to outside', () => { + beginTransientCollectionFocus(root); + expect(shouldIgnoreTransientFocusChange(outside, root)).toBe(false); + }); + + it('should not ignore focus changes when transient focus is inactive', () => { + expect(shouldIgnoreTransientFocusChange(root, child)).toBe(false); + }); + + it('sets ignoreFocusEvent while transient focus is active', () => { + expect(ignoreTransientFocus).toBe(false); + expect(ignoreFocusEvent).toBe(false); + beginTransientCollectionFocus(root); + expect(ignoreTransientFocus).toBe(true); + expect(ignoreFocusEvent).toBe(true); + endTransientCollectionFocus(); + expect(ignoreTransientFocus).toBe(false); + expect(ignoreFocusEvent).toBe(false); + }); +}); diff --git a/packages/react-aria/test/interactions/useFocusWithin.test.js b/packages/react-aria/test/interactions/useFocusWithin.test.js index c8605916e1d..66f11d0f80a 100644 --- a/packages/react-aria/test/interactions/useFocusWithin.test.js +++ b/packages/react-aria/test/interactions/useFocusWithin.test.js @@ -11,7 +11,12 @@ */ import {act, render, waitFor} from '@react-spectrum/test-utils-internal'; +import { + beginTransientCollectionFocus, + endTransientCollectionFocus +} from '../../src/interactions/utils'; import React, {useState} from 'react'; +import {useFocusRing} from '../../src/focus/useFocusRing'; import {useFocusWithin} from '../../src/interactions/useFocusWithin'; function Example(props) { @@ -229,4 +234,53 @@ describe('useFocusWithin', function () { {type: 'focuschange', isFocused: false} ]); }); + + it('ignores focus within changes when pivoting to collection root during shift tab exit', function () { + let changes = []; + let tree = render( + changes.push(isFocused)}> + + + ); + + let inner = tree.getByTestId('inner'); + let el = tree.getByTestId('example'); + act(() => inner.focus()); + changes = []; + + act(() => { + beginTransientCollectionFocus(el); + el.focus(); + }); + + expect(changes).toEqual([]); + endTransientCollectionFocus(); + }); + + it('does not re-render useFocusRing when pivoting to collection root', function () { + function FocusRingExample(props) { + let {focusWithinProps} = useFocusRing({within: true}); + props.onRender(); + return ( +
+ +
+ ); + } + + let renderCount = 0; + render( renderCount++} />); + let inner = document.querySelector('[data-testid="inner"]'); + let el = document.querySelector('[data-testid="example"]'); + act(() => inner.focus()); + let countAfterFocus = renderCount; + + act(() => { + beginTransientCollectionFocus(el); + el.focus(); + }); + + expect(renderCount).toBe(countAfterFocus); + endTransientCollectionFocus(); + }); }); diff --git a/packages/react-aria/test/selection/useSelectableCollection.test.js b/packages/react-aria/test/selection/useSelectableCollection.test.js index b09e54d5c90..3929610cf85 100644 --- a/packages/react-aria/test/selection/useSelectableCollection.test.js +++ b/packages/react-aria/test/selection/useSelectableCollection.test.js @@ -11,6 +11,7 @@ */ import { + act, fireEvent, installPointerEvent, pointerMap, @@ -116,6 +117,40 @@ describe('useSelectableCollection', () => { expect(document.activeElement).toBe(options[0]); }); + it('focuses the collection root on shift+tab from an internal tab stop', async () => { + let {getByRole} = render( + <> + + + + + + + + + + + ); + let listbox = getByRole('listbox'); + let inner = within(listbox).getByRole('button', {name: 'inner one'}); + + await user.tab(); + await user.tab(); + act(() => { + inner.focus(); + }); + expect(document.activeElement).toBe(inner); + + fireEvent.keyDown(document.activeElement, { + key: 'Tab', + shiftKey: true, + code: 'Tab', + bubbles: true + }); + + expect(document.activeElement).toBe(listbox); + }); + describe.each` type | prepare | actions ${'VO Events'} | ${installPointerEvent} | ${[el => fireEvent.pointerDown(el, {button: 0, pointerType: 'virtual'}), el => {