diff --git a/app/components/assets/custom-fields-inputs.tsx b/app/components/assets/custom-fields-inputs.tsx index 2904ae850..86d4b6ffe 100644 --- a/app/components/assets/custom-fields-inputs.tsx +++ b/app/components/assets/custom-fields-inputs.tsx @@ -1,10 +1,18 @@ import type { ReactElement } from "react"; -import { useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import type { CustomField as RawCustomField, CustomFieldType, } from "@prisma/client"; +import { ChevronDownIcon } from "@radix-ui/react-icons"; +import { + Popover, + PopoverTrigger, + PopoverPortal, + PopoverContent, +} from "@radix-ui/react-popover"; import { Link, useLoaderData, useNavigation } from "@remix-run/react"; +import { Search } from "lucide-react"; import type { Zorm } from "react-zorm"; import type { z } from "zod"; import type { ShelfAssetCustomFieldValueType } from "~/modules/asset/types"; @@ -13,18 +21,12 @@ import type { loader } from "~/routes/_layout+/assets.$assetId_.edit"; import { useHints } from "~/utils/client-hints"; import { getCustomFieldDisplayValue } from "~/utils/custom-fields"; import { isFormProcessing } from "~/utils/form"; +import { tw } from "~/utils/tw"; import { zodFieldIsRequired } from "~/utils/zod"; import FormRow from "../forms/form-row"; import Input from "../forms/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "../forms/select"; import { Switch } from "../forms/switch"; -import { SearchIcon } from "../icons/library"; +import { CheckIcon, SearchIcon } from "../icons/library"; import { MarkdownEditor } from "../markdown/markdown-editor"; import { Button } from "../shared/button"; @@ -37,8 +39,6 @@ export default function AssetCustomFields({ zo: Zorm>; schema: z.ZodObject; }) { - const optionTriggerRef = useRef(null); - /** Get the custom fields from the loader */ const { customFields, asset } = useLoaderData(); @@ -116,68 +116,17 @@ export default function AssetCustomFields({ ) : null} ), - OPTION: (field) => { - const val = getCustomFieldVal(field.id); - const options = field.options.filter((o) => o !== null && o !== ""); - return ( - <> - - - - ); - }, + + + ), MULTILINE_TEXT: (field) => { const value = customFieldsValues?.find( (cfv) => cfv.customFieldId === field.id @@ -307,3 +256,153 @@ export default function AssetCustomFields({ ); } + +/** Component that renders select for CustomField OPTION fields */ +function OptionSelect({ + field, + getCustomFieldVal, +}: { + field: CustomField; + getCustomFieldVal: (id: string) => string; +}) { + // State for popover, search, selection + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const [value, setValue] = useState(getCustomFieldVal(field.id) || ""); + const [searchQuery, setSearchQuery] = useState(""); + const [selectedIndex, setSelectedIndex] = useState(0); + + // Refs for elements + const triggerRef = useRef(null); + const searchInputRef = useRef(null); + + // Filter options based on search + const filteredOptions = useMemo(() => { + const options = field.options.filter((o) => o !== null && o !== ""); + if (!searchQuery) return options; + + return options.filter((option) => + option.toLowerCase().includes(searchQuery.toLowerCase()) + ); + }, [field.options, searchQuery]); + + const displayValue = value || `Choose ${field.name}`; + + // Handle option selection + function handleOptionClick(option: string) { + if (value === option) { + setValue(""); + } else { + setValue(option); + } + setIsPopoverOpen(false); + setSearchQuery(""); + } + + // Keyboard navigation handler + const handleKeyDown = (event: React.KeyboardEvent) => { + switch (event.key) { + case "ArrowDown": + event.preventDefault(); + setSelectedIndex((prev) => + prev < filteredOptions.length - 1 ? prev + 1 : prev + ); + break; + case "ArrowUp": + event.preventDefault(); + setSelectedIndex((prev) => (prev > 0 ? prev - 1 : 0)); + break; + case "Enter": + event.preventDefault(); + if (filteredOptions[selectedIndex]) { + handleOptionClick(filteredOptions[selectedIndex]); + } + break; + } + }; + + // Ensure selected option is visible + useEffect(() => { + const selectedElement = document.getElementById(`option-${selectedIndex}`); + selectedElement?.scrollIntoView({ block: "nearest" }); + }, [selectedIndex]); + + return ( + <> + + + + + + + + {/* Search input */} +
+ + setSearchQuery(e.target.value)} + onKeyDown={handleKeyDown} + /> +
+ + {/* Options list */} + {filteredOptions.length === 0 ? ( +
No options found
+ ) : ( + filteredOptions.map((option, index) => { + const isSelected = value === option; + const isHighlighted = index === selectedIndex; + + return ( +
handleOptionClick(option)} + style={{ + width: triggerRef.current?.clientWidth || "auto", + }} + > + {option} + {isSelected && ( + + + + )} +
+ ); + }) + )} +
+
+
+ + ); +} diff --git a/app/components/assets/form.tsx b/app/components/assets/form.tsx index 7364e2ad4..4a20602fb 100644 --- a/app/components/assets/form.tsx +++ b/app/components/assets/form.tsx @@ -257,6 +257,7 @@ export const AssetForm = ({ disabled={disabled} defaultValue={category ?? undefined} model={{ name: "category", queryKey: "name" }} + triggerWrapperClassName="flex flex-col !gap-0 justify-start items-start [&_.inner-label]:w-full [&_.inner-label]:text-left " contentLabel="Categories" label="Category" hideLabel @@ -322,6 +323,7 @@ export const AssetForm = ({ disabled={disabled} selectionMode="set" fieldName="newLocationId" + triggerWrapperClassName="flex flex-col !gap-0 justify-start items-start [&_.inner-label]:w-full [&_.inner-label]:text-left " defaultValue={location || undefined} model={{ name: "location", queryKey: "name" }} contentLabel="Locations"