import React, { CSSProperties, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import useFloatingMenu from '../ui/useFloatingMenu'; import { CheckIcon, CircleDashedCheckIcon, PlusIcon } from '../ui/icons'; export type AssignmentState = 'all' | 'partial' | 'none'; export interface SelectionAssignmentMenuItem { id?: string; label?: string; state?: AssignmentState; count?: number | null; total?: number | null; color?: string | null; value?: string; payload?: unknown; } export interface NormalizedSelectionAssignmentItem { id: string; label: string; state: AssignmentState; count: number | null; total: number | null; payload: unknown; } export interface SelectionAssignmentMenuProps { label: React.ReactNode; items?: SelectionAssignmentMenuItem[]; placeholder?: string; emptyMessage?: string; createLabel?: string; onToggle?: (item: NormalizedSelectionAssignmentItem) => Promise | void; onCreate?: (value: string) => Promise | void; disabled?: boolean; className?: string; triggerContent?: React.ReactNode; triggerClassName?: string; showStateIndicators?: boolean; showCounts?: boolean; onOpenMenu?: () => void; renderItemLabel?: (item: NormalizedSelectionAssignmentItem) => React.ReactNode; positionStrategy?: 'absolute' | 'fixed'; closeOnSelection?: boolean; sortByState?: boolean; freezeSortOnOpen?: boolean; } const STATE_ORDER: Record = { all: 0, partial: 1, none: 2, }; const normalizeItems = (items?: SelectionAssignmentMenuItem[]): NormalizedSelectionAssignmentItem[] => (Array.isArray(items) ? items : []) .map((item) => { if (!item) { return null; } const trimmedLabel = item.label?.trim?.() || ''; if (!trimmedLabel) { return null; } const state: AssignmentState = item.state === 'all' ? 'all' : item.state === 'partial' ? 'partial' : 'none'; const numericCount = Number.isFinite(item.count) ? Number(item.count) : null; const numericTotal = Number.isFinite(item.total) ? Number(item.total) : null; return { id: item.id ?? trimmedLabel, label: trimmedLabel, state, count: numericCount, total: numericTotal, payload: item.payload ?? item, }; }) .filter((item): item is NormalizedSelectionAssignmentItem => Boolean(item)); const SelectionAssignmentMenu: React.FC = ({ label, items = [], placeholder = 'Search…', emptyMessage = 'No entries', createLabel = 'Add', onToggle, onCreate, disabled = false, className, triggerContent = null, triggerClassName = 'quick-add__chip quick-add__trigger panel-floating-actions__trigger', showStateIndicators = true, showCounts = true, onOpenMenu, renderItemLabel, positionStrategy = 'absolute', closeOnSelection = true, sortByState = true, freezeSortOnOpen = false, }) => { const anchorRef = useRef(null); const inputRef = useRef(null); const [query, setQuery] = useState(''); const [pending, setPending] = useState(false); const [sortSnapshot, setSortSnapshot] = useState | null>(null); const { isOpen, toggle, close, menuRef, menuStyle, updatePosition, } = useFloatingMenu({ anchorRef, align: 'center', positionStrategy, minWidth: 220, }) as { isOpen: boolean; toggle: () => void; close: () => void; menuRef: React.MutableRefObject; menuStyle: CSSProperties | null; updatePosition: () => void; }; useEffect(() => { if (disabled && isOpen) { close(); } }, [disabled, isOpen, close]); useEffect(() => { if (!isOpen) { return undefined; } setQuery(''); setPending(false); const frame = requestAnimationFrame(() => { updatePosition(); if (inputRef.current) { inputRef.current.focus(); inputRef.current.select?.(); } }); return () => cancelAnimationFrame(frame); }, [isOpen, updatePosition]); const normalizedItems = useMemo(() => normalizeItems(items), [items]); const sortedByStateItems = useMemo(() => { if (!sortByState) { return normalizedItems; } return normalizedItems.slice().sort((a, b) => { const stateDiff = STATE_ORDER[a.state] - STATE_ORDER[b.state]; if (stateDiff !== 0) { return stateDiff; } return a.label.localeCompare(b.label); }); }, [normalizedItems, sortByState]); useEffect(() => { if (!isOpen || !freezeSortOnOpen || !sortByState) { setSortSnapshot(null); return; } setSortSnapshot((prev) => prev ?? sortedByStateItems.map((item) => item.id)); }, [isOpen, freezeSortOnOpen, sortByState, sortedByStateItems]); const orderedItems = useMemo(() => { if (freezeSortOnOpen && sortSnapshot && sortByState) { const itemMap = new Map( sortedByStateItems.map((item) => [item.id, item]), ); const seen = new Set(); const fromSnapshot = sortSnapshot .map((id) => { const entry = itemMap.get(id); if (entry) { seen.add(entry.id); } return entry || null; }) .filter((entry): entry is NormalizedSelectionAssignmentItem => Boolean(entry)); const remaining = sortedByStateItems.filter((item) => !seen.has(item.id)); return [...fromSnapshot, ...remaining]; } return sortedByStateItems; }, [freezeSortOnOpen, sortSnapshot, sortByState, sortedByStateItems]); const filteredItems = useMemo(() => { const search = query.trim().toLowerCase(); if (!search) { return orderedItems; } return orderedItems.filter((item) => item.label.toLowerCase().includes(search)); }, [orderedItems, query]); const handleToggle = useCallback( async (item: NormalizedSelectionAssignmentItem) => { if (!onToggle) { return; } setPending(true); try { await onToggle(item); setPending(false); if (closeOnSelection) { close(); } } catch (error) { setPending(false); console.error('[selection-assignment] toggle failed', error); } }, [onToggle, close, closeOnSelection], ); const handleCreate = useCallback( async (event: React.FormEvent) => { event?.preventDefault?.(); if (!onCreate) { return; } const value = query.trim(); if (!value) { return; } setPending(true); try { await onCreate(value); setPending(false); close(); } catch (error) { setPending(false); console.error('[selection-assignment] creation failed', error); } }, [onCreate, query, close], ); const existingLabels = useMemo( () => new Set(normalizedItems.map((item) => item.label.toLowerCase())), [normalizedItems], ); const canCreate = Boolean(onCreate); const trimmedQuery = query.trim(); const queryKey = trimmedQuery.toLowerCase(); const canSubmitCreate = canCreate && trimmedQuery.length > 0 && !existingLabels.has(queryKey) && !pending; const handleTriggerClick = useCallback(() => { if (disabled) { return; } if (!isOpen) { onOpenMenu?.(); } toggle(); }, [disabled, isOpen, onOpenMenu, toggle]); return (
{isOpen ? (
setQuery(event.target.value)} placeholder={placeholder} aria-label={placeholder} disabled={pending} /> {canCreate ? ( ) : null}
{filteredItems.length ? ( filteredItems.map((item) => { const isAll = item.state === 'all'; const isPartial = item.state === 'partial'; const icon = showStateIndicators ? isAll ?
) : null}
); }; export default SelectionAssignmentMenu;