/**
* Menu — the list of things you can do to something.
*
* It is not a Select, and the difference is what the rows *are*. A select's
* rows are values: picking one answers a question the form asked, and the
* trigger then shows the answer. A menu's rows are verbs — rename, duplicate,
* delete — and the trigger goes on saying the same thing afterwards, because
* nothing about it was chosen. Anything built out of a popover and a column of
* pressables ends up re-deriving that distinction by hand, and re-deriving the
* roles, the dismiss-on-select rule and the destructive colour with it.
*
* ```tsx
*
* ```
*
* The panel is a `Popover` underneath, so the menu inherits its measuring,
* flipping and edge-clamping rather than owning a second copy of them — a menu
* near the bottom of the screen opens upwards for the same reason a popover
* does, and `presentation="bottom-sheet"` moves the same rows into a sheet.
*
* Submenus expand in place rather than flying out sideways. A flyout needs a
* pointer to travel from the parent row to the child panel without crossing
* anything that would close it; a finger has no such path, and a second panel
* hanging off the first is usually the thing that pushes a menu off the edge
* of a phone. Opening downwards into the panel keeps every row under the
* thumb that opened it.
*/
import {
Children,
createContext,
isValidElement,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactElement,
type ReactNode,
} from 'react';
import { Pressable, View, type PressableProps, type ViewProps } from 'react-native';
import Animated, {
useAnimatedStyle,
useReducedMotion,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
import { tv } from 'tailwind-variants';
import { useCSSVariable } from 'uniwind';
import { Text, textChildren } from '../../primitives/text';
import { useDirectionSign } from '../../hooks/use-direction';
import { CheckIcon, ChevronRightIcon, CircleIcon } from '../../icons';
import { selectionTick } from '../../utils/haptics';
import { cn } from '../../utils/cn';
import { Popover, type PopoverContentProps, type PopoverProps } from '../popover';
/** Side of the column every indicator is drawn in. */
const INDICATOR_SIZE = 18;
/**
* A theme token as a colour an icon will accept.
*
* `useCSSVariable` answers with whatever the token holds, which for a length
* or a number is not a colour at all — so anything that is not a string is
* dropped and the icon falls back to what it inherits.
*/
function useTint(variable: string): string | undefined {
const raw = useCSSVariable(variable);
return typeof raw === 'string' ? raw : undefined;
}
/**
* The colour an indicator falls back to before the stylesheet has been read.
*
* The glyphs here are drawn by a general-purpose icon set rather than by this
* library's own, and that 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 INDICATOR_FALLBACK = '#737373';
/** Icon stroke, matched to the weight this library's own glyphs are drawn at. */
const INDICATOR_STROKE = 2;
/** How long a row takes to light up under a finger, and to let go again. */
const PRESS_IN_DURATION = 90;
const PRESS_OUT_DURATION = 160;
/** How far a pressed row shrinks. Enough to feel, not enough to see move. */
const PRESS_SCALE = 0.98;
const menuVariants = tv({
slots: {
/*
* No background here: the panel's surface is a layer of its own, drawn by
* `Menu.Background` behind the rows, so that a caller can replace it with
* a gradient or a blur without also having to redraw the rows.
*/
content: 'gap-0.5 rounded-3xl p-1.5',
background: 'absolute inset-0 rounded-3xl bg-overlay',
label: 'px-3 pb-1 pt-2',
item: 'w-full flex-row items-center gap-2.5 rounded-2xl px-2.5 py-2.5',
itemLabel: 'text-base font-medium text-overlay-foreground',
itemDescription: 'text-sm',
shortcut: 'text-xs tracking-widest',
separator: 'my-1 h-px bg-border',
indicator: 'items-center justify-center',
},
variants: {
variant: {
// The pressed fill is animated rather than switched, so it is not a
// class here — only what the animation cannot carry.
default: {},
/*
* The row is tinted rather than only recoloured. A red word on an
* otherwise ordinary row is easy to read past at a glance, and this is
* the one row in the menu where reading past it is expensive.
*/
destructive: {
itemLabel: 'text-destructive',
},
},
disabled: {
true: { item: 'opacity-[0.45]' },
},
/** Reserves the indicator column on a row that has no indicator of its own. */
inset: {
true: { item: 'ps-[38px]', label: 'ps-[38px]' },
},
},
defaultVariants: {
variant: 'default',
disabled: false,
inset: false,
},
});
export type MenuItemVariant = 'default' | 'destructive';
const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
/**
* The pressed state of a row, animated rather than switched.
*
* `active:` swaps a class wholesale, which lands the fill in one frame and
* takes it away in one frame — on a row the size of a menu item that reads as
* a flash rather than as a press. Interpolating a shared value fades it in and
* back out on the UI thread, and carries a shrink along with it that a class
* cannot express at all.
*/
function useMenuPress(variant: MenuItemVariant) {
const reduced = useReducedMotion();
const pressed = useSharedValue(0);
const accent = useTint('--color-accent');
const destructive = useTint('--color-destructive-subtle');
const fill = variant === 'destructive' ? destructive : accent;
/*
* The fill is a layer with an animated opacity rather than an animated
* `backgroundColor`, because crossing *from* transparent needs a colour to
* cross from and the row has none of its own — what is behind it is whatever
* the panel's background layer happens to be. Fading the row itself would
* take the label with it, so only the fill fades.
*/
const fillStyle = useAnimatedStyle(() => ({ opacity: pressed.value }));
const rowStyle = useAnimatedStyle(() => ({
transform: [{ scale: 1 - (1 - PRESS_SCALE) * pressed.value }],
}));
return {
rowStyle,
fillStyle,
fill,
onPressIn: () => {
pressed.value = reduced ? 1 : withTiming(1, { duration: PRESS_IN_DURATION });
},
onPressOut: () => {
pressed.value = reduced ? 0 : withTiming(0, { duration: PRESS_OUT_DURATION });
},
};
}
interface MenuContextValue {
close: () => void;
haptics: boolean;
}
const MenuContext = createContext(null);
function useMenu(component: string): MenuContextValue {
const context = useContext(MenuContext);
if (!context) {
throw new Error(`${component} must be used within a