('[role="main"], main')
+ )?.focus();
+ return;
+ }
+ if (!opener.isConnected) {
return;
}
opener.focus();
@@ -497,6 +526,12 @@ export function MobileNav({
const t = useComponentTranslator();
// Read from AppShell context as fallback
const appShellMobile = useAppShellMobile();
+ // Read when the drawer closes, from a timer or a teardown.
+ const shellFocus = use(AppShellDrawerFocusContext);
+ const shellFocusRef = useRef(shellFocus);
+ useIsomorphicLayoutEffect(() => {
+ shellFocusRef.current = shellFocus;
+ });
const isOpen = isOpenProp ?? appShellMobile.isMobileNavOpen;
// Share the id from AppShell context so the toggle's aria-controls resolves to
// this drawer; fall back to a locally generated id when used standalone.
@@ -611,7 +646,7 @@ export function MobileNav({
releaseHoldRef.current?.();
releaseHoldRef.current = null;
closeTimeoutRef.current = setTimeout(() => {
- closeAndRestoreFocus(dialog, triggerElementRef);
+ closeAndRestoreFocus(dialog, triggerElementRef, shellFocusRef.current);
setIsCloseHeld(false);
}, resolveCloseDelay(dialog));
} else {
@@ -651,7 +686,7 @@ export function MobileNav({
releaseHoldRef.current?.();
releaseHoldRef.current = null;
if (dialog?.open) {
- closeAndRestoreFocus(dialog, triggerElementRef);
+ closeAndRestoreFocus(dialog, triggerElementRef, shellFocusRef.current);
}
};
}, []);
diff --git a/packages/core/src/MobileNav/MobileNavFocus.test.tsx b/packages/core/src/MobileNav/MobileNavFocus.test.tsx
index 109ff325790d2..b95b5e4dd7e65 100644
--- a/packages/core/src/MobileNav/MobileNavFocus.test.tsx
+++ b/packages/core/src/MobileNav/MobileNavFocus.test.tsx
@@ -185,6 +185,38 @@ function DetachHarness() {
);
}
+/** Hides the opener (an inert navigation region) as the drawer closes. */
+function HiddenOpenerHarness() {
+ const [isOpen, setIsOpen] = useState(false);
+ const [isHidden, setIsHidden] = useState(false);
+ return (
+ <>
+
+ setIsOpen(true)}>
+ Open nav
+
+
+
+ {
+ setIsHidden(true);
+ setIsOpen(false);
+ }}>
+ Hide
+
+
+
+ Content
+
+ >
+ );
+}
+
/** Opens the drawer without ever focusing the button that opens it. */
function BodyHarness() {
const [isOpen, setIsOpen] = useState(false);
@@ -522,6 +554,21 @@ describe('MobileNav focus restoration', () => {
}
});
+ it('moves focus to main when the opener is connected but hidden', () => {
+ vi.useFakeTimers();
+ try {
+ render( );
+ openFromTrigger();
+ fireEvent.click(screen.getByTestId('hide'));
+ act(() => {
+ vi.advanceTimersByTime(400);
+ });
+ expect(screen.getByTestId('main')).toHaveFocus();
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
it('leaves focus alone when nothing was focused before opening', () => {
vi.useFakeTimers();
try {
diff --git a/packages/core/src/TopNav/TopNav.tsx b/packages/core/src/TopNav/TopNav.tsx
index b1b8754833ffe..8cda15cb12002 100644
--- a/packages/core/src/TopNav/TopNav.tsx
+++ b/packages/core/src/TopNav/TopNav.tsx
@@ -16,7 +16,12 @@
* - /packages/cli/assets/templates/blocks/components/TopNav/ (showcase blocks)
*/
-import type {ReactNode} from 'react';
+import {isValidElement, use, useMemo, type ReactNode} from 'react';
+import {
+ AppShellDrawerFocusContext,
+ type AppShellDrawerFocus,
+} from '../AppShell/appShellDrawerFocus';
+import {TopNavShellContext} from './TopNavShellContext';
import type {BaseProps} from '../BaseProps';
import * as stylex from '@stylexjs/stylex';
import {spacingVars} from '../theme/tokens.stylex';
@@ -121,12 +126,29 @@ const styles = stylex.create({
flexDirection: 'column',
gap: spacingVars['--spacing-0-5'],
},
+ // The items of a hidden top nav stay mounted in the drawer, keeping state.
+ drawerItemsHidden: {
+ display: 'none',
+ },
drawerDivider: {
marginBlock: spacingVars['--spacing-2'],
},
drawerExtraContent: {},
});
+/**
+ * Whether a top nav element has items of its own to put in the phone drawer,
+ * read from its props during render. Internal: AppShell reads it to tell an
+ * empty drawer. A slot that is not a TopNav element counts as having items.
+ */
+export function topNavHasDrawerItems(topNav: ReactNode): boolean {
+ if (!isValidElement(topNav) || topNav.type !== TopNav) {
+ return true;
+ }
+ const {startContent, children, centerContent} = topNav.props;
+ return isRenderable(startContent ?? children) || isRenderable(centerContent);
+}
+
export interface TopNavProps extends BaseProps {
/** Ref forwarded to the root element */
ref?: React.Ref;
@@ -211,8 +233,27 @@ export function TopNav({
const hasStartContent = isRenderable(resolvedStartContent);
const hasMobileContent = isRenderable(mobileContent);
const hasCollapsibleContent = hasStartContent || hasCenterContent;
- // Show mobile toggle when there's ANY drawer content — own items OR SideNav via context
- const hasMobileDrawerContent = hasCollapsibleContent || hasMobileContent;
+ const shell = use(TopNavShellContext);
+ const isOwnContentHidden = shell?.isOwnContentHidden === true;
+ // Show the mobile toggle only for drawer content still visible: its own
+ // items, or the SideNav passed in through context, unless hidden.
+ const hasMobileDrawerContent =
+ (hasCollapsibleContent && !isOwnContentHidden) ||
+ (hasMobileContent && shell?.isMobileContentHidden !== true);
+ // A drawer with nothing visible closes, and its menu button is gone, so
+ // focus goes to main.
+ const drawerFocus = use(AppShellDrawerFocusContext);
+ const ownDrawerFocus = useMemo(
+ () =>
+ drawerFocus == null
+ ? null
+ : {
+ main: drawerFocus.main,
+ isDrawerEmpty: () =>
+ !hasMobileDrawerContent || drawerFocus.isDrawerEmpty(),
+ },
+ [drawerFocus, hasMobileDrawerContent],
+ );
// =========================================================================
// Mobile bar mode — heading + endContent + toggle, hide nav items.
@@ -254,22 +295,34 @@ export function TopNav({
}
return (
-
- {hasCollapsibleContent && (
-
- {resolvedStartContent}
- {centerContent}
-
- )}
- {hasCollapsibleContent && hasMobileContent && (
-
- )}
- {hasMobileContent && (
-
- {mobileContent}
-
- )}
-
+
+
+ {hasCollapsibleContent && (
+
+ {resolvedStartContent}
+ {centerContent}
+
+ )}
+ {hasCollapsibleContent &&
+ hasMobileContent &&
+ !isOwnContentHidden &&
+ shell?.isMobileContentHidden !== true && (
+
+ )}
+ {hasMobileContent && (
+
+ {mobileContent}
+
+ )}
+
+
);
}
diff --git a/packages/core/src/TopNav/TopNavShellContext.ts b/packages/core/src/TopNav/TopNavShellContext.ts
new file mode 100644
index 0000000000000..07f367d36ea78
--- /dev/null
+++ b/packages/core/src/TopNav/TopNavShellContext.ts
@@ -0,0 +1,24 @@
+// Copyright (c) Meta Platforms, Inc. and affiliates.
+
+'use client';
+
+/**
+ * @file TopNavShellContext.ts
+ * @input React createContext
+ * @output Exports `TopNavShellContext`: what AppShell tells its TopNav about
+ * hidden regions
+ * @position Internal to Core, not exported from the TopNav entry. With a
+ * hidden region, the phone drawer keeps that region mounted but hidden.
+ */
+
+import {createContext} from 'react';
+
+export interface TopNavShell {
+ /** The top nav is hidden: its own items stay mounted but hidden. */
+ readonly isOwnContentHidden: boolean;
+ /** The side nav content the drawer also holds is hidden. */
+ readonly isMobileContentHidden: boolean;
+}
+
+export const TopNavShellContext = createContext(null);
+TopNavShellContext.displayName = 'TopNavShellContext';