import { useState, useRef, useEffect, useCallback, useMemo } from "react"; import { TextInput } from "@mantine/core"; import { IconX } from "@tabler/icons-react"; import clsx from "clsx"; import { IBaseProperty, SelectTypeOptions, Choice, } from "@/ee/base/types/base.types"; import { choiceColor } from "@/ee/base/components/cells/choice-color"; import { useUpdatePropertyMutation } from "@/ee/base/queries/base-property-query"; import { generateBaseChoiceId } from "@/ee/base/utils/generate-base-id"; import { useListKeyboardNav } from "@/ee/base/hooks/use-list-keyboard-nav"; import cellClasses from "@/ee/base/styles/cells.module.css"; const CHOICE_COLORS = [ "gray", "red", "pink", "grape", "violet", "indigo", "blue", "cyan", "teal", "green", "lime", "yellow", "orange", ]; const STATUS_CATEGORY_LABELS: Record = { todo: "To Do", inProgress: "In Progress", complete: "Complete", }; const STATUS_CATEGORY_ORDER = ["todo", "inProgress", "complete"]; type NavItem = | { kind: "choice"; choice: Choice } | { kind: "add" }; type ChoiceGroup = { label: string | null; choices: Choice[] }; type ChoicePickerProps = { property: IBaseProperty; selectedIds: string[]; /** Multi keeps the picker open, hides picked options from the list and * shows them as removable tags instead. */ multiple?: boolean; /** Group options under status category headings. */ grouped?: boolean; /** Offer "Add option: " when the search has no exact match. */ allowCreate?: boolean; onToggle: (choice: Choice) => void; onEscape: () => void; }; /** Searchable choice list shared by select-like editors (modal fields; the * grid cells render the same UI and can migrate here). */ export function ChoicePicker({ property, selectedIds, multiple = false, grouped = false, allowCreate = false, onToggle, onEscape, }: ChoicePickerProps) { const typeOptions = property.typeOptions as SelectTypeOptions | undefined; const choices = typeOptions?.choices ?? []; const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]); const selectedChoices = choices.filter((c) => selectedSet.has(c.id)); const [search, setSearch] = useState(""); const searchRef = useRef(null); useEffect(() => { requestAnimationFrame(() => searchRef.current?.focus()); }, []); const groups = useMemo(() => { const filtered = ( search ? choices.filter((c) => c.name.toLowerCase().includes(search.toLowerCase()), ) : choices ).filter((c) => !multiple || !selectedSet.has(c.id)); if (!grouped) return [{ label: null, choices: filtered }]; const byCategory: Record = {}; for (const choice of filtered) { const cat = choice.category ?? "todo"; (byCategory[cat] ??= []).push(choice); } return STATUS_CATEGORY_ORDER.filter((key) => byCategory[key]?.length).map( (key) => ({ label: STATUS_CATEGORY_LABELS[key] ?? key, choices: byCategory[key] }), ); }, [choices, search, grouped, multiple, selectedSet]); const flatChoices = useMemo(() => groups.flatMap((g) => g.choices), [groups]); const choiceIdxMap = useMemo(() => { const m = new Map(); flatChoices.forEach((c, i) => m.set(c.id, i)); return m; }, [flatChoices]); const updatePropertyMutation = useUpdatePropertyMutation(); const trimmedSearch = search.trim(); const hasExactMatch = useMemo( () => trimmedSearch.length > 0 && choices.some((c) => c.name.toLowerCase() === trimmedSearch.toLowerCase()), [choices, trimmedSearch], ); const showAddOption = allowCreate && trimmedSearch.length > 0 && !hasExactMatch; const addOptionColor = useMemo( () => CHOICE_COLORS[choices.length % CHOICE_COLORS.length], [choices.length], ); const navItems = useMemo( () => [ ...flatChoices.map((c) => ({ kind: "choice" as const, choice: c })), ...(showAddOption ? [{ kind: "add" as const }] : []), ], [flatChoices, showAddOption], ); const { activeIndex, setActiveIndex, handleNavKey, setOptionRef } = useListKeyboardNav(navItems.length, [search, showAddOption]); const handleAddOption = useCallback(() => { if (!trimmedSearch) return; const newChoice: Choice = { id: generateBaseChoiceId(), name: trimmedSearch, color: addOptionColor, }; const newChoices = [...choices, newChoice]; updatePropertyMutation.mutate({ propertyId: property.id, pageId: property.pageId, typeOptions: { ...typeOptions, choices: newChoices, choiceOrder: newChoices.map((c) => c.id), }, }); onToggle(newChoice); setSearch(""); }, [trimmedSearch, addOptionColor, choices, typeOptions, property, updatePropertyMutation, onToggle]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); onEscape(); return; } if (handleNavKey(e)) return; if (e.key === "Enter") { if (activeIndex >= 0 && activeIndex < navItems.length) { e.preventDefault(); const item = navItems[activeIndex]; if (item.kind === "choice") onToggle(item.choice); else handleAddOption(); return; } if (showAddOption) { e.preventDefault(); handleAddOption(); } } }, [onEscape, handleNavKey, activeIndex, navItems, onToggle, handleAddOption, showAddOption], ); const addOptionIdx = flatChoices.length; return ( <> {multiple && selectedChoices.length > 0 && (
{selectedChoices.map((choice) => ( {choice.name} ))}
)} setSearch(e.currentTarget.value)} onKeyDown={handleKeyDown} mb={4} data-autofocus />
{groups.map((group) => (
{group.label && (
{group.label}
)} {group.choices.map((choice) => { const idx = choiceIdxMap.get(choice.id) ?? -1; const isSelected = !multiple && selectedSet.has(choice.id); return (
setActiveIndex(idx)} onClick={() => onToggle(choice)} > {choice.name}
); })}
))} {showAddOption && (
setActiveIndex(addOptionIdx)} onClick={handleAddOption} > Add option: {trimmedSearch}
)}
); }