/** * ContextMenu — the actions that belong to a piece of content, opened on it. * * A `Menu` hangs off a control that exists to be pressed: a ⋯ button, a toolbar * item, something whose whole job is to open the menu. A context menu has no * such control. The target is the content itself — a message, a note, a photo, * a row — and the actions are reached by holding it, by a named accessibility * action, or from the keyboard. * * ```tsx * * * Would you like an interactive todo list? * * * }>Share * }>Copy * * }> * Report * * * * ``` * * ## It is a Menu, and deliberately so * * The rows here *are* `Menu`'s rows — the same components, not a second set * styled to match. `ContextMenu.Item` and `Menu.Item` are one implementation, * so the destructive colour, the press-in scale, the indicator column and the * dismiss-on-select rule cannot drift apart between the two ways of reaching * them. The panel is `Menu`'s panel, which is `Popover`'s, so `presentation`, * submenus and edge-flipping all arrive already working. * * What this component owns is what a menu opened on content needs and a menu * opened from a button does not: alternate invocation paths, and where the * panel goes. * * ## Anchored to the finger, not to the target * * A toolbar menu is placed against its trigger, because the trigger is small * and its position is the only sensible answer. A context menu's target is * often most of the screen — a whole message, a whole card — and the middle of * it is not where the finger was. So the anchor is the press point by default, * and the panel unfolds from it the way a popover unfolds from a button. * * `anchor="target"` places it against the target's bounds instead, which is the * better answer for something small and list-shaped, where the panel lining up * with the row reads as belonging to it. * * ## Why the gesture is not a Pressable * * The target usually has a press of its own — open the thread, play the video, * follow the link — and the two must not both fire. React Native's `Pressable` * decides between them after the fact, and the tap can still get through on the * way to a long press; the recogniser here is asked for the arbitration up * front instead, so a hold that opens the menu never also counts as a press. * * It is also what lets the target be anything at all. A cloned `onLongPress` * needs a child that takes one, which rules out exactly the plain views — * bubbles, cards, images — that content-native actions are usually attached to. */ import { Children, cloneElement, createContext, isValidElement, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactElement, type ReactNode, } from 'react'; import { View, type AccessibilityActionInfo, type ViewProps, } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { FadeOut, runOnJS, useAnimatedStyle, useReducedMotion, useSharedValue, withSpring, } from 'react-native-reanimated'; import { useCSSVariable } from 'uniwind'; import { Portal } from '../../primitives/portal'; import { Menu, type MenuCheckboxItemProps, type MenuContentProps, type MenuItemProps, type MenuProps, } from '../menu'; import { usePopoverAnchor } from '../popover'; import { cn } from '../../utils/cn'; import { selectionTick } from '../../utils/haptics'; import { contextMenuAccessibilityInvocation, contextMenuKeyInvocation, type ContextMenuInvocation, type ContextMenuKeyDownEvent, } from './context-menu-invocation'; /** * How long the target is held before the menu opens. * * Long enough not to fire while a finger is on its way to a scroll, short * enough that nobody wonders whether the hold is working. The platforms sit * either side of this; it is the value the rest of this library holds at. */ const DEFAULT_DELAY = 350; /** * How far the finger may travel during the hold before it stops counting. * * Generous rather than tight, and the reason is what the target usually sits * in: a scroller. A threshold small enough to feel precise cancels the menu for * anyone whose thumb drifts while holding still, and the gesture it is being * told apart from — a scroll — has moved a great deal further than this by the * time it matters. */ const DEFAULT_SLOP = 12; /** * Extra height on every row. * * A context menu is opened by a hold and read with the hand still over it, at * whatever angle the phone happened to be held at. A menu dropped from a button * is aimed at deliberately; this one is landed on. The rows are taller than * `Menu`'s for that reason alone, and it is the only measurement that differs. */ const ROW_CLASS = 'py-4 ps-4 pe-3.5'; /** * Floor for the panel's width. * * A context menu has no trigger to take a width from, and a column of one-word * verbs left to size itself lands somewhere around a thumb's width — too narrow * to aim at, and too narrow to read as a panel belonging to the whole piece of * content it was opened on. */ const DEFAULT_MIN_WIDTH = 280; /** * The colour an icon falls back to before the stylesheet has been read. * * A general-purpose icon set defaults an unset colour to `currentColor`, which * React Native cannot resolve and refuses to paint. A neutral mid grey is * legible on either a light or a dark panel for the frame or two it lasts. */ const ICON_FALLBACK = '#737373'; function useTint(variable: string, fallback: string): string { const raw = useCSSVariable(variable); return typeof raw === 'string' ? raw : fallback; } /** * Paints a row's glyph to match its label, without the caller saying so twice. * * The icons on these rows come from whatever set the app already uses, and a * general-purpose one has no idea what an overlay's foreground is — left alone * it paints `currentColor`, which React Native will not draw at all. Setting it * here means a destructive row's icon turns red along with its label, which is * the one place the two disagreeing would matter. * * An explicit colour on the element still wins: a brand mark that carries its * own colours is not something to overrule. */ function useGlyph(variant: 'default' | 'destructive' | undefined) { const foreground = useTint('--color-overlay-foreground', ICON_FALLBACK); const destructive = useTint('--color-destructive', ICON_FALLBACK); const tint = variant === 'destructive' ? destructive : foreground; return useCallback( (icon: ReactNode): ReactNode => { if (!isValidElement(icon)) return icon; const element = icon as ReactElement<{ color?: string }>; if (element.props.color !== undefined) return element; return cloneElement(element, { color: tint }); }, [tint] ); } /** The target's frame in window coordinates, taken as the menu opens. */ interface TargetRect { x: number; y: number; width: number; height: number; } interface ContextMenuContextValue { /** Where the target was when it was held, or `null` before anything has. */ target: TargetRect | null; setTarget: (rect: TargetRect | null) => void; /** The trigger's children, so the preview can draw the same thing again. */ content: ReactNode; setContent: (node: ReactNode) => void; /** Whether a `ContextMenu.Preview` was declared inside the panel. */ hasPreview: boolean; } const ContextMenuContext = createContext(null); function useContextMenu(part: string): ContextMenuContextValue { const context = useContext(ContextMenuContext); if (!context) throw new Error(`${part} must be used inside .`); return context; } /** * Whether a `ContextMenu.Preview` appears anywhere in the declared tree. * * Sniffed from the elements rather than reported by the preview when it mounts, * because the answer is needed *before* it does: the trigger has to know at the * moment of the hold whether the panel will be sharing the screen with a lifted * copy of the target, and by the time a child of the panel exists the anchor has * already been set. */ function declaresPreview(children: ReactNode): boolean { let found = false; Children.forEach(children, (child) => { if (found || !isValidElement(child)) return; if (child.type === ContextMenuPreview) { found = true; return; } const inner = (child.props as { children?: ReactNode }).children; if (inner && declaresPreview(inner)) found = true; }); return found; } /** * The root takes exactly what `Menu`'s root takes — `open`, `onOpenChange`, * `defaultOpen`, `presentation` and `haptics` — because it *is* that root. */ export type ContextMenuProps = MenuProps; /** * The root. Provides the menu's own context and the popover underneath it. * * It renders a `Menu`, which is not a shortcut — it is the point. Everything a * menu is, this is, and the parts below are the only difference. */ function ContextMenuRoot({ children, ...props }: ContextMenuProps) { const [target, setTarget] = useState(null); const [content, setContent] = useState(null); const hasPreview = useMemo(() => declaresPreview(children), [children]); const context = useMemo( () => ({ target, setTarget, content, setContent, hasPreview }), [target, content, hasPreview] ); return ( {children} ); } /** Which rectangle the panel is placed against. */ export type ContextMenuAnchor = 'point' | 'target'; export interface ContextMenuTriggerProps extends Omit { /** * Classes on the wrapper the content sits in, which lays out like any other * view — it does not shrink to its child, because the things held are usually * meant to fill their place in the layout. It is also the rect * `anchor="target"` measures. */ className?: string; /** * The content the actions belong to. Anything at all — it is not required to * be pressable, and is not cloned or altered. */ children: ReactNode; /** * `point` anchors the panel where the finger landed, `target` against the * bounds of the whole trigger. * * Point is the default because a context menu's target is usually large, and * the middle of a whole message is not where the press was. Reach for * `target` when the target is small and list-shaped and the panel should read * as lining up with it. Keyboard and accessibility opens always use the target * bounds, because those modalities have no pointer coordinate. */ anchor?: ContextMenuAnchor; /** How long the hold has to last, in milliseconds. 350 by default. */ delay?: number; /** * How far the finger may move during the hold before it stops being one, in * points. 12 by default. * * Loose rather than tight, because the target is usually inside a scroller: a * threshold small enough to feel precise cancels the menu for anyone whose * thumb drifts while holding still, and a scroll has travelled much further * than this by the time the two need telling apart. Tighten it only for a * target that cannot be scrolled. */ slop?: number; /** A short press on the target, which the hold never also counts as. */ onPress?: () => void; /** * Tick the haptic engine as the menu opens. Needs the optional * `expo-haptics`, and is silent without it. * * Worth setting more often than not. A hold has no edge to it the way a press * does — nothing moves under the finger at the moment it takes — so the tick * is what tells someone the hold has been long enough, before the panel has * had time to say so. */ haptics?: boolean; /** Nothing opens the menu, and the short press stops firing too. */ disabled?: boolean; /** * Called first for keyboard events. Prevent the event to keep ContextMenu * from handling it. Context Menu and Shift+F10 open the menu; Enter and Space * mirror the trigger's accessible activation. */ onKeyDown?: (event: ContextMenuKeyDownEvent) => void; } /** * Wraps the content and opens the menu when held, through accessibility * actions, or from the keyboard. * * The wrapper is a plain view and lays out like one, stretching as a view does * rather than shrinking to its child. That is the opposite of what a tooltip's * trigger wants, and for the opposite reason: a tooltip names a control and * belongs over it, while the things held here — a bubble, a card, a row — are * usually meant to fill their place in the layout, and a wrapper that collapsed * around them would change it. * * It is also the rect measured under `anchor="target"`, which is why that * anchoring lines the panel up with the row rather than with the text in it. */ function ContextMenuTrigger({ className, children, anchor = 'point', delay = DEFAULT_DELAY, slop = DEFAULT_SLOP, onPress, haptics = false, disabled = false, accessible, accessibilityActions, onAccessibilityAction, accessibilityRole, accessibilityState, focusable, tabIndex, onKeyDown, ...props }: ContextMenuTriggerProps) { const { setOpen, anchorTo } = usePopoverAnchor('ContextMenu.Trigger'); const { setTarget, setContent, hasPreview } = useContextMenu('ContextMenu.Trigger'); const ref = useRef(null); /* * A preview overrules `anchor`, and has to. * * The panel is placed outside whatever rectangle it is given, so anchoring to * the target is what keeps it clear of the lifted copy of that target. Anchor * to the press instead and the panel opens over the very thing the preview * exists to show. */ const against: ContextMenuAnchor = hasPreview ? 'target' : anchor; /* * Both branches end up doing the same thing — set an anchor, then open — and * differ only in which rectangle they set. A point is a zero-sized rect, * which the popover places a panel against exactly as it does a trigger's * bounds; there is no separate code path for it downstream. * * The target is measured either way, because the preview draws at that rect * whatever the panel is placed against. */ const open = useCallback( (point?: { x: number; y: number }) => { // Ticked here rather than in the gesture callback so it fires once the // hold has been accepted, which is the moment there is something to // confirm — and on the same side of the bridge as the opening. if (haptics) selectionTick(); // Measured on opening rather than on layout: the target may have // scrolled since, and a stale rect anchors the panel to where it was. ref.current?.measureInWindow((mx, my, width, height) => { setTarget({ x: mx, y: my, width, height }); setContent(children); /* * The panel is placed outside the rectangle it is given, so under a * preview that rectangle has to be the target's *lifted* bounds rather * than its resting ones. The lift grows the target about its middle, * and anchoring to the smaller rect left the panel overlapping the * last few points of it — where the lifted copy, drawn over the panel, * simply hid the row underneath. */ const grown = hasPreview ? (PREVIEW_SCALE - 1) / 2 : 0; anchorTo( against === 'point' && point ? { ...point, width: 0, height: 0 } : { x: mx - width * grown, y: my - height * grown, width: width * (1 + grown * 2), height: height * (1 + grown * 2), } ); setOpen(true); }); }, [against, anchorTo, setOpen, haptics, setTarget, setContent, children, hasPreview] ); const openAt = useCallback((x: number, y: number) => open({ x, y }), [open]); // Keyboard and accessibility actions have no pointer coordinate to honour. // The measured target is the only real location they can anchor against. const openFromTarget = useCallback(() => open(), [open]); const invoke = useCallback( (invocation: ContextMenuInvocation) => { if (invocation === 'menu') openFromTarget(); else onPress?.(); }, [onPress, openFromTarget] ); const triggerActions = useMemo(() => { if (disabled) return undefined; const reserved = new Set(['activate', 'showMenu']); return [ { name: 'activate' }, { name: 'showMenu', label: 'Show menu' }, ...(accessibilityActions ?? []).filter(({ name }) => !reserved.has(name)), ]; }, [accessibilityActions, disabled]); const handleAccessibilityAction = useCallback< NonNullable >( (event) => { const actionName = event.nativeEvent.actionName; const invocation = contextMenuAccessibilityInvocation(actionName, !!onPress, disabled); if (invocation) { invoke(invocation); return; } // Reserved actions cannot bypass a disabled trigger through a consumer // handler, while unrelated custom actions still compose normally. if (actionName === 'activate' || actionName === 'showMenu') return; onAccessibilityAction?.(event); }, [disabled, invoke, onAccessibilityAction, onPress] ); const handleKeyDown = useCallback( (event: ContextMenuKeyDownEvent) => { onKeyDown?.(event); if (event.isDefaultPrevented()) return; const invocation = contextMenuKeyInvocation(event.nativeEvent, !!onPress, disabled); if (!invocation) return; event.preventDefault(); invoke(invocation); }, [disabled, invoke, onKeyDown, onPress] ); // React Native exposes key events at runtime (and in its generated types), // while its compatibility ViewProps declaration does not yet list them. const keyboardProps = { onKeyDown: handleKeyDown } as ViewProps; const gesture = useMemo(() => { /* * The hold and the tap are given to the recogniser as alternatives, so it * decides between them rather than both firing. That is the whole reason * this is a gesture and not a `Pressable`: the target below usually has a * press of its own, and a hold that opened the menu must not also count as * one. * * `absoluteX`/`absoluteY` are window coordinates, which is the space the * popover places panels in — so the press point needs no conversion. */ const hold = Gesture.LongPress() .minDuration(delay) .maxDistance(slop) .enabled(!disabled) .onStart((event) => { runOnJS(openAt)(event.absoluteX, event.absoluteY); }); const tap = Gesture.Tap() .enabled(!disabled && !!onPress) .onEnd((_event, success) => { if (success && onPress) runOnJS(onPress)(); }); return Gesture.Exclusive(hold, tap); }, [delay, slop, disabled, onPress, openAt]); return ( {/* `collapsable={false}` keeps the wrapper as a real view on Android, where a view that only groups children is otherwise flattened away — and a flattened view cannot be measured, which `anchor="target"` needs. */} {children} ); } /** How far the lifted target grows. Enough to read as off the page. */ const PREVIEW_SCALE = 1.04; /** Coming off the page. Soft, because the target is large and close to the eye. */ const PREVIEW_SPRING = { damping: 20, stiffness: 220, mass: 0.7 } as const; export interface ContextMenuPreviewProps { /** * Drawn instead of the target itself. For a target that would be wrong to * repeat — one carrying a video, a live map, a text field with a cursor in * it — or one that should show more of itself once it has the screen. * * Left out, the target is drawn again as it stands, which is what makes the * lift read as the content coming forward rather than as a picture of it * appearing. */ children?: ReactNode; /** Extra classes on the lifted copy. */ className?: string; } /** * The target, lifted off the page while its actions are up. * * Declared inside `ContextMenu.Content`, but not drawn there — it floats over * the dimmed screen at the place the target was measured, and the panel is * anchored to that same rectangle so the two never overlap. Its presence is * what switches the anchor: a panel placed at the press point would open across * the very content the preview exists to hold up. * * What it draws is the trigger's own children, rendered a second time. That * keeps the lift honest — it is the content itself coming forward, at the size * and in the place it already occupied — and it is why a target that should not * simply be repeated can pass its own `children` instead. * * It takes no touches. The actions are in the panel; the lifted content is * there to say what they are about, and a second live copy of a pressable card * would be a second place to press. */ function ContextMenuPreview({ children, className }: ContextMenuPreviewProps) { const { target, content } = useContextMenu('ContextMenu.Preview'); const lift = useSharedValue(0); const reduced = useReducedMotion(); useEffect(() => { lift.value = reduced ? 1 : withSpring(1, PREVIEW_SPRING); }, [lift, reduced]); const style = useAnimatedStyle(() => ({ opacity: lift.value, transform: [{ scale: 1 + lift.value * (PREVIEW_SCALE - 1) }], })); if (!target) return null; return ( {children ?? content} ); } ContextMenuPreview.displayName = 'ContextMenu.Preview'; export interface ContextMenuItemProps extends MenuItemProps { /** * The row's glyph, drawn at the trailing edge rather than in front of the * label. Painted to match the label unless it carries a colour of its own. */ icon?: ReactNode; } /** * One row: the verb at the leading edge, its glyph at the trailing one. * * The other way round is right for a menu dropped from a button, where the * glyphs form a column the eye runs down to find the row it wants. A context * menu is not read that way. It appears under the hand that opened it, already * over the content, and what is being scanned is the *words* — so the words * start at the edge, flush with one another, and the glyph sits at the far side * confirming the row rather than introducing it. * * It is still `Menu.Item` underneath, handed the glyph as its trailing slot. So * the press-in fill, the destructive colour and the dismiss-on-select rule are * one implementation shared with `Menu`, and only the arrangement differs. */ function ContextMenuItem({ className, icon, variant, ...props }: ContextMenuItemProps) { const glyph = useGlyph(variant); return ( ); } ContextMenuItem.displayName = 'ContextMenu.Item'; /** * A row carrying a state. Its tick stays at the leading edge, where `Menu` puts * it, because a tick is not a glyph naming the row — it is the answer to it, * and a column of them is what makes a set of choices readable as one. */ function ContextMenuCheckboxItem({ className, ...props }: MenuCheckboxItemProps) { return ; } ContextMenuCheckboxItem.displayName = 'ContextMenu.CheckboxItem'; /** * Everything `Menu.Content` takes. The four listed here are the ones whose * defaults differ, and they are listed so the difference is visible. */ export interface ContextMenuContentProps extends MenuContentProps { /** Which side of the anchor the panel opens on. Down from the press, flipping * above it near the bottom of the screen. */ placement?: MenuContentProps['placement']; /** Where it sits along the other axis. From the press, not centred on it. */ align?: MenuContentProps['align']; /** Gap between the anchor and the panel. Small, so it reads as coming out of * the press rather than floating near it. */ offset?: number; /** * Floor for the panel's width. A context menu has no trigger to take its * width from, and a column of one-word verbs is too narrow to aim at. */ minWidth?: number; /** Dim the screen behind the panel. On here, unlike a plain popover. */ scrim?: boolean; } /** * The panel, with the defaults a context menu wants rather than a popover's. * * It unfolds down and from the press rather than being centred on it — * centring is right for a panel under a button, and wrong for one at a * fingertip, where it would put half the panel back under the hand that opened * it. Near the bottom of the screen `Popover` flips it above the press and * clamps it into the safe area, so the one case where down does not work * answers itself. * * The gap to the anchor is small, so the panel reads as coming out of the press * rather than floating near it. * * The screen dims behind it, which a popover does not do. A context menu is * modal in practice — the content underneath is what the actions are *about*, * so nothing else on the screen is available while it is up, and the dim is * what says so. */ function ContextMenuContent({ placement = 'bottom', align = 'start', offset = 8, minWidth = DEFAULT_MIN_WIDTH, scrim = true, children, ...props }: ContextMenuContentProps) { // The panel is portalled, so it mounts outside this provider's subtree. // Re-provided here so `ContextMenu.Preview`, which is declared among these // rows, can still reach the rectangle the trigger measured. const context = useContextMenu('ContextMenu.Content'); return ( {children} ); } /* * Everything not listed here is Menu's, passed straight through. The three that * are listed are wrappers rather than second implementations — they set a class * or move a slot and hand the row back to `Menu.Item`, so the press-in fill, * the destructive colour and the dismiss-on-select rule stay in one place and * cannot drift between the two ways of reaching a list of verbs. */ export const ContextMenu = Object.assign(ContextMenuRoot, { Trigger: ContextMenuTrigger, Content: ContextMenuContent, Preview: ContextMenuPreview, Background: Menu.Background, Label: Menu.Label, Item: ContextMenuItem, CheckboxItem: ContextMenuCheckboxItem, RadioGroup: Menu.RadioGroup, RadioItem: Menu.RadioItem, Separator: Menu.Separator, Sub: Menu.Sub, SubTrigger: Menu.SubTrigger, SubContent: Menu.SubContent, });