Skip to content

Commit 8a0bd83

Browse files
rafal2228solimantsnowystinger
authored
Modify isElementInScope check to disable FocusScope with another FocusScope as child (#2139)
* Modify isElementInScope check to disable FocusScope with another FocusScope as child * Check focus scope as direct/nested child in single loop * Add nested focus scope keyboard navigation story * Focus scope story mount portals in custom div * Update packages/@react-aria/focus/stories/FocusScope.stories.tsx Co-authored-by: solimant <[email protected]> * Convert FocusScope stories to CSF Co-authored-by: solimant <[email protected]> Co-authored-by: Robert Snow <[email protected]>
1 parent 2cc6a74 commit 8a0bd83

File tree

3 files changed

+188
-3
lines changed

3 files changed

+188
-3
lines changed

packages/@react-aria/focus/src/FocusScope.tsx

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,9 +101,9 @@ export function FocusScope(props: FocusScopeProps) {
101101

102102
return (
103103
<FocusContext.Provider value={focusManager}>
104-
<span hidden ref={startRef} />
104+
<span data-focus-scope-start hidden ref={startRef} />
105105
{children}
106-
<span hidden ref={endRef} />
106+
<span data-focus-scope-end hidden ref={endRef} />
107107
</FocusContext.Provider>
108108
);
109109
}
@@ -273,8 +273,25 @@ function isElementInAnyScope(element: Element, scopes: Set<RefObject<HTMLElement
273273
return false;
274274
}
275275

276+
const focusScopeDataAttrNames = [
277+
'data-focus-scope-start',
278+
'data-focus-scope-end'
279+
];
280+
281+
function isFocusScopeDirectChild(scope: HTMLElement) {
282+
return focusScopeDataAttrNames.some(name => scope.getAttribute(name) !== null);
283+
}
284+
285+
function isFocusScopeNestedChild(scope: HTMLElement) {
286+
return focusScopeDataAttrNames.some(name => scope.querySelector(`[${name}]`));
287+
}
288+
289+
function isFocusScopeInScope(scopes: HTMLElement[]) {
290+
return scopes.some(scope => isFocusScopeDirectChild(scope) || isFocusScopeNestedChild(scope));
291+
}
292+
276293
function isElementInScope(element: Element, scope: HTMLElement[]) {
277-
return scope.some(node => node.contains(element));
294+
return !isFocusScopeInScope(scope) && scope.some(node => node.contains(element));
278295
}
279296

280297
function focusElement(element: HTMLElement | null, scroll = false) {
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/*
2+
* Copyright 2021 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import {FocusScope} from '../';
14+
import {Meta, Story} from '@storybook/react';
15+
import React, {ReactNode, useState} from 'react';
16+
import ReactDOM from 'react-dom';
17+
18+
const dialogsRoot = 'dialogsRoot';
19+
20+
interface StoryProps {
21+
usePortal: boolean
22+
}
23+
24+
const meta: Meta<StoryProps> = {
25+
title: 'FocusScope',
26+
component: FocusScope
27+
};
28+
29+
export default meta;
30+
31+
const Template = (): Story<StoryProps> => ({usePortal}) => <Example usePortal={usePortal} />;
32+
33+
function MaybePortal({children, usePortal}: { children: ReactNode, usePortal: boolean}) {
34+
if (!usePortal) {
35+
return <>{children}</>;
36+
}
37+
38+
return ReactDOM.createPortal(
39+
<>{children}</>,
40+
document.getElementById(dialogsRoot)
41+
);
42+
}
43+
44+
function NestedDialog({onClose, usePortal}: {onClose: VoidFunction, usePortal: boolean}) {
45+
let [open, setOpen] = useState(false);
46+
47+
return (
48+
<MaybePortal usePortal={usePortal}>
49+
<FocusScope contain restoreFocus autoFocus>
50+
<div>
51+
<input />
52+
53+
<input />
54+
55+
<input />
56+
57+
<button type="button" onClick={() => setOpen(true)}>
58+
Open dialog
59+
</button>
60+
61+
<button type="button" onClick={onClose}>
62+
close
63+
</button>
64+
65+
{open && <NestedDialog onClose={() => setOpen(false)} usePortal={usePortal} />}
66+
</div>
67+
</FocusScope>
68+
</MaybePortal>
69+
);
70+
}
71+
72+
function Example({usePortal}: StoryProps) {
73+
let [open, setOpen] = useState(false);
74+
75+
return (
76+
<div>
77+
<input />
78+
79+
<button type="button" onClick={() => setOpen(true)}>
80+
Open dialog
81+
</button>
82+
83+
{open && <NestedDialog onClose={() => setOpen(false)} usePortal={usePortal} />}
84+
85+
<div id={dialogsRoot} />
86+
</div>
87+
);
88+
}
89+
90+
export const KeyboardNavigation = Template().bind({});
91+
KeyboardNavigation.args = {usePortal: false};
92+
93+
export const KeyboardNavigationInsidePortal = Template().bind({});
94+
KeyboardNavigationInsidePortal.args = {usePortal: true};

packages/@react-aria/focus/test/FocusScope.test.js

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -828,6 +828,80 @@ describe('FocusScope', function () {
828828
fireEvent.focusIn(input3);
829829
expect(document.activeElement).toBe(input3);
830830
});
831+
832+
it('should lock tab navigation inside direct child focus scope', function () {
833+
function Test() {
834+
return (
835+
<div>
836+
<input data-testid="outside" />
837+
<FocusScope autoFocus restoreFocus contain>
838+
<input data-testid="parent1" />
839+
<input data-testid="parent2" />
840+
<input data-testid="parent3" />
841+
<FocusScope autoFocus restoreFocus contain>
842+
<input data-testid="child1" />
843+
<input data-testid="child2" />
844+
<input data-testid="child3" />
845+
</FocusScope>
846+
</FocusScope>
847+
</div>
848+
);
849+
}
850+
851+
let {getByTestId} = render(<Test />);
852+
let child1 = getByTestId('child1');
853+
let child2 = getByTestId('child2');
854+
let child3 = getByTestId('child3');
855+
856+
expect(document.activeElement).toBe(child1);
857+
userEvent.tab();
858+
expect(document.activeElement).toBe(child2);
859+
userEvent.tab();
860+
expect(document.activeElement).toBe(child3);
861+
userEvent.tab();
862+
expect(document.activeElement).toBe(child1);
863+
userEvent.tab({shift: true});
864+
expect(document.activeElement).toBe(child3);
865+
});
866+
867+
it('should lock tab navigation inside nested child focus scope', function () {
868+
function Test() {
869+
return (
870+
<div>
871+
<input data-testid="outside" />
872+
<FocusScope autoFocus restoreFocus contain>
873+
<input data-testid="parent1" />
874+
<input data-testid="parent2" />
875+
<input data-testid="parent3" />
876+
<div>
877+
<div>
878+
<FocusScope autoFocus restoreFocus contain>
879+
<input data-testid="child1" />
880+
<input data-testid="child2" />
881+
<input data-testid="child3" />
882+
</FocusScope>
883+
</div>
884+
</div>
885+
</FocusScope>
886+
</div>
887+
);
888+
}
889+
890+
let {getByTestId} = render(<Test />);
891+
let child1 = getByTestId('child1');
892+
let child2 = getByTestId('child2');
893+
let child3 = getByTestId('child3');
894+
895+
expect(document.activeElement).toBe(child1);
896+
userEvent.tab();
897+
expect(document.activeElement).toBe(child2);
898+
userEvent.tab();
899+
expect(document.activeElement).toBe(child3);
900+
userEvent.tab();
901+
expect(document.activeElement).toBe(child1);
902+
userEvent.tab({shift: true});
903+
expect(document.activeElement).toBe(child3);
904+
});
831905
});
832906

833907
describe('scope child of document.body', function () {

0 commit comments

Comments
 (0)