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
61 changes: 56 additions & 5 deletions src/components/ButtonWithDropdownMenu/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Button from '@components/ButtonComposed';
import Icon from '@components/Icon';
import InlineIcon from '@components/Icon/InlineIcon';
import type BaseModalProps from '@components/Modal/types';
import PopoverMenu from '@components/PopoverMenu';
import Text from '@components/Text';

Expand All @@ -13,15 +14,18 @@ import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';

import {isSafari} from '@libs/Browser';
import {resolvePopoverLauncherElement, setActivePopoverLauncher} from '@libs/LauncherStack';
import mergeRefs from '@libs/mergeRefs';
import restoreFocusWithModality from '@libs/restoreFocusWithModality';

import CONST from '@src/CONST';
import type {AnchorPosition} from '@src/styles';

import type {GestureResponderEvent, StyleProp, TextStyle} from 'react-native';
import type {ValueOf} from 'type-fest';

import React, {useEffect, useImperativeHandle, useRef, useState} from 'react';
import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react';
import {View} from 'react-native';

import type {ButtonWithDropdownMenuProps} from './types';
Expand Down Expand Up @@ -102,10 +106,35 @@ function ButtonWithDropdownMenu<IValueType>({ref, ...props}: ButtonWithDropdownM
const StyleUtils = useStyleUtils();
const [selectedItemIndex, setSelectedItemIndex] = useState(defaultSelectedIndex);
const [isMenuVisible, setIsMenuVisible] = useState(false);
const [restoreFocusType, setRestoreFocusType] = useState<BaseModalProps['restoreFocusType']>();
// When an item uses shouldCallAfterModalHide, restore the anchor before onSelected so nav can capture it.
const shouldRestoreAnchorOnHideRef = useRef(false);
// In tests, skip the popover anchor position calculation. The default values are needed for popover menu to be rendered in tests.
const defaultPopoverAnchorPosition = process.env.NODE_ENV === 'test' ? {horizontal: 100, vertical: 100} : null;
const [popoverAnchorPosition, setPopoverAnchorPosition] = useState<AnchorPosition | null>(defaultPopoverAnchorPosition);
const dropdownAnchor = useRef<View | null>(null);

const registerDropdownLauncher = useCallback(() => {
// Register before open — mouse-open often leaves activeElement as body, so FocusTrap can't infer the launcher.
const anchor = resolvePopoverLauncherElement(dropdownAnchor);
if (anchor) {
setActivePopoverLauncher(anchor);
}
}, []);

const setMenuVisible = useCallback(
(visible: boolean) => {
if (visible) {
registerDropdownLauncher();
}
setIsMenuVisible(visible);
},
[registerDropdownLauncher],
);

const toggleMenuVisible = useCallback(() => {
setMenuVisible(!isMenuVisible);
}, [isMenuVisible, setMenuVisible]);
// We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to apply correct popover styles
// eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
const {isSmallScreenWidth} = useResponsiveLayout();
Expand Down Expand Up @@ -164,7 +193,7 @@ function ButtonWithDropdownMenu<IValueType>({ref, ...props}: ButtonWithDropdownM
(e) => {
if (shouldAlwaysShowDropdownMenu || options.length) {
if (!isSplitButton) {
setIsMenuVisible(!isMenuVisible);
toggleMenuVisible();
return;
}
if (selectedItem?.value) {
Expand Down Expand Up @@ -193,14 +222,14 @@ function ButtonWithDropdownMenu<IValueType>({ref, ...props}: ButtonWithDropdownM

const handlePress = (event?: GestureResponderEvent | KeyboardEvent) => {
if (!isSplitButton) {
setIsMenuVisible(!isMenuVisible);
toggleMenuVisible();
} else if (selectedItem?.value) {
onPress(event, selectedItem.value);
}
};

useImperativeHandle(ref, () => ({
setIsMenuVisible,
setIsMenuVisible: setMenuVisible,
}));

const IconComponent = shouldUseShortForm ? InlineIcon : Icon;
Expand Down Expand Up @@ -265,7 +294,7 @@ function ButtonWithDropdownMenu<IValueType>({ref, ...props}: ButtonWithDropdownM
accessibilityState={{expanded: isMenuVisible}}
stayNormalOnDisable={stayNormalOnDisable}
style={[styles.pl0]}
onPress={() => setIsMenuVisible(!isMenuVisible)}
onPress={toggleMenuVisible}
removeBorderRadius={CONST.BUTTON_REMOVE_BORDER_RADIUS.LEFT}
size={size}
innerStyles={[styles.dropDownButtonCartIconContainerPadding, innerStyleDropButton, isButtonSizeSmall && styles.dropDownButtonCartIcon]}
Expand Down Expand Up @@ -333,8 +362,28 @@ function ButtonWithDropdownMenu<IValueType>({ref, ...props}: ButtonWithDropdownM
onOptionsMenuHide?.();
}}
onModalShow={onOptionsMenuShow}
onModalHide={() => {
setRestoreFocusType(undefined);
if (!shouldRestoreAnchorOnHideRef.current) {
return;
}
shouldRestoreAnchorOnHideRef.current = false;
// Put focus back on the anchor before shouldCallAfterModalHide runs onSelected / NavigationFocusReturn captures.
const anchor = resolvePopoverLauncherElement(dropdownAnchor);
if (anchor) {
restoreFocusWithModality(anchor);
}
}}
onItemSelected={(selectedSubitem, index, event) => {
onSubItemSelected?.(selectedSubitem, index, event);
// Match PopoverMenu: Safari runs shouldCallAfterModalHide immediately (no defer),
// so do not arm post-hide restore — that would refocus the anchor behind the destination.
const willDeferSelection = !!selectedSubitem.shouldCallAfterModalHide && !isSafari();
if (willDeferSelection) {
shouldRestoreAnchorOnHideRef.current = true;
} else {
setRestoreFocusType(CONST.MODAL.RESTORE_FOCUS_TYPE.PRESERVE);
}
if (selectedSubitem.shouldCloseModalOnSelect !== false) {
setIsMenuVisible(false);
}
Expand All @@ -348,6 +397,8 @@ function ButtonWithDropdownMenu<IValueType>({ref, ...props}: ButtonWithDropdownM
headerText={menuHeaderText}
shouldUseScrollView={shouldPopoverUseScrollView}
containerStyles={containerStyles}
shouldEnableNewFocusManagement
restoreFocusType={restoreFocusType}
menuItems={options.map((item, index) => ({
...item,
onSelected: item.onSelected
Expand Down
25 changes: 20 additions & 5 deletions src/components/FocusTrap/FocusTrapForModal/index.web.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,36 @@
import useThemeStyles from '@hooks/useThemeStyles';

import blurActiveElement from '@libs/Accessibility/blurActiveElement';
import {markActivePopoverLauncherDeactivated, setActivePopoverLauncher} from '@libs/LauncherStack';
import {markActivePopoverLauncherDeactivated, pickActiveLauncher, pickLauncher, setActivePopoverLauncher} from '@libs/LauncherStack';
import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager';
import resolveFocusTrapLauncher from '@libs/resolveFocusTrapLauncher';
import restoreFocusWithModality from '@libs/restoreFocusWithModality';
import sharedTrapStack from '@libs/sharedTrapStack';

import {FocusTrap} from 'focus-trap-react';
import React, {useRef} from 'react';
import {View} from 'react-native';

import type FocusTrapForModalProps from './FocusTrapForModalProps';

function FocusTrapForModal({children, active, initialFocus = false, shouldPreventScroll = false, shouldReturnFocus = true}: FocusTrapForModalProps) {
const styles = useThemeStyles();
Comment thread
Krishna2323 marked this conversation as resolved.
// Track this trap's own launcher so onPostDeactivate targets the right shared-stack entry.
const cachedLauncherRef = useRef<HTMLElement | null>(null);
// Host we own (same pattern as FormElement) — dContents so it does not affect modal layout/alignment.
const trapContainerRef = useRef<View | null>(null);

return (
<FocusTrap
active={active}
focusTrapOptions={{
onActivate: () => {
// Capture for nav-back return — independent of shouldReturnFocus (which gates only focus-trap-react's same-screen return below).
const launcher = document.activeElement;
const activeElement = document.activeElement;
const fromActive = activeElement instanceof HTMLElement && activeElement !== document.body ? activeElement : null;
const container = trapContainerRef.current instanceof HTMLElement ? trapContainerRef.current : null;
const launcher = resolveFocusTrapLauncher(fromActive, pickActiveLauncher(), container, pickLauncher());
blurActiveElement();
if (launcher instanceof HTMLElement && launcher !== document.body) {
if (launcher && document.contains(launcher)) {
cachedLauncherRef.current = launcher;
setActivePopoverLauncher(launcher);
}
Expand All @@ -46,7 +56,12 @@ function FocusTrapForModal({children, active, initialFocus = false, shouldPreven
setReturnFocus: false,
}}
>
{children}
<View
ref={trapContainerRef}
style={styles.dContents}
>
{children}
</View>
</FocusTrap>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,12 +107,15 @@ function WorkspaceRowThreeDotsMenu({item, onDeleteWorkspace, pendingDeletePolicy
menuItems.push({
icon: icons.Plus,
text: translate('workspace.common.duplicateWorkspace'),
// After the popover hides so the 3-dot anchor is focused again and NavigationFocusReturn can capture it.
shouldCallAfterModalHide: true,
onSelected: () => (item.policyID ? Navigation.navigate(ROUTES.WORKSPACE_DUPLICATE.getRoute(item.policyID)) : undefined),
});
if (item.isEligibleToCopy) {
menuItems.push({
icon: icons.Copy,
text: translate('workspace.copyPolicySettings.title'),
shouldCallAfterModalHide: true,
onSelected: () => {
if (!item.policyID) {
return;
Expand Down
38 changes: 35 additions & 3 deletions src/components/ThreeDotsMenu/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';

import {isMobile} from '@libs/Browser';
import {isMobile, isSafari} from '@libs/Browser';
import {resolvePopoverLauncherElement, setActivePopoverLauncher} from '@libs/LauncherStack';
import restoreFocusWithModality from '@libs/restoreFocusWithModality';

import type {AnchorPosition} from '@styles/index';
import variables from '@styles/variables';
Expand Down Expand Up @@ -67,6 +69,8 @@ function ThreeDotsMenu({
const [restoreFocusType, setRestoreFocusType] = useState<BaseModalProps['restoreFocusType']>();
const [position, setPosition] = useState<AnchorPosition>();
const buttonRef = useRef<View>(null);
// When an item uses shouldCallAfterModalHide, restore the anchor before onSelected so nav/confirm capture it.
const shouldRestoreAnchorOnHideRef = useRef(false);
const {translate} = useLocalize();
const expensifyIcons = useMemoizedLazyExpensifyIcons(['ThreeDots']);
const isBehindModal = modal?.willAlertModalBecomeVisible && !modal?.isPopover && !shouldOverlay;
Expand Down Expand Up @@ -94,6 +98,13 @@ function ThreeDotsMenu({
return;
}
hideProductTrainingTooltip?.();

// Register before blur — FocusTrapForModal.onActivate only sees document.activeElement, which is
// body after this blur, so without this NavigationFocusReturn has no launcher for Back restore.
const anchor = resolvePopoverLauncherElement(buttonRef);
if (anchor) {
setActivePopoverLauncher(anchor);
}
buttonRef.current?.blur();

// Dismiss the keyboard before opening the menu so the menu doesn't
Expand Down Expand Up @@ -202,12 +213,33 @@ function ThreeDotsMenu({
</View>
<PopoverMenu
onClose={hidePopoverMenu}
onModalHide={() => setRestoreFocusType(undefined)}
onModalHide={() => {
setRestoreFocusType(undefined);
if (!shouldRestoreAnchorOnHideRef.current) {
return;
}
shouldRestoreAnchorOnHideRef.current = false;
// ComposerFocusManager often has a null saved input because we blur before open;
// put focus back on the anchor before shouldCallAfterModalHide runs onSelected.
const anchor = resolvePopoverLauncherElement(buttonRef);
if (anchor) {
restoreFocusWithModality(anchor);
}
}}
isVisible={isPopupMenuVisible && !isBehindModal && isContainerFocused}
anchorPosition={position ?? anchorPosition ?? {horizontal: 0, vertical: 0}}
anchorAlignment={anchorAlignment}
onItemSelected={(item) => {
setRestoreFocusType(CONST.MODAL.RESTORE_FOCUS_TYPE.PRESERVE);
// Match PopoverMenu: Safari runs shouldCallAfterModalHide immediately (no defer),
// so do not arm post-hide restore — that would refocus the anchor behind the destination.
const willDeferSelection = !!item.shouldCallAfterModalHide && !isSafari();
if (willDeferSelection) {
// Let the anchor regain focus before the deferred action (nav / confirm modal).
shouldRestoreAnchorOnHideRef.current = true;
} else {
// Immediate selection (incl. Safari) — skip flashing focus back onto the 3-dot button.
setRestoreFocusType(CONST.MODAL.RESTORE_FOCUS_TYPE.PRESERVE);
}
hidePopoverMenu(item);
}}
menuItems={menuItems}
Expand Down
35 changes: 31 additions & 4 deletions src/libs/LauncherStack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
* Stack of popover/modal launcher elements — the element that opened a focus trap. Top is the most recent.
* pickLauncher prefers the topmost active entry, else the most recent deactivated-within-LAUNCHER_CLEAR_DELAY_MS.
*/
import type {RefObject} from 'react';
import type {View} from 'react-native';

import {LAUNCHER_CLEAR_DELAY_MS, LAUNCHER_STACK_MAX} from './focusReturnTimings';

// deactivatedAt is set on trap close; entry lives LAUNCHER_CLEAR_DELAY_MS so deferred-nav popovers can still consume it.
Expand All @@ -11,13 +14,24 @@ type LauncherEntry = {element: HTMLElement; deactivatedAt?: number};
const launcherStack: LauncherEntry[] = [];
let hasWarnedAboutOverflow = false;

/** Resolve a RN View ref to its web host node for LauncherStack registration. No-op on native. */
function resolvePopoverLauncherElement(ref: RefObject<View | null> | null | undefined): HTMLElement | null {
if (typeof document === 'undefined' || !ref?.current) {
return null;
}
// On web, RN View refs are DOM nodes; instanceof avoids an unsafe cast.
const node = ref.current;
if (!(node instanceof HTMLElement) || !document.contains(node)) {
return null;
}
return node;
}

// Two passes so nested traps resolve to the outer (active) launcher, not the just-closed inner.
function pickLauncher(): HTMLElement | null {
function pickActiveLauncher(): HTMLElement | null {
if (typeof document === 'undefined') {
return null;
}
// Monotonic — Date.now() would misbehave on clock jumps.
const now = performance.now();
for (let i = launcherStack.length - 1; i >= 0; i -= 1) {
const entry = launcherStack.at(i);
if (!entry) {
Expand All @@ -31,6 +45,19 @@ function pickLauncher(): HTMLElement | null {
return entry.element;
}
}
return null;
}

function pickLauncher(): HTMLElement | null {
if (typeof document === 'undefined') {
return null;
}
const active = pickActiveLauncher();
if (active) {
return active;
}
// Monotonic — Date.now() would misbehave on clock jumps.
const now = performance.now();
for (let i = launcherStack.length - 1; i >= 0; i -= 1) {
const entry = launcherStack.at(i);
if (entry?.deactivatedAt === undefined) {
Expand Down Expand Up @@ -97,4 +124,4 @@ function resetLauncherStackForTests(): void {
hasWarnedAboutOverflow = false;
}

export {pickLauncher, consumeLauncher, setActivePopoverLauncher, markActivePopoverLauncherDeactivated, resetLauncherStackForTests};
export {pickLauncher, pickActiveLauncher, consumeLauncher, setActivePopoverLauncher, markActivePopoverLauncherDeactivated, resetLauncherStackForTests, resolvePopoverLauncherElement};
16 changes: 16 additions & 0 deletions src/libs/resolveFocusTrapLauncher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* Chooses which element FocusTrapForModal should register as the launcher on activate.
* Pure so nested-trap vs new-modal cases can be unit-tested without the focus-trap harness.
*/
function resolveFocusTrapLauncher(fromActive: HTMLElement | null, activeStacked: HTMLElement | null, container: HTMLElement | null, fallback: HTMLElement | null): HTMLElement | null {
// Parent trap already moved focus into this container (PopoverMenu Modal → content).
// Keep the registered outside opener — do not capture the ephemeral in-menu node.
// A newly opened nested modal still uses fromActive when the opener is outside this container.
const focusAlreadyInsideTrap = !!fromActive && !!container?.contains(fromActive);
if (focusAlreadyInsideTrap && activeStacked && fromActive !== activeStacked) {
return activeStacked;
}
return fromActive ?? fallback;
}

export default resolveFocusTrapLauncher;
4 changes: 3 additions & 1 deletion src/pages/workspace/WorkspaceNamePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
import TextInput from '@components/TextInput';

import useAutoFocusInput from '@hooks/useAutoFocusInput';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';

Expand All @@ -30,6 +31,7 @@ type Props = WithPolicyProps;
function WorkspaceNamePage({policy}: Props) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const {inputCallbackRef} = useAutoFocusInput();

const submit = useCallback(
(values: FormOnyxValues<typeof ONYXKEYS.FORMS.WORKSPACE_SETTINGS_FORM>) => {
Expand Down Expand Up @@ -97,7 +99,7 @@ function WorkspaceNamePage({policy}: Props) {
accessibilityLabel={translate('workspace.common.workspaceName')}
defaultValue={policy?.name}
spellCheck={false}
autoFocus
ref={inputCallbackRef}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This doesn't fix the root cause.
I think we should hold this PR for #97306 as immediate auto focused pages like this will be fixed there.

/>
</View>
</FormProvider>
Expand Down
Loading
Loading