Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions packages/react-aria/src/interactions/useFocus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Target = FocusableElement> extends FocusEvents<Target> {
/** Whether the focus events should be disabled. */
Expand Down Expand Up @@ -47,7 +47,10 @@ export function useFocus<Target extends FocusableElement = FocusableElement>(
onBlurProp(e);
}

if (onFocusChange) {
if (
onFocusChange &&
!shouldIgnoreTransientFocusChange(e.relatedTarget as Element, e.currentTarget as Element)
) {
onFocusChange(false);
}

Expand All @@ -72,7 +75,12 @@ export function useFocus<Target extends FocusableElement = FocusableElement>(
onFocusProp(e);
}

if (onFocusChange) {
let isTransientPivot = shouldIgnoreTransientFocusChange(
e.relatedTarget as Element,
e.currentTarget as Element
);

if (onFocusChange && !isTransientPivot) {
onFocusChange(true);
}

Expand Down
22 changes: 21 additions & 1 deletion packages/react-aria/src/interactions/useFocusWithin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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);
}
Expand Down
67 changes: 66 additions & 1 deletion packages/react-aria/src/interactions/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -111,6 +111,71 @@ export function useSyntheticBlurEvent<Target extends Element = Element>(
}

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
Expand Down
3 changes: 3 additions & 0 deletions packages/react-aria/src/selection/useSelectableCollection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -401,6 +403,7 @@ export function useSelectableCollection(

let shiftTab = () => {
if (!allowsTabNavigation && ref.current) {
beginTransientCollectionFocus(ref.current);
ref.current.focus();
}
return {shouldContinuePropagation: true, shouldPreventDefault: false};
Expand Down
71 changes: 71 additions & 0 deletions packages/react-aria/test/interactions/transientFocus.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
54 changes: 54 additions & 0 deletions packages/react-aria/test/interactions/useFocusWithin.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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(
<Example onFocusWithinChange={isFocused => changes.push(isFocused)}>
<button data-testid="inner">inner</button>
</Example>
);

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 (
<div tabIndex={-1} {...focusWithinProps} data-testid="example">
<button data-testid="inner">inner</button>
</div>
);
}

let renderCount = 0;
render(<FocusRingExample onRender={() => 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();
});
});
35 changes: 35 additions & 0 deletions packages/react-aria/test/selection/useSelectableCollection.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
*/

import {
act,
fireEvent,
installPointerEvent,
pointerMap,
Expand Down Expand Up @@ -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(
<>
<button>before</button>
<List aria-label="Test">
<Item key="i1" textValue="One">
<button>inner one</button>
</Item>
<Item key="i2" textValue="Two">
<button>inner two</button>
</Item>
</List>
</>
);
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 => {
Expand Down