import { createContext, useCallback, useContext, useEffect, useMemo, useRef, type ComponentProps, type ReactElement, type ReactNode, } from "react"; import { useTranslation } from "react-i18next"; import { Pressable, Text, View } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { BottomSheetBackdrop, BottomSheetScrollView } from "@gorhom/bottom-sheet"; import { ChevronLeft } from "lucide-react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { IsolatedBottomSheetModal, useIsolatedBottomSheetVisibility, type ContextBridge, } from "@/components/ui/isolated-bottom-sheet-modal"; import { SPACING, type Theme } from "@/styles/theme"; import { useMenuContext, MenuContextProvider } from "./menu-context"; import { MenuPage } from "./menu-item"; import { currentPageId, isSubPageOpen } from "./menu-navigation"; import { AnchoredSurface, MenuOverlay } from "./menu-overlay"; import { getMenuSheetBottomPadding } from "./menu-sheet-layout"; import type { Alignment, Placement } from "./menu-anchor"; import type { KeyboardFocusScope } from "@/keyboard/actions"; const ThemedChevronLeft = withUnistyles(ChevronLeft); const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); // `backgroundStyle` and `handleIndicatorStyle` are style-shaped props the Babel plugin does not // track, so the sheet is wrapped rather than reading the theme through a hook. // See docs/unistyles.md. const ThemedBottomSheetModal = withUnistyles(IsolatedBottomSheetModal, (theme) => ({ backgroundStyle: { borderTopLeftRadius: theme.borderRadius.xl, borderTopRightRadius: theme.borderRadius.xl, borderWidth: 1, backgroundColor: theme.colors.surface0, borderColor: theme.colors.border, }, handleIndicatorStyle: { width: 36, height: 4, backgroundColor: theme.colors.surface2, }, })); /** How long the pointer must rest on a submenu row before its flyout opens. */ const HOVER_OPEN_DELAY_MS = 90; /** * How long a flyout survives after the pointer leaves. The pointer still crosses sibling rows on * its way down into the flyout, so leaving the trigger cannot close it immediately. */ const HOVER_CLOSE_GRACE_MS = 260; /** * How far the flyout sits *over* its parent surface rather than beside it. The overlap is what * makes the hand-off reliable: with a gap there is a strip of backdrop between the two where the * pointer belongs to neither, and every pixel of it is a chance to dismiss the thing you are * reaching for. Overlapping removes the strip instead of timing around it. */ const SUBMENU_OVERLAP = 5; export interface MenuPageDefinition { id: string; title: string; content: ReactNode; } interface MenuSurfaceContextValue { registerSubAnchor: (id: string, ref: React.RefObject) => void; hoverOpen: (sub: { id: string; depth: number }) => void; hoverClose: (depth: number) => void; cancelHoverClose: () => void; } const MenuSurfaceContext = createContext(null); export function useMenuSurface(componentName: string): MenuSurfaceContextValue { const ctx = useContext(MenuSurfaceContext); if (!ctx) { throw new Error(`${componentName} must be used within `); } return ctx; } export interface MenuSurfaceProps { /** Root page content. */ children: ReactNode; /** Sub pages, reachable from a `MenuSubTrigger` on the root page. */ pages?: readonly MenuPageDefinition[]; /** Title shown on the sheet's root page. Sheets always have a header; popovers never do. */ sheetTitle?: string; side?: Placement; align?: Alignment; offset?: number; width?: number; minWidth?: number; maxWidth?: number; maxHeight?: number; fullWidth?: boolean; horizontalPadding?: number; scrollable?: boolean; testID?: string; /** Limits ordinary app shortcuts to the time this menu owns keyboard focus. */ keyboardFocusScope?: KeyboardFocusScope; } /** * The open menu. Reads its presentation from the menu root and renders either an anchored * popover (plus one flyout per open submenu) or a bottom sheet showing one page at a time. */ export function MenuSurface(props: MenuSurfaceProps): ReactElement | null { const { presentation } = useMenuContext("MenuSurface"); if (presentation === "sheet") { return ; } return ; } function useSubAnchors(): { value: MenuSurfaceContextValue; getAnchor: (id: string) => React.RefObject | null; } { const anchors = useRef(new Map>()); const openTimer = useRef | null>(null); const closeTimer = useRef | null>(null); const { openSub, closeSub } = useMenuContext("MenuSurface"); const clearTimers = useCallback(() => { if (openTimer.current) clearTimeout(openTimer.current); if (closeTimer.current) clearTimeout(closeTimer.current); openTimer.current = null; closeTimer.current = null; }, []); useEffect(() => clearTimers, [clearTimers]); const registerSubAnchor = useCallback((id: string, ref: React.RefObject) => { anchors.current.set(id, ref); }, []); const hoverOpen = useCallback( (sub: { id: string; depth: number }) => { clearTimers(); openTimer.current = setTimeout(() => openSub(sub), HOVER_OPEN_DELAY_MS); }, [clearTimers, openSub], ); const hoverClose = useCallback( (depth: number) => { if (openTimer.current) clearTimeout(openTimer.current); openTimer.current = null; if (closeTimer.current) clearTimeout(closeTimer.current); closeTimer.current = setTimeout(() => closeSub(depth), HOVER_CLOSE_GRACE_MS); }, [closeSub], ); const cancelHoverClose = useCallback(() => { if (closeTimer.current) clearTimeout(closeTimer.current); closeTimer.current = null; }, []); const value = useMemo( () => ({ registerSubAnchor, hoverOpen, hoverClose, cancelHoverClose }), [registerSubAnchor, hoverOpen, hoverClose, cancelHoverClose], ); const getAnchor = useCallback((id: string) => anchors.current.get(id) ?? null, []); return { value, getAnchor }; } function MenuPopoverSurface({ children, pages = [], side = "bottom", align = "start", offset = 4, width, minWidth = 180, maxWidth, maxHeight, fullWidth = false, horizontalPadding = 16, scrollable = false, testID, keyboardFocusScope, }: MenuSurfaceProps): ReactElement | null { const menu = useMenuContext("MenuSurface"); const { value: surfaceValue, getAnchor } = useSubAnchors(); const handleClose = useCallback(() => menu.setOpen(false), [menu]); // Every open page in the path gets its own flyout, so a submenu of a submenu simply stacks. const openPages = useMemo( () => menu.path .map((id, depth) => { const page = pages.find((candidate) => candidate.id === id); return page ? { page, depth } : null; }) .filter((entry): entry is { page: MenuPageDefinition; depth: number } => entry !== null), [menu.path, pages], ); return ( <> {children} {openPages.map(({ page, depth }) => ( ))} ); } /** * A submenu's own floating surface. It keeps itself alive while the pointer is inside it — * without that, walking from the row into the flyout would cross the gap between them and * trigger the close it was travelling to avoid. */ function MenuFlyout({ page, depth, anchorRef, minWidth, maxHeight, scrollable, testID, }: { page: MenuPageDefinition; depth: number; anchorRef: React.RefObject | null; minWidth?: number; maxHeight?: number; scrollable?: boolean; testID?: string; }): ReactElement | null { const menu = useMenuContext("MenuFlyout"); const { hoverClose, cancelHoverClose } = useMenuSurface("MenuFlyout"); const handleClose = useCallback(() => menu.setOpen(false), [menu]); const handleHoverOut = useCallback(() => hoverClose(depth), [hoverClose, depth]); if (!anchorRef) return null; return ( {page.content} ); } function MenuSheetSurface({ children, pages = [], sheetTitle, testID, keyboardFocusScope, }: MenuSurfaceProps): ReactElement | null { const menu = useMenuContext("MenuSurface"); const { value: surfaceValue } = useSubAnchors(); const safeAreaInsets = useSafeAreaInsets(); const sheetScrollContentStyle = useMemo( () => ({ paddingBottom: getMenuSheetBottomPadding({ safeAreaBottom: safeAreaInsets.bottom, breathingRoom: SPACING[1], }), }), [safeAreaInsets.bottom], ); const sheetDataSet = useMemo( () => (keyboardFocusScope ? { keyboardScope: keyboardFocusScope } : undefined), [keyboardFocusScope], ); const handleClose = useCallback(() => menu.setOpen(false), [menu]); const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({ visible: menu.open, isEnabled: true, onClose: handleClose, }); const renderBackdrop = useCallback( (backdropProps: ComponentProps) => ( ), [], ); const openPageId = currentPageId(menu.path); const openPage = openPageId ? pages.find((page) => page.id === openPageId) : null; const depth = menu.path.length; // The sheet's content is teleported out of this subtree, so both menu contexts have to be // rebuilt on the other side. Providing them around the modal instead would put them on the // wrong side of the portal and every item inside would throw. See `ContextBridge`. const contextBridge = useCallback( (content) => ( {content} ), [menu, surfaceValue], ); return ( {openPage ? ( <> {openPage.content} ) : ( <> {sheetTitle ? : null} {children} )} ); } function MenuSheetHeader({ title, onBack, }: { title: string; onBack: (() => void) | null; }): ReactElement { const { t } = useTranslation(); return ( {onBack ? ( ) : null} {title} ); } const styles = StyleSheet.create((theme) => ({ sheetHeader: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], paddingHorizontal: theme.spacing[3], paddingTop: theme.spacing[1], paddingBottom: theme.spacing[3], }, sheetBackButton: { width: 24, height: 24, alignItems: "center", justifyContent: "center", }, sheetTitle: { fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.medium, color: theme.colors.foreground, flexShrink: 1, }, })); export { isSubPageOpen };