import { type ReactNode, useCallback, useRef, useState } from 'react'; import { type LayoutChangeEvent, Pressable, type StyleProp, StyleSheet, useWindowDimensions, View, type ViewStyle, } from 'react-native'; import { AddLine as Plus } from 'rn-motion-ui-icons/icons/add-line'; import { CloseLine as X } from 'rn-motion-ui-icons/icons/close-line'; import { useReducedMotion } from '../../../hooks/use-reduced-motion'; import { cn } from '../../../lib/cn'; import { EASE_OUT, springLayout } from '../../../lib/ease'; import type { SurfaceElevation } from '../../../lib/elevated'; import { GLASS_SURFACE } from '../../../lib/glass'; import { MENU_RADIUS } from '../../../lib/radius'; import { MotiView } from '../../../moti/components/view'; import { AnimatePresence } from '../../../moti/presence/animate-presence'; import { TIMING_INSTANT } from '../../../theme/motion'; import { Surface } from '../../display/Surface/surface'; import { ThemedIcon } from '../../icon/themed-icon'; import { Text } from '../../typography/Text/text'; import { OverlayBlur } from '../Overlay/overlay-blur'; import { OverlayShell } from '../Overlay/overlay-shell'; import type { OverlayType } from '../Overlay/overlay-type'; /** Props passed to a morphing menu icon renderer. */ export type MorphingIconProps = { size?: number; color?: string }; /** Icon renderer — compatible with this project's icon set signature. */ export type MorphingIcon = (props: MorphingIconProps) => ReactNode; // biome-ignore lint/style/useExportsLast: type collocated with sibling MorphingIcon export for readability export type MorphingMenuItem = { label: string; icon: MorphingIcon; testID?: string }; const TRIGGER_W = 144; const TRIGGER_H = 44; const PANEL_W = 320; const BOX_H = 300; const COLS = 3; /** Card size adjustments after mount (measured panel height). */ const MENU_LAYOUT = springLayout({ stiffness: 300, damping: 32, mass: 0.9 }); /** Card enter/exit scale — the trigger width relative to the panel width, so the * card grows out of the trigger's footprint instead of snapping at full size. */ const CARD_SCALE = TRIGGER_W / PANEL_W; // The card is centred on the trigger's centre point, so the 320×300 stage sits // at (72 - 160, 22 - 150) relative to the trigger's top-left corner. const STAGE_LEFT = TRIGGER_W / 2 - PANEL_W / 2; const STAGE_TOP = TRIGGER_H / 2 - BOX_H / 2; // Folder-open feel: a touch of overshoot as the card expands, kept subtle. const SPRING_FOLDER = { type: 'spring', stiffness: 300, damping: 32, mass: 0.9 } as const; // `pointerEvents: 'box-none'` MUST come from StyleSheet.create, not an inline // style object — the inline path drops it on react-native-web, and the empty // stage margins would then swallow the scrim taps meant to close the menu. const styles = StyleSheet.create({ stage: { alignItems: 'center', justifyContent: 'center', pointerEvents: 'box-none' }, }); export type MorphingMenuProps = { items: MorphingMenuItem[]; onSelect?: (label: string) => void; /** Header label shown above the grid. Default "Create". */ title?: string; /** Trigger label. Default "Create". */ triggerLabel?: string; /** * Swap the morph card's ladder shadow for the input field's large, diffuse * halo (`shadow-floating`). It replaces the `shadow-elevated-N` rung rather * than adding to it, so the card keeps its `elevation` tint but trades the * layered drop for the halo. @default false */ floating?: boolean; /** Use the shared lightweight blur and glass rim on the panel. @default false */ glass?: boolean; /** * Surface elevation of the morph card (0–3) — drives the background tint and * the `shadow-elevated-N` recipe. `0` is the flat resting surface (no shadow * or border). Defaults to `6`. */ elevation?: SurfaceElevation; /** Additional UniWind class names merged onto the outer wrapper. */ className?: string; style?: StyleProp; /** * The scrim behind the panel: `"blur"`, `"opacity"`, or `"none"`. Defaults to * `"none"` — like `MorphingFab`, it morphs in place with no scrim. */ overlay?: OverlayType; /** When false, pressing outside the panel will not close it. Defaults to true. */ closeOnOutsidePress?: boolean; /** * Fires after the menu has fully presented (iOS `Modal.onShow`) — the moment it * is safe to request keyboard focus on content inside it. No-op on web. */ onShow?: () => void; /** * Root testID. The trigger takes `-trigger`, the close button `-close`, and * each cell `-item-` — positional, since `MorphingMenuItem` has no `id`. * An item's own `testID` overrides the one derived for it. */ testID?: string; }; function cellClass(i: number, count: number) { const rows = Math.ceil(count / COLS); const lastRowStart = (rows - 1) * COLS; const borderR = i % COLS !== COLS - 1 && i < count - 1; const borderB = i < lastRowStart; if (borderR && borderB) return 'items-center justify-center px-3 py-6 hairline-r hairline-b border-border'; if (borderR) return 'items-center justify-center px-3 py-6 hairline-r border-border'; if (borderB) return 'items-center justify-center px-3 py-6 hairline-b border-border'; return 'items-center justify-center px-3 py-6'; } type MorphingCellProps = { item: MorphingMenuItem; className: string; reduce: boolean; dist: number; onSelect: (label: string) => void; testID?: string; }; function MorphingCell({ item, className, reduce, dist, onSelect, testID }: MorphingCellProps) { const handlePress = useCallback(() => onSelect(item.label), [onSelect, item.label]); const Icon = item.icon; return ( {/* Icons default to the `foreground` token — no explicit color needed. */} {item.label} ); } type MorphingPanelProps = { items: MorphingMenuItem[]; title: string; reduce: boolean; rows: number; onSelect: (label: string) => void; onClose: () => void; testID?: string; }; /** Header + grid, fixed at the panel's open-state size and centred in the card. */ function MorphingPanel({ items, title, reduce, rows, onSelect, onClose, testID }: MorphingPanelProps) { const headerEnter = reduce ? TIMING_INSTANT : { type: 'timing' as const, duration: 200, delay: 120 }; const headerExit = reduce ? TIMING_INSTANT : { type: 'timing' as const, duration: 120 }; return ( {title} {items.map((item, i) => { // Radial stagger: delay each cell by its distance from the grid // centre so the morph reads as centre-out, not row-by-row. const col = i % COLS; const row = Math.floor(i / COLS); const dist = Math.hypot(col - (COLS - 1) / 2, row - (rows - 1) / 2); return ( ); })} ); } type MeasurePanelProps = { items: MorphingMenuItem[]; title: string; onLayout: (e: LayoutChangeEvent) => void }; /** * Hidden measuring copy of the panel, mounted in the page so its natural height * is known before the first open even though the animated panel only mounts * once the overlay Modal opens. */ function MeasurePanel({ items, title, onLayout }: MeasurePanelProps) { return ( {title} {items.map((item, i) => { const Icon = item.icon; return ( {item.label} ); })} ); } export function MorphingMenu({ items, onSelect, title = 'Create', triggerLabel = 'Create', floating = false, glass = false, elevation = 3, className, style, overlay = 'none', closeOnOutsidePress = true, onShow, testID, }: MorphingMenuProps) { const reduce = useReducedMotion(); const { width: windowWidth } = useWindowDimensions(); const [open, setOpen] = useState(false); // Measured window position of the trigger — the morph card re-anchors here // inside the overlay Modal so it grows out of the same spot the trigger sits. const [anchor, setAnchor] = useState<{ x: number; y: number } | null>(null); // Measured natural height of the panel content — the card's open-state frame. const [panelH, setPanelH] = useState(null); const triggerRef = useRef(null); const morph = reduce ? { type: 'timing' as const, duration: 150 } : SPRING_FOLDER; const rows = Math.ceil(items.length / COLS); const select = useCallback( (label: string) => { onSelect?.(label); setOpen(false); }, [onSelect], ); const handleClose = useCallback(() => setOpen(false), []); const handleOpen = useCallback(() => { const node = triggerRef.current; if (!node) { setOpen(true); return; } node.measureInWindow((x, y) => { setAnchor({ x, y }); setOpen(true); }); }, []); const onPanelLayout = useCallback((e: LayoutChangeEvent) => { const next = e.nativeEvent.layout.height; if (next > 0) setPanelH(next); }, []); const faceFade = reduce ? TIMING_INSTANT : { type: 'timing' as const, duration: 120 }; const faceReturn = reduce ? TIMING_INSTANT : { type: 'timing' as const, duration: 150, delay: 80 }; const backdropTransition = reduce ? TIMING_INSTANT : { type: 'timing' as const, duration: 200, easing: EASE_OUT }; return ( <> {/* Trigger — reserves the footprint in normal flow and opens the menu. */} {triggerLabel} {({ open: isAnimOpen, onExitComplete }) => ( {isAnimOpen && anchor ? ( {/* Scrim: dims the page when `overlay`, and closes on outside press when `closeOnOutsidePress`. Always mounted so a press outside still lands on something when the dim is hidden. Blur sits outside the dim's opacity fade — a parent opacity fades out the CSS backdrop-filter on web (backdrop-root clipping), so OverlayBlur fades its own opacity instead. */} {overlay === 'blur' ? : null} {/* Centring stage for the morph, anchored on the trigger's centre point. The card below is its only child, so as the card's frame animates the stage keeps it centred. */} {/* Trigger face: fades out as the morph begins. Its background masks the panel's hairlines while the card is closing. */} {triggerLabel} ) : null} )} ); }