// biome-ignore-all lint/style/useExportsLast: the entry types head the module so the components below read against them
// biome-ignore-all lint/style/useComponentExportOnlyModules: the separator metric is exported alongside the components
/**
* Menu — the list that goes *inside* a dropdown, a popover or a context menu.
*
* The anchored surfaces in this package (`AdaptiveDropdown`, `HoverMenu`,
* `Popover`) each own a panel: where it sits, how it opens,
* when it closes. None of them own what is in it, so every consumer ends up
* hand-rolling the same `View` of `MenuItem`s, the same hairline between groups,
* and the same icon-gutter bookkeeping. This is that list, once.
*
* ## It is the inside, not the frame
*
* The list draws no surface, no border, no radius and no width. Rows run edge to
* edge horizontally and separators span the full width, because a row's press
* highlight reaching the panel's sides is what makes it read as a menu row rather
* than a floating button.
*
* The one thing it does own is its **vertical** inset. A row flush against a
* rounded panel edge is wrong in every panel that holds this list, so the list
* caps itself top and bottom rather than making four containers each remember to.
* The frame around it stays the caller's:
*
* ```tsx
* // Surface, radius and width are the panel's; the top and bottom inset is the list's.
*
* {({ close }) => }
*
* ```
*
* Pass `className="py-*"` to retune that inset, or `py-0` to drop it — a panel
* that has to predict its own height before layout wants a number it pinned
* itself, which is what the file-system background menu does.
*
* The list renders a single `View` to hang `role="menu"`, the accessible name and
* that inset on — a `Fragment` would be marginally leaner and would leave the
* menu semantics with nowhere to live.
*
* ## The interface
*
* One `entries` array, holding four things in any order:
*
* | Entry | Renders |
* | --- | --- |
* | `{ id, label, … }` | a `MenuItem` row — the default, so `type` is optional |
* | `{ type: 'separator' }` | a hairline between groups |
* | `{ type: 'label', label }` | a group caption, readable but not selectable |
* | `{ type: 'node', node }` | anything at all — a switch row, an input, a chart |
*
* A bare `ReactElement` is the shorthand for the last one, and `false` /
* `null` / `undefined` entries are dropped, so a menu can be assembled with
* `&&` inline rather than filtered first:
*
* ```tsx
*
,
* { id: 'delete', label: 'Delete', icon: Trash2, destructive: true, onSelect: remove },
* ]}
* />
* ```
*
* ## What it does for you
*
* - **Closes the panel.** Give it `onClose` and every row calls it before its own
* `onSelect`, in that order — so an action that navigates does not leave the
* panel stranded on the old screen. Rows that should stay open (a checklist, a
* toggle) set `closeOnSelect: false`.
* - **Aligns the labels.** If any row has an icon, the rows without one reserve
* the slot, so nothing shifts left. `iconGutter` forces it either way.
* - **Names every entry for tests.** Give the list a `testID` and each entry
* derives one from it, so a row, a caption or a separator can be reached
* without a role query. See {@link MenuProps.testID}.
* - **Carries the semantics.** `role="menu"` on the list, `menuitem` on each row,
* selected/disabled state on both a11y trees. A group caption is
* `presentation` — readable, never offered as a dead command. Set
* `role="none"` when the panel around it already announces itself as a menu.
*
* ## What it deliberately does not do
*
* **No frame.** No background, no border, no radius, no horizontal padding, no
* width, no scrolling. The rows go edge to edge and the surface around them
* belongs to whatever is holding it — `AdaptiveDropdown`'s panel, a `Card`, a
* sidebar column. A menu that painted its own
* surface would sit as a second visible box inside the first one.
*
* The vertical inset is the exception, and the reason is that it is not really
* part of the frame: it is the space the first and last row need from the panel's
* rounded corners, which is the same in every panel. Horizontal padding is not,
* so it stays out — indent the rows and the highlight stops reaching the edge.
*
* The list element that remains is not a frame; it carries `role="menu"`, the
* accessible name, that vertical inset, and the gap between the individually
* rounded rows of `'sidebar'` mode.
*/
import { Fragment, isValidElement, type ReactElement, type ReactNode, useCallback, useRef } from 'react';
import { View } from 'react-native';
import { useMenuKeyboardNavigation } from '../../hooks/use-menu-keyboard';
import { useReducedMotion } from '../../hooks/use-reduced-motion';
import { cn } from '../../lib/cn';
import { MotiView } from '../../moti/components/view';
import { MENU_ITEM_STAGGER_MS, MOTION_STANDARD, TIMING_INSTANT } from '../../theme/motion';
import { Text } from '../typography/Text/text';
import { MenuItem, type MenuItemIcon, type MenuItemMode, type MenuItemSize, type MenuVariant } from './menu-item';
export type { MenuVariant } from './menu-item';
/** An action row — the default entry, so `type` may be omitted. */
export type MenuActionEntry = {
type?: 'item';
/** Stable identity for the row. Also used as its React key and to build its testID. */
id: string;
/** Row label. A string keeps the row single-line; a node is rendered as-is. */
label: ReactNode;
/** Leading icon. */
icon?: MenuItemIcon;
/** Runs on press, after the panel has been told to close. */
onSelect?: () => void;
/** Marks the row as the active/selected one — highlight fill plus `selected` state. */
active?: boolean;
/** Greys the row out, blocks the press, and says so to assistive tech. */
disabled?: boolean;
/** Paints the label and icon in the `danger` token — "Delete", "Revoke", "Leave". */
destructive?: boolean;
/** Trailing node — a shortcut hint, a badge, a submenu chevron. Rendered as-is. */
trailing?: ReactNode;
/**
* Whether choosing this row closes the panel. Turn it off for rows that toggle
* something and should stay put — a checklist, a density switch.
* @default true
*/
closeOnSelect?: boolean;
/** Overrides the announced name when `label` is a node rather than a string. */
accessibilityLabel?: string;
/** Per-row override of the menu's `size`. */
size?: MenuItemSize;
/** Per-row override of the menu's `mode`. */
mode?: MenuItemMode;
/**
* Puts the icon in a coloured rounded square (iOS settings rows). The row then
* ignores `mode`, as `MenuItem`'s icon-tile variant does.
*/
iconBackgroundColor?: string;
/** Icon stroke colour when `iconBackgroundColor` is set. @default 'white' */
iconColor?: string;
className?: string;
testID?: string;
};
/** A hairline between two groups of rows. */
export type MenuSeparatorEntry = { type: 'separator'; id?: string; className?: string; testID?: string };
/** A caption naming the group below it. Readable, never selectable. */
export type MenuLabelEntry = { type: 'label'; id?: string; label: ReactNode; className?: string; testID?: string };
/** Anything at all, dropped into the list as-is. */
export type MenuNodeEntry = { type: 'node'; id?: string; node: ReactNode; className?: string; testID?: string };
/**
* One thing in a menu. A bare `ReactElement` is shorthand for
* `{ type: 'node' }`, and falsy entries are dropped so a list can be built with
* `&&` inline.
*/
export type MenuEntry =
| MenuActionEntry
| MenuSeparatorEntry
| MenuLabelEntry
| MenuNodeEntry
| ReactElement
| false
| null
| undefined;
export type MenuProps = {
/** The list, in render order. See {@link MenuEntry}. */
entries: readonly MenuEntry[];
/**
* Closes the surrounding panel. Every row calls it before its own `onSelect`
* unless the row sets `closeOnSelect: false`. Wire it to the `close` a
* dropdown's render prop hands you.
*/
onClose?: () => void;
/** Called after any row's own `onSelect`, with that row. Handy for analytics. */
onSelect?: (entry: MenuActionEntry) => void;
/**
* Row size — row padding, icon dimensions, label ramp, and the caption and
* separator spacing that goes with them. Not the frame around the list: that
* is the panel's, at whatever size. @default 'md'
*/
size?: MenuItemSize;
/**
* `'menu'` — flush rows, highlight overlay (dropdowns, context menus).
* `'sidebar'` — rounded rows with a gap, muted until active (nav lists).
* @default 'menu'
*/
mode?: MenuItemMode;
/**
* Whether rows without an icon reserve the icon slot so every label lines up.
* `'auto'` turns it on when at least one row has one. @default 'auto'
*/
iconGutter?: 'auto' | 'on' | 'off';
/**
* `'menu'` (default) announces the list as a menu and each row as a menuitem.
* `'none'` drops the list's own role — for a panel that already carries
* `role="menu"` itself, so the two don't nest one menu inside another. The
* panels in this package leave the role to the list, which is the arrangement
* that puts each `menuitem` directly inside the element owning `menu`.
*/
role?: 'menu' | 'none';
/**
* Row style. `'base'` (default) is the CommandPalette look — icon leading, no
* borders between rows. `'segmented'` is the hold-menu look — icon trailing,
* a hairline below each row but the last, centred captions and solid band
* separators. See {@link MenuVariant}. @default 'base'
*/
variant?: MenuVariant;
/** Accessible name for the list. */
accessibilityLabel?: string;
/**
* Merged onto the list element. The only style that element carries of its own
* is the vertical inset, so `py-*` here replaces it (`py-0` drops it) and
* everything else is additive — see the note on the frame in the module header
* before reaching for it.
*/
className?: string;
/**
* Goes on the list element, and every entry derives its own from it:
*
* | Entry | testID |
* | --- | --- |
* | action row | `-item-` |
* | separator / caption / node | `-` |
*
* An action row is named by its `id`, which it always has. The other three
* are named by the React key instead — the same string, when they carry an
* `id`, and a positional `separator-0` / `label-1` fallback when they don't,
* which is the one name guaranteed not to collide with a neighbour.
*
* Any entry's own `testID` overrides its derived one, and with no `testID`
* here nothing is named at all.
*/
testID?: string;
};
/**
* Per-size metrics — all of it *between* the rows, none of it around them: the
* gap for the rounded rows of sidebar mode, the breathing room a separator takes
* from its neighbours, and the caption's own padding (matching the row padding at
* that size, so a group label lines up with the labels under it).
*
* The list's own vertical inset is not here on purpose: it is the gap the end
* rows need from a rounded panel corner, which is a property of the panel rather
* than of the row scale, so it is one fixed value at every size. See {@link Menu}.
*/
const SIZE_SCALE: Record = {
sm: { gapClass: 'gap-0.5', labelClass: 'px-2 pt-0.5 pb-1' },
md: { gapClass: 'gap-0.5', labelClass: 'px-3 pt-0.5 pb-1.5' },
lg: { gapClass: 'gap-1', labelClass: 'px-4 pt-1 pb-2' },
};
/** One text-size step below each menu size — keeps the caption subordinate to the rows. */
const LABEL_TEXT_SIZE: Record = { sm: 'xs', md: 'sm', lg: 'base' };
/**
* Total vertical space a `separator` entry takes at each size.
*
* Published because a panel that places itself *before* it has been laid out has
* to predict its own height, and a separator is the one entry whose height is
* fixed in px rather than following a text line box — every other entry ends up
* as tall as its text line box, which is a font measurement no caller can do
* ahead of layout.
*
* The panel's height estimate (`HOLD_MENU_SEPARATOR_HEIGHT` in `menu-placement`)
* mirrors the `md` value rather than importing it: that layout module is
* deliberately free of component imports so it stays a pure unit under test. This
* constant is what that mirror points at — retune the scale above and the two
* numbers to check are here and in `HOLD_MENU_SEPARATOR_HEIGHT`.
*/
export const MENU_SEPARATOR_HEIGHT: Record = {
sm: 5, // h-px (1px) + my-0.5 (2px ×2)
md: 10, // h-0.5 (2px) + my-1 (4px ×2)
lg: 16, // h-1 (4px) + my-1.5 (6px ×2)
};
export type MenuSeparatorProps = { className?: string; testID?: string; size?: MenuItemSize; variant?: MenuVariant };
const SEPARATOR_SIZE_CLASS: Record = { sm: 'h-px my-0.5', md: 'h-0.5 my-1', lg: 'h-1 my-1.5' };
/**
* The `'segmented'` separator: a solid band between groups, no margin. Its `md`
* height (8 px) is mirrored by `HOLD_MENU_SEGMENTED_SEPARATOR_HEIGHT` in
* `menu-placement`, so a panel that predicts its own height before layout can.
*/
const SEGMENTED_SEPARATOR_CLASS: Record = { sm: 'h-1.5', md: 'h-2', lg: 'h-2.5' };
/** The hairline between two groups. Exported so a `node` entry can draw a matching one. */
export function MenuSeparator({ className, testID, size = 'md', variant = 'base' }: MenuSeparatorProps) {
const sizeClass = variant === 'segmented' ? SEGMENTED_SEPARATOR_CLASS[size] : SEPARATOR_SIZE_CLASS[size];
return ;
}
export type MenuLabelProps = {
children: ReactNode;
className?: string;
testID?: string;
size?: MenuItemSize;
variant?: MenuVariant;
/** Draws the hairline below a `'segmented'` caption, like the rows around it. */
bottomBorder?: boolean;
};
/** The caption above a group. Exported for the same reason as {@link MenuSeparator}. */
export function MenuLabel({ children, className, testID, size = 'md', variant = 'base', bottomBorder = false }: MenuLabelProps) {
// `'segmented'` titles are centred full rows — the hold-menu section header —
// not the small left-aligned caption the base list uses.
return (
{children}
);
}
type MenuRowProps = {
entry: MenuActionEntry;
size: MenuItemSize;
mode: MenuItemMode;
variant: MenuVariant;
bottomBorder: boolean;
iconPlaceholder: boolean;
isMenu: boolean;
reduce: boolean;
/** Zero-based position among action rows — drives the staggered enter delay. */
index: number;
onClose?: () => void;
onSelect?: (entry: MenuActionEntry) => void;
testID?: string;
};
/** One action row. Its own component so `onPress` is a stable per-row callback. */
function MenuRow({
entry,
size,
mode,
variant,
bottomBorder,
iconPlaceholder,
isMenu,
reduce,
onClose,
onSelect,
testID,
index,
}: MenuRowProps) {
const handlePress = useCallback(() => {
// Close first, then act: the panel starts leaving while the action runs, so
// an action that navigates does not leave a modal stranded on the old screen.
if (entry.closeOnSelect !== false) onClose?.();
entry.onSelect?.();
onSelect?.(entry);
}, [entry, onClose, onSelect]);
// Each row fades and slides in from the side, staggered 25 ms apart so the list
// reads as arriving rather than popping into place. Reduced motion skips both
// the travel and the stagger — the row appears in place.
const staggerDelay = reduce ? 0 : index * MENU_ITEM_STAGGER_MS;
return (
);
}
/** Tells the bare-element shorthand apart from the four entry objects. */
function isElementEntry(entry: MenuEntry): entry is ReactElement {
return isValidElement(entry);
}
/** An action row — the entry with no `type` of its own, or an explicit `'item'`. */
function isActionEntry(entry: RenderableEntry): entry is MenuActionEntry {
return !isElementEntry(entry) && (entry.type ?? 'item') === 'item';
}
/** A `MenuEntry` with the falsy members dropped — what actually renders. */
type RenderableEntry = MenuActionEntry | MenuSeparatorEntry | MenuLabelEntry | MenuNodeEntry | ReactElement;
/** An entry paired with the React key the list renders it under. */
type KeyedEntry = { key: string; entry: RenderableEntry };
/**
* Drops the falsy entries and pairs what is left with a key.
*
* An action row is keyed by its `id`. The other three have nothing intrinsic to
* key by — a separator is not *about* anything — so they take an explicit `id`
* when given one and otherwise fall back to a per-type running count
* (`separator-0`, `separator-1`, …). That is positional, deliberately: entry
* lists are authored in source rather than sorted or reordered at runtime, and
* none of the three holds state that a re-key would lose. A `node` entry that
* does hold state — an input mid-edit, a video mid-play — should carry an `id`.
*/
function keyEntries(entries: readonly MenuEntry[]): KeyedEntry[] {
const counts = new Map();
const nextKey = (type: string) => {
const seen = counts.get(type) ?? 0;
counts.set(type, seen + 1);
return `${type}-${seen}`;
};
return entries
.filter((entry): entry is RenderableEntry => Boolean(entry))
.map((entry) => {
// An element written inline should not have to be keyed by its author, but
// one that was keyed keeps that key — it may be identifying real state.
if (isElementEntry(entry)) return { key: entry.key ?? nextKey('node'), entry };
return { key: entry.id ?? nextKey(entry.type ?? 'item'), entry };
});
}
/**
* A menu list for the inside of an anchored panel — action rows, separators,
* group captions and arbitrary nodes from one `entries` array.
*
* See the module header for the entry shapes and what the list handles on your
* behalf (closing the panel, aligning labels, carrying the a11y semantics).
*
* @example
* // Inside a dropdown: the render prop's `close` is what makes rows dismiss it.
*
* {({ close }) => }
*
*/
export function Menu({
entries,
onClose,
onSelect,
size = 'md',
mode = 'menu',
iconGutter = 'auto',
role = 'menu',
variant = 'base',
accessibilityLabel,
className,
testID,
}: MenuProps) {
const reduce = useReducedMotion();
const scale = SIZE_SCALE[size];
const isMenu = role === 'menu';
const segmented = variant === 'segmented';
const listRef = useRef(null);
// Arrow-key roving focus between rows, only when the list actually carries the
// menu role (`role="none"` is a plain list inside a panel that announces itself
// a menu, so there is no menuitem to rove between).
useMenuKeyboardNavigation(listRef, isMenu);
const keyed = keyEntries(entries);
// Reserve the icon slot on iconless rows only when the menu is actually mixed —
// a list where nothing has an icon should not be indented by a phantom gutter.
// `'segmented'` has no leading gutter to reserve: its icons sit trailing.
const hasIcon = keyed.some(({ entry }) => isActionEntry(entry) && Boolean(entry.icon));
const iconPlaceholder = !segmented && (iconGutter === 'auto' ? hasIcon : iconGutter === 'on');
// `'segmented'` draws a hairline below every row but the last, so name that
// last row up front — the last action row or caption — instead of per entry.
const lastRowKey = segmented
? keyed.filter(({ entry }) => isActionEntry(entry) || entry.type === 'label').at(-1)?.key
: undefined;
const list = (
{(() => {
let actionIndex = 0;
return keyed.map(({ key, entry }) => {
// A caller writing an element inline should not have to key it, so the
// Fragment carries the one `keyEntries` assigned and adds no node.
if (isElementEntry(entry)) return {entry};
// The non-action entries take the React key rather than the bare `id` an
// action row uses: `key` is the one string `keyEntries` already
// guarantees is unique across the list, so an unnamed separator gets a
// stable `-separator-0` instead of colliding with its neighbours.
const entryTestID = entry.testID ?? (testID ? `${testID}-${key}` : undefined);
switch (entry.type) {
case 'separator':
return ;
case 'label':
return (
{entry.label}
);
case 'node':
return (
{entry.node}
);
default:
actionIndex += 1; // the `MenuRow` below also increments, but this is the one that counts for the stagger
return (
);
}
});
})()}
);
return list;
}