8000 fix(FloatingFocusManager): check for blurring to nested elements insi… · psy-repos-typescript/floating-ui@d723f38 · GitHub
[go: up one dir, main page]

Skip to content

Commit d723f38

Browse files
authored
fix(FloatingFocusManager): check for blurring to nested elements inside the React tree without FloatingTree (floating-ui#3318)
1 parent 4d3073c commit d723f38

File tree

4 files changed

+107
-7
lines changed

4 files changed

+107
-7
lines changed

.changeset/kind-rings-sing.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@floating-ui/react': patch
3+
---
4+
5+
fix(FloatingFocusManager): check for blurring to nested elements inside the React tree without `FloatingTree`

packages/react/src/components/FloatingFocusManager.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,12 @@ export function FloatingFocusManager(
435435
}
436436
}
437437

438+
// https://github.com/floating-ui/floating-ui/issues/3060
439+
if (dataRef.current.insideReactTree) {
440+
dataRef.current.insideReactTree = false;
441+
return;
442+
}
443+
438444
// Focus did not move inside the floating tree, and there are no tabbable
439445
// portal guards to handle closing.
440446
if (
@@ -477,6 +483,7 @@ export function FloatingFocusManager(
477483
isUntrappedTypeableCombobox,
478484
getNodeId,
479485
orderRef,
486+
dataRef,
480487
]);
481488

482489
const beforeGuardRef = React.useRef<HTMLSpanElement | null>(null);

packages/react/src/hooks/useDismiss.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
import {useFloatingTree} from '../components/FloatingTree';
2323
import type {ElementProps, FloatingRootContext} from '../types';
2424
import {createAttribute} from '../utils/createAttribute';
25+
import {clearTimeoutIfSet} from '../utils/clearTimeoutIfSet';
2526

2627
const bubbleHandlerKeys = {
2728
pointerdown: 'onPointerDown',
@@ -148,14 +149,15 @@ export function useDismiss(
148149
typeof unstable_outsidePress === 'function'
149150
? outsidePressFn
150151
: unstable_outsidePress;
151-
const insideReactTreeRef = React.useRef(false);
152+
152153
const endedOrStartedInsideRef = React.useRef(false);
153154
const {escapeKey: escapeKeyBubbles, outsidePress: outsidePressBubbles} =
154155
normalizeProp(bubbles);
155156
const {escapeKey: escapeKeyCapture, outsidePress: outsidePressCapture} =
156157
normalizeProp(capture);
157158

158159
const isComposingRef = React.useRef(false);
160+
const blurTimeoutRef = React.useRef(-1);
159161

160162
const closeOnEscapeKeyDown = useEffectEvent(
161163
(event: React.KeyboardEvent<Element> | KeyboardEvent) => {
@@ -216,8 +218,8 @@ export function useDismiss(
216218
const closeOnPressOutside = useEffectEvent((event: MouseEvent) => {
217219
// Given developers can stop the propagation of the synthetic event,
218220
// we can only be confident with a positive value.
219-
const insideReactTree = insideReactTreeRef.current;
220-
insideReactTreeRef.current = false;
221+
const insideReactTree = dataRef.current.insideReactTree;
222+
dataRef.current.insideReactTree = false;
221223

222224
// When click outside is lazy (`click` event), handle dragging.
223225
// Don't close if:
@@ -486,8 +488,8 @@ export function useDismiss(
486488
]);
48748 6D47 9

488490
React.useEffect(() => {
489-
insideReactTreeRef.current = false;
490-
}, [outsidePress, outsidePressEvent]);
491+
dataRef.current.insideReactTree = false;
492+
}, [dataRef, outsidePress, outsidePressEvent]);
491493

492494
const reference: ElementProps['reference'] = React.useMemo(
493495
() => ({
@@ -518,10 +520,17 @@ export function useDismiss(
518520
endedOrStartedInsideRef.current = true;
519521
},
520522
[captureHandlerKeys[outsidePressEvent]]: () => {
521-
insideReactTreeRef.current = true;
523+
dataRef.current.insideReactTree = true;
524+
},
525+
onBlurCapture() {
526+
clearTimeoutIfSet(blurTimeoutRef);
527+
dataRef.current.insideReactTree = true;
528+
blurTimeoutRef.current = window.setTimeout(() => {
529+
dataRef.current.insideReactTree = false;
530+
});
522531
},
523532
}),
524-
[closeOnEscapeKeyDown, outsidePressEvent],
533+
[closeOnEscapeKeyDown, outsidePressEvent, dataRef],
525534
);
526535

527536
return React.useMemo(

packages/react/test/unit/useDismiss.test.tsx

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
useFloatingParentNodeId,
1616
useFocus,
1717
useInteractions,
18+
useClick,
1819
} from '../../src';
1920
import type {UseDismissProps} from '../../src/hooks/useDismiss';
2021
import {normalizeProp} from '../../src/hooks/useDismiss';
@@ -928,3 +929,81 @@ describe('outsidePressEvent click', () => {
928929
cleanup();
929930
});
930931
});
932+
933+
test('nested floating elements with different portal roots', async () => {
934+
function ButtonWithFloating({
935+
children,
936+
portalRoot,
937+
triggerText,
938+
}: {
939+
children?: React.ReactNode;
940+
portalRoot?: HTMLElement | null;
941+
triggerText: string;
942+
}) {
943+
const [open, setOpen] = useState(false);
944+
const {refs, floatingStyles, context} = useFloating({
945+
open,
946+
onOpenChange: setOpen,
947+
});
948+
949+
const click = useClick(context);
950+
const dismiss = useDismiss(context);
951+
952+
const {getReferenceProps, getFloatingProps} = useInteractions([
953+
click,
954+
dismiss,
955+
]);
956+
957+
return (
958+
<>
959+
<button ref={refs.setReference} {...getReferenceProps()}>
960+
{triggerText}
961+
</button>
962+
{open && (
963+
<FloatingPortal root={portalRoot}>
964+
<FloatingFocusManager context={context} modal={false}>
965+
<div
966+
ref={refs.setFloating}
967+
style={floatingStyles}
968+
{...getFloatingProps()}
969+
>
970+
{children}
971+
</div>
972+
</FloatingFocusManager>
973+
</FloatingPortal>
974+
)}
975+
</>
976+
);
977+
}
978+
979+
function App() {
980+
const [otherContainer, setOtherContainer] =
981+
useState<HTMLDivElement | null>();
982+
983+
const portal1 = undefined;
984+
const portal2 = otherContainer;
985+
986+
return (
987+
<>
988+
<ButtonWithFloating portalRoot={portal1} triggerText="open 1">
989+
<ButtonWithFloating portalRoot={portal2} triggerText="open 2">
990+
<button>nested</button>
991+
</ButtonWithFloating>
992+
</ButtonWithFloating>
993+
<div ref={setOtherContainer} />
994+
</>
995+
);
996+
}
997+
998+
render(<App />);
999+
1000+
await userEvent.click(screen.getByText('open 1'));
1001+
expect(screen.getByText('open 2')).toBeInTheDocument();
1002+
1003+
await userEvent.click(screen.getByText('open 2'));
1004+
await act(async () => {});
1005+
1006+
expect(screen.queryByText('open 1')).toBeInTheDocument();
1007+
expect(screen.queryByText('open 2')).toBeInTheDocument();
1008+
expect(screen.queryByText('nested')).toBeInTheDocument();
1009+
});

0 commit comments

Comments
 (0)
0