import { cva } from 'class-variance-authority'; import { type ReactNode, useCallback, useState } from 'react'; import { type LayoutChangeEvent, Pressable, type StyleProp, View, type ViewStyle } from 'react-native'; import { CloseLine as X } from 'rn-motion-ui-icons/icons/close-line'; import { More1Line as MoreHorizontal } from 'rn-motion-ui-icons/icons/more-1-line'; import { usePressState } from '../../../hooks/use-press-state'; import { useReducedMotion } from '../../../hooks/use-reduced-motion'; import { cn } from '../../../lib/cn'; import { SPRING_PRESS, springLayout } from '../../../lib/ease'; import { MotiView } from '../../../moti/components/view'; import { AnimatePresence } from '../../../moti/presence/animate-presence'; import { TIMING_INSTANT } from '../../../theme/motion'; import { ThemedIcon } from '../../icon/themed-icon'; import { Text } from '../../typography/Text/text'; export type OverflowActionsSize = 'sm' | 'md'; export type OverflowActionItem = { id: string; label: ReactNode; icon?: ReactNode; onPress?: () => void; disabled?: boolean; accessibilityLabel?: string; testID?: string; }; // biome-ignore lint/style/useExportsLast: props type before spring constants — collocated for readability export type OverflowActionsProps = { primaryActions: OverflowActionItem[]; overflowActions: OverflowActionItem[]; expanded?: boolean; defaultExpanded?: boolean; onExpandedChange?: (expanded: boolean) => void; onAction?: (item: OverflowActionItem) => void; collapseOnAction?: boolean; size?: OverflowActionsSize; /** Toggle a11y label when collapsed. */ openLabel?: string; /** Toggle a11y label when expanded. */ closeLabel?: string; /** Additional UniWind class names merged onto the outer row. */ className?: string; style?: StyleProp; /** * Root testID. Every action derives `-item-` from it and the toggle takes * `-toggle`. An action's own `testID` wins over its derived one. The offscreen * measurer that duplicates the overflow actions stays unnamed either way, so a * query matches the visible button only. */ testID?: string; /** Replace the open-state close icon. Default: ``. */ closeIcon?: ReactNode; /** Replace the closed-state overflow trigger icon. Default: ``. */ triggerIcon?: ReactNode; }; // Softer than the app defaults so the group stays attached to the toggle. const SHELL_SPRING = { type: 'spring', stiffness: 220, damping: 17, mass: 0.85 } as const; /** Reveal clip rides `springLayout` on the same spring as `SHELL_SPRING`. */ const REVEAL_LAYOUT = springLayout(SHELL_SPRING); // cva drives the static per-size styling; class strings stay static literals. // No flex `gap` here: the overflow clip is an always-mounted flex child, so a // track gap would render on both sides of it even at width 0 (phantom spacing // when collapsed). Inter-segment spacing is driven by explicit margins instead. const group = cva('flex-row items-center', { variants: { size: { sm: 'gap-1', md: 'gap-1.5' } }, defaultVariants: { size: 'md' }, }); const action = cva(cn('flex-row shrink-0 items-center justify-center rounded-full bg-surface-3'), { variants: { size: { sm: 'h-8 gap-1.5 px-3', md: 'h-9 gap-2 px-3.5' } }, defaultVariants: { size: 'md' }, }); const toggle = cva('shrink-0 items-center justify-center rounded-full bg-primary', { variants: { size: { sm: 'h-8 w-8', md: 'h-9 w-9' } }, defaultVariants: { size: 'md' }, }); export function OverflowActions({ primaryActions, overflowActions, expanded, defaultExpanded = false, onExpandedChange, onAction, collapseOnAction = false, size = 'md', openLabel = 'Show extra actions', closeLabel = 'Hide extra actions', className, style, testID, closeIcon, triggerIcon, }: OverflowActionsProps) { const reduce = useReducedMotion(); const [internal, setInternal] = useState(defaultExpanded); const [togglePressed, setTogglePressed] = useState(false); // Natural width of the overflow group, measured offscreen so the clip has an // explicit target to spring toward (0 while collapsed → this while expanded). const [overflowWidth, setOverflowWidth] = useState(0); const controlled = expanded !== undefined; const isExpanded = controlled ? expanded : internal; const gap = size === 'sm' ? 4 : 6; const setExpanded = useCallback( (next: boolean) => { if (!controlled) setInternal(next); onExpandedChange?.(next); }, [controlled, onExpandedChange], ); const handleAction = useCallback( (item: OverflowActionItem) => { item.onPress?.(); onAction?.(item); if (collapseOnAction) setExpanded(false); }, [onAction, collapseOnAction, setExpanded], ); const handleOverflowLayout = useCallback((e: LayoutChangeEvent) => setOverflowWidth(e.nativeEvent.layout.width), []); const handleTogglePressIn = useCallback(() => setTogglePressed(true), []); const handleTogglePressOut = useCallback(() => setTogglePressed(false), []); const handleTogglePress = useCallback(() => setExpanded(!isExpanded), [setExpanded, isExpanded]); const spring = reduce ? TIMING_INSTANT : SHELL_SPRING; return ( {primaryActions.map((item) => ( ))} {/* Animated clip: the group is measured offscreen at natural width, then the visible clip springs its width + left margin between 0 (collapsed) and that width (expanded). Flex reflows each frame, so the toggle to the right glides over smoothly instead of teleporting. */} {/* Offscreen measurer at natural size — feeds the spring its target. aria-hidden keeps the duplicate buttons out of the accessibility tree so queries like findByRole('button', { name }) don't match twice, and these get no testID for the same reason: aria-hidden is invisible to getByTestId, so a named measurer would match alongside the real row. */} {overflowActions.map((item) => ( ))} {overflowActions.map((item) => ( ))} {isExpanded ? (closeIcon ?? ) : (triggerIcon ?? )} ); } export type ActionButtonProps = { item: OverflowActionItem; size: OverflowActionsSize; reduce: boolean; onAction: (item: OverflowActionItem) => void; /** * Already derived by the caller rather than built from a root here, because the * offscreen measurer renders the same actions a second time and must stay * unnamed — two nodes under one testID would make every query ambiguous. */ testID?: string; }; function ActionButton({ item, size, reduce, onAction, testID }: ActionButtonProps) { const { pressed, pressHandlers } = usePressState(); const handlePress = useCallback(() => onAction(item), [onAction, item]); return ( {item.icon === null ? null : item.icon} {typeof item.label === 'string' || typeof item.label === 'number' ? ( {item.label} ) : ( item.label )} ); }