// Copyright (c) Meta Platforms, Inc. and affiliates. "use client"; /** * @file MobileNav.tsx * @input Uses React, useEffect, useRef, useCallback, ReactNode, StyleX * @output Exports MobileNav component and MobileNavProps * @position Core implementation; consumed by index.ts * * Full-height slide-out drawer overlay for mobile navigation. * The mobile counterpart to SideNav — accepts the same children * (SideNavSection, SideNavItem, or any ReactNode). * * Uses the native `` element with `showModal()` for top-layer rendering. * This eliminates z-index stacking issues — the drawer renders above everything * without manual z-index management. The browser provides: * - Top layer promotion (no z-index needed) * - `::backdrop` pseudo-element * - Body scroll lock * - Focus trapping * * Escape is owned by the shared dismissal stack (`useLayerDismissal`), so a * drawer opened over another layer takes the press and nothing behind it * closes. The native `cancel` event still handles the dismissals the browser * starts itself, such as the Android back gesture. * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/MobileNav/index.ts (exports if types change) * - /packages/core/src/Layer/useLayerDismissal.ts (dismissal stack) * - /packages/core/src/hooks/scrollbarGutter.ts (shared scroll-lock gutter) * - /packages/cli/assets/templates/blocks/components/MobileNav/ (showcase blocks) */ import { useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode, type CSSProperties, } from "react"; import * as stylex from "../style-runtime.js"; import "../theme/tokens.stylex.js"; import { borderVars, colorVars, durationVars, easeVars, spacingVars, } from "../theme/tokens.stylex.js"; import { Button } from "../../button.js"; import { NavigationToggleIcon } from "./NavigationToggleIcon.js"; import { Heading } from "../Heading/Heading.js"; import { useAppShellMobile } from "../AppShell/AppShellMobileContext.js"; import { holdScrollbarGutter, type ScrollbarGutterHold, } from "../hooks/scrollbarGutter.js"; import { mergeProps, composeEventHandlers } from "../utils/index.js"; import { overlayPaddingReset } from "../Layout/padding.stylex.js"; import { LayerDepthProvider } from "../Layer/LayerDepthContext.js"; import { useLayerDismissal } from "../Layer/useLayerDismissal.js"; import type { BaseProps } from "../BaseProps.js"; import { themeProps } from "../utils/themeProps.js"; import { useTranslator } from "../i18n/index.js"; import { useMergedRefs } from "../hooks/useMergedRefs.js"; // ============================================================================= // Styles // ============================================================================= const styles = { dialog: { kVAEAm: "xixxii4", kogj98: "x1ghz6dp", kmVPX3: "x1717udv", ks0D6T: "x1x1rfll", kskxy: "x7ab17h", kpwlN0: "x10a8y8t", kzqmXN: "xn9wirt", kZKoxP: "xtdtrs8", kWkggS: "xjbqb8w", kVQacm: "x7giv3", kZeWKH: "xish69e", kFalU9: "x5ve5x3", kI3sdo: "x1a2a7pz", k1xSpc: "x1s85apg", k1ekBW: "xrgi2yo", kIyJzY: "x80gvsz", kzIqYQ: "xd00j3c", $$css: true as const, }, open: { k1xSpc: "x78zum5", $$css: true as const, }, backdrop: { kGyWv1: "xnixb3f", kba3nw: "x1abwkk1", k5sjJv: "xph5o2a", kND0Po: "x167zut7", k9an0g: "xft5bk6", kb4ib: "x15h3t91", kA5Tbj: "x1viac0w", $$css: true as const, }, backdropOpen: { k5sjJv: "xb3n6bw xxiuuzi", $$css: true as const, }, drawer: { kVAEAm: "x10l6tqk", k87sOh: "x13vifvy", krVfgx: "x1ey2m1c", k1xSpc: "x78zum5", kXwgrk: "xdt5ytf", kWkggS: "x10xzikg", kB7OPa: "x9f619", kVQacm: "xb3r6kr", k1ekBW: "x11xpdln", kIyJzY: "x80gvsz", kAMwcw: "xlr8y92", kI3sdo: "x1a2a7pz", k6CgDc: "xzg1mie", $$css: true as const, }, drawerStart: { kLqNvP: "x1o0tod", ke9TFa: "xw8tdv1", k8ry5P: "x18b5jzi", kBCPoo: "x1gejf6u", k3aq6I: "x5i6ehr xttggg", $$css: true as const, }, drawerStartOpen: { k3aq6I: "xbryuvx x6mt36l x14gflnl", $$css: true as const, }, drawerEnd: { kt4wiu: "xtijo5x", k2ei4v: "xgbv0en", kVhnKS: "x1t7ytsu", kGJrpR: "x1j92z86", k3aq6I: "xumwmo6 x1df3fe5", $$css: true as const, }, drawerEndOpen: { k3aq6I: "xbryuvx x1yqmsfc x1lymnkk", $$css: true as const, }, headerText: { keTefX: "x11g1kdw", $$css: true as const, }, }; const _temp = { kzqmXN: "xn9wirt", ks0D6T: "xf68679", $$css: true, }; const dynamicStyles = { width: (w: number) => [ _temp, { "--x-maxWidth": ((val) => typeof val === "number" ? val + "px" : val != null ? val : undefined)( `${w}px`, ), }, ], }; // ============================================================================= // Close timing // ============================================================================= /** @internal Retained for compatibility with the upstream timing utility. */ export function parseShortestDurationMs(value: string): number | null { const durations = value .split(",") .map((part) => { const trimmed = part.trim(); const value = Number.parseFloat(trimmed); return Number.isFinite(value) && trimmed.endsWith("ms") ? value : Number.isFinite(value) && trimmed.endsWith("s") ? value * 1000 : null; }) .filter((value): value is number => value !== null); return durations.length ? Math.min(...durations) : null; } function resolveCloseDelay(dialog: HTMLDialogElement): number { if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return 0; const panel = dialog.querySelector("[data-nav-panel]"); const duration = panel ? parseShortestDurationMs(getComputedStyle(panel).transitionDuration) : null; return (duration ?? 260) + 40; } // ============================================================================= // Types // ============================================================================= export interface MobileNavProps extends Omit { /** Ref forwarded to the root element */ ref?: React.Ref; /** * Whether the drawer is open. * Inside AppShell, this is managed automatically via context. * Outside AppShell, provide this prop to control the drawer yourself. */ isOpen?: boolean; /** * Callback fired when the drawer visibility changes. * Called with `false` when the drawer should close * (backdrop click, escape, close button). * Inside AppShell, this is managed automatically via context. * Outside AppShell, provide this prop to control the drawer yourself. */ onOpenChange?: (isOpen: boolean) => void; /** * Drawer content — typically SideNavSection/SideNavItem, or any ReactNode. */ children: ReactNode; /** * Header content for the drawer. Rendered next to the close button. * Pass a string for a simple text heading, or a ReactNode for * custom content (logo, SideNavHeading, search bar, etc.). */ header?: ReactNode; /** * Width of the drawer in pixels. * @default 320 */ width?: number; /** * Which side the drawer slides from. * - `'start'` — slides from the inline-start edge (left in LTR) * - `'end'` — slides from the inline-end edge (right in LTR) * - `'auto'` — determined by the trigger element's position: if the * toggle is in the start half of the viewport the drawer opens from * start, otherwise from end. * @default 'auto' */ side?: "start" | "end" | "auto"; /** * Accessible label for the drawer. Falls back to header string, then 'Navigation'. */ label?: string; /** * Test ID for the root element. */ "data-testid"?: string; } // ============================================================================= // Component // ============================================================================= /** * A slide-out drawer overlay for mobile navigation. * * The mobile counterpart to SideNav. Renders a full-height drawer that slides * in from the start (left in LTR) or end (right in LTR) edge of the viewport, * with a semi-transparent backdrop behind it. * * Uses the native `` element with `showModal()` for top-layer rendering, * which provides built-in focus trapping, body scroll lock, and `::backdrop`. * No manual z-index needed — the browser's top layer handles stacking. * * When used inside AppShell, `isOpen` and `onOpenChange` are managed * automatically via context. When used standalone, provide them as props. * * @example * ``` * * * * }> * * * * ``` */ export function MobileNav({ isOpen: isOpenProp, onOpenChange: onOpenChangeProp, children, header, width = 320, side = "auto", label, "data-testid": testId, xstyle, className, style, onClick: onClickProp, ref, ...rest }: MobileNavProps) { const t = useTranslator(); // Read from AppShell context as fallback const appShellMobile = useAppShellMobile(); 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. const fallbackId = useId(); const dialogId = appShellMobile.mobileNavId || fallbackId; const onOpenChange = useMemo( () => onOpenChangeProp ?? ((open: boolean) => { if (open) { appShellMobile.openMobileNav(); } else { appShellMobile.closeMobileNav(); } }), [onOpenChangeProp, appShellMobile], ); const dialogRef = useRef(null); const closeTimeoutRef = useRef>(null); const gutterRef = useRef(null); const [triggerPosition, setTriggerPosition] = useState({ position: "fixed", insetInlineEnd: 16, top: 16, zIndex: 1, }); const [entered, setEntered] = useState(false); // Gives back the gutter held open in place of the hidden scrollbar. const releaseGutter = useCallback(() => { if (gutterRef.current) { gutterRef.current.release(); gutterRef.current = null; } }, []); // Resolved side — computed from trigger position when side='auto' const [resolvedSide, setResolvedSide] = useState<"start" | "end">( side === "auto" ? "end" : side, ); // Capture the trigger before showModal() moves focus. The close control sits // outside the sliding panel, at exactly the same screen coordinates. useEffect(() => { if (!isOpen) { return; } { const trigger = document.activeElement as HTMLElement | null; if (trigger && trigger !== document.body) { const rect = trigger.getBoundingClientRect(); const triggerCenter = rect.left + rect.width / 2; // eslint-disable-next-line @eslint-react/set-state-in-effect -- side is resolved from trigger layout immediately before showModal() if (side === "auto") setResolvedSide( triggerCenter < window.innerWidth / 2 ? "start" : "end", ); setTriggerPosition({ position: "fixed", left: rect.left, top: rect.top, width: rect.width, height: rect.height, zIndex: 1, }); } } if (side !== "auto") { // eslint-disable-next-line @eslint-react/set-state-in-effect -- side prop changes must update the open dialog placement setResolvedSide(side); } }, [isOpen, side]); // Open/close the dialog via showModal()/close() // close() is delayed so the slide-out transition can play. useEffect(() => { const dialog = dialogRef.current; if (!dialog) { return; } let animationFrame = 0; if (isOpen) { // Taken first: every mutation below is one that can hide the scrollbar, // and the gutter has to be measured while it is still there. gutterRef.current ??= holdScrollbarGutter(document.documentElement); if (!dialog.open) { dialog.showModal(); } animationFrame = requestAnimationFrame(() => setEntered(true)); // Prevent background scrolling and iOS pull-to-refresh. // overflow: clip avoids creating a scroll container (unlike hidden), // so there's no scroll bounce and no need to save/restore scroll position. document.documentElement.style.overflow = "clip"; gutterRef.current.settle(); } else if (dialog.open) { setEntered(false); closeTimeoutRef.current = setTimeout(() => { dialog.close(); document.documentElement.style.overflow = ""; releaseGutter(); }, resolveCloseDelay(dialog)); } return () => { cancelAnimationFrame(animationFrame); if (closeTimeoutRef.current) { clearTimeout(closeTimeoutRef.current); closeTimeoutRef.current = null; } document.documentElement.style.overflow = ""; releaseGutter(); }; }, [isOpen, releaseGutter]); // Unmounting at a responsive breakpoint must release the native top layer. useEffect(() => { const dialog = dialogRef.current; return () => { if (dialog?.open) { dialog.close(); } }; }, []); const { shouldDismissOnCloseRequest } = useLayerDismissal({ isActive: isOpen, onDismiss: () => onOpenChange(false), }); // The native `cancel` event is the browser's own close-watcher firing: an // Android back gesture, or a close request the stack never saw a press for. // Escape presses the stack owns never arrive here — it preventDefault()s // those, which suppresses the close watcher. // // Always preventDefault so the browser cannot close a controlled // behind React's back, then answer with the stack's own rules: top-most // only, and never while an IME composition is in progress. const handleCancel = useCallback( (event: React.SyntheticEvent) => { event.preventDefault(); if (!shouldDismissOnCloseRequest()) { return; } onOpenChange(false); }, [onOpenChange, shouldDismissOnCloseRequest], ); // Handle clicks on the dialog backdrop area (outside the drawer) const handleDialogClick = useCallback( (event: React.MouseEvent) => { // Only close if click was directly on the dialog element (the transparent overlay), // not on the drawer or its children if (event.target === event.currentTarget) { onOpenChange(false); } }, [onOpenChange], ); const isStart = resolvedSide === "start"; return ( ); } MobileNav.displayName = "MobileNav";