import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Platform, Pressable, ScrollView, type StyleProp, TextInput, View, type ViewStyle } from 'react-native'; import type { IconProps } from 'rn-motion-ui-icons/icon-props'; import { CloseLine } from 'rn-motion-ui-icons/icons/close-line'; import { SearchLine as Search } from 'rn-motion-ui-icons/icons/search-line'; import { useAutoFocusOnShow } from '../../../hooks/use-auto-focus-on-show'; import { useBreakpointAtLeast } from '../../../hooks/use-breakpoint'; import { useReducedMotion } from '../../../hooks/use-reduced-motion'; import type { SurfaceElevation } from '../../../lib/elevated'; import { isMenuNavKey, nextMenuIndex } from '../../../lib/menu-keyboard'; import { useThemeColor } from '../../../theme/use-theme-color'; import { IconButton } from '../../buttons/IconButton/icon-button'; import { ThemedIcon } from '../../icon/themed-icon'; import { MenuItem } from '../../rows/menu-item'; import { Text } from '../../typography/Text/text'; import { AdaptiveModal } from '../AdaptiveModal/adaptive-modal'; import type { OverlayType } from '../Overlay/overlay-type'; // Renders inside AdaptiveModal with `customLayout` + `scrollable={false}`: the // palette owns its layout (a fixed search bar over a scrollable list) while // AdaptiveModal supplies the adaptive surface (narrow → fullSheet, wide → // centered modal), backdrop, exit animation and mount-gating. The web // Cmd/Ctrl+K shortcut has no RN equivalent, so `shortcut` is kept for API // parity but is a no-op. Rows are tapped, or — on web — arrow-navigated with // the same index math as `useMenuKeyboardNavigation`, but roving the `active` // highlight rather than DOM focus so the search input stays focused; the // highlight tracks the tapped/last-focused item and fades in via moti. On wide // screens the `ESC` kbd chip is the close button and rows show their // keyboard-shortcut hints; on narrow (touch) screens those shortcuts are // meaningless and a proper close button sits in the top right instead. const ESC_LABEL = 'ESC'; /** The group caption above each run of rows. */ const GROUP_LABEL_CLASS = 'px-2 py-1.5 text-[10px] text-muted-foreground uppercase tracking-wider'; /** Props passed to a command palette icon renderer. */ export type CommandIconProps = IconProps; /** Icon renderer — compatible with this project's icon set signature. */ export type CommandIcon = (props: CommandIconProps) => ReactNode; export type CommandItem = { id: string; label: string; group?: string; hint?: string; keywords?: string[]; icon?: CommandIcon; badge?: ReactNode; onSelect: () => void; testID?: string; }; // biome-ignore lint/style/useExportsLast: props type before fuzzyMatch helper — collocated for readability export type CommandPaletteProps = { items: CommandItem[]; /** Kept for web parity; on RN there is no window shortcut, so this is a no-op. */ shortcut?: string; placeholder?: string; emptyMessage?: string; open?: boolean; onOpenChange?: (open: boolean) => void; style?: StyleProp; accessibilityLabel?: string; /** The scrim behind the panel: `"blur"`, `"opacity"`, or `"none"`. Defaults to `"blur"`. */ overlay?: OverlayType; /** Overrides `overlay` on the small-screen bottom sheet. When omitted, the sheet uses `overlay`. */ smallScreenOverlay?: OverlayType; /** When false, pressing outside the palette will not close it. Defaults to true. */ closeOnOutsidePress?: boolean; /** Surface elevation (0–3) — drives the drop shadow + dark-mode rim. `0` is the flat resting surface (no shadow or border). Defaults to 3. */ elevation?: SurfaceElevation; /** * Root testID. Each row derives `-item-` from it and each group heading * `-group-` — the heading is plain text with no role to query by. An * item's own `testID` wins over its derived one. */ testID?: string; /** Replace the search input icon. Default: ``. */ searchIcon?: ReactNode; /** * Fires after the palette has fully presented (iOS `Modal.onShow`) — the moment * it is safe to request keyboard focus on content inside it. The palette's own * search input is focused first, then this callback runs. No-op on web. */ onShow?: () => void; }; function fuzzyMatch(needle: string, hay: string) { if (!needle) return true; const n = needle.toLowerCase(); const h = hay.toLowerCase(); let i = 0; for (const ch of h) { if (ch === n[i]) i += 1; if (i === n.length) return true; } return false; } type CommandRowProps = { item: CommandItem; index: number; isActive: boolean; hasIcons: boolean; /** Whether to show the item's keyboard-shortcut hint (wide screens only). */ showShortcuts: boolean; reduce: boolean; onActivate: (index: number) => void; onSelect: (item: CommandItem) => void; /** The palette's root `testID`; the row derives its own from it. */ testID?: string; }; function CommandRow({ item, index, isActive, hasIcons, showShortcuts, reduce, onActivate, onSelect, testID }: CommandRowProps) { const handlePressIn = useCallback(() => onActivate(index), [onActivate, index]); const handlePress = useCallback(() => onSelect(item), [onSelect, item]); // Compose badge + hint into a single trailing node when either is present. const trailing = item.badge || (showShortcuts && item.hint) ? ( <> {item.badge ? {item.badge} : null} {showShortcuts && item.hint ? ( {item.hint} ) : null} ) : undefined; return ( ); } // biome-ignore lint/complexity/noExcessiveLinesPerFunction: filtering/grouping + adaptive-surface wiring + the on-show focus chain are collocated around shared state export function CommandPalette({ items, placeholder = 'Type a command or search…', emptyMessage = 'No results found.', open: controlledOpen, onOpenChange, style, accessibilityLabel = 'Command palette', testID, searchIcon, overlay = 'blur', smallScreenOverlay, closeOnOutsidePress = true, elevation = 3, onShow, }: CommandPaletteProps) { const reduce = useReducedMotion(); const placeholderColor = useThemeColor('muted-foreground'); const inputRef = useRef(null); const { focusOnShow } = useAutoFocusOnShow(inputRef); // Wide = the palette renders as a centred modal (≥ `sm`, matching the // AdaptiveModal wide breakpoint); narrow = the full-sheet touch surface where // keyboard shortcuts don't exist and a tap-to-close button takes their place. const isWideScreen = useBreakpointAtLeast('sm'); const [internalOpen, setInternalOpen] = useState(false); const controlled = controlledOpen !== undefined; const open = controlled ? controlledOpen : internalOpen; const [query, setQuery] = useState(''); // `-1` means no row is highlighted; the highlight appears only once a row is // tapped/pressed (onPressIn → onActivate). Defaulting to 0 preselects the // first item on every open, which reads as an accidental selection. const [active, setActive] = useState(-1); const setOpen = useCallback( (v: boolean) => { if (!controlled) setInternalOpen(v); onOpenChange?.(v); }, [controlled, onOpenChange], ); // biome-ignore lint/plugin: query and active cursor must reset on each open — this responds to the open event, not derivable from render-time state useEffect(() => { if (open) { setQuery(''); setActive(-1); } }, [open]); const updateQuery = useCallback((value: string) => { setQuery(value); setActive(-1); }, []); const filtered = useMemo(() => { if (!query) return items; return items.filter((it) => { const haystacks = [it.label, it.group ?? '', ...(it.keywords ?? [])]; return haystacks.some((h) => fuzzyMatch(query, h)); }); }, [items, query]); const hasIcons = useMemo(() => items.some((it) => it.icon), [items]); const grouped = useMemo(() => { const map = new Map(); for (const it of filtered) { const g = it.group ?? 'Results'; const groupItems = map.get(g) ?? []; groupItems.push(it); map.set(g, groupItems); } return Array.from(map.entries()); }, [filtered]); const handleClose = useCallback(() => setOpen(false), [setOpen]); // Focus the search input once presented (native), then let the caller piggyback // on the same moment. `autoFocus` is left on the input so web — where the modal // has no `onShow` — still autofocuses. const handleShow = useCallback(() => { focusOnShow(); onShow?.(); }, [focusOnShow, onShow]); const handleSelect = useCallback( (item: CommandItem) => { item.onSelect(); setOpen(false); }, [setOpen], ); // Keyboard selection (web): ArrowUp/Down/Home/End rove the `active` highlight // and Enter commits it — the same index math as `useMenuKeyboardNavigation`, // but on the *highlight* rather than DOM focus, so the search input stays // focused while the user walks the results. Attached to `document` rather than // the palette node because the rows mount inside a portal that trails `open` by // a tick (`useModalRender` keeps the Modal mounted through its exit animation), // so a ref-backed listener would attach before the node exists. Keydowns bubble // from the autofocused search input up to the document, and the `open` gate // leaves keys pressed while the palette is closed alone. // biome-ignore lint/plugin: keyboard selection is an external DOM side effect — arrow keys and Enter have no declarative RN equivalent. useEffect(() => { if (Platform.OS !== 'web' || !open) return; const onKeyDown = (event: KeyboardEvent) => { const key = event.key; if (key === 'Enter') { const item = active >= 0 && active < filtered.length ? filtered[active] : undefined; if (item) { event.preventDefault(); handleSelect(item); } return; } if (!isMenuNavKey(key)) return; event.preventDefault(); setActive((current) => nextMenuIndex(current, filtered.length, key)); }; document.addEventListener('keydown', onKeyDown); return () => document.removeEventListener('keydown', onKeyDown); }, [open, active, filtered, handleSelect]); let cursor = 0; return ( {searchIcon ?? } {isWideScreen ? ( {ESC_LABEL} ) : ( )} {filtered.length === 0 ? ( {emptyMessage} ) : ( grouped.map(([group, list]) => ( {group} {list.map((it) => { const idx = cursor; cursor += 1; return ( ); })} )) )} ); }