diff --git a/apps/roam/src/components/CreateNodeDialog.tsx b/apps/roam/src/components/CreateNodeDialog.tsx index 4211f7c56..2d7ed9532 100644 --- a/apps/roam/src/components/CreateNodeDialog.tsx +++ b/apps/roam/src/components/CreateNodeDialog.tsx @@ -1,3 +1,13 @@ +/** + * @deprecated This component is deprecated and will be removed in a future version. + * Please use ModifyNodeDialog instead for a unified create/edit experience. + * + * Migration guide: + * - Replace `renderCreateNodeDialog` with `renderModifyNodeDialog` + * - Map props: mode: "create", nodeType: defaultNodeTypeUid, content: initialTitle + * - See ModifyNodeDialog.tsx for full API + */ + import React, { useEffect, useRef, useState } from "react"; import { Dialog, Classes, InputGroup, Label, Button } from "@blueprintjs/core"; import renderOverlay from "roamjs-components/util/renderOverlay"; diff --git a/apps/roam/src/components/FuzzySelectInput.tsx b/apps/roam/src/components/FuzzySelectInput.tsx new file mode 100644 index 000000000..4c32438e9 --- /dev/null +++ b/apps/roam/src/components/FuzzySelectInput.tsx @@ -0,0 +1,238 @@ +import React, { useState, useCallback, useMemo, useRef, useEffect } from "react"; +import { + Button, + TextArea, + InputGroup, + Menu, + MenuItem, + Popover, + PopoverPosition, +} from "@blueprintjs/core"; +import fuzzy from "fuzzy"; +import { Result } from "~/utils/types"; + +type FuzzySelectInputProps = { + value?: T; + setValue: (q: T) => void; + onLockedChange?: (isLocked: boolean) => void; + mode: "create" | "edit"; + initialUid: string; + options?: T[]; + placeholder?: string; + autoFocus?: boolean; + disabled?: boolean; + initialIsLocked?: boolean; +}; + +const FuzzySelectInput = ({ + value, + setValue, + onLockedChange, + mode, + initialUid, + options = [], + placeholder = "Enter value", + autoFocus, + disabled, + initialIsLocked, +}: FuzzySelectInputProps) => { + const [isLocked, setIsLocked] = useState(initialIsLocked || false); + const [query, setQuery] = useState(() => value?.text || ""); + const [isOpen, setIsOpen] = useState(false); + const [activeIndex, setActiveIndex] = useState(0); + const [isFocused, setIsFocused] = useState(false); + + const menuRef = useRef(null); + const inputRef = useRef(null); + + // Fuzzy filter options + const filteredItems = useMemo(() => { + if (!query) return options; + return fuzzy + .filter(query, options, { extract: (item) => item.text }) + .map((result) => result.original); + }, [query, options]); + + // Handle option selection + const handleSelect = useCallback( + (item: T) => { + if (mode === "create" && item.uid && item.uid !== initialUid) { + // Lock the value + setIsLocked(true); + setQuery(item.text); + setValue(item); + setIsOpen(false); + onLockedChange?.(true); + } else { + // Just update the value + setQuery(item.text); + setValue(item); + setIsOpen(false); + } + }, + [mode, initialUid, setValue, onLockedChange], + ); + + // Handle clear locked value + const handleClear = useCallback(() => { + setIsLocked(false); + setQuery(""); + setValue({ text: "", uid: "" } as T); + onLockedChange?.(false); + }, [setValue, onLockedChange]); + + // Handle keyboard navigation + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "ArrowDown") { + e.preventDefault(); + setActiveIndex((prev) => + prev < filteredItems.length - 1 ? prev + 1 : prev, + ); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setActiveIndex((prev) => (prev > 0 ? prev - 1 : 0)); + } else if (e.key === "Enter") { + e.preventDefault(); + e.stopPropagation(); + if (isOpen && filteredItems[activeIndex]) { + handleSelect(filteredItems[activeIndex]); + } + } else if (e.key === "Escape") { + e.preventDefault(); + setIsOpen(false); + } + }, + [filteredItems, activeIndex, isOpen, handleSelect], + ); + + // Update value as user types + useEffect(() => { + if (mode === "create" && !isLocked) { + setValue({ text: query, uid: "" } as T); + } + }, [query, mode, isLocked, setValue]); + + // Open/close dropdown based on filtered items + // Only show dropdown if input is focused + useEffect(() => { + if (isFocused && filteredItems.length > 0 && query) { + setIsOpen(true); + } else { + setIsOpen(false); + } + }, [filteredItems.length, query, isFocused]); + + // Reset active index when filtered items change + useEffect(() => { + setActiveIndex(0); + }, [filteredItems]); + + // Scroll active item into view + useEffect(() => { + if (menuRef.current && isOpen) { + const activeElement = menuRef.current.children[ + activeIndex + ] as HTMLElement; + if (activeElement) { + activeElement.scrollIntoView({ + block: "nearest", + behavior: "smooth", + }); + } + } + }, [activeIndex, isOpen]); + + // Edit mode: simple TextArea + if (mode === "edit") { + return ( +