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
54 changes: 54 additions & 0 deletions packages/react/src/dialog/popup/DialogPopup.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,60 @@ describe('<Dialog.Popup />', () => {
});

describe('prop: finalFocus', () => {
it.each([
{ name: 'focuses the specified element', useExplicitTarget: true },
{ name: 'leaves focus on the document when using the default', useExplicitTarget: false },
])(
'$name on backdrop press when preventScroll is unsupported',
async ({ useExplicitTarget }) => {
const originalFocus = HTMLElement.prototype.focus;
const focusSpy = vi
.spyOn(HTMLElement.prototype, 'focus')
.mockImplementation(function focusWithoutOptions(this: HTMLElement) {
originalFocus.call(this);
});

try {
function TestDialog() {
const finalFocusRef = React.useRef<HTMLButtonElement>(null);

return (
<React.Fragment>
<button ref={finalFocusRef}>Continue</button>
<Dialog.Root>
<Dialog.Trigger>Open</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Backdrop data-testid="backdrop" />
<Dialog.Popup
finalFocus={() => (useExplicitTarget ? finalFocusRef.current : true)}
>
<Dialog.Close>Close</Dialog.Close>
</Dialog.Popup>
</Dialog.Portal>
</Dialog.Root>
</React.Fragment>
);
}

const { user } = await render(<TestDialog />);
const continueButton = screen.getByRole('button', { name: 'Continue' });
await user.click(screen.getByRole('button', { name: 'Open' }));
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Close' })).toHaveFocus();
});

await user.click(screen.getByTestId('backdrop'));

await waitFor(() => {
expect(screen.queryByRole('dialog')).toBe(null);
});
expect(useExplicitTarget ? continueButton : document.body).toHaveFocus();
} finally {
focusSpy.mockRestore();
}
},
);

it('respects finalFocus when initialFocus points outside the popup', async () => {
function TestComponent() {
const initialRef = React.useRef<HTMLInputElement>(null);
Expand Down
69 changes: 36 additions & 33 deletions packages/react/src/utils/popups/focus/FloatingFocusManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -806,6 +806,8 @@ export function FloatingFocusManager(props: FloatingFocusManagerProps): React.JS
: elementFocusedBeforeOpen,
);

let outsidePressWithoutPreventScroll = false;

function onOpenChangeLocal(details: FloatingUIOpenChangeDetails) {
if (!details.open) {
closeTypeRef.current = getEventType(details.nativeEvent, lastInteractionTypeRef.current);
Expand All @@ -824,35 +826,27 @@ export function FloatingFocusManager(props: FloatingFocusManagerProps): React.JS
return;
}

if (details.nested) {
preventReturnFocusRef.current = false;
} else if (
preventReturnFocusRef.current = false;
outsidePressWithoutPreventScroll = false;

if (
details.nested ||
isVirtualClick(details.nativeEvent as MouseEvent) ||
isVirtualPointerEvent(details.nativeEvent as PointerEvent)
) {
preventReturnFocusRef.current = false;
} else {
// On outside press, only return focus to the reference when the browser supports the
// `focus({ preventScroll })` option; without it, restoring focus scrolls the page.
// Chrome on Android and Samsung Internet still don't support `preventScroll`
// (https://issues.chromium.org/issues/41453122), so the runtime check keeps return
// focus disabled there to avoid the scroll jump.
let isPreventScrollSupported = false;
ownerDocument(floatingFocusElement)
.createElement('div')
.focus({
get preventScroll() {
isPreventScrollSupported = true;
return false;
},
});

if (isPreventScrollSupported) {
preventReturnFocusRef.current = false;
} else {
preventReturnFocusRef.current = true;
}
return;
}

// Chrome on Android and Samsung Internet can ignore `focus({ preventScroll })`.
// Avoid automatically returning focus in that case, since the page could scroll.
let isPreventScrollSupported = false;
doc.createElement('div').focus({
get preventScroll() {
isPreventScrollSupported = true;
return false;
},
});
outsidePressWithoutPreventScroll = !isPreventScrollSupported;
}

events.on('openchange', onOpenChangeLocal);
Expand All @@ -873,6 +867,18 @@ export function FloatingFocusManager(props: FloatingFocusManagerProps): React.JS
resolvedReturnFocusValue = true;
}

const specifiedReturnElement =
typeof resolvedReturnFocusValue === 'boolean' ? null : resolveRef(resolvedReturnFocusValue);

if (outsidePressWithoutPreventScroll) {
// The application can opt in to moving focus by naming a specific element.
return explicitReturnFocusRef.current === false ? null : specifiedReturnElement;
}

if (specifiedReturnElement) {
return specifiedReturnElement;
}

const referenceReturnElement = domReference?.isConnected ? domReference : null;
const previousReturnElement =
elementFocusedBeforeOpen?.isConnected && getNodeName(elementFocusedBeforeOpen) !== 'body'
Expand All @@ -887,11 +893,7 @@ export function FloatingFocusManager(props: FloatingFocusManagerProps): React.JS
defaultReturnElement = getPreviouslyFocusedElement() || null;
}

if (typeof resolvedReturnFocusValue === 'boolean') {
return defaultReturnElement;
}

return resolveRef(resolvedReturnFocusValue) || defaultReturnElement || null;
return defaultReturnElement;
}

return () => {
Expand Down Expand Up @@ -926,9 +928,10 @@ export function FloatingFocusManager(props: FloatingFocusManagerProps): React.JS
// focus. Use the newest visible element focused before it instead, unless the closing
// commit already moved focus (e.g. `autoFocus`).
if (tabbableReturnElement?.isConnected && closest(tabbableReturnElement, '[hidden]')) {
tabbableReturnElement = [activeEl, doc.body].includes(activeElement(doc))
? getPreviouslyFocusedElement(true) || null
: null;
tabbableReturnElement =
!outsidePressWithoutPreventScroll && [activeEl, doc.body].includes(activeElement(doc))
? getPreviouslyFocusedElement(true) || null
: null;
}
// Read in the cleanup on purpose: the latest `explicitReturnFocus` decides.
const hasExplicitReturnFocus =
Expand Down
Loading