import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { PlusIcon } from './icons'; import useFloatingMenu from './useFloatingMenu'; const normalizeOption = (option, index) => { if (option == null) { return null; } if (typeof option === 'string') { return { id: option, label: option, original: option, index, }; } const label = option.label ?? option.name; if (!label) { return null; } return { id: option.id ?? label, label, original: option, index, }; }; const QuickAddMenu = ({ onSelectOption, onCreate, options = [], placeholder = 'Search or create…', createLabel = 'Add', emptyMessage = 'No matches', className, triggerAriaLabel = 'Add item', triggerTitle = 'Add', renderOption, menuMinWidth = 220, triggerClassName = 'icon-button quick-add__trigger', triggerContent = null, disabled = false, align = 'start', positionStrategy = 'fixed', }) => { const anchorRef = useRef(null); const inputRef = useRef(null); const [query, setQuery] = useState(''); const [submitting, setSubmitting] = useState(false); const { isOpen, toggle, close, menuRef, menuStyle, updatePosition, } = useFloatingMenu({ anchorRef, minWidth: menuMinWidth, matchAnchorWidth: false, align, positionStrategy, }); useEffect(() => { if (disabled && isOpen) { close(); } }, [disabled, isOpen, close]); useEffect(() => { if (!isOpen) { return undefined; } setQuery(''); setSubmitting(false); const frame = requestAnimationFrame(() => { inputRef.current?.focus(); inputRef.current?.select?.(); updatePosition(); }); return () => cancelAnimationFrame(frame); }, [isOpen, updatePosition]); const normalizedOptions = useMemo( () => options .map((option, index) => normalizeOption(option, index)) .filter((option) => option && typeof option.label === 'string'), [options], ); const filteredOptions = useMemo(() => { if (!query.trim()) { return normalizedOptions; } const search = query.trim().toLowerCase(); return normalizedOptions.filter((option) => option.label.toLowerCase().includes(search)); }, [normalizedOptions, query]); const handleSelect = useCallback( async (option) => { if (!option || !onSelectOption) { return; } setSubmitting(true); try { await onSelectOption(option.original ?? option.label, option); setSubmitting(false); close(); } catch (error) { setSubmitting(false); console.error('[quick-add] option selection failed', error); } }, [close, onSelectOption], ); const handleCreate = useCallback( async (event) => { event.preventDefault(); if (!onCreate) { return; } const value = query.trim(); if (!value) { return; } setSubmitting(true); try { await onCreate(value); setSubmitting(false); close(); } catch (error) { setSubmitting(false); console.error('[quick-add] creation failed', error); } }, [close, onCreate, query], ); const canCreate = Boolean(onCreate); const isAnchoredMenu = positionStrategy === 'absolute' && align === 'start'; const menuClassName = 'menu menu--floating'; const anchoredMenuStyle = isAnchoredMenu && menuStyle ? { top: menuStyle.top, left: menuStyle.left, ...(menuStyle.width ? { width: menuStyle.width } : null), } : undefined; const menuInlineStyle = isAnchoredMenu ? anchoredMenuStyle : menuStyle || undefined; const hasFloatingWidthVar = Boolean(menuInlineStyle && Object.prototype.hasOwnProperty.call(menuInlineStyle, '--floating-min-width')); const menuStyleWithVar = hasFloatingWidthVar ? menuInlineStyle : { ...(menuInlineStyle || {}), '--floating-min-width': `${Math.max(menuMinWidth, 0)}px`, }; return (