Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix no blur called on activeelement removed from dom #3393

Closed
wants to merge 25 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
a249e49
Fix no blur called on activeelement removed from dom
snowystinger Aug 9, 2022
0c826dd
lint
snowystinger Aug 10, 2022
b702b3a
Save point - get mutation observer firing correctly
snowystinger Aug 11, 2022
035e2c1
fix tests
snowystinger Aug 11, 2022
cc17f40
Merge branch 'main' into fix-no-blur-for-activeelement-being-removed
snowystinger Aug 11, 2022
9a8fc9f
fix lint and types
snowystinger Aug 12, 2022
52f8d9c
Merge branch 'fix-no-blur-for-activeelement-being-removed' of github.…
snowystinger Aug 12, 2022
0337856
Merge branch 'main' into fix-no-blur-for-activeelement-being-removed
snowystinger Aug 12, 2022
996ef85
expose useSyntheticBlur as a util
snowystinger Aug 12, 2022
44dc441
Fix cases where onBlur fired after unmount of component
snowystinger Aug 12, 2022
295e2ed
clean up blur handles when unmounting
snowystinger Aug 13, 2022
3123afd
only one node removal mutation observer
snowystinger Aug 17, 2022
e2c7c23
Revert "only one node removal mutation observer"
snowystinger Aug 17, 2022
a8449f3
clean up remaining event listeners
snowystinger Aug 17, 2022
96feabf
Single node removed observer
snowystinger Aug 17, 2022
4123cb5
fix lint
snowystinger Aug 17, 2022
74eeb89
fix ssr
snowystinger Aug 17, 2022
7c83594
remove dead code and fix comments/types
snowystinger Aug 18, 2022
fb255df
Merge branch 'main' into fix-no-blur-for-activeelement-being-removed
snowystinger Aug 18, 2022
3340ac4
remove extraneous code
snowystinger Aug 18, 2022
1a742c8
Merge branch 'main' into fix-no-blur-for-activeelement-being-removed
LFDanLu Aug 25, 2022
799f8c2
code review
snowystinger Aug 30, 2022
7f7b4e7
Merge branch 'main' into fix-no-blur-for-activeelement-being-removed
LFDanLu Sep 2, 2022
296a353
Merge branch 'main' into fix-no-blur-for-activeelement-being-removed
snowystinger Sep 12, 2022
14dbc0e
small perf benefits
snowystinger Sep 12, 2022
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
49 changes: 31 additions & 18 deletions packages/@react-aria/focus/src/FocusScope.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@
import {FocusableElement} from '@react-types/shared';
import {focusSafely} from './focusSafely';
import {isElementVisible} from './isElementVisible';
import React, {ReactNode, RefObject, useContext, useEffect, useMemo, useRef} from 'react';
import React, {ReactNode, RefObject, useCallback, useContext, useEffect, useMemo, useRef} from 'react';
import {useLayoutEffect} from '@react-aria/utils';
import {useSyntheticBlurEvent} from '@react-aria/utils';


export interface FocusScopeProps {
Expand Down Expand Up @@ -262,6 +263,23 @@ function useFocusContainment(scopeRef: RefObject<Element[]>, contain: boolean) {
let focusedNode = useRef<FocusableElement>();

let raf = useRef(null);

let onBlur = useCallback((e) => {
// Firefox doesn't shift focus back to the Dialog properly without this
raf.current = requestAnimationFrame(() => {
LFDanLu marked this conversation as resolved.
Show resolved Hide resolved
// Use document.activeElement instead of e.relatedTarget so we can tell if user clicked into iframe
if (shouldContainFocus(scopeRef) && !isElementInChildScope(document.activeElement, scopeRef)) {
activeScope = scopeRef;
if (document.body.contains(e.target)) {
focusedNode.current = e.target;
focusedNode.current.focus();
} else if (activeScope) {
focusFirstInScope(activeScope.current);
}
}
});
}, [scopeRef, focusedNode]);
let onSyntheticFocus = useSyntheticBlurEvent(onBlur);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

need two of these or target ends up becoming stale

useLayoutEffect(() => {
let scope = scopeRef.current;
if (!contain) {
Expand Down Expand Up @@ -305,6 +323,7 @@ function useFocusContainment(scopeRef: RefObject<Element[]>, contain: boolean) {
if ((!activeScope || isAncestorScope(activeScope, scopeRef)) && isElementInScope(e.target, scopeRef.current)) {
activeScope = scopeRef;
focusedNode.current = e.target;
onSyntheticFocus(e);
LFDanLu marked this conversation as resolved.
Show resolved Hide resolved
} else if (shouldContainFocus(scopeRef) && !isElementInChildScope(e.target, scopeRef)) {
// If a focus event occurs outside the active scope (e.g. user tabs from browser location bar),
// restore focus to the previously focused node or the first tabbable element in the active scope.
Expand All @@ -315,25 +334,10 @@ function useFocusContainment(scopeRef: RefObject<Element[]>, contain: boolean) {
}
} else if (shouldContainFocus(scopeRef)) {
focusedNode.current = e.target;
onSyntheticFocus(e);
}
};

let onBlur = (e) => {
// Firefox doesn't shift focus back to the Dialog properly without this
raf.current = requestAnimationFrame(() => {
// Use document.activeElement instead of e.relatedTarget so we can tell if user clicked into iframe
if (shouldContainFocus(scopeRef) && !isElementInChildScope(document.activeElement, scopeRef)) {
activeScope = scopeRef;
if (document.body.contains(e.target)) {
focusedNode.current = e.target;
focusedNode.current.focus();
} else if (activeScope) {
focusFirstInScope(activeScope.current);
}
}
});
};

document.addEventListener('keydown', onKeyDown, false);
document.addEventListener('focusin', onFocus, false);
scope.forEach(element => element.addEventListener('focusin', onFocus, false));
Expand All @@ -344,7 +348,7 @@ function useFocusContainment(scopeRef: RefObject<Element[]>, contain: boolean) {
scope.forEach(element => element.removeEventListener('focusin', onFocus, false));
scope.forEach(element => element.removeEventListener('focusout', onBlur, false));
};
}, [scopeRef, contain]);
}, [scopeRef, contain, onBlur]);

// eslint-disable-next-line arrow-body-style
useEffect(() => {
Expand All @@ -354,6 +358,15 @@ function useFocusContainment(scopeRef: RefObject<Element[]>, contain: boolean) {
}
};
}, [raf]);

useEffect(() => {
// the autofocus attribute is too fast for our useLayoutEffect, so we check if some element in scope already has focus on mount
// and attach our blur fixer here
if (shouldContainFocus(scopeRef) && isElementInChildScope(document.activeElement, scopeRef)) {
let event = {target: document.activeElement};
onSyntheticFocus(event as React.FocusEvent);
}
}, []);
}

function isElementInAnyScope(element: Element) {
Expand Down
18 changes: 7 additions & 11 deletions packages/@react-aria/focus/stories/FocusScope.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
* governing permissions and limitations under the License.
*/

import {Button} from '@react-spectrum/button';
import {FocusScope} from '../';
import {Meta, Story} from '@storybook/react';
import React, {ReactNode, useState} from 'react';
Expand Down Expand Up @@ -107,31 +108,26 @@ export function Example({isPortaled, contain}: StoryProps) {

function FocusableFirstInScopeExample() {
let [contentIndex, setContentIndex] = useState(0);
let [buttonRemoved, setButtonRemoved] = useState(false);
function DialogContent(index = 0) {
const nextIndex = index === 2 ? 0 : index + 1;
const nextIndex = index + 1;
return (
<>
<div key={index}>
<h1 id={`heading-${index}`}>Dialog {index + 1}</h1>
{index === 2 ?
{index >= 2 ?
(
<>
<p>The end of the road.</p>
<button id={`button-${index}`} key={`button-${index}`} onClick={(e) => {(e.target as Element).remove(); setButtonRemoved(true);}}>Remove Me</button>
{buttonRemoved &&
<p>With no tabbable elements within the scope, FocusScope will try to focus the first focusable element within the scope, in this case, the dialog itself.</p>
}
<p>With no tabbable elements within the scope, FocusScope will try to focus the first focusable element within the scope, in this case, the dialog itself.</p>
</>
) :
(
<>
<p>Content that will be replaced by <strong>Dialog {nextIndex + 1}</strong>.</p>
<button id={`button-${index}`} key={`button-${index}`} onClick={() => setContentIndex(nextIndex)}>Go to Dialog {nextIndex + 1}</button>
<Button variant="primary" id={`button-${index}`} onPress={() => setContentIndex(nextIndex)}>Go to Dialog {nextIndex + 1}</Button>
</>
)
}

</>
</div>
);
}
const contents = [];
Expand Down
49 changes: 49 additions & 0 deletions packages/@react-aria/focus/test/FocusRing.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/*
* 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 {act, render} from '@react-spectrum/test-utils';
import {FocusRing} from '../';
import React from 'react';
import userEvent from '@testing-library/user-event';


describe('FocusScope', function () {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
// make sure to clean up any raf's that may be running to restore focus on unmount
act(() => {jest.runAllTimers();});
});

describe('focus containment', function () {
it('should contain focus within the scope', function () {
let {getByTestId, rerender} = render(
<>
<FocusRing focusRingClass="bar">
<FocusRing focusRingClass="foo">
<input data-testid="input1" />
</FocusRing>
</FocusRing>
</>
);

let input1 = getByTestId('input1');
userEvent.tab();
expect(document.activeElement).toBe(input1);
rerender((<>
<FocusRing focusRingClass="bar"><div /></FocusRing>
</>));
expect(input1).not.toBeInTheDocument();
});
});
});
72 changes: 36 additions & 36 deletions packages/@react-aria/focus/test/FocusScope.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* governing permissions and limitations under the License.
*/

import {act, fireEvent, render, waitFor} from '@react-spectrum/test-utils';
import {act, fireEvent, render} from '@react-spectrum/test-utils';
import {defaultTheme} from '@adobe/react-spectrum';
import {DialogContainer} from '@react-spectrum/dialog';
import {FocusScope, useFocusManager} from '../';
Expand Down Expand Up @@ -652,45 +652,45 @@ describe('FocusScope', function () {

describe('focusable first in scope', function () {
it('should restore focus to the first focusable or tabbable element within the scope when focus is lost within the scope', async function () {
let {getByTestId} = render(
<div>
<FocusScope contain>
<div role="dialog" data-testid="focusable" tabIndex={-1}>
<Item data-testid="tabbable1" autoFocus tabIndex={null}>Remove me!</Item>
<Item data-testid="item1" tabIndex={0}>Remove me, too!</Item>
<Item data-testid="item2" tabIndex={-1}>Remove me, three!</Item>
</div>
</FocusScope>
</div>
);
let tree = render(<Example />);
function Example(props) {
let [contentIndex, setContentIndex] = useState(0);

function Item(props) {
let focusManager = useFocusManager();
let onClick = e => {
focusManager.focusNext();
act(() => {
// remove fails to fire blur event in jest-dom
e.target.blur();
e.target.remove();
jest.runAllTimers();
});
};
return <button tabIndex={-1} {...props} onClick={onClick} />;
return (
<div>
<FocusScope contain>
<div role="dialog" data-testid="focusable" tabIndex={-1}>
{contentIndex === 0 && <button key={0} data-testid="item1" autoFocus onClick={() => setContentIndex(1)}>First</button>}
{contentIndex === 1 && <button key={1} data-testid="item2" tabIndex={0} onClick={() => setContentIndex(2)}>Second</button>}
{contentIndex === 2 && <button key={2} data-testid="item3" tabIndex={-1} onClick={() => setContentIndex(3)}>Third</button>}
</div>
</FocusScope>
</div>
);
}
let focusable = getByTestId('focusable');
let tabbable1 = getByTestId('tabbable1');
let item1 = getByTestId('item1');
let item2 = getByTestId('item2');
expect(document.activeElement).toBe(tabbable1);
fireEvent.click(tabbable1);
expect(tabbable1).not.toBeInTheDocument();
await waitFor(() => expect(document.activeElement).toBe(item1));
let focusable = tree.getByTestId('focusable');
let item1 = tree.getByTestId('item1');
expect(document.activeElement).toBe(item1);

fireEvent.click(item1);
expect(item1).not.toBeInTheDocument();
await waitFor(() => expect(document.activeElement).toBe(item2));
// wait for the async MutationObserver
await act(async () => Promise.resolve());
act(() => {jest.runAllTimers();});
let item2 = tree.getByTestId('item2');
expect(document.activeElement).toBe(item2);

fireEvent.click(item2);
expect(item2).not.toBeInTheDocument();
await waitFor(() => expect(document.activeElement).toBe(focusable));
await act(async () => Promise.resolve());
act(() => {jest.runAllTimers();});
expect(item1).not.toBeInTheDocument();
let item3 = tree.getByTestId('item3');
expect(document.activeElement).toBe(focusable);

fireEvent.click(item3);
await act(async () => Promise.resolve());
act(() => {jest.runAllTimers();});
expect(item3).not.toBeInTheDocument();
expect(document.activeElement).toBe(focusable);
});
});
});
Expand Down
2 changes: 1 addition & 1 deletion packages/@react-aria/interactions/src/useFocus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

import {DOMAttributes, FocusEvents} from '@react-types/shared';
import {FocusEvent, useCallback} from 'react';
import {useSyntheticBlurEvent} from './utils';
import {useSyntheticBlurEvent} from '@react-aria/utils';

export interface FocusProps extends FocusEvents {
/** Whether the focus events should be disabled. */
Expand Down
2 changes: 1 addition & 1 deletion packages/@react-aria/interactions/src/useFocusWithin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

import {DOMAttributes} from '@react-types/shared';
import {FocusEvent, useCallback, useRef} from 'react';
import {useSyntheticBlurEvent} from './utils';
import {useSyntheticBlurEvent} from '@react-aria/utils';

export interface FocusWithinProps {
/** Whether the focus within events should be disabled. */
Expand Down
Loading